codeplain 0.3.11.dev17__py3-none-any.whl → 0.3.11.dev18__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.11.dev17.dist-info → codeplain-0.3.11.dev18.dist-info}/METADATA +1 -1
- {codeplain-0.3.11.dev17.dist-info → codeplain-0.3.11.dev18.dist-info}/RECORD +8 -7
- codeplain_REST_api.py +27 -1
- plain2code.py +61 -5
- tests/test_render_finished_report.py +200 -0
- {codeplain-0.3.11.dev17.dist-info → codeplain-0.3.11.dev18.dist-info}/WHEEL +0 -0
- {codeplain-0.3.11.dev17.dist-info → codeplain-0.3.11.dev18.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.11.dev17.dist-info → codeplain-0.3.11.dev18.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
change_detection.py,sha256=CazORZrezeZSw2I6VBvieN-c2SEjieZPo6pNrKwmMVA,6915
|
|
2
|
-
codeplain_REST_api.py,sha256=
|
|
2
|
+
codeplain_REST_api.py,sha256=ptb2u0mC5FJqMNeUbcc-bNXNhy9NRJZa5vI80uSFSwQ,22715
|
|
3
3
|
concept_utils.py,sha256=TpyiQ4DfMtO231j-lPj65i0suRTPr8t9j3QNf9KiM4A,8080
|
|
4
4
|
conformance_fix_journal.py,sha256=7wL3UOc3BWaUo5MCmiFbb3kIwOQ6iEpdQSyEmedo8Vk,9488
|
|
5
5
|
diff_utils.py,sha256=AjiQlqo5pRos_8hVXZo5yBurl5BzSrTMGrQv4dCtRCg,1198
|
|
@@ -12,7 +12,7 @@ metadata_utils.py,sha256=YO17o7IAma5TT1ZN0pTwrLVr_KxFQ8APv02KqKPpI5k,1641
|
|
|
12
12
|
module_renderer.py,sha256=IJBle8mbBARn_xrekjSpsVID7719yy2rNPkM15-ONoI,6982
|
|
13
13
|
partial_rendering.py,sha256=uzZc2j4d3pAXzfiHw_y1TK4yLRsLOb1Mm21570AGxJ8,13837
|
|
14
14
|
path_resolution.py,sha256=0tMqU0a1g4DYhTOp7QjLVSEkv_cgasb4NI1mK809dUM,2233
|
|
15
|
-
plain2code.py,sha256=
|
|
15
|
+
plain2code.py,sha256=FV13ItuD9FQN4FhS4H7RXHh7MoQaAlGSh1ctkjeMSoo,20368
|
|
16
16
|
plain2code_arguments.py,sha256=xMK-R13Vu005bHz3MUDY8F0Cf3ED9hCPqNsC6jGZmkk,17620
|
|
17
17
|
plain2code_console.py,sha256=YGBONkQtwZUkrjxTKhqcBoRzW0EmSfYHPH8P4_FM60I,6040
|
|
18
18
|
plain2code_events.py,sha256=rgDF5PDtNr_lEPvJmd1tIQXZrzwgzZW5b-_pykugqe4,1587
|
|
@@ -96,6 +96,7 @@ tests/test_plainfile.py,sha256=wZ_PN4GULPWIpl_AK_r_6jkgtX0Ch8EHmN5IhX7huOE,11027
|
|
|
96
96
|
tests/test_plainfileparser.py,sha256=M-7VDs0N79yqJOZRv55K12Kaz73oFz7Lqh3L_dqcpX4,25199
|
|
97
97
|
tests/test_plainspec.py,sha256=ng55E8ajrH2-gPC1p_QDIe_zXplPvoqHJ-d57UWKFTw,3577
|
|
98
98
|
tests/test_prepare_repositories_layout.py,sha256=PhJQLnHfjfjo6CTWoicyEKgL0_GtjwB2OKYAWSBVHtk,14638
|
|
99
|
+
tests/test_render_finished_report.py,sha256=QtViWItlo8YB8bLOv3YwvfLprVZgZ9r8Q1Td7_vGi18,7445
|
|
99
100
|
tests/test_requires.py,sha256=6tnHYaKudxN-RiQjSFoi9nHe_x0ze3QF-f-j67zAE74,1250
|
|
100
101
|
tests/test_resolve_config_file.py,sha256=b87tDOvyzbsnE-QRWbZ97ou7NuyaAgskv-dbYBum31c,2830
|
|
101
102
|
tests/test_script_path_resolution.py,sha256=tiEqMevQRXr9YpfHN0xldOOQpLAUgnSqTzeZWkKHaUY,4156
|
|
@@ -116,8 +117,8 @@ tui/spinner.py,sha256=Ro6Gd9Przf-whuHqPRY6HwI0T57yJjyNPbhDbigZKZE,2471
|
|
|
116
117
|
tui/state_handlers.py,sha256=zbUS_D9eU8qIwVpaxPEp9zL9bpg8gXsWNoUaqg9TLrQ,16664
|
|
117
118
|
tui/styles.css,sha256=kiC7Og_2G1E7owoiZgV95-3_dSeQnsNj3qzaY04xkDg,6727
|
|
118
119
|
tui/widget_helpers.py,sha256=jitm2WHiKj-NVFL-hULxWzckPreRbUTl9OIZLN31-ek,6997
|
|
119
|
-
codeplain-0.3.11.
|
|
120
|
-
codeplain-0.3.11.
|
|
121
|
-
codeplain-0.3.11.
|
|
122
|
-
codeplain-0.3.11.
|
|
123
|
-
codeplain-0.3.11.
|
|
120
|
+
codeplain-0.3.11.dev18.dist-info/METADATA,sha256=zpMl6ZYhRFiw3yPqVZLyxFmFQkGagK6_kgmA8ELWkuI,7257
|
|
121
|
+
codeplain-0.3.11.dev18.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
122
|
+
codeplain-0.3.11.dev18.dist-info/entry_points.txt,sha256=oDZkBqu9WhtZApb_K6ia8-fn9aojwmAsgnKELceX5T4,46
|
|
123
|
+
codeplain-0.3.11.dev18.dist-info/licenses/LICENSE,sha256=pCeKgQ1mXE5OmNUuKOfOZh1T1vqkeCUxghE7N8qgnzc,11345
|
|
124
|
+
codeplain-0.3.11.dev18.dist-info/RECORD,,
|
codeplain_REST_api.py
CHANGED
|
@@ -11,6 +11,10 @@ from plain2code_state import RunState
|
|
|
11
11
|
MAX_RETRIES = 4
|
|
12
12
|
RETRY_DELAY = 3
|
|
13
13
|
|
|
14
|
+
# A report the render does not consume must not be able to hold the process open
|
|
15
|
+
# at exit, and requests waits indefinitely by default.
|
|
16
|
+
REPORT_TIMEOUT_SECONDS = 5
|
|
17
|
+
|
|
14
18
|
RETRY_ERROR_CODES = [
|
|
15
19
|
"LLMInternalError",
|
|
16
20
|
]
|
|
@@ -125,6 +129,7 @@ class CodeplainAPI:
|
|
|
125
129
|
run_state: Optional[RunState],
|
|
126
130
|
num_retries: int = MAX_RETRIES,
|
|
127
131
|
silent: bool = False,
|
|
132
|
+
timeout: Optional[int] = None,
|
|
128
133
|
):
|
|
129
134
|
if run_state is not None:
|
|
130
135
|
self._extend_payload_with_run_state(payload, run_state)
|
|
@@ -134,7 +139,7 @@ class CodeplainAPI:
|
|
|
134
139
|
|
|
135
140
|
for attempt in range(num_retries + 1):
|
|
136
141
|
try:
|
|
137
|
-
response = requests.post(endpoint_url, headers=headers, json=payload)
|
|
142
|
+
response = requests.post(endpoint_url, headers=headers, json=payload, timeout=timeout)
|
|
138
143
|
|
|
139
144
|
try:
|
|
140
145
|
response_json = response.json()
|
|
@@ -173,6 +178,27 @@ class CodeplainAPI:
|
|
|
173
178
|
}
|
|
174
179
|
return self.post_request(endpoint_url, headers, payload, None, num_retries=0, silent=True)
|
|
175
180
|
|
|
181
|
+
def render_finished(self, outcome: str, run_state: RunState):
|
|
182
|
+
"""Report the terminal state a render reached.
|
|
183
|
+
|
|
184
|
+
Called once per render, whatever the outcome. The render state machine
|
|
185
|
+
lives here, so the API cannot tell a finished render from one still
|
|
186
|
+
going; it has to be told.
|
|
187
|
+
|
|
188
|
+
No retries and no console output: a report has no result the render
|
|
189
|
+
needs, and must not add minutes to a render that has already ended nor
|
|
190
|
+
print retry noise over the exit summary.
|
|
191
|
+
"""
|
|
192
|
+
endpoint_url = f"{self.api_url}/render_finished"
|
|
193
|
+
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
|
|
194
|
+
payload = {
|
|
195
|
+
"outcome": outcome,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return self.post_request(
|
|
199
|
+
endpoint_url, headers, payload, run_state, num_retries=0, silent=True, timeout=REPORT_TIMEOUT_SECONDS
|
|
200
|
+
)
|
|
201
|
+
|
|
176
202
|
def render_functional_requirement(
|
|
177
203
|
self,
|
|
178
204
|
frid: str,
|
plain2code.py
CHANGED
|
@@ -61,6 +61,11 @@ from tui.plain_module_render_choice_tui import PlainModuleRenderChoiceTUI
|
|
|
61
61
|
DEFAULT_TEMPLATE_DIRS = "standard_template_library"
|
|
62
62
|
RENDER_THREAD_SHUTDOWN_TIMEOUT = 0.7
|
|
63
63
|
|
|
64
|
+
# The terminal states a render can reach, as the API names them.
|
|
65
|
+
RENDER_OUTCOME_COMPLETED = "completed"
|
|
66
|
+
RENDER_OUTCOME_FAILED = "failed"
|
|
67
|
+
RENDER_OUTCOME_CANCELLED = "cancelled"
|
|
68
|
+
|
|
64
69
|
# Exceptions that represent expected, user-facing error conditions. They are
|
|
65
70
|
# reported to the user directly and must never be sent to Sentry as crashes.
|
|
66
71
|
EXPECTED_EXCEPTIONS = (
|
|
@@ -177,6 +182,52 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI) -> Optional[str]
|
|
|
177
182
|
return response.get("user_email")
|
|
178
183
|
|
|
179
184
|
|
|
185
|
+
def _render_outcome(run_state: RunState, interrupted: bool = False) -> str:
|
|
186
|
+
"""Name the terminal state a render reached.
|
|
187
|
+
|
|
188
|
+
Ctrl-C is the user stopping the render, so it is a cancellation. It does not
|
|
189
|
+
set render_cancelled: the exit summary reads that flag, and its wording for
|
|
190
|
+
an interrupt stays as it is.
|
|
191
|
+
|
|
192
|
+
Cancellation is tested first. A cancelled render leaves render_succeeded
|
|
193
|
+
False, so asking about success first would report it as a failure.
|
|
194
|
+
"""
|
|
195
|
+
if interrupted or run_state.render_cancelled:
|
|
196
|
+
return RENDER_OUTCOME_CANCELLED
|
|
197
|
+
if run_state.render_succeeded:
|
|
198
|
+
return RENDER_OUTCOME_COMPLETED
|
|
199
|
+
return RENDER_OUTCOME_FAILED
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def report_render_finished(
|
|
203
|
+
codeplainAPI: codeplain_api.CodeplainAPI, run_state: RunState, interrupted: bool = False
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Tell the API which terminal state this render reached.
|
|
206
|
+
|
|
207
|
+
The API cannot work it out for itself - the state machine lives here - and
|
|
208
|
+
main()'s finally is the one place that sees every ending: success, failure,
|
|
209
|
+
cancellation, an unexpected crash and a keyboard interrupt alike. The state
|
|
210
|
+
machine's own completed and failed hooks see only two of the five.
|
|
211
|
+
|
|
212
|
+
Reported only once the connection check has set a user email, which is
|
|
213
|
+
exactly the condition "the API knows who we are". A missing key, an invalid
|
|
214
|
+
key and an outdated client all leave it unset, and none of them got as far
|
|
215
|
+
as a render.
|
|
216
|
+
|
|
217
|
+
Never raises. A report must not change the exit code, and must not mask the
|
|
218
|
+
error that ended the render.
|
|
219
|
+
"""
|
|
220
|
+
if not run_state.user_email:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
codeplainAPI.render_finished(_render_outcome(run_state, interrupted), run_state)
|
|
225
|
+
except Exception:
|
|
226
|
+
# Deliberately silent: logging has stopped by now, and a failed report
|
|
227
|
+
# is not the user's problem.
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
180
231
|
def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None:
|
|
181
232
|
"""Warn when any loaded module (including required modules) defines acceptance tests
|
|
182
233
|
but no conformance tests script is configured.
|
|
@@ -214,6 +265,7 @@ def validate_linked_resources(plain_module) -> None:
|
|
|
214
265
|
|
|
215
266
|
def render( # noqa: C901
|
|
216
267
|
plain_module: plain_modules.PlainModule,
|
|
268
|
+
codeplainAPI: codeplain_api.CodeplainAPI,
|
|
217
269
|
args,
|
|
218
270
|
run_state: RunState,
|
|
219
271
|
event_bus: EventBus,
|
|
@@ -224,10 +276,6 @@ def render( # noqa: C901
|
|
|
224
276
|
if args.render_range or args.render_from:
|
|
225
277
|
render_range = plain_spec.compute_render_range(args, plain_module.plain_source)
|
|
226
278
|
|
|
227
|
-
codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console)
|
|
228
|
-
assert args.api is not None and args.api != "", "API URL is required"
|
|
229
|
-
codeplainAPI.api_url = args.api
|
|
230
|
-
|
|
231
279
|
run_state.user_email = _check_connection(codeplainAPI)
|
|
232
280
|
|
|
233
281
|
stop_event = threading.Event()
|
|
@@ -419,6 +467,10 @@ def main(): # noqa: C901
|
|
|
419
467
|
if not args.api:
|
|
420
468
|
args.api = system_config.default_api_url
|
|
421
469
|
|
|
470
|
+
assert args.api is not None and args.api != "", "API URL is required"
|
|
471
|
+
codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console)
|
|
472
|
+
codeplainAPI.api_url = args.api
|
|
473
|
+
|
|
422
474
|
run_state = RunState(spec_filename=args.filename, replay_with=args.replay_with)
|
|
423
475
|
|
|
424
476
|
if args.headless:
|
|
@@ -431,6 +483,7 @@ def main(): # noqa: C901
|
|
|
431
483
|
|
|
432
484
|
exc_info = None
|
|
433
485
|
error_message = None
|
|
486
|
+
interrupted = False
|
|
434
487
|
|
|
435
488
|
try:
|
|
436
489
|
# Validate API key is present
|
|
@@ -438,10 +491,11 @@ def main(): # noqa: C901
|
|
|
438
491
|
raise MissingAPIKey(
|
|
439
492
|
"Your API key is required. Please set the CODEPLAIN_API_KEY environment variable or provide it with the --api-key argument.\n"
|
|
440
493
|
)
|
|
441
|
-
render(plain_module, args, run_state, event_bus, default_log_level)
|
|
494
|
+
render(plain_module, codeplainAPI, args, run_state, event_bus, default_log_level)
|
|
442
495
|
except BaseException as e:
|
|
443
496
|
if isinstance(e, KeyboardInterrupt):
|
|
444
497
|
error_message = "Keyboard interrupt"
|
|
498
|
+
interrupted = True
|
|
445
499
|
else:
|
|
446
500
|
error_message = str(e) if str(e) else repr(e)
|
|
447
501
|
|
|
@@ -460,6 +514,8 @@ def main(): # noqa: C901
|
|
|
460
514
|
# Remove any scratch extractions created for archive-only ("<module>.module") modules.
|
|
461
515
|
for module in plain_module.all_required_modules + [plain_module]:
|
|
462
516
|
module.cleanup_scratch()
|
|
517
|
+
# Last, so nothing the user is waiting for sits behind a network call.
|
|
518
|
+
report_render_finished(codeplainAPI, run_state, interrupted)
|
|
463
519
|
|
|
464
520
|
if args.headless and (exc_info is not None or not run_state.render_succeeded):
|
|
465
521
|
sys.exit(1)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Tests for reporting a render's terminal state to the API.
|
|
2
|
+
|
|
3
|
+
The render state machine lives in the client, so the API only learns how a
|
|
4
|
+
render ended because main() tells it. Two things are worth proving: that the
|
|
5
|
+
outcome names the right ending, and that main() reports on every path out -
|
|
6
|
+
including the ones the state machine never sees, like a keyboard interrupt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from argparse import Namespace
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import plain2code
|
|
15
|
+
from plain2code_exceptions import InvalidAPIKey
|
|
16
|
+
from plain2code_state import RunState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_run_state(succeeded=False, cancelled=False, user_email="user@codeplain.ai"):
|
|
20
|
+
run_state = RunState(spec_filename="project.plain")
|
|
21
|
+
run_state.set_render_succeeded(succeeded)
|
|
22
|
+
if cancelled:
|
|
23
|
+
run_state.set_render_cancelled()
|
|
24
|
+
run_state.user_email = user_email
|
|
25
|
+
return run_state
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestRenderOutcome:
|
|
29
|
+
def test_a_successful_render_is_completed(self):
|
|
30
|
+
assert plain2code._render_outcome(make_run_state(succeeded=True)) == "completed"
|
|
31
|
+
|
|
32
|
+
def test_an_unsuccessful_render_is_failed(self):
|
|
33
|
+
assert plain2code._render_outcome(make_run_state(succeeded=False)) == "failed"
|
|
34
|
+
|
|
35
|
+
def test_a_cancelled_render_is_cancelled(self):
|
|
36
|
+
"""A cancelled render leaves render_succeeded False, so cancellation has to
|
|
37
|
+
be asked about first or it would be reported as a failure."""
|
|
38
|
+
assert plain2code._render_outcome(make_run_state(cancelled=True)) == "cancelled"
|
|
39
|
+
|
|
40
|
+
def test_cancellation_wins_over_a_success_already_recorded(self):
|
|
41
|
+
"""Cancelling during the dist step leaves both flags set."""
|
|
42
|
+
assert plain2code._render_outcome(make_run_state(succeeded=True, cancelled=True)) == "cancelled"
|
|
43
|
+
|
|
44
|
+
def test_an_interrupted_render_is_cancelled(self):
|
|
45
|
+
"""Ctrl-C is the user stopping the render. It does not set the cancelled
|
|
46
|
+
flag, so the outcome has to be told about it."""
|
|
47
|
+
assert plain2code._render_outcome(make_run_state(), interrupted=True) == "cancelled"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestReportRenderFinished:
|
|
51
|
+
def test_reports_the_outcome_with_the_render_state(self):
|
|
52
|
+
api = MagicMock()
|
|
53
|
+
run_state = make_run_state(succeeded=True)
|
|
54
|
+
|
|
55
|
+
plain2code.report_render_finished(api, run_state)
|
|
56
|
+
|
|
57
|
+
api.render_finished.assert_called_once_with("completed", run_state)
|
|
58
|
+
|
|
59
|
+
def test_says_nothing_when_the_api_never_identified_us(self):
|
|
60
|
+
"""user_email is set by the connection check, so an absent one means a
|
|
61
|
+
missing key, an invalid key or an outdated client - none of which rendered."""
|
|
62
|
+
api = MagicMock()
|
|
63
|
+
|
|
64
|
+
plain2code.report_render_finished(api, make_run_state(user_email=None))
|
|
65
|
+
|
|
66
|
+
api.render_finished.assert_not_called()
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize(
|
|
69
|
+
"failure",
|
|
70
|
+
[
|
|
71
|
+
ConnectionError("api unreachable"),
|
|
72
|
+
TimeoutError("api slow"),
|
|
73
|
+
RuntimeError("something else entirely"),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
def test_a_failed_report_never_propagates(self, failure):
|
|
77
|
+
"""The report is the last thing a render does. It must not become the
|
|
78
|
+
reason the render looks like it failed."""
|
|
79
|
+
api = MagicMock()
|
|
80
|
+
api.render_finished.side_effect = failure
|
|
81
|
+
|
|
82
|
+
plain2code.report_render_finished(api, make_run_state(succeeded=True))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_args(api_key="test-key", headless=False):
|
|
86
|
+
return Namespace(
|
|
87
|
+
version=False,
|
|
88
|
+
status=False,
|
|
89
|
+
full_plain=False,
|
|
90
|
+
dry_run=False,
|
|
91
|
+
api_key=api_key,
|
|
92
|
+
api="https://api.example.com",
|
|
93
|
+
filename="project.plain",
|
|
94
|
+
template_dir=None,
|
|
95
|
+
build_folder="build",
|
|
96
|
+
render_range=None,
|
|
97
|
+
render_from=None,
|
|
98
|
+
headless=headless,
|
|
99
|
+
replay_with=None,
|
|
100
|
+
log_to_file=False,
|
|
101
|
+
log_file_name="codeplain.log",
|
|
102
|
+
conformance_tests_script=None,
|
|
103
|
+
unittests_script=None,
|
|
104
|
+
prepare_environment_script=None,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_main(render_side_effect=None, api_key="test-key"):
|
|
109
|
+
"""Drive main() with everything but the reporting path stubbed out.
|
|
110
|
+
|
|
111
|
+
render() is replaced, so the stub is what decides how the render "ended" -
|
|
112
|
+
which is the point: the report must be made from main()'s finally whatever
|
|
113
|
+
render did, not from inside the state machine.
|
|
114
|
+
|
|
115
|
+
Returns the API client mock so the caller can inspect the report.
|
|
116
|
+
"""
|
|
117
|
+
api = MagicMock()
|
|
118
|
+
|
|
119
|
+
def fake_render(plain_module, codeplainAPI, args, run_state, event_bus, default_log_level="INFO"):
|
|
120
|
+
run_state.user_email = "user@codeplain.ai"
|
|
121
|
+
if render_side_effect is not None:
|
|
122
|
+
render_side_effect(run_state)
|
|
123
|
+
|
|
124
|
+
with (
|
|
125
|
+
patch("plain2code.parse_arguments", return_value=make_args(api_key=api_key)),
|
|
126
|
+
patch("plain2code.file_utils.get_template_directories", return_value=["templates"]),
|
|
127
|
+
patch("plain2code.plain_modules.PlainModule", return_value=MagicMock(all_required_modules=[])),
|
|
128
|
+
patch("plain2code.setup_logging", return_value="INFO"),
|
|
129
|
+
patch("plain2code.initialize_telemetry"),
|
|
130
|
+
patch("plain2code.stop_narrating"),
|
|
131
|
+
patch("plain2code.print_exit_summary"),
|
|
132
|
+
patch("plain2code.dump_crash_logs"),
|
|
133
|
+
patch("plain2code.capture_crash"),
|
|
134
|
+
patch("plain2code.codeplain_api.CodeplainAPI", return_value=api),
|
|
135
|
+
patch("plain2code.render", side_effect=fake_render),
|
|
136
|
+
):
|
|
137
|
+
plain2code.main()
|
|
138
|
+
|
|
139
|
+
return api
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestMainReportsEveryEnding:
|
|
143
|
+
"""main()'s finally is the only place that sees all of these."""
|
|
144
|
+
|
|
145
|
+
def test_a_completed_render_is_reported(self):
|
|
146
|
+
api = run_main(lambda run_state: run_state.set_render_succeeded(True))
|
|
147
|
+
|
|
148
|
+
assert api.render_finished.call_args.args[0] == "completed"
|
|
149
|
+
|
|
150
|
+
def test_a_failed_render_is_reported(self):
|
|
151
|
+
def fail(run_state):
|
|
152
|
+
run_state.set_render_succeeded(False)
|
|
153
|
+
raise Exception("rendering blew up")
|
|
154
|
+
|
|
155
|
+
api = run_main(fail)
|
|
156
|
+
|
|
157
|
+
assert api.render_finished.call_args.args[0] == "failed"
|
|
158
|
+
|
|
159
|
+
def test_an_expected_error_is_reported_as_a_failure(self):
|
|
160
|
+
def fail(run_state):
|
|
161
|
+
raise InvalidAPIKey("key went stale mid-render")
|
|
162
|
+
|
|
163
|
+
api = run_main(fail)
|
|
164
|
+
|
|
165
|
+
assert api.render_finished.call_args.args[0] == "failed"
|
|
166
|
+
|
|
167
|
+
def test_a_cancelled_render_is_reported(self):
|
|
168
|
+
api = run_main(lambda run_state: run_state.set_render_cancelled())
|
|
169
|
+
|
|
170
|
+
assert api.render_finished.call_args.args[0] == "cancelled"
|
|
171
|
+
|
|
172
|
+
def test_a_keyboard_interrupt_is_reported_as_cancelled(self):
|
|
173
|
+
"""Ctrl-C never enters a terminal state in the state machine, so this
|
|
174
|
+
ending exists only in main(). In headless mode it is the only way to stop
|
|
175
|
+
a render, and stopping is a cancellation, not a failure."""
|
|
176
|
+
|
|
177
|
+
def interrupt(run_state):
|
|
178
|
+
raise KeyboardInterrupt()
|
|
179
|
+
|
|
180
|
+
api = run_main(interrupt)
|
|
181
|
+
|
|
182
|
+
assert api.render_finished.call_args.args[0] == "cancelled"
|
|
183
|
+
|
|
184
|
+
def test_a_keyboard_interrupt_leaves_the_exit_summary_alone(self):
|
|
185
|
+
"""The summary reads render_cancelled. Only the reported outcome changes."""
|
|
186
|
+
captured = {}
|
|
187
|
+
|
|
188
|
+
def interrupt(run_state):
|
|
189
|
+
captured["run_state"] = run_state
|
|
190
|
+
raise KeyboardInterrupt()
|
|
191
|
+
|
|
192
|
+
run_main(interrupt)
|
|
193
|
+
|
|
194
|
+
assert captured["run_state"].render_cancelled is False
|
|
195
|
+
|
|
196
|
+
def test_a_missing_api_key_reports_nothing(self):
|
|
197
|
+
"""render() is never reached, so nothing identified the caller."""
|
|
198
|
+
api = run_main(api_key=None)
|
|
199
|
+
|
|
200
|
+
api.render_finished.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|