codeplain 0.3.12.dev25__py3-none-any.whl → 0.3.12.dev27__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ """How a render behaves with and without a terminal attached.
2
+
3
+ The TUI needs a person at a terminal: it reads keys from stdin, draws on stderr,
4
+ and the console summary goes to stdout. Started from a script or an agent
5
+ (`codeplain module.plain > render.log 2>&1 &`), stdin is at end-of-file.
6
+ Textual's input thread then reads EOF in a tight loop - millions of reads a
7
+ second - so the process pins a CPU core for the whole render, and a stopped
8
+ render waits for an Enter nobody can press. One such render stayed alive for
9
+ an hour and a half at ~100% CPU until it was killed.
10
+
11
+ So without a terminal codeplain runs as --headless does, and a stopped render
12
+ exits non-zero. With a terminal the TUI is kept, and waiting for Enter must
13
+ stay idle.
14
+ """
15
+
16
+ import os
17
+ import struct
18
+ import subprocess
19
+ import sys
20
+ import textwrap
21
+ import threading
22
+ import time
23
+ from argparse import Namespace
24
+ from contextlib import ExitStack
25
+ from unittest.mock import MagicMock, patch
26
+
27
+ import pytest
28
+
29
+ import plain2code
30
+
31
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+
33
+
34
+ class FakeStream:
35
+ def __init__(self, is_terminal: bool):
36
+ self._is_terminal = is_terminal
37
+
38
+ def isatty(self) -> bool:
39
+ return self._is_terminal
40
+
41
+
42
+ class TestAttachedToTerminal:
43
+ @pytest.fixture
44
+ def streams(self, monkeypatch):
45
+ def set_streams(stdin=True, stdout=True, stderr=True):
46
+ for name, value in (("stdin", stdin), ("stdout", stdout), ("stderr", stderr)):
47
+ monkeypatch.setattr(sys, name, value if value is None else FakeStream(value))
48
+
49
+ return set_streams
50
+
51
+ def test_all_three_streams_on_a_terminal(self, streams):
52
+ streams()
53
+
54
+ assert plain2code.attached_to_terminal() is True
55
+
56
+ @pytest.mark.parametrize("stream", ["stdin", "stdout", "stderr"])
57
+ def test_any_stream_off_the_terminal(self, streams, stream):
58
+ """stdin carries the keys, stderr the TUI, stdout the summary. Losing any
59
+ of them leaves a TUI nobody can drive or see."""
60
+ streams(**{stream: False})
61
+
62
+ assert plain2code.attached_to_terminal() is False
63
+
64
+ def test_a_closed_stream(self, streams):
65
+ """sys.stdin is None when the process has no stdin at all."""
66
+ streams(stdin=None)
67
+
68
+ assert plain2code.attached_to_terminal() is False
69
+
70
+
71
+ def make_args():
72
+ return Namespace(
73
+ version=False,
74
+ status=False,
75
+ full_plain=False,
76
+ dry_run=False,
77
+ api_key="test-key",
78
+ api="https://api.example.com",
79
+ filename="project.plain",
80
+ template_dir=None,
81
+ build_folder="build",
82
+ render_range=None,
83
+ render_from=None,
84
+ force_render=False,
85
+ headless=False,
86
+ replay_with=None,
87
+ log_to_file=False,
88
+ log_file_name="codeplain.log",
89
+ render_conformance_tests=False,
90
+ conformance_tests_script=None,
91
+ unittests_script=None,
92
+ prepare_environment_script=None,
93
+ )
94
+
95
+
96
+ def run_render(succeeded, terminal, render_state=None, render_choices=None):
97
+ """Drive the real main() and render() up to the module renderer.
98
+
99
+ The module renderer stands in for the state machine: it decides how the
100
+ render ended. Everything the decision under test does not touch - the API,
101
+ parsing, logging, telemetry - is stubbed.
102
+
103
+ Returns the mocks for the two TUIs and the module renderer, the args main()
104
+ rendered with, and the exit status it asked for (None when it returned
105
+ normally).
106
+ """
107
+ args = make_args()
108
+ module_renderer = MagicMock()
109
+
110
+ def make_module_renderer(codeplainAPI, plain_module, render_choice, render_range, args, run_state, *rest, **kw):
111
+ module_renderer.render_module.side_effect = lambda: run_state.set_render_succeeded(succeeded)
112
+ return module_renderer
113
+
114
+ progress_tui = MagicMock()
115
+ progress_tui.return_value.run.side_effect = lambda: progress_tui.call_args.kwargs["on_ready"]()
116
+ choice_tui = MagicMock()
117
+
118
+ patches = [
119
+ patch("plain2code.attached_to_terminal", return_value=terminal),
120
+ patch("plain2code.parse_arguments", return_value=args),
121
+ patch("plain2code.file_utils.get_template_directories", return_value=["templates"]),
122
+ patch("plain2code.plain_modules.PlainModule", return_value=MagicMock(all_required_modules=[])),
123
+ patch("plain2code.setup_logging", return_value="INFO"),
124
+ patch("plain2code.initialize_telemetry"),
125
+ patch("plain2code.stop_narrating"),
126
+ patch("plain2code.print_exit_summary"),
127
+ patch("plain2code.dump_crash_logs"),
128
+ patch("plain2code.capture_crash"),
129
+ patch("plain2code.codeplain_api.CodeplainAPI"),
130
+ patch("plain2code._check_connection", return_value="user@codeplain.ai"),
131
+ patch("plain2code.signal.signal"),
132
+ patch("plain2code.warn_if_acceptance_tests_without_conformance_script"),
133
+ patch("plain2code.get_plain_module_render_state", return_value=render_state),
134
+ patch("plain2code.get_render_choices", return_value=render_choices),
135
+ patch("plain2code.ModuleRenderer", side_effect=make_module_renderer),
136
+ patch("plain2code.Plain2CodeTUI", progress_tui),
137
+ patch("plain2code.PlainModuleRenderChoiceTUI", choice_tui),
138
+ patch.object(plain2code.console, "quiet", plain2code.console.quiet),
139
+ ]
140
+
141
+ exit_status = None
142
+ with ExitStack() as stack:
143
+ for each in patches:
144
+ stack.enter_context(each)
145
+ try:
146
+ plain2code.main()
147
+ except SystemExit as e:
148
+ exit_status = e.code
149
+
150
+ return Namespace(
151
+ progress_tui=progress_tui,
152
+ choice_tui=choice_tui,
153
+ module_renderer=module_renderer,
154
+ exit_status=exit_status,
155
+ args=args,
156
+ )
157
+
158
+
159
+ class TestRenderWithoutATerminal:
160
+ def test_the_tui_never_starts(self):
161
+ result = run_render(succeeded=False, terminal=False)
162
+
163
+ result.progress_tui.assert_not_called()
164
+ result.module_renderer.render_module.assert_called_once()
165
+
166
+ def test_a_stopped_render_exits_non_zero(self):
167
+ """The status --headless gives, so a script can tell it stopped."""
168
+ result = run_render(succeeded=False, terminal=False)
169
+
170
+ assert result.exit_status == 1
171
+
172
+ def test_a_completed_render_exits_cleanly(self):
173
+ result = run_render(succeeded=True, terminal=False)
174
+
175
+ assert result.exit_status is None
176
+
177
+ def test_says_why_it_runs_without_the_tui(self, caplog):
178
+ caplog.set_level("INFO", logger="codeplain")
179
+
180
+ run_render(succeeded=True, terminal=False)
181
+
182
+ assert "No terminal attached" in caplog.text
183
+
184
+ def test_never_asks_where_to_resume(self):
185
+ """The choice prompt is a TUI too, and would wait for a key just the same."""
186
+ destructive = MagicMock(is_destructive=True)
187
+ choices = {"restart": destructive, "other": MagicMock(), "quit": MagicMock()}
188
+
189
+ result = run_render(succeeded=True, terminal=False, render_state=MagicMock(), render_choices=choices)
190
+
191
+ result.choice_tui.assert_not_called()
192
+
193
+
194
+ class TestRenderWithATerminal:
195
+ def test_the_tui_still_runs(self):
196
+ result = run_render(succeeded=True, terminal=True)
197
+
198
+ result.progress_tui.assert_called_once()
199
+ assert result.args.headless is False
200
+
201
+ def test_says_nothing_about_a_missing_terminal(self, caplog):
202
+ caplog.set_level("INFO", logger="codeplain")
203
+
204
+ run_render(succeeded=True, terminal=True)
205
+
206
+ assert "No terminal attached" not in caplog.text
207
+
208
+
209
+ IDLE_SECONDS = 2.0
210
+
211
+ # Drives the real TUI to the stopped state, then measures the CPU the process
212
+ # spends over IDLE_SECONDS while it waits for Enter.
213
+ STOPPED_TUI = textwrap.dedent("""
214
+ import os, sys, threading, time
215
+
216
+ from event_bus import EventBus
217
+ from plain2code_events import RenderFailed
218
+ from plain2code_state import RunState
219
+ from tui.plain2code_tui import Plain2CodeTUI
220
+
221
+ report_path, idle_seconds = sys.argv[1], float(sys.argv[2])
222
+ event_bus = EventBus()
223
+
224
+ def stop_then_measure():
225
+ event_bus.publish(RenderFailed(error_message="unit tests still failing"))
226
+ start = time.process_time()
227
+ time.sleep(idle_seconds)
228
+ with open(report_path + ".tmp", "w") as report:
229
+ report.write(str(time.process_time() - start))
230
+ os.replace(report_path + ".tmp", report_path)
231
+
232
+ Plain2CodeTUI(
233
+ event_bus=event_bus,
234
+ run_state=RunState(spec_filename="x.plain"),
235
+ on_ready=threading.Thread(target=stop_then_measure, daemon=True).start,
236
+ render_id="test",
237
+ unittests_script=None,
238
+ conformance_tests_script=None,
239
+ prepare_environment_script=None,
240
+ state_machine_version="0.0.0",
241
+ css_path="styles.css",
242
+ ).run()
243
+ """)
244
+
245
+
246
+ @pytest.mark.skipif(sys.platform == "win32", reason="needs a POSIX pseudo-terminal")
247
+ def test_a_stopped_render_on_a_terminal_waits_for_enter_without_spinning(tmp_path):
248
+ # Unix-only modules: imported here so Windows can still collect the other tests in this file.
249
+ import fcntl
250
+ import termios
251
+
252
+ controller, terminal = os.openpty()
253
+ fcntl.ioctl(terminal, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0))
254
+ report = tmp_path / "cpu_seconds"
255
+
256
+ process = subprocess.Popen(
257
+ [sys.executable, "-c", STOPPED_TUI, str(report), str(IDLE_SECONDS)],
258
+ stdin=terminal,
259
+ stdout=terminal,
260
+ stderr=terminal,
261
+ cwd=REPO_ROOT,
262
+ env={**os.environ, "TERM": "xterm-256color"},
263
+ start_new_session=True,
264
+ )
265
+ os.close(terminal)
266
+
267
+ def drain():
268
+ # The TUI blocks once the terminal buffer fills, so keep reading it.
269
+ try:
270
+ while os.read(controller, 65536):
271
+ pass
272
+ except OSError:
273
+ pass
274
+
275
+ threading.Thread(target=drain, daemon=True).start()
276
+
277
+ try:
278
+ deadline = time.monotonic() + 60
279
+ while not report.exists():
280
+ assert process.poll() is None, "the TUI exited before Enter was pressed"
281
+ assert time.monotonic() < deadline, "the TUI never reached the stopped state"
282
+ time.sleep(0.1)
283
+ cpu_seconds = float(report.read_text())
284
+
285
+ os.write(controller, b"\r")
286
+ exit_status = process.wait(timeout=15)
287
+ finally:
288
+ if process.poll() is None:
289
+ process.kill()
290
+ process.wait()
291
+ os.close(controller)
292
+
293
+ # A busy loop burns about one CPU-second per second of waiting.
294
+ assert cpu_seconds < IDLE_SECONDS / 4
295
+ assert exit_status == 0