From 06ce36d0b2ec0df656c4257d2a930b1000c3093b Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 20 Mar 2026 16:25:58 -0400 Subject: [PATCH 1/7] feat: support units for progress bars --- craft_cli/messages.py | 7 ++++++- craft_cli/printer.py | 6 +++++- examples.py | 4 ++-- tests/unit/test_printer.py | 24 ++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/craft_cli/messages.py b/craft_cli/messages.py index 76dfc4c1..c85b1730 100644 --- a/craft_cli/messages.py +++ b/craft_cli/messages.py @@ -116,6 +116,7 @@ def __init__( delta: bool, # noqa: FBT001 (boolean positional arg) use_timestamp: bool, # noqa: FBT001 (boolean positional arg) ephemeral_context: bool, # noqa: FBT001 (boolean positional arg) + units: str | None, ) -> None: self.printer = printer self.total = total @@ -124,6 +125,7 @@ def __init__( self.stream = stream self.delta = delta self.use_timestamp = use_timestamp + self.units = units # this is only for the "before" and "after" messages; the progress itself # is always ephemeral @@ -168,6 +170,7 @@ def advance(self, amount: float) -> None: progress=self.accumulated, total=self.total, use_timestamp=self.use_timestamp, + units=self.units, ) @@ -683,6 +686,8 @@ def progress_bar( text: str, total: float, delta: bool = True, # noqa: FBT001, FBT002 + *, + units: str | None = None, ) -> _Progresser: """Progress information for a potentially long-running single step of a command. @@ -694,7 +699,7 @@ def progress_bar( """ stream, use_timestamp, ephemeral = self._get_progress_params(permanent=False) return _Progresser( - self._printer, total, text, stream, delta, use_timestamp, ephemeral + self._printer, total, text, stream, delta, use_timestamp, ephemeral, units ) @_active_guard() diff --git a/craft_cli/printer.py b/craft_cli/printer.py index de4aa18b..3bda5ccd 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -61,6 +61,7 @@ class _MessageInfo: ephemeral: bool = False bar_progress: int | float | None = None bar_total: int | float | None = None + bar_units: str | None = None use_timestamp: bool = False end_line: bool = False created_at: datetime = field(default_factory=datetime.now, compare=False) @@ -369,7 +370,8 @@ def _write_bar_terminal(self, message: _MessageInfo) -> None: # Should not happen as the caller checks the message raise ValueError("Tried to write a bar message with invalid attributes") - numerical_progress = f"{message.bar_progress}/{message.bar_total}" + units = f" {message.bar_units}" if message.bar_units is not None else "" + numerical_progress = f"{message.bar_progress}/{message.bar_total}{units}" bar_percentage = min(message.bar_progress / message.bar_total, 1) # terminal size minus the text and numerical progress, and 5 (the cursor at the end, @@ -471,6 +473,7 @@ def progress_bar( progress: float, total: float, use_timestamp: bool, + units: str | None, ) -> None: """Show a progress bar to the given stream.""" text = self._apply_secrets(text) @@ -480,6 +483,7 @@ def progress_bar( text=text.rstrip(), bar_progress=progress, bar_total=total, + bar_units=units, ephemeral=True, # so it gets eventually overwritten by other message use_timestamp=use_timestamp, ) diff --git a/examples.py b/examples.py index 8d090b1a..f4475603 100755 --- a/examples.py +++ b/examples.py @@ -322,14 +322,14 @@ def example_23() -> None: def example_24() -> None: - """Show a progress bar in verbose mode.""" + """Show a progress bar in verbose mode with units.""" emit.set_mode(EmitterMode.VERBOSE) emit.progress("We need to know!", permanent=True) emit.progress("Deciding to build a computer or upload it...") time.sleep(1.5) - with emit.progress_bar("Uploading computer: planetary model", 1788) as progress: + with emit.progress_bar("Uploading computer: planetary model", 1788, units="YB") as progress: for uploaded in [500, 500, 500, 288]: progress.advance(uploaded) time.sleep(1.5) diff --git a/tests/unit/test_printer.py b/tests/unit/test_printer.py index 63a465d5..a71b0320 100644 --- a/tests/unit/test_printer.py +++ b/tests/unit/test_printer.py @@ -24,6 +24,7 @@ import time from datetime import datetime from io import StringIO +from pathlib import Path import pytest from craft_cli import printer as printermod @@ -784,6 +785,29 @@ def test_writebarterminal_having_previous_message_ephemeral( assert not err +def test_writebarterminal_uses_units( + capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, log_filepath: Path +) -> None: + monkeypatch.setattr(printermod, "_get_terminal_width", lambda: 100) + printer = Printer(log_filepath) + + msg = _MessageInfo( + sys.stdout, + "counting syllables in 'ubuntu'", + bar_progress=2, + bar_total=3, + bar_units="syllables", + ) + printer._write_bar_terminal(msg) + + out, err = capsys.readouterr() + assert ( + out + == "counting syllables in 'ubuntu' [██████████████████████████████████ ] 2/3 syllables" + ) + assert not err + + # -- tests for the writing bar (captured version) function From f698ec504d0ea61265f2bb96a9bda3fef04e2e28 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 20 Mar 2026 16:28:28 -0400 Subject: [PATCH 2/7] docs: add changelog entry --- docs/changelog.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index ffa6a396..843aa872 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,16 @@ Changelog See the `Releases page`_ on GitHub for a complete list of commits that are included in each version. +.. _release-3.4.0: + +3.4.0 (YYYY-MM-DD) +------------------ + +New features: + +- Add a ``units`` parameter :py:meth:`.Emitter.progress_bar` to label the units of + the displayed progress. + .. _release-3.3.0: 3.3.0 (2026-03-05) From f5dcb7d550c58809e28401415cec891b863e507e Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 20 Mar 2026 16:37:55 -0400 Subject: [PATCH 3/7] fix: unexplode ci --- examples.py | 4 ++- tests/unit/test_messages_helpers.py | 42 +++++++++++++++++++++++++---- tests/unit/test_printer.py | 6 +++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/examples.py b/examples.py index f4475603..5a9eb284 100755 --- a/examples.py +++ b/examples.py @@ -329,7 +329,9 @@ def example_24() -> None: emit.progress("Deciding to build a computer or upload it...") time.sleep(1.5) - with emit.progress_bar("Uploading computer: planetary model", 1788, units="YB") as progress: + with emit.progress_bar( + "Uploading computer: planetary model", 1788, units="YB" + ) as progress: for uploaded in [500, 500, 500, 288]: progress.advance(uploaded) time.sleep(1.5) diff --git a/tests/unit/test_messages_helpers.py b/tests/unit/test_messages_helpers.py index bb42f64c..c3e0c593 100644 --- a/tests/unit/test_messages_helpers.py +++ b/tests/unit/test_messages_helpers.py @@ -215,6 +215,7 @@ def test_progresser_absolute_mode(): delta=False, ephemeral_context=ephemeral, use_timestamp=use_timestamp, + units=None, ) as progresser: progresser.advance(20) progresser.advance(30.0) @@ -224,10 +225,20 @@ def test_progresser_absolute_mode(): stream, "test text (--->)", ephemeral=ephemeral, use_timestamp=use_timestamp ), call.progress_bar( - stream, text, progress=20, total=total, use_timestamp=use_timestamp + stream, + text, + progress=20, + total=total, + use_timestamp=use_timestamp, + units=None, ), call.progress_bar( - stream, text, progress=30.0, total=total, use_timestamp=use_timestamp + stream, + text, + progress=30.0, + total=total, + use_timestamp=use_timestamp, + units=None, ), call.show( stream, "test text (<---)", ephemeral=ephemeral, use_timestamp=use_timestamp @@ -251,6 +262,7 @@ def test_progresser_delta_mode(): delta=True, ephemeral_context=ephemeral, use_timestamp=use_timestamp, + units=None, ) as progresser: progresser.advance(20.5) progresser.advance(30) @@ -260,10 +272,20 @@ def test_progresser_delta_mode(): stream, "test text (--->)", ephemeral=ephemeral, use_timestamp=use_timestamp ), call.progress_bar( - stream, text, progress=20.5, total=total, use_timestamp=use_timestamp + stream, + text, + progress=20.5, + total=total, + use_timestamp=use_timestamp, + units=None, ), call.progress_bar( - stream, text, progress=50.5, total=total, use_timestamp=use_timestamp + stream, + text, + progress=50.5, + total=total, + use_timestamp=use_timestamp, + units=None, ), call.show( stream, "test text (<---)", ephemeral=ephemeral, use_timestamp=use_timestamp @@ -283,6 +305,7 @@ def test_progresser_negative_values(delta): delta, True, # noqa: FBT003 True, # noqa: FBT003 + None, ) as progresser: with pytest.raises(ValueError, match="The advance amount cannot be negative"): progresser.advance(-1) @@ -292,7 +315,16 @@ def test_progresser_dont_consume_exceptions(): """It lets the exceptions go through.""" fake_printer = MagicMock() with pytest.raises(ValueError): # noqa: PT011 - with _Progresser(fake_printer, 123, "test text", sys.stdout, True, True, True): # noqa: FBT003 + with _Progresser( + fake_printer, + 123, + "test text", + sys.stdout, + delta=True, + use_timestamp=True, + ephemeral_context=True, + units=None, + ): raise ValueError diff --git a/tests/unit/test_printer.py b/tests/unit/test_printer.py index a71b0320..4b21d981 100644 --- a/tests/unit/test_printer.py +++ b/tests/unit/test_printer.py @@ -1053,7 +1053,7 @@ def test_progress_bar_valid_streams_captured(stream, recording_printer, monkeypa before = datetime.now() recording_printer.progress_bar( - stream, "test text", progress=20, total=100, use_timestamp=False + stream, "test text", progress=20, total=100, use_timestamp=False, units=None ) # check message written @@ -1505,7 +1505,9 @@ def test_secrets_progress_bar(capsys, log_filepath, monkeypatch): expected = "apple ***** orange *****" printer.set_secrets(secrets) - printer.progress_bar(stream, message, progress=0.0, total=1.0, use_timestamp=False) + printer.progress_bar( + stream, message, progress=0.0, total=1.0, use_timestamp=False, units=None + ) _, stderr = capsys.readouterr() assert remove_control_characters(stderr).startswith(expected) From 2b6ed8abc4f62b4de93641b232c619a6a455bbc3 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 20 Mar 2026 16:40:21 -0400 Subject: [PATCH 4/7] fix(test): two more sneaky tests --- tests/unit/test_printer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_printer.py b/tests/unit/test_printer.py index 4b21d981..c2cbbfb0 100644 --- a/tests/unit/test_printer.py +++ b/tests/unit/test_printer.py @@ -1016,7 +1016,7 @@ def test_progress_bar_valid_streams_terminal(stream, recording_printer, monkeypa before = datetime.now() recording_printer.progress_bar( - stream, "test text", progress=20, total=100, use_timestamp=False + stream, "test text", progress=20, total=100, use_timestamp=False, units=None ) # check message written @@ -1102,7 +1102,7 @@ def test_spin(isatty, monkeypatch, recording_printer): def test_progress_bar_no_stream(recording_printer): """No stream no message.""" recording_printer.progress_bar( - None, "test text", progress=20, total=100, use_timestamp=False + None, "test text", progress=20, total=100, use_timestamp=False, units=None ) assert not recording_printer.written_terminal_lines assert not recording_printer.written_terminal_bars From 93bca114f7fe97aee183ff63212bde55e07fd044 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 24 Mar 2026 08:40:52 -0400 Subject: [PATCH 5/7] fix: avoid a possible injection --- craft_cli/printer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/craft_cli/printer.py b/craft_cli/printer.py index 3bda5ccd..ddcb8251 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -370,7 +370,12 @@ def _write_bar_terminal(self, message: _MessageInfo) -> None: # Should not happen as the caller checks the message raise ValueError("Tried to write a bar message with invalid attributes") - units = f" {message.bar_units}" if message.bar_units is not None else "" + if message.bar_units: + sanitized = message.bar_units.replace("{", "{{").replace("}", "}}") + units = f" {sanitized}" + else: + units = "" + numerical_progress = f"{message.bar_progress}/{message.bar_total}{units}" bar_percentage = min(message.bar_progress / message.bar_total, 1) From f0784399ea2941def23e4fc81319ddfdd7aaf00b Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Tue, 24 Mar 2026 11:17:25 -0400 Subject: [PATCH 6/7] fix: backwards compat --- craft_cli/printer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/craft_cli/printer.py b/craft_cli/printer.py index ddcb8251..d53c9486 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -478,7 +478,7 @@ def progress_bar( progress: float, total: float, use_timestamp: bool, - units: str | None, + units: str | None = None, ) -> None: """Show a progress bar to the given stream.""" text = self._apply_secrets(text) From ca1b05fd2a2d82bb4a55dff2762804a03f863562 Mon Sep 17 00:00:00 2001 From: Imani Pelton Date: Fri, 27 Mar 2026 09:15:04 -0400 Subject: [PATCH 7/7] fix: preserve total count if units make line too long --- craft_cli/printer.py | 10 ++++++++-- tests/unit/test_printer.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/craft_cli/printer.py b/craft_cli/printer.py index d53c9486..59b8a79b 100644 --- a/craft_cli/printer.py +++ b/craft_cli/printer.py @@ -376,13 +376,14 @@ def _write_bar_terminal(self, message: _MessageInfo) -> None: else: units = "" - numerical_progress = f"{message.bar_progress}/{message.bar_total}{units}" + numerical_progress = f"{message.bar_progress}/{message.bar_total}" bar_percentage = min(message.bar_progress / message.bar_total, 1) # terminal size minus the text and numerical progress, and 5 (the cursor at the end, # two spaces before and after the bar, and two surrounding brackets) terminal_width = _get_terminal_width() - bar_width = terminal_width - len(text) - len(numerical_progress) - 5 + bar_width_no_units = terminal_width - len(text) - len(numerical_progress) - 5 + bar_width = bar_width_no_units - len(units) # only show the bar with progress if there is enough space, otherwise just the # message (truncated, if needed) @@ -390,6 +391,11 @@ def _write_bar_terminal(self, message: _MessageInfo) -> None: completed_width = math.floor(bar_width * min(bar_percentage, 100)) completed_bar = _PROGRESS_BAR_SYMBOL * completed_width empty_bar = " " * (bar_width - completed_width) + line = f"{maybe_cr}{text} [{completed_bar}{empty_bar}] {numerical_progress}{units}" + elif bar_width_no_units > 0: + completed_width = math.floor(bar_width_no_units * min(bar_percentage, 100)) + completed_bar = _PROGRESS_BAR_SYMBOL * completed_width + empty_bar = " " * (bar_width_no_units - completed_width) line = f"{maybe_cr}{text} [{completed_bar}{empty_bar}] {numerical_progress}" else: text = text[: terminal_width - 1] # space for cursor diff --git a/tests/unit/test_printer.py b/tests/unit/test_printer.py index c2cbbfb0..d32c61aa 100644 --- a/tests/unit/test_printer.py +++ b/tests/unit/test_printer.py @@ -808,6 +808,23 @@ def test_writebarterminal_uses_units( assert not err +def test_writebarterminal_skip_long_units( + capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, log_filepath: Path +) -> None: + """Don't print out the units if they're too long for the terminal""" + monkeypatch.setattr(printermod, "_get_terminal_width", lambda: 40) + printer = Printer(log_filepath) + + text = "0123456789" + msg = _MessageInfo( + sys.stdout, text, bar_progress=5, bar_total=10, bar_units="A" * 5000 + ) + printer._write_bar_terminal(msg) + + out, _ = capsys.readouterr() + assert out == "0123456789 [██████████ ] 5/10" + + # -- tests for the writing bar (captured version) function