Skip to content

Commit 200d85e

Browse files
authored
Merge branch 'experimaestro:master' into master
2 parents 8d0e401 + 56c2538 commit 200d85e

3 files changed

Lines changed: 177 additions & 36 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for the TUI log viewer's carriage-return handling"""
2+
3+
from experimaestro.tui.log_viewer import LogFile, _apply_carriage_returns
4+
5+
6+
def test_apply_carriage_returns_keeps_last_segment():
7+
assert _apply_carriage_returns(" 10%\r 20%\r 30%") == " 30%"
8+
9+
10+
def test_apply_carriage_returns_no_cr():
11+
assert _apply_carriage_returns("hello") == "hello"
12+
13+
14+
def test_log_file_handles_tqdm_progress(tmp_path):
15+
"""A typical tqdm sequence should collapse into a single completed line."""
16+
log = tmp_path / "task.out"
17+
log.write_text("\rStep 0%\rStep 50%\rStep 100%\nDone\n")
18+
19+
reader = LogFile(str(log))
20+
complete, partial = reader.read_tail()
21+
assert complete == ["Step 100%", "Done"]
22+
assert partial == ""
23+
24+
25+
def test_log_file_partial_line_buffering(tmp_path):
26+
"""Partial last line (no \\n) should be returned as partial, not as a full line."""
27+
log = tmp_path / "task.out"
28+
log.write_text("first\n\rprog 10%")
29+
30+
reader = LogFile(str(log))
31+
complete, partial = reader.read_tail()
32+
assert complete == ["first"]
33+
assert partial == "prog 10%"
34+
35+
36+
def test_log_file_progress_across_reads(tmp_path):
37+
"""Progress updates split across reads should overwrite, not stack."""
38+
log = tmp_path / "task.out"
39+
log.write_text("start\n\rprog 10%")
40+
41+
reader = LogFile(str(log))
42+
complete, partial = reader.read_tail()
43+
assert complete == ["start"]
44+
assert partial == "prog 10%"
45+
46+
# Append more progress updates and a final newline + next line
47+
with open(log, "a") as f:
48+
f.write("\rprog 50%\rprog 100%\nnext line\n")
49+
50+
complete, partial = reader.read_new_content()
51+
assert complete == ["prog 100%", "next line"]
52+
assert partial == ""
53+
54+
55+
def test_log_file_truncation_resets_partial(tmp_path):
56+
log = tmp_path / "task.out"
57+
log.write_text("start\n\rprog 10%")
58+
59+
reader = LogFile(str(log))
60+
reader.read_tail()
61+
assert reader._partial_line == "prog 10%"
62+
63+
# Truncate
64+
log.write_text("")
65+
complete, partial = reader.read_new_content()
66+
assert complete == []
67+
assert partial == ""
68+
assert reader._partial_line == ""
69+
70+
71+
def test_log_file_partial_within_chunk(tmp_path):
72+
"""Multiple \\r updates inside one read chunk collapse to the last."""
73+
log = tmp_path / "task.out"
74+
log.write_text("")
75+
76+
reader = LogFile(str(log))
77+
reader.read_tail()
78+
79+
# Write a chunk containing multiple \r updates and no trailing \n
80+
with open(log, "a") as f:
81+
f.write("\rA\rB\rC")
82+
83+
complete, partial = reader.read_new_content()
84+
assert complete == []
85+
assert partial == "C"

src/experimaestro/tools/documentation.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@
1414
from docutils.nodes import document
1515
from docutils.parsers.rst import Directive, Parser, directives
1616
from docutils.utils import new_document
17+
from sphinx.directives.other import TocTree
18+
from sphinx.domains.python import PyCurrentModule
19+
from termcolor import cprint
20+
21+
from experimaestro import Config
22+
from experimaestro.sphinx import PyObject
1723

1824

19-
# ``docutils.frontend.get_default_settings`` is the modern API (docutils >=
20-
# 0.19); older versions exposed the same through ``OptionParser``. Support
21-
# both so users aren't forced to a docutils floor just to import this
22-
# module.
25+
# ``docutils.frontend.get_default_settings`` is the modern API (docutils
26+
# >= 0.19); older versions exposed the same through ``OptionParser``.
27+
# Support both so users aren't forced to a docutils floor just to import
28+
# this module.
2329
try:
2430
from docutils.frontend import get_default_settings as _get_default_settings
2531
except ImportError: # pragma: no cover - exercised only on old docutils
@@ -29,14 +35,6 @@ def _get_default_settings(*components):
2935
return _OptionParser(components=components).get_default_values()
3036

3137

32-
from sphinx.directives.other import TocTree
33-
from sphinx.domains.python import PyCurrentModule
34-
from termcolor import cprint
35-
36-
from experimaestro import Config
37-
from experimaestro.sphinx import PyObject
38-
39-
4038
def documented_from_objects(objects_inv: Path) -> Set[str]:
4139
inv = sphobjinv.Inventory(objects_inv)
4240
return set(

src/experimaestro/tui/log_viewer.py

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,27 @@
1818
INITIAL_TAIL_SIZE = 256 * 1024 # 256KB
1919

2020

21+
def _apply_carriage_returns(line: str) -> str:
22+
"""Collapse a line as a terminal would: keep only content after the last \\r.
23+
24+
tqdm-like progress bars rewrite the same line via \\r; treat that as
25+
overwrite rather than as a new line.
26+
"""
27+
if "\r" in line:
28+
return line.rsplit("\r", 1)[-1]
29+
return line
30+
31+
2132
class LogFile:
2233
"""Efficient log file reader that tracks position and watches for changes"""
2334

2435
def __init__(self, path: str):
2536
self.path = Path(path)
2637
self.position = 0
2738
self.size = 0
39+
# Buffer for the last incomplete line (no trailing \n yet).
40+
# Stored already collapsed for any \r updates seen so far.
41+
self._partial_line = ""
2842
self._update_size()
2943

3044
def _update_size(self) -> None:
@@ -34,17 +48,35 @@ def _update_size(self) -> None:
3448
except OSError:
3549
self.size = 0
3650

37-
def read_tail(self, max_bytes: int = INITIAL_TAIL_SIZE) -> str:
38-
"""Read the last N bytes of the file"""
51+
def _process_content(self, content: str) -> tuple[list[str], str]:
52+
"""Split a chunk into completed lines plus a trailing partial line.
53+
54+
Carriage returns are handled like a terminal would: within each
55+
\\n-terminated line, only the substring after the last \\r is kept.
56+
"""
57+
combined = self._partial_line + content
58+
parts = combined.split("\n")
59+
# parts[-1] is the partial trailing line (empty if combined ended in \n)
60+
complete = [_apply_carriage_returns(line) for line in parts[:-1]]
61+
partial = _apply_carriage_returns(parts[-1])
62+
self._partial_line = partial
63+
return complete, partial
64+
65+
def read_tail(self, max_bytes: int = INITIAL_TAIL_SIZE) -> tuple[list[str], str]:
66+
"""Read the last N bytes of the file.
67+
68+
Returns ``(complete_lines, partial_line)``. ``partial_line`` is the
69+
in-progress last line (no trailing \\n yet) and may be empty.
70+
"""
3971
if not self.path.exists():
40-
return ""
72+
return [], ""
4173

4274
self._update_size()
4375
if self.size == 0:
44-
return ""
76+
return [], ""
4577

4678
try:
47-
with open(self.path, "r", errors="replace") as f:
79+
with open(self.path, "r", errors="replace", newline="") as f:
4880
# Start from max_bytes before end, or beginning
4981
start_pos = max(0, self.size - max_bytes)
5082
f.seek(start_pos)
@@ -55,32 +87,40 @@ def read_tail(self, max_bytes: int = INITIAL_TAIL_SIZE) -> str:
5587

5688
content = f.read()
5789
self.position = f.tell()
58-
return content
90+
# Reset partial buffer since we're reading from a known boundary
91+
self._partial_line = ""
92+
return self._process_content(content)
5993
except Exception:
60-
return ""
94+
return [], ""
95+
96+
def read_new_content(self) -> tuple[list[str], str]:
97+
"""Read any new content since last read.
6198
62-
def read_new_content(self) -> str:
63-
"""Read any new content since last read"""
99+
Returns ``(new_complete_lines, partial_line)``. ``partial_line`` is the
100+
current in-progress last line (which may have changed even when no new
101+
complete lines were produced).
102+
"""
64103
if not self.path.exists():
65-
return ""
104+
return [], self._partial_line
66105

67106
self._update_size()
68107

69108
# File was truncated or rotated
70109
if self.size < self.position:
71110
self.position = 0
111+
self._partial_line = ""
72112

73113
if self.position >= self.size:
74-
return ""
114+
return [], self._partial_line
75115

76116
try:
77-
with open(self.path, "r", errors="replace") as f:
117+
with open(self.path, "r", errors="replace", newline="") as f:
78118
f.seek(self.position)
79119
content = f.read()
80120
self.position = f.tell()
81-
return content
121+
return self._process_content(content)
82122
except Exception:
83-
return ""
123+
return [], self._partial_line
84124

85125
def has_new_content(self) -> bool:
86126
"""Check if there's new content without reading it"""
@@ -96,29 +136,39 @@ def __init__(self, file_path: str, widget_id: str):
96136
self.file_path = file_path
97137
self.log_file = LogFile(file_path)
98138
self.following = True
139+
self._last_partial = ""
99140

100141
def compose(self) -> ComposeResult:
101142
yield Static(f"📄 {self.file_path}", classes="log-file-path")
102143
yield RichLog(id=f"{self.id}-content", wrap=True, highlight=True, markup=False)
144+
yield Static("", id=f"{self.id}-partial", classes="log-partial")
103145

104-
def on_mount(self) -> None:
105-
"""Load initial content from tail of file"""
106-
content = self.log_file.read_tail()
107-
if content:
146+
def _apply_update(self, complete_lines: list[str], partial: str) -> None:
147+
if complete_lines:
108148
log_widget = self.query_one(f"#{self.id}-content", RichLog)
109-
for line in content.splitlines():
149+
for line in complete_lines:
110150
log_widget.write(line)
151+
if partial != self._last_partial:
152+
partial_widget = self.query_one(f"#{self.id}-partial", Static)
153+
partial_widget.update(partial)
154+
partial_widget.display = bool(partial)
155+
self._last_partial = partial
156+
157+
def on_mount(self) -> None:
158+
"""Load initial content from tail of file"""
159+
complete_lines, partial = self.log_file.read_tail()
160+
# Hide partial widget when there is nothing in progress
161+
partial_widget = self.query_one(f"#{self.id}-partial", Static)
162+
partial_widget.display = bool(partial)
163+
self._apply_update(complete_lines, partial)
111164

112165
def refresh_content(self) -> None:
113166
"""Check for and append new content"""
114167
if not self.following:
115168
return
116169

117-
new_content = self.log_file.read_new_content()
118-
if new_content:
119-
log_widget = self.query_one(f"#{self.id}-content", RichLog)
120-
for line in new_content.splitlines():
121-
log_widget.write(line)
170+
complete_lines, partial = self.log_file.read_new_content()
171+
self._apply_update(complete_lines, partial)
122172

123173
def scroll_to_end(self) -> None:
124174
"""Scroll to end of log"""
@@ -175,6 +225,14 @@ class LogViewerScreen(Screen, inherit_bindings=False):
175225
height: 1fr;
176226
border: solid $primary;
177227
}
228+
229+
.log-partial {
230+
background: $boost;
231+
padding: 0 1;
232+
height: auto;
233+
color: $text;
234+
text-style: italic;
235+
}
178236
"""
179237

180238
BINDINGS = [

0 commit comments

Comments
 (0)