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.
- {codeplain-0.3.12.dev25.dist-info → codeplain-0.3.12.dev27.dist-info}/METADATA +1 -1
- {codeplain-0.3.12.dev25.dist-info → codeplain-0.3.12.dev27.dist-info}/RECORD +18 -14
- codeplain_REST_api.py +26 -32
- plain2code.py +20 -0
- plain2code_arguments.py +2 -1
- render_machine/actions/fix_unit_tests.py +224 -47
- render_machine/actions/run_unit_tests.py +9 -0
- render_machine/agent_tools.py +270 -0
- render_machine/render_context.py +9 -0
- render_machine/render_types.py +45 -0
- tests/test_agent_tools.py +166 -0
- tests/test_fix_unit_tests_action.py +239 -0
- tests/test_fix_unit_tests_conformance_context.py +120 -25
- tests/test_render_finished_report.py +3 -0
- tests/test_terminal_attachment.py +295 -0
- {codeplain-0.3.12.dev25.dist-info → codeplain-0.3.12.dev27.dist-info}/WHEEL +0 -0
- {codeplain-0.3.12.dev25.dist-info → codeplain-0.3.12.dev27.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.12.dev25.dist-info → codeplain-0.3.12.dev27.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|