improved tty-less progress reporting

Previously when running borg in a systemd service (and similar when piping to
a file and co.), these problems occurred:

- The carriage return both made it so that journald interpreted the output as
  binary, therefore not printing the text, while also not buffering
  correctly, so that log output was only available every once in a while
  in the form [40k blob data]. This can partially be worked around by
  using `journalctl -a` to view the logs, which at least prints the text,
  though only sporadically.

- The path was getting truncated to a short length, since the default
  get_terminal_size returns a column width of 80, which isn't relevant
  when printing to e.g. journald.

This commit fixes this by introducing a new code path for when stream is
not a tty, which always prints the full paths and ends lines with a linefeed.

This is based on unfinished PR #8939 by @infinisil, thanks for your suggestion!

Forward port of PR #9055 to master.
This commit is contained in:
Thomas Waldmann 2025-10-16 01:59:43 +02:00
parent 160a966044
commit 8cbe4b8d48
No known key found for this signature in database
GPG key ID: 243ACFA951F78E01
3 changed files with 53 additions and 19 deletions

View file

@ -149,6 +149,7 @@ Bytes sent to remote: {stats.tx_bytes}
def show_progress(self, item=None, final=False, stream=None, dt=None):
now = time.monotonic()
if dt is None or now - self.last_progress > dt:
stream = stream or sys.stderr
self.last_progress = now
if self.output_json:
if not final:
@ -160,6 +161,14 @@ Bytes sent to remote: {stats.tx_bytes}
data.update({"time": time.time(), "type": "archive_progress", "finished": final})
msg = json.dumps(data)
end = "\n"
elif not stream.isatty():
# Non-TTY output: use normal linefeeds and do not truncate the path.
if not final:
msg = "{0.osize_fmt} O {0.usize_fmt} U {0.nfiles} N ".format(self)
msg += remove_surrogates(item.path) if item else ""
else:
msg = ""
end = "\n"
else:
columns, lines = get_terminal_size()
if not final:
@ -174,7 +183,7 @@ Bytes sent to remote: {stats.tx_bytes}
else:
msg = " " * columns
end = "\r"
print(msg, end=end, file=stream or sys.stderr, flush=True)
print(msg, end=end, file=stream, flush=True)
def is_special(mode):

View file

@ -33,26 +33,51 @@ def test_stats_basic(stats):
assert stats.usize == 20
@pytest.mark.parametrize(
"item_path, update_size, expected_output",
[
("", 0, "20 B O 20 B U 1 N "), # test unchanged 'stats' fixture
("foo", 10**3, "1.02 kB O 20 B U 1 N foo"), # test updated original size and set item path
# test long item path which exceeds 80 characters
("foo" * 40, 10**3, "1.02 kB O 20 B U 1 N foofoofoofoofoofoofoofoofo...foofoofoofoofoofoofoofoofoofoo"),
],
)
def test_stats_progress(item_path, update_size, expected_output, stats, monkeypatch, columns=80):
monkeypatch.setenv("COLUMNS", str(columns))
out = StringIO()
item = Item(path=item_path) if item_path else None
s = expected_output
def test_stats_progress_tty(stats, monkeypatch, columns=80):
class TTYStringIO(StringIO):
def isatty(self):
return True
stats.update(update_size, unique=False)
stats.show_progress(item=item, stream=out)
monkeypatch.setenv("COLUMNS", str(columns))
out = TTYStringIO()
stats.show_progress(stream=out)
s = "20 B O 20 B U 1 N "
buf = " " * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = TTYStringIO()
stats.update(10**3, unique=False)
stats.show_progress(item=Item(path="foo"), final=False, stream=out)
s = "1.02 kB O 20 B U 1 N foo"
buf = " " * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
out = TTYStringIO()
stats.show_progress(item=Item(path="foo" * 40), final=False, stream=out)
s = "1.02 kB O 20 B U 1 N foofoofoofoofoofoofoofoofo...foofoofoofoofoofoofoofoofoofoo"
buf = " " * (columns - len(s))
assert out.getvalue() == s + buf + "\r"
def test_stats_progress_file(stats, monkeypatch):
out = StringIO()
stats.show_progress(stream=out)
s = "20 B O 20 B U 1 N "
assert out.getvalue() == s + "\n"
out = StringIO()
stats.update(10**3, unique=False)
path = "foo"
stats.show_progress(item=Item(path=path), final=False, stream=out)
s = f"1.02 kB O 20 B U 1 N {path}"
assert out.getvalue() == s + "\n"
out = StringIO()
path = "foo" * 40
stats.show_progress(item=Item(path=path), final=False, stream=out)
s = f"1.02 kB O 20 B U 1 N {path}"
assert out.getvalue() == s + "\n"
def test_stats_format(stats):
assert (

View file

@ -634,7 +634,7 @@ def test_progress_on(archivers, request):
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
cmd(archiver, "repo-create", RK_ENCRYPTION)
output = cmd(archiver, "create", "test4", "input", "--progress")
assert "\r" in output
assert "0 B O 0 B U 0 N" in output
def test_progress_off(archivers, request):
@ -642,7 +642,7 @@ def test_progress_off(archivers, request):
create_regular_file(archiver.input_path, "file1", size=1024 * 80)
cmd(archiver, "repo-create", RK_ENCRYPTION)
output = cmd(archiver, "create", "test5", "input")
assert "\r" not in output
assert "0 B O 0 B U 0 N" not in output
def test_file_status(archivers, request):