Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion craft_cli/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -168,6 +170,7 @@ def advance(self, amount: float) -> None:
progress=self.accumulated,
total=self.total,
use_timestamp=self.use_timestamp,
units=self.units,
)


Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand Down
17 changes: 16 additions & 1 deletion craft_cli/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -369,20 +370,32 @@ 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")

if message.bar_units:
sanitized = message.bar_units.replace("{", "{{").replace("}", "}}")
units = f" {sanitized}"
else:
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)
if bar_width > 0:
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
Expand Down Expand Up @@ -471,6 +484,7 @@ def progress_bar(
progress: float,
total: float,
use_timestamp: bool,
units: str | None = None,
) -> None:
"""Show a progress bar to the given stream."""
text = self._apply_secrets(text)
Expand All @@ -480,6 +494,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,
)
Expand Down
10 changes: 10 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,14 +322,16 @@ 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)
Expand Down
42 changes: 37 additions & 5 deletions tests/unit/test_messages_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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


Expand Down
51 changes: 47 additions & 4 deletions tests/unit/test_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -784,6 +785,46 @@ 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


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


Expand Down Expand Up @@ -992,7 +1033,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
Expand Down Expand Up @@ -1029,7 +1070,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
Expand Down Expand Up @@ -1078,7 +1119,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
Expand Down Expand Up @@ -1481,7 +1522,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)
Expand Down
Loading