Skip to content

Commit 91cea12

Browse files
committed
Log to stdout in headless mode
Headless suppresses Rich output and attaches no TUI handler, so the only sink was the log file. The process printed nothing for its entire run, and a CI job collects that file only after the render ends, so a wedged render and a healthy one are indistinguishable for as long as it matters. A stdout handler now carries the same records with the same formatting. StreamHandler flushes per record, so they arrive as they happen. Attaching a second handler exposed a bug in the formatters. One record is passed to every handler in turn, and both formatters rewrote record.msg in place, so each re-indented the continuation lines the other had already indented. They now format a copy.
1 parent 3b1f943 commit 91cea12

5 files changed

Lines changed: 233 additions & 20 deletions

File tree

‎docs/plain2code_cli.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,9 @@ options:
118118
If set, render the state machine graph.
119119
--logging-config-path LOGGING_CONFIG_PATH
120120
Path to the logging configuration file.
121-
--headless Run in headless mode: no TUI, no terminal output
122-
except a single render-started message. All logs are
123-
written to the log file.
121+
--headless Run in headless mode: no TUI. Progress is written to
122+
the log file and streamed to stdout as it happens; the
123+
specification text itself stays in the log file only.
124124
--status Display account status including user information, API
125125
key label, and rendering credits. Does not render any
126126
code.

‎plain2code.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@
4444
)
4545
from plain2code_logger import (
4646
LOGGER_NAME,
47+
NARRATION_HANDLER_NAME,
4748
CrashLogHandler,
4849
ElapsedTimeFormatter,
4950
IndentedFormatter,
5051
LoggingHandler,
5152
dump_crash_logs,
53+
stop_narrating,
5254
)
5355
from plain2code_state import RunState
5456
from plain2code_telemetry import capture_crash, initialize_telemetry
@@ -125,6 +127,17 @@ def setup_logging(
125127
handler = LoggingHandler(event_bus, run_state)
126128
handler.setFormatter(formatter)
127129
root_logger.addHandler(handler)
130+
else:
131+
# Headless has no TUI to narrate the render and suppresses Rich output, so
132+
# without this the process says nothing on stdout for its entire run: a CI job
133+
# cannot distinguish a render wedged for hours from a healthy one until the
134+
# log file is collected at the end. StreamHandler flushes
135+
# per record, so these arrive as they happen.
136+
stdout_handler = logging.StreamHandler(sys.stdout)
137+
stdout_handler.setFormatter(file_formatter)
138+
stdout_handler.setLevel(configured_log_level)
139+
stdout_handler.set_name(NARRATION_HANDLER_NAME)
140+
root_logger.addHandler(stdout_handler)
128141

129142
if log_to_file:
130143
try:
@@ -423,6 +436,7 @@ def main(): # noqa: C901
423436
if exc_info:
424437
dump_crash_logs(args, run_state)
425438
capture_crash(exc_info, run_state, args)
439+
stop_narrating()
426440
print_exit_summary(
427441
run_state,
428442
args.filename,

‎plain2code_arguments.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,8 +424,8 @@ def create_parser():
424424
"--headless",
425425
action="store_true",
426426
default=False,
427-
help="Run in headless mode: no TUI, no terminal output except a single render-started message. "
428-
"All logs are written to the log file.",
427+
help="Run in headless mode: no TUI. Progress is written to the log file and streamed "
428+
"to stdout as it happens; the specification text itself stays in the log file only.",
429429
)
430430

431431
parser.add_argument(

‎plain2code_logger.py‎

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import logging
23

34
from event_bus import EventBus
@@ -17,18 +18,28 @@
1718
FILE_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
1819

1920

21+
def _with_indented_message(record, indent: str):
22+
"""A copy of the record whose continuation lines are indented.
23+
24+
One record is handed to every attached handler in turn, so a formatter that
25+
rewrites `record.msg` in place is rewriting it for the handlers that come after
26+
it too — a headless render logging to both stdout and a file would indent each
27+
continuation line twice, once per formatter. Copying keeps each handler's
28+
formatting local to that handler.
29+
"""
30+
indented = copy.copy(record)
31+
indented.msg = record.getMessage().replace("\n", "\n" + indent)
32+
indented.args = None # getMessage() already interpolated them into msg
33+
return indented
34+
35+
2036
class IndentedFormatter(logging.Formatter):
2137
def __init__(self, fmt=None, datefmt=None, indent=16):
2238
super().__init__(fmt=fmt, datefmt=datefmt)
2339
self._indent = " " * indent
2440

2541
def format(self, record):
26-
original_message = record.getMessage()
27-
28-
modified_message = original_message.replace("\n", "\n" + self._indent)
29-
30-
record.msg = modified_message
31-
return super().format(record)
42+
return super().format(_with_indented_message(record, self._indent))
3243

3344

3445
class ElapsedTimeFormatter(logging.Formatter):
@@ -51,16 +62,11 @@ def format(self, record):
5162
seconds = offset_seconds % 60
5263
elapsed_time = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
5364

54-
# Add elapsed_time to the record so it can be used in the format string
55-
record.elapsed_time = elapsed_time
56-
57-
# Handle multi-line messages with proper indentation
58-
original_message = record.getMessage()
59-
indent = " " * len(elapsed_time + " ")
60-
modified_message = original_message.replace("\n", "\n" + indent)
61-
record.msg = modified_message
65+
# Continuation lines line up under the message, past the timestamp column.
66+
indented = _with_indented_message(record, " " * len(elapsed_time + " "))
67+
indented.elapsed_time = elapsed_time
6268

63-
return super().format(record)
69+
return super().format(indented)
6470

6571

6672
class LoggingHandler(logging.Handler):
@@ -132,3 +138,19 @@ def dump_crash_logs(args, run_state: RunState, formatter=None):
132138

133139
if crash_handler and args.filename:
134140
crash_handler.dump_to_file(args.log_file_name, formatter)
141+
142+
143+
NARRATION_HANDLER_NAME = "headless-narration"
144+
145+
146+
def stop_narrating() -> None:
147+
"""Detaches the headless stdout sink once the render is over.
148+
149+
The summary prints with Rich and bypasses logging, so a still-attached sink reports
150+
every failure twice.
151+
"""
152+
root_logger = logging.getLogger(LOGGER_NAME)
153+
for handler in list(root_logger.handlers):
154+
if handler.get_name() == NARRATION_HANDLER_NAME:
155+
root_logger.removeHandler(handler)
156+
handler.close()

‎tests/test_headless_logging.py‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Tests for what a headless render says while it is running.
2+
3+
Headless suppresses Rich output and attaches no TUI handler, so before this the only
4+
sink was the log file — the process was silent on stdout for its entire run and a
5+
CI job could not tell a render wedged for hours from a healthy one until the file was
6+
collected at the end. One four-hour render produced 858 lines, all of them in its first
7+
two minutes.
8+
9+
The second half of this file guards the hazard that adding a second handler exposes:
10+
one record is handed to every handler in turn, so a formatter that rewrites
11+
`record.msg` in place corrupts the output of the handlers after it.
12+
"""
13+
14+
import logging
15+
import sys
16+
from unittest.mock import MagicMock
17+
18+
import pytest
19+
20+
from plain2code import setup_logging
21+
from plain2code_console import console
22+
from plain2code_logger import (
23+
LOGGER_NAME,
24+
ElapsedTimeFormatter,
25+
IndentedFormatter,
26+
stop_narrating,
27+
)
28+
29+
30+
@pytest.fixture
31+
def run_state():
32+
state = MagicMock()
33+
state.get_live_render_time.return_value = 3661 # 01:01:01
34+
return state
35+
36+
37+
def record(message, args=None):
38+
return logging.LogRecord(
39+
name="codeplain",
40+
level=logging.INFO,
41+
pathname=__file__,
42+
lineno=1,
43+
msg=message,
44+
args=args,
45+
exc_info=None,
46+
)
47+
48+
49+
def test_the_elapsed_formatter_stamps_the_render_time(run_state):
50+
assert ElapsedTimeFormatter(run_state).format(record("hello")) == "[01:01:01] INFO codeplain: hello"
51+
52+
53+
def test_continuation_lines_are_indented_past_the_timestamp(run_state):
54+
formatted = ElapsedTimeFormatter(run_state).format(record("first\nsecond"))
55+
56+
assert formatted == "[01:01:01] INFO codeplain: first\n second"
57+
58+
59+
def test_formatting_twice_does_not_indent_twice(run_state):
60+
"""Two handlers share one record. Formatting must be idempotent from the record's
61+
point of view, or the file log inherits the stdout log's indentation."""
62+
formatter = ElapsedTimeFormatter(run_state)
63+
entry = record("first\nsecond")
64+
65+
first_pass = formatter.format(entry)
66+
second_pass = formatter.format(entry)
67+
68+
assert first_pass == second_pass
69+
70+
71+
def test_two_different_formatters_do_not_corrupt_each_other(run_state):
72+
"""The real pairing in a headless render that also logs to a file."""
73+
entry = record("first\nsecond")
74+
75+
IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry)
76+
elapsed = ElapsedTimeFormatter(run_state).format(entry)
77+
78+
assert elapsed == "[01:01:01] INFO codeplain: first\n second"
79+
80+
81+
def test_the_indented_formatter_leaves_the_record_alone(run_state):
82+
entry = record("first\nsecond")
83+
84+
IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry)
85+
86+
assert entry.msg == "first\nsecond"
87+
88+
89+
def test_arguments_are_interpolated_exactly_once(run_state):
90+
"""The copy carries an already-interpolated message, so its args must be cleared —
91+
otherwise the parent formatter interpolates a second time and raises."""
92+
assert ElapsedTimeFormatter(run_state).format(record("value is %s", ("x",))).endswith("value is x")
93+
94+
95+
@pytest.fixture
96+
def configured_handlers(run_state):
97+
"""setup_logging mutates the process-wide "codeplain" logger; restore it after."""
98+
logger = logging.getLogger(LOGGER_NAME)
99+
saved_handlers, saved_level = list(logger.handlers), logger.level
100+
101+
def configure(headless):
102+
logger.handlers = []
103+
args = MagicMock()
104+
args.verbose = False
105+
args.logging_config_path = None
106+
setup_logging(args, MagicMock(), run_state, log_to_file=False, log_file_path="", headless=headless)
107+
return logger.handlers
108+
109+
yield configure
110+
111+
logger.handlers, logger.level = saved_handlers, saved_level
112+
113+
114+
def stdout_handlers(handlers):
115+
return [h for h in handlers if type(h) is logging.StreamHandler and h.stream is sys.stdout]
116+
117+
118+
def test_a_headless_render_narrates_to_stdout(configured_handlers):
119+
assert len(stdout_handlers(configured_handlers(headless=True))) == 1
120+
121+
122+
def test_the_stdout_handler_carries_the_elapsed_time_format(configured_handlers):
123+
"""It has to be readable as a render log, not just present."""
124+
handler = stdout_handlers(configured_handlers(headless=True))[0]
125+
126+
assert isinstance(handler.formatter, ElapsedTimeFormatter)
127+
128+
129+
def test_an_interactive_render_does_not_duplicate_output_on_stdout(configured_handlers):
130+
"""The TUI already draws the log; a second copy on stdout would fight it for the
131+
terminal."""
132+
assert stdout_handlers(configured_handlers(headless=False)) == []
133+
134+
135+
def test_the_formatter_survives_a_run_state_that_cannot_report_time():
136+
"""A record can be logged before the render clock exists; it must still be readable."""
137+
broken = MagicMock()
138+
broken.get_live_render_time.side_effect = RuntimeError("no clock yet")
139+
140+
assert ElapsedTimeFormatter(broken).format(record("hello")) == "[00:00:00] INFO codeplain: hello"
141+
142+
143+
@pytest.fixture
144+
def quiet_console():
145+
"""Headless suppresses Rich output, so stdout carries the log sink and nothing else."""
146+
saved, console.quiet = console.quiet, True
147+
yield
148+
console.quiet = saved
149+
150+
151+
def test_specification_text_reaches_the_stdout_sink(configured_handlers, quiet_console, capsys):
152+
"""The narration carries the functional requirement, like every other record.
153+
154+
These logs are the user's own, on the user's own machine, and the specification is
155+
theirs. Withholding it here buys nothing and costs them the detail that says which
156+
requirement a wedged render is stuck on. The rule this sink was briefly written
157+
against is about what *we* retain - see the Sentry and analytics scrubbers, which
158+
keep specification text out of anything leaving the machine."""
159+
configured_handlers(headless=True)
160+
161+
console.info("Rendering functionality 3: the user's whole functional requirement")
162+
console.info("Running unit tests.")
163+
164+
printed = capsys.readouterr().out
165+
assert "whole functional requirement" in printed
166+
assert "Running unit tests." in printed
167+
168+
169+
def test_narration_stops_before_the_exit_summary(configured_handlers, quiet_console, capsys):
170+
"""The summary bypasses logging, so a still-attached sink reports a failure twice."""
171+
configured_handlers(headless=True)
172+
capsys.readouterr()
173+
174+
stop_narrating()
175+
console.info("this arrives after the render is over")
176+
177+
assert "after the render is over" not in capsys.readouterr().out

0 commit comments

Comments
 (0)