codeplain 0.3.9__py3-none-any.whl → 0.3.10.dev2__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.9.dist-info → codeplain-0.3.10.dev2.dist-info}/METADATA +36 -8
- {codeplain-0.3.9.dist-info → codeplain-0.3.10.dev2.dist-info}/RECORD +27 -26
- {codeplain-0.3.9.dist-info → codeplain-0.3.10.dev2.dist-info}/WHEEL +1 -1
- concept_utils.py +6 -0
- module_renderer.py +6 -0
- partial_rendering.py +35 -4
- plain2code.py +31 -3
- plain2code_arguments.py +7 -4
- plain2code_console.py +3 -1
- plain2code_exceptions.py +8 -0
- plain2code_logger.py +37 -15
- plain_file.py +18 -1
- plain_modules.py +197 -7
- render_machine/actions/prepare_repositories.py +5 -0
- render_machine/conformance_tests.py +40 -3
- render_machine/implementation_code_helpers.py +5 -1
- render_machine/render_context.py +13 -1
- system_config.py +35 -0
- tests/test_cli_output.py +13 -0
- tests/test_headless_logging.py +177 -0
- tests/test_plain_modules.py +267 -1
- tests/test_plainfileparser.py +41 -4
- tests/test_prepare_repositories_layout.py +158 -0
- tests/test_system_config.py +37 -0
- tui/plain_module_render_choice_tui.py +25 -8
- {codeplain-0.3.9.dist-info → codeplain-0.3.10.dev2.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.9.dist-info → codeplain-0.3.10.dev2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
tests/test_plain_modules.py
CHANGED
|
@@ -7,14 +7,16 @@ These tests build real ``PlainModule`` instances from fixtures in
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import shutil
|
|
10
11
|
import tempfile
|
|
12
|
+
import zipfile
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
|
|
13
15
|
import pytest
|
|
14
16
|
|
|
15
17
|
from change_detection import determine_partial_render_start
|
|
16
18
|
from git_utils import FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE, add_all_files_and_commit, init_git_repo
|
|
17
|
-
from plain2code_exceptions import ModuleDoesNotExistError
|
|
19
|
+
from plain2code_exceptions import InvalidModuleArchiveError, ModuleDoesNotExistError
|
|
18
20
|
from plain_modules import MODULE_METADATA_FILENAME, PlainModule
|
|
19
21
|
|
|
20
22
|
# --------------------------------------------------------------------------
|
|
@@ -361,6 +363,270 @@ def test_wipe_module_removes_whole_module_folder(solo_module):
|
|
|
361
363
|
assert not os.path.exists(solo_module.module_folder)
|
|
362
364
|
|
|
363
365
|
|
|
366
|
+
# --------------------------------------------------------------------------
|
|
367
|
+
# Zipped modules (<module>.module archives)
|
|
368
|
+
# --------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _init_repo_with_finished_frid(repo_path: str, module_name: str, frid: str) -> None:
|
|
372
|
+
"""Init a git repo at ``repo_path`` and commit a FUNCTIONAL_REQUIREMENT_FINISHED checkpoint."""
|
|
373
|
+
os.makedirs(repo_path, exist_ok=True)
|
|
374
|
+
init_git_repo(repo_path, module_name=module_name)
|
|
375
|
+
marker = Path(repo_path) / f"frid_{frid}.txt"
|
|
376
|
+
marker.write_text(f"frid {frid}\n")
|
|
377
|
+
add_all_files_and_commit(
|
|
378
|
+
repo_path,
|
|
379
|
+
FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(frid),
|
|
380
|
+
module_name=module_name,
|
|
381
|
+
frid=frid,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _setup_full_module(module: PlainModule, frid: str = "1") -> None:
|
|
386
|
+
"""Create a realistic on-disk module: code/ and tests/ git repos plus metadata."""
|
|
387
|
+
_init_repo_with_finished_frid(module.module_build_folder, module.module_name, frid)
|
|
388
|
+
_init_repo_with_finished_frid(module.module_conformance_tests_folder, module.module_name, frid)
|
|
389
|
+
_write_metadata(module, {"source_hash": "seed", "functionalities": ["fr1"]})
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _zip_module_flat(module_folder: str, archive_path: str) -> None:
|
|
393
|
+
"""Zip the *contents* of ``module_folder`` (code/, tests/, .codeplain/ at the archive
|
|
394
|
+
root, including .git) into ``archive_path``."""
|
|
395
|
+
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
396
|
+
for root, _dirs, files in os.walk(module_folder):
|
|
397
|
+
for name in files:
|
|
398
|
+
full = os.path.join(root, name)
|
|
399
|
+
zf.write(full, os.path.relpath(full, module_folder))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _archive_module(module: PlainModule) -> str:
|
|
403
|
+
"""Turn an on-disk module into an archive-only module: zip it, then remove the folder."""
|
|
404
|
+
archive_path = module.module_archive_path
|
|
405
|
+
_zip_module_flat(module.module_folder, archive_path)
|
|
406
|
+
shutil.rmtree(module.module_folder)
|
|
407
|
+
return archive_path
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_folder_takes_precedence_over_archive(solo_module):
|
|
411
|
+
"""When both the folder and the archive exist, the folder is used and the archive untouched."""
|
|
412
|
+
_setup_full_module(solo_module)
|
|
413
|
+
# Create a stray archive alongside the real folder.
|
|
414
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
415
|
+
zf.writestr("code/marker.txt", "x")
|
|
416
|
+
|
|
417
|
+
default = solo_module._default_module_folder
|
|
418
|
+
solo_module.materialize()
|
|
419
|
+
|
|
420
|
+
assert solo_module.module_folder == default
|
|
421
|
+
assert solo_module.module_build_folder == os.path.join(default, "code")
|
|
422
|
+
assert os.path.isfile(solo_module.module_archive_path) # untouched
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_materialize_resolves_paths_to_scratch(solo_module):
|
|
426
|
+
_setup_full_module(solo_module)
|
|
427
|
+
default = solo_module._default_module_folder
|
|
428
|
+
_archive_module(solo_module)
|
|
429
|
+
|
|
430
|
+
assert solo_module.is_archived_only()
|
|
431
|
+
solo_module.materialize()
|
|
432
|
+
|
|
433
|
+
# Paths now resolve into a scratch extraction, not the real (absent) folder.
|
|
434
|
+
assert solo_module.module_folder != default
|
|
435
|
+
assert not os.path.exists(default) # real folder NOT created
|
|
436
|
+
assert os.path.isfile(solo_module.module_archive_path) # archive preserved
|
|
437
|
+
assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git"))
|
|
438
|
+
assert os.path.isdir(os.path.join(solo_module.module_conformance_tests_folder, ".git"))
|
|
439
|
+
# Git-backed reads work against the scratch tree.
|
|
440
|
+
name, frid = solo_module.get_module_render_status()
|
|
441
|
+
assert name == solo_module.module_name
|
|
442
|
+
assert frid == "1"
|
|
443
|
+
|
|
444
|
+
solo_module.cleanup_scratch()
|
|
445
|
+
assert solo_module.module_folder == default # resolution reset
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_get_repo_none_before_materialize_and_present_after(solo_module):
|
|
449
|
+
"""Guards the skip-detection bug: an archive-only module reads as "no repo" until materialized,
|
|
450
|
+
so the orchestrator's `get_repo() is not None` check only passes after materialize()."""
|
|
451
|
+
_setup_full_module(solo_module)
|
|
452
|
+
_archive_module(solo_module)
|
|
453
|
+
|
|
454
|
+
assert solo_module.get_repo() is None # would wrongly force a re-render if consumed as-is
|
|
455
|
+
solo_module.materialize()
|
|
456
|
+
assert solo_module.get_repo() is not None # now recognised as already rendered
|
|
457
|
+
|
|
458
|
+
solo_module.cleanup_scratch()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def test_materialize_is_idempotent(solo_module):
|
|
462
|
+
_setup_full_module(solo_module)
|
|
463
|
+
_archive_module(solo_module)
|
|
464
|
+
solo_module.materialize()
|
|
465
|
+
first = solo_module.module_folder
|
|
466
|
+
solo_module.materialize()
|
|
467
|
+
assert solo_module.module_folder == first
|
|
468
|
+
solo_module.cleanup_scratch()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_ensure_module_unpacked_from_archive(solo_module):
|
|
472
|
+
_setup_full_module(solo_module)
|
|
473
|
+
default = solo_module._default_module_folder
|
|
474
|
+
_archive_module(solo_module)
|
|
475
|
+
|
|
476
|
+
solo_module.ensure_module_unpacked()
|
|
477
|
+
|
|
478
|
+
assert solo_module.module_folder == default
|
|
479
|
+
assert os.path.isdir(os.path.join(default, "code", ".git"))
|
|
480
|
+
assert os.path.isdir(os.path.join(default, "tests", ".git"))
|
|
481
|
+
assert not os.path.exists(solo_module.module_archive_path) # archive removed
|
|
482
|
+
# Idempotent: a second call is a no-op.
|
|
483
|
+
solo_module.ensure_module_unpacked()
|
|
484
|
+
assert os.path.isdir(default)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_ensure_module_unpacked_after_materialize(solo_module):
|
|
488
|
+
"""materialize() then ensure_module_unpacked() lands content in the real folder."""
|
|
489
|
+
_setup_full_module(solo_module)
|
|
490
|
+
default = solo_module._default_module_folder
|
|
491
|
+
_archive_module(solo_module)
|
|
492
|
+
|
|
493
|
+
solo_module.materialize()
|
|
494
|
+
solo_module.ensure_module_unpacked()
|
|
495
|
+
|
|
496
|
+
assert solo_module.module_folder == default
|
|
497
|
+
assert os.path.isdir(os.path.join(default, "code", ".git"))
|
|
498
|
+
assert not os.path.exists(solo_module.module_archive_path)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_ensure_module_unpacked_noop_when_folder_exists(solo_module):
|
|
502
|
+
"""A stray archive next to an existing folder is removed; the folder is kept."""
|
|
503
|
+
_setup_full_module(solo_module)
|
|
504
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
505
|
+
zf.writestr("code/marker.txt", "x")
|
|
506
|
+
|
|
507
|
+
solo_module.ensure_module_unpacked()
|
|
508
|
+
|
|
509
|
+
assert os.path.isdir(solo_module.module_build_folder)
|
|
510
|
+
assert not os.path.exists(solo_module.module_archive_path)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_wipe_module_removes_archive_and_scratch(solo_module):
|
|
514
|
+
_setup_full_module(solo_module)
|
|
515
|
+
_archive_module(solo_module)
|
|
516
|
+
solo_module.materialize()
|
|
517
|
+
scratch = solo_module._scratch_dir
|
|
518
|
+
|
|
519
|
+
solo_module.wipe_module()
|
|
520
|
+
|
|
521
|
+
assert not os.path.exists(solo_module.module_archive_path)
|
|
522
|
+
assert scratch is not None and not os.path.exists(scratch)
|
|
523
|
+
assert solo_module.module_folder == solo_module._default_module_folder
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def test_render_status_parity_folder_vs_archive(fixtures_dir, tmp_build_folder):
|
|
527
|
+
"""The same module (same logical path) reports the same status and code hash whether it
|
|
528
|
+
is read as an unpacked folder or from a "<module>.module" archive."""
|
|
529
|
+
module = PlainModule("pr_solo.plain", tmp_build_folder, [fixtures_dir])
|
|
530
|
+
_setup_full_module(module, frid="1")
|
|
531
|
+
folder_status = module.get_module_render_status()
|
|
532
|
+
folder_hash = module.get_module_code_hash()
|
|
533
|
+
|
|
534
|
+
_archive_module(module) # zip in place, remove the folder
|
|
535
|
+
module.materialize()
|
|
536
|
+
|
|
537
|
+
assert module.get_module_render_status() == folder_status
|
|
538
|
+
assert module.get_module_code_hash() == folder_hash
|
|
539
|
+
module.cleanup_scratch()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def test_code_hash_is_location_independent(fixtures_dir):
|
|
543
|
+
"""The code hash depends only on content, not the build folder's absolute path, so it is stable
|
|
544
|
+
across directories/machines. This is what makes .module archives distributable: consuming the
|
|
545
|
+
same code at a different location must not report a spurious 'required module code changed'."""
|
|
546
|
+
with tempfile.TemporaryDirectory() as build_a, tempfile.TemporaryDirectory() as build_b:
|
|
547
|
+
mod_a = PlainModule("pr_solo.plain", build_a, [fixtures_dir])
|
|
548
|
+
mod_b = PlainModule("pr_solo.plain", build_b, [fixtures_dir])
|
|
549
|
+
for mod in (mod_a, mod_b):
|
|
550
|
+
os.makedirs(mod.module_build_folder, exist_ok=True)
|
|
551
|
+
(Path(mod.module_build_folder) / "main.py").write_text("print('hi')\n")
|
|
552
|
+
|
|
553
|
+
assert mod_a.module_build_folder != mod_b.module_build_folder
|
|
554
|
+
assert mod_a.get_module_code_hash() == mod_b.get_module_code_hash()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def test_archive_has_conformance_tests(solo_module):
|
|
558
|
+
_setup_full_module(solo_module) # code/ + tests/
|
|
559
|
+
_archive_module(solo_module)
|
|
560
|
+
assert solo_module.archive_has_conformance_tests() is True
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def test_code_only_archive_materializes_without_tests(solo_module):
|
|
564
|
+
"""A module rendered with conformance tests off has no tests/, so its archive has none. It must
|
|
565
|
+
still validate and materialize (tests/ is optional)."""
|
|
566
|
+
_init_repo_with_finished_frid(solo_module.module_build_folder, solo_module.module_name, "1")
|
|
567
|
+
_write_metadata(solo_module, {"source_hash": "seed"})
|
|
568
|
+
_archive_module(solo_module) # code/ + .codeplain, no tests/
|
|
569
|
+
|
|
570
|
+
assert solo_module.archive_has_conformance_tests() is False
|
|
571
|
+
assert solo_module.is_archived_only()
|
|
572
|
+
|
|
573
|
+
solo_module.materialize() # must not raise
|
|
574
|
+
assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git"))
|
|
575
|
+
assert not os.path.isdir(solo_module.module_conformance_tests_folder)
|
|
576
|
+
solo_module.cleanup_scratch()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_ensure_module_unpacked_code_only_archive(solo_module):
|
|
580
|
+
_init_repo_with_finished_frid(solo_module.module_build_folder, solo_module.module_name, "1")
|
|
581
|
+
_archive_module(solo_module) # code/ only
|
|
582
|
+
|
|
583
|
+
solo_module.ensure_module_unpacked()
|
|
584
|
+
assert os.path.isdir(os.path.join(solo_module._default_module_folder, "code", ".git"))
|
|
585
|
+
assert not os.path.isdir(os.path.join(solo_module._default_module_folder, "tests"))
|
|
586
|
+
assert not os.path.exists(solo_module.module_archive_path)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_materialize_rejects_non_zip(solo_module):
|
|
590
|
+
with open(solo_module.module_archive_path, "w") as f:
|
|
591
|
+
f.write("not a zip")
|
|
592
|
+
with pytest.raises(InvalidModuleArchiveError):
|
|
593
|
+
solo_module.materialize()
|
|
594
|
+
assert solo_module._scratch_dir is None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def test_materialize_rejects_missing_subfolders(solo_module):
|
|
598
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
599
|
+
zf.writestr("code/marker.txt", "x") # tests/ missing, and no .git
|
|
600
|
+
with pytest.raises(InvalidModuleArchiveError):
|
|
601
|
+
solo_module.materialize()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def test_materialize_rejects_missing_git_repo(solo_module):
|
|
605
|
+
"""code/ and tests/ present but without a .git directory is rejected."""
|
|
606
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
607
|
+
zf.writestr("code/main.py", "print('x')")
|
|
608
|
+
zf.writestr("tests/test_main.py", "def test_x(): pass")
|
|
609
|
+
with pytest.raises(InvalidModuleArchiveError):
|
|
610
|
+
solo_module.materialize()
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def test_materialize_rejects_zip_slip(solo_module):
|
|
614
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
615
|
+
zf.writestr("../escape.txt", "x")
|
|
616
|
+
with pytest.raises(InvalidModuleArchiveError):
|
|
617
|
+
solo_module.materialize()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def test_ensure_module_unpacked_leaves_no_partial_on_invalid_archive(solo_module):
|
|
621
|
+
default = solo_module._default_module_folder
|
|
622
|
+
with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
|
|
623
|
+
zf.writestr("code/marker.txt", "x") # invalid layout
|
|
624
|
+
with pytest.raises(InvalidModuleArchiveError):
|
|
625
|
+
solo_module.ensure_module_unpacked()
|
|
626
|
+
assert not os.path.exists(default) # no half-written module folder
|
|
627
|
+
assert os.path.isfile(solo_module.module_archive_path) # archive preserved on failure
|
|
628
|
+
|
|
629
|
+
|
|
364
630
|
# --------------------------------------------------------------------------
|
|
365
631
|
# seed_module_metadata
|
|
366
632
|
# --------------------------------------------------------------------------
|
tests/test_plainfileparser.py
CHANGED
|
@@ -36,7 +36,7 @@ def test_unknown_section():
|
|
|
36
36
|
with pytest.raises(
|
|
37
37
|
Exception,
|
|
38
38
|
match=re.escape(
|
|
39
|
-
"Syntax error at line
|
|
39
|
+
"Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
|
|
40
40
|
),
|
|
41
41
|
):
|
|
42
42
|
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|
|
@@ -50,7 +50,7 @@ def test_duplicate_section():
|
|
|
50
50
|
"""
|
|
51
51
|
with pytest.raises(
|
|
52
52
|
Exception,
|
|
53
|
-
match=re.escape("Syntax error at line
|
|
53
|
+
match=re.escape("Syntax error at line 4: Duplicate specification heading (`definitions`)"),
|
|
54
54
|
):
|
|
55
55
|
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|
|
56
56
|
|
|
@@ -64,11 +64,48 @@ code block
|
|
|
64
64
|
"""
|
|
65
65
|
with pytest.raises(
|
|
66
66
|
Exception,
|
|
67
|
-
match=re.escape("Syntax error at line
|
|
67
|
+
match=re.escape("Syntax error at line 3: Invalid source structure (`code block`)"),
|
|
68
68
|
):
|
|
69
69
|
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def test_syntax_error_line_number_accounts_for_frontmatter():
|
|
73
|
+
plain_source = """---
|
|
74
|
+
description: 'Plain file with frontmatter'
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
***definitions***
|
|
78
|
+
|
|
79
|
+
***Unknown Section:***
|
|
80
|
+
"""
|
|
81
|
+
with pytest.raises(
|
|
82
|
+
Exception,
|
|
83
|
+
match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
|
|
84
|
+
):
|
|
85
|
+
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_syntax_error_line_number_with_windows_line_endings():
|
|
89
|
+
plain_source = (
|
|
90
|
+
"---\r\n"
|
|
91
|
+
"description: 'Plain file with frontmatter'\r\n"
|
|
92
|
+
"---\r\n"
|
|
93
|
+
"\r\n"
|
|
94
|
+
"***definitions***\r\n"
|
|
95
|
+
"\r\n"
|
|
96
|
+
"***Unknown Section:***\r\n"
|
|
97
|
+
)
|
|
98
|
+
with pytest.raises(
|
|
99
|
+
Exception,
|
|
100
|
+
match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
|
|
101
|
+
):
|
|
102
|
+
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_normalize_line_endings_does_not_duplicate_newlines():
|
|
106
|
+
assert plain_file.normalize_line_endings("a\r\nb\rc\nd") == "a\nb\nc\nd"
|
|
107
|
+
|
|
108
|
+
|
|
72
109
|
def test_plain_file_parser_with_comments(get_test_data_path):
|
|
73
110
|
_, plain_sections, _ = plain_file.plain_file_parser(
|
|
74
111
|
"plain_file_parser_with_comments.plain",
|
|
@@ -423,7 +460,7 @@ def test_acceptance_tests_top_level_rejected():
|
|
|
423
460
|
with pytest.raises(
|
|
424
461
|
PlainSyntaxError,
|
|
425
462
|
match=re.escape(
|
|
426
|
-
"Syntax error at line
|
|
463
|
+
"Syntax error at line 2: acceptance tests heading should be nested under specific functional spec."
|
|
427
464
|
),
|
|
428
465
|
):
|
|
429
466
|
plain_file.parse_plain_source(plain_source, {}, [], [], [])
|