From 8cbe4b8d48ce181eaeb2af269bdb6cf6813813f8 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 16 Oct 2025 01:59:43 +0200 Subject: [PATCH] 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. --- src/borg/archive.py | 11 +++- src/borg/testsuite/archive_test.py | 57 +++++++++++++------ .../testsuite/archiver/create_cmd_test.py | 4 +- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index 1e01c3b5e..e2e7cf000 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -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): diff --git a/src/borg/testsuite/archive_test.py b/src/borg/testsuite/archive_test.py index a3028d792..97a2ef71c 100644 --- a/src/borg/testsuite/archive_test.py +++ b/src/borg/testsuite/archive_test.py @@ -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 ( diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index c0543ad78..94deb4211 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -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):