codeplain 0.3.10.dev4__py3-none-any.whl → 0.3.11__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.
- cli_output/status.py +13 -8
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/METADATA +4 -4
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/RECORD +27 -21
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/WHEEL +1 -1
- codeplain_REST_api.py +32 -1
- file_utils.py +14 -0
- git_preflight.py +41 -0
- plain2code.py +76 -5
- plain2code_console.py +5 -4
- plain_file.py +51 -18
- plain_modules.py +6 -6
- render_machine/actions/fix_conformance_test.py +11 -0
- render_machine/actions/fix_unit_tests.py +25 -0
- render_machine/render_types.py +5 -0
- tests/test_cli_output.py +15 -8
- tests/test_console_log_styles.py +61 -5
- tests/test_dry_run_link_validation.py +60 -0
- tests/test_fix_unit_tests_conformance_context.py +230 -0
- tests/test_git_preflight.py +98 -0
- tests/test_plainfileparser.py +82 -22
- tests/test_render_finished_report.py +200 -0
- tests/test_tui_components.py +147 -0
- tui/components.py +37 -10
- tui/plain2code_tui.py +9 -0
- tui/widget_helpers.py +11 -4
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/entry_points.txt +0 -0
- {codeplain-0.3.10.dev4.dist-info → codeplain-0.3.11.dist-info}/licenses/LICENSE +0 -0
plain_file.py
CHANGED
|
@@ -90,7 +90,7 @@ def remove_quotes(token):
|
|
|
90
90
|
token.children = tuple(new_children)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def check_section_for_linked_resources(section):
|
|
93
|
+
def check_section_for_linked_resources(section, template_dirs):
|
|
94
94
|
linked_resources = []
|
|
95
95
|
for link in traverse(section, klass=Link):
|
|
96
96
|
parsed_url = urlparse(link.node.target)
|
|
@@ -99,11 +99,19 @@ def check_section_for_linked_resources(section):
|
|
|
99
99
|
f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})."
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
resolved_target = file_utils.resolve_linked_resource(template_dirs, link.node.target)
|
|
103
|
+
if resolved_target is None:
|
|
104
|
+
searched_dirs = "\n".join(f" {position}. {dir}" for position, dir in enumerate(template_dirs, start=1))
|
|
105
|
+
raise PlainSyntaxError(
|
|
106
|
+
f"Plain syntax error: Link {link.node.target} does not exist. "
|
|
107
|
+
f"Linked resources are looked up relative to the following directories "
|
|
108
|
+
f"(highest to lowest precedence):\n{searched_dirs}"
|
|
109
|
+
)
|
|
104
110
|
|
|
105
|
-
if not os.path.isfile(
|
|
106
|
-
raise PlainSyntaxError(
|
|
111
|
+
if not os.path.isfile(resolved_target):
|
|
112
|
+
raise PlainSyntaxError(
|
|
113
|
+
f"Plain syntax error: Link {link.node.target} must be a file (resolved to {resolved_target})."
|
|
114
|
+
)
|
|
107
115
|
|
|
108
116
|
if len(link.node.children) != 1:
|
|
109
117
|
raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).")
|
|
@@ -122,15 +130,15 @@ def check_section_for_linked_resources(section):
|
|
|
122
130
|
section.linked_resources = linked_resources
|
|
123
131
|
|
|
124
132
|
|
|
125
|
-
def check_for_linked_resources(plain_source):
|
|
133
|
+
def check_for_linked_resources(plain_source, template_dirs):
|
|
126
134
|
for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
|
|
127
135
|
if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"):
|
|
128
136
|
for requirement in plain_source[specification_heading].children:
|
|
129
|
-
check_section_for_linked_resources(requirement)
|
|
137
|
+
check_section_for_linked_resources(requirement, template_dirs)
|
|
130
138
|
|
|
131
139
|
if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS):
|
|
132
140
|
for acceptance_test in requirement.acceptance_tests:
|
|
133
|
-
check_section_for_linked_resources(acceptance_test)
|
|
141
|
+
check_section_for_linked_resources(acceptance_test, template_dirs)
|
|
134
142
|
|
|
135
143
|
|
|
136
144
|
def process_section_code_variables(section, code_variables):
|
|
@@ -615,15 +623,45 @@ def parse_plain_source( # noqa: C901
|
|
|
615
623
|
f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec."
|
|
616
624
|
)
|
|
617
625
|
|
|
626
|
+
defined_concepts = set[str]()
|
|
618
627
|
if plain_source[plain_spec.DEFINITIONS] is not None:
|
|
619
628
|
with PlainRenderer() as renderer:
|
|
620
629
|
for token in plain_source[plain_spec.DEFINITIONS].children:
|
|
621
630
|
rendered_token = renderer.render(token)
|
|
622
631
|
new_concepts, _ = concept_utils.extract_concepts_from_definition(rendered_token)
|
|
632
|
+
defined_concepts.update(new_concepts)
|
|
623
633
|
for concept in new_concepts:
|
|
624
634
|
if concept in required_concepts:
|
|
625
635
|
required_concepts.remove(concept)
|
|
626
636
|
|
|
637
|
+
if EXPORTED_CONCEPTS_DIRECTIVE in plain_source_obj.metadata:
|
|
638
|
+
module_name = modules_trace[-1] if modules_trace else None
|
|
639
|
+
subject = f"Module '{module_name}'" if module_name else "Module"
|
|
640
|
+
|
|
641
|
+
for entry in plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]:
|
|
642
|
+
exported_concepts, errors = concept_utils.extract_concepts_from_definition(entry)
|
|
643
|
+
if errors:
|
|
644
|
+
raise PlainSyntaxError(
|
|
645
|
+
f"Plain syntax error: {subject} exports an invalid concept (`{entry}`). "
|
|
646
|
+
f"Expected a concept token, e.g. :ConceptName:."
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
for exported_concept in exported_concepts:
|
|
650
|
+
if exported_concept in concept_utils.DEFAULT_CONCEPTS:
|
|
651
|
+
raise PlainSyntaxError(
|
|
652
|
+
f"Plain syntax error: {subject} cannot export default concept {exported_concept}. "
|
|
653
|
+
f"Only user-defined concepts can be exported."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
if exported_concept not in defined_concepts:
|
|
657
|
+
message = f"Plain syntax error: {subject} exports undefined concept {exported_concept}."
|
|
658
|
+
if REQUIRES_DIRECTIVE in plain_source_obj.metadata:
|
|
659
|
+
message += (
|
|
660
|
+
" An exported concept must be defined in this module's definitions or in an "
|
|
661
|
+
"imported module; a concept inherited through requires cannot be re-exported."
|
|
662
|
+
)
|
|
663
|
+
raise PlainSyntaxError(message)
|
|
664
|
+
|
|
627
665
|
required_modules = []
|
|
628
666
|
if REQUIRES_DIRECTIVE in plain_source_obj.metadata:
|
|
629
667
|
required_modules = plain_source_obj.metadata[REQUIRES_DIRECTIVE]
|
|
@@ -710,15 +748,7 @@ def process_required_modules(
|
|
|
710
748
|
if EXPORTED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata:
|
|
711
749
|
exported_concepts = list[str]()
|
|
712
750
|
for concept in plain_file_parse_result.plain_source_obj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]:
|
|
713
|
-
|
|
714
|
-
raise PlainSyntaxError(
|
|
715
|
-
f"Plain syntax error: Default concept cannot be exported: {concept}. Only user-defined concepts can be exported."
|
|
716
|
-
)
|
|
717
|
-
|
|
718
|
-
if isinstance(concept, str):
|
|
719
|
-
exported_concepts.extend(concept_utils.extract_concepts_from_definition(concept)[0])
|
|
720
|
-
else:
|
|
721
|
-
raise PlainSyntaxError(f"Plain syntax error: Invalid exported concept: {concept}.")
|
|
751
|
+
exported_concepts.extend(concept_utils.extract_concepts_from_definition(concept)[0])
|
|
722
752
|
|
|
723
753
|
with PlainRenderer() as renderer:
|
|
724
754
|
for exported_concept in exported_concepts:
|
|
@@ -737,6 +767,9 @@ def process_exported_definitions(plain_source: dict, exported_definitions: list[
|
|
|
737
767
|
if len(exported_definitions) == 0:
|
|
738
768
|
return
|
|
739
769
|
|
|
770
|
+
if plain_source[plain_spec.DEFINITIONS] is None:
|
|
771
|
+
plain_source[plain_spec.DEFINITIONS] = mistletoe.Document("")
|
|
772
|
+
|
|
740
773
|
with PlainRenderer() as renderer:
|
|
741
774
|
for exported_definition in exported_definitions:
|
|
742
775
|
add_defintion = True
|
|
@@ -820,7 +853,7 @@ def plain_file_parser( # noqa: C901
|
|
|
820
853
|
|
|
821
854
|
process_acceptance_tests(plain_file_parse_result.plain_source)
|
|
822
855
|
|
|
823
|
-
check_for_linked_resources(plain_file_parse_result.plain_source)
|
|
856
|
+
check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs)
|
|
824
857
|
|
|
825
858
|
marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source)
|
|
826
859
|
|
plain_modules.py
CHANGED
|
@@ -6,18 +6,18 @@ import tempfile
|
|
|
6
6
|
import zipfile
|
|
7
7
|
from functools import cached_property
|
|
8
8
|
|
|
9
|
+
from git_preflight import require_git
|
|
9
10
|
from plain2code_exceptions import (
|
|
10
|
-
GitNotInstalledError,
|
|
11
11
|
InvalidModuleArchiveError,
|
|
12
12
|
MissingPreviousFunctionalitiesError,
|
|
13
13
|
ModuleDoesNotExistError,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
# Must run before GitPython is imported, which raises on a missing or broken git.
|
|
17
|
+
require_git()
|
|
18
|
+
|
|
19
|
+
from git import Repo
|
|
20
|
+
from git.exc import NoSuchPathError
|
|
21
21
|
|
|
22
22
|
import file_utils
|
|
23
23
|
import git_utils
|
|
@@ -189,6 +189,17 @@ class FixConformanceTest(BaseAction):
|
|
|
189
189
|
ctx.test_that_triggered_code_change = (ctx.current_testing_module_name, ctx.current_testing_frid)
|
|
190
190
|
ctx.execution_phase = TestExecutionPhase.RETRYING_AFTER_CODE_CHANGE
|
|
191
191
|
|
|
192
|
+
# Remember the change so that the unit tests fixer (which runs next) preserves it instead of
|
|
193
|
+
# reverting it - see FixUnitTests.
|
|
194
|
+
summary = fix_attempt_summary if isinstance(fix_attempt_summary, dict) else {}
|
|
195
|
+
ctx.implementation_code_fixes.append(
|
|
196
|
+
{
|
|
197
|
+
"hypothesis": summary.get("hypothesis"),
|
|
198
|
+
"approach": summary.get("approach"),
|
|
199
|
+
"code_diff": code_diff_files_content,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
192
203
|
journal.record_fix(
|
|
193
204
|
ctx.current_testing_module_name,
|
|
194
205
|
ctx.current_testing_frid,
|
|
@@ -32,6 +32,13 @@ class FixUnitTests(BaseAction):
|
|
|
32
32
|
|
|
33
33
|
render_utils.print_inputs(render_context, existing_files_content, "Files sent as input to unit tests fixing:")
|
|
34
34
|
|
|
35
|
+
conformance_tests_fixes = self._get_conformance_tests_fixes(render_context)
|
|
36
|
+
if conformance_tests_fixes:
|
|
37
|
+
console.info(
|
|
38
|
+
f"Unit tests are fixed while preserving {len(conformance_tests_fixes)} implementation code change(s) "
|
|
39
|
+
"made to fix the conformance tests."
|
|
40
|
+
)
|
|
41
|
+
|
|
35
42
|
response_files = render_context.codeplain_api.fix_unittests_issue(
|
|
36
43
|
render_context.frid_context.frid,
|
|
37
44
|
render_context.plain_source_tree,
|
|
@@ -41,6 +48,7 @@ class FixUnitTests(BaseAction):
|
|
|
41
48
|
render_context.get_required_modules_functionalities(),
|
|
42
49
|
previous_unittests_issue,
|
|
43
50
|
run_state=render_context.run_state,
|
|
51
|
+
conformance_tests_fixes=conformance_tests_fixes,
|
|
44
52
|
)
|
|
45
53
|
|
|
46
54
|
_, changed_files = file_utils.update_build_folder_with_rendered_files(
|
|
@@ -52,3 +60,20 @@ class FixUnitTests(BaseAction):
|
|
|
52
60
|
console.print_files("Files fixed:", render_context.build_folder, response_files, style=console.OUTPUT_STYLE)
|
|
53
61
|
|
|
54
62
|
return self.SUCCESSFUL_OUTCOME, None
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _get_conformance_tests_fixes(render_context: RenderContext) -> list[dict] | None:
|
|
66
|
+
"""Implementation code changes the conformance tests fixer made before these unit tests were run.
|
|
67
|
+
|
|
68
|
+
Only present when unit tests are processed inside the conformance tests phase - the implementation
|
|
69
|
+
and refactoring unit test passes have no conformance tests running context.
|
|
70
|
+
"""
|
|
71
|
+
conformance_tests_running_context = getattr(render_context, "conformance_tests_running_context", None)
|
|
72
|
+
if conformance_tests_running_context is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
implementation_code_fixes = getattr(conformance_tests_running_context, "implementation_code_fixes", None)
|
|
76
|
+
if not implementation_code_fixes:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
return list(implementation_code_fixes)
|
render_machine/render_types.py
CHANGED
|
@@ -91,6 +91,11 @@ class ConformanceTestsRunningContext:
|
|
|
91
91
|
|
|
92
92
|
self.current_testing_frid_high_level_implementation_plan: Optional[str] = None
|
|
93
93
|
|
|
94
|
+
# Implementation code changes made by the conformance tests fixer during this conformance phase, in
|
|
95
|
+
# order. Each entry is {"hypothesis": str | None, "approach": str | None, "code_diff": {file: diff}}.
|
|
96
|
+
# Handed to the unit tests fixer so it adjusts the unit tests instead of reverting these changes.
|
|
97
|
+
self.implementation_code_fixes: list[dict] = []
|
|
98
|
+
|
|
94
99
|
def get_conformance_tests_json(self, module_name: str) -> dict:
|
|
95
100
|
return self._conformance_tests_json[module_name]
|
|
96
101
|
|
tests/test_cli_output.py
CHANGED
|
@@ -67,7 +67,7 @@ class TestDisplayCreditLine:
|
|
|
67
67
|
|
|
68
68
|
@patch("cli_output.status.console")
|
|
69
69
|
def test_display_active_free_trial(self, mock_console):
|
|
70
|
-
"""Test displaying active free
|
|
70
|
+
"""Test displaying active free credits."""
|
|
71
71
|
plan_credits = {
|
|
72
72
|
"type": "free",
|
|
73
73
|
"total": 50,
|
|
@@ -78,7 +78,7 @@ class TestDisplayCreditLine:
|
|
|
78
78
|
|
|
79
79
|
mock_console.print.assert_called_once()
|
|
80
80
|
call_args = mock_console.print.call_args[0][0]
|
|
81
|
-
assert "Free
|
|
81
|
+
assert "Free credits" in call_args
|
|
82
82
|
assert "4 of 50 remaining" in call_args
|
|
83
83
|
assert "expires Dec 1, 2028" in call_args
|
|
84
84
|
|
|
@@ -216,8 +216,10 @@ class TestDisplayStatusMessage:
|
|
|
216
216
|
}
|
|
217
217
|
_display_status_message(plan_credits, [], [])
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
mock_console.print.
|
|
219
|
+
mock_console.print.assert_called_once()
|
|
220
|
+
call_args = mock_console.print.call_args[0][0]
|
|
221
|
+
assert "To manage your plan" in call_args
|
|
222
|
+
assert "No rendering credits remaining" not in call_args
|
|
221
223
|
|
|
222
224
|
@patch("cli_output.status.console")
|
|
223
225
|
def test_has_active_purchased_credits(self, mock_console):
|
|
@@ -230,8 +232,10 @@ class TestDisplayStatusMessage:
|
|
|
230
232
|
]
|
|
231
233
|
_display_status_message(None, purchased_credits, [])
|
|
232
234
|
|
|
233
|
-
|
|
234
|
-
mock_console.print.
|
|
235
|
+
mock_console.print.assert_called_once()
|
|
236
|
+
call_args = mock_console.print.call_args[0][0]
|
|
237
|
+
assert "To manage your plan" in call_args
|
|
238
|
+
assert "No rendering credits remaining" not in call_args
|
|
235
239
|
|
|
236
240
|
@patch("cli_output.status.console")
|
|
237
241
|
def test_has_active_promo_credits(self, mock_console):
|
|
@@ -244,8 +248,10 @@ class TestDisplayStatusMessage:
|
|
|
244
248
|
]
|
|
245
249
|
_display_status_message(None, [], promo_credits)
|
|
246
250
|
|
|
247
|
-
|
|
248
|
-
mock_console.print.
|
|
251
|
+
mock_console.print.assert_called_once()
|
|
252
|
+
call_args = mock_console.print.call_args[0][0]
|
|
253
|
+
assert "To manage your plan" in call_args
|
|
254
|
+
assert "No rendering credits remaining" not in call_args
|
|
249
255
|
|
|
250
256
|
@patch("cli_output.status.console")
|
|
251
257
|
def test_no_credits_remaining(self, mock_console):
|
|
@@ -259,6 +265,7 @@ class TestDisplayStatusMessage:
|
|
|
259
265
|
mock_console.print.assert_called_once()
|
|
260
266
|
call_args = mock_console.print.call_args[0][0]
|
|
261
267
|
assert "No rendering credits remaining" in call_args
|
|
268
|
+
assert "https://platform.codeplain.ai/plans" in call_args
|
|
262
269
|
|
|
263
270
|
@patch("cli_output.status.console")
|
|
264
271
|
def test_expired_plan_credits(self, mock_console):
|
tests/test_console_log_styles.py
CHANGED
|
@@ -70,14 +70,70 @@ class TestConsoleColorParam:
|
|
|
70
70
|
|
|
71
71
|
assert handler.records[0].log_color is None
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
|
|
74
|
+
class TestTerminalTextRendersVerbatim:
|
|
75
|
+
"""
|
|
76
|
+
Message text must reach the terminal unchanged, and must not influence its own
|
|
77
|
+
styling. Colors are still applied but they are chosen by the call, never parsed
|
|
78
|
+
out of the text:
|
|
79
|
+
- Rich markup tags must not be interpreted
|
|
80
|
+
- the repr highlighter must not restyle brackets and numbers
|
|
81
|
+
- emoji shortcodes must not be substituted by glyphs
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def test_markup_closing_tag_prints_verbatim(self):
|
|
85
|
+
console = Plain2CodeConsole()
|
|
76
86
|
with console.capture() as capture:
|
|
77
|
-
console.
|
|
78
|
-
|
|
87
|
+
console.error("Error: [/closing] tag")
|
|
88
|
+
# markup=True raises MarkupError on an unmatched closing tag
|
|
79
89
|
assert "[/closing]" in capture.get()
|
|
80
90
|
|
|
91
|
+
def test_markup_style_tag_prints_verbatim(self):
|
|
92
|
+
console = Plain2CodeConsole()
|
|
93
|
+
with console.capture() as capture:
|
|
94
|
+
console.error("Error: [bold]important text")
|
|
95
|
+
# markup=True silently consumes the tag and styles the rest
|
|
96
|
+
assert "[bold]" in capture.get()
|
|
97
|
+
|
|
98
|
+
def test_bracketed_number_prints_verbatim(self, monkeypatch):
|
|
99
|
+
# Rich emits no ANSI unless it believes it is writing to a colored terminal
|
|
100
|
+
# (FORCE_COLOR alone is not enough: TERM=dumb still resolves to no colors)
|
|
101
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
102
|
+
monkeypatch.setenv("TERM", "xterm-256color")
|
|
103
|
+
console = Plain2CodeConsole()
|
|
104
|
+
assert console.color_system is not None # should fail test immediately if no colors
|
|
105
|
+
|
|
106
|
+
with console.capture() as capture:
|
|
107
|
+
console.error("Error: [Errno 8] failed")
|
|
108
|
+
# highlight=True breaks this up with colour codes
|
|
109
|
+
assert "[Errno 8]" in capture.get()
|
|
110
|
+
|
|
111
|
+
def test_concept_name_matching_emoji_prints_verbatim(self):
|
|
112
|
+
console = Plain2CodeConsole()
|
|
113
|
+
with console.capture() as capture:
|
|
114
|
+
console.error("Syntax error: Invalid concept: :Package:")
|
|
115
|
+
assert ":Package:" in capture.get()
|
|
116
|
+
assert "📦" not in capture.get()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestPrintHelpersDoNotSubstituteEmoji:
|
|
120
|
+
"""
|
|
121
|
+
Same rule as TestTerminalTextRendersVerbatim, for the two helpers that bypass _log_and_print.
|
|
122
|
+
Only emoji is covered here, markup and highlighting still apply to them.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def test_print_list_does_not_substitute_emoji(self):
|
|
126
|
+
console = Plain2CodeConsole()
|
|
127
|
+
with console.capture() as capture:
|
|
128
|
+
console.print_list(["The :Package: is a concept"])
|
|
129
|
+
assert ":Package:" in capture.get()
|
|
130
|
+
|
|
131
|
+
def test_print_files_does_not_substitute_emoji_in_tree_labels(self):
|
|
132
|
+
console = Plain2CodeConsole()
|
|
133
|
+
with console.capture() as capture:
|
|
134
|
+
console.print_files("Files added:", "build", {":Package:.py": "x"})
|
|
135
|
+
assert ":Package:" in capture.get()
|
|
136
|
+
|
|
81
137
|
|
|
82
138
|
class TestLoggingHandlerForwardsColor:
|
|
83
139
|
"""LoggingHandler must forward the log_color record attribute into LogMessageEmitted."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Regression tests for linked-resource validation during --dry-run.
|
|
2
|
+
|
|
3
|
+
A linked resource must be a single text-based file. Rendering rejects binary
|
|
4
|
+
targets in ``file_utils.load_linked_resources`` (a failed UTF-8 decode raises
|
|
5
|
+
``UnsupportedResourceType``); dry-run must surface the same errors through
|
|
6
|
+
``plain2code.validate_linked_resources``, which reuses that exact mechanism.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from plain2code import validate_linked_resources
|
|
12
|
+
from plain2code_exceptions import UnsupportedResourceType
|
|
13
|
+
from plain_modules import PlainModule
|
|
14
|
+
|
|
15
|
+
PLAIN_TEMPLATE = """***definitions***
|
|
16
|
+
|
|
17
|
+
- :Widget: is an item managed by the application.
|
|
18
|
+
- Its icon is [the widget resource](resources/{resource_name}).
|
|
19
|
+
|
|
20
|
+
***implementation reqs***
|
|
21
|
+
|
|
22
|
+
- Implementation should be in Python.
|
|
23
|
+
|
|
24
|
+
***functional specs***
|
|
25
|
+
|
|
26
|
+
- A :Widget: can be created.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_module(tmp_path, monkeypatch, resource_name, resource_bytes):
|
|
31
|
+
resources_dir = tmp_path / "resources"
|
|
32
|
+
resources_dir.mkdir()
|
|
33
|
+
(resources_dir / resource_name).write_bytes(resource_bytes)
|
|
34
|
+
(tmp_path / "binlink.plain").write_text(PLAIN_TEMPLATE.format(resource_name=resource_name))
|
|
35
|
+
|
|
36
|
+
# Link targets are resolved relative to the current working directory at parse time.
|
|
37
|
+
monkeypatch.chdir(tmp_path)
|
|
38
|
+
return PlainModule("binlink.plain", str(tmp_path / "build"), [str(tmp_path)])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_dry_run_validation_rejects_binary_linked_resource(tmp_path, monkeypatch):
|
|
42
|
+
module = _make_module(tmp_path, monkeypatch, "image.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\xff\xfe\xfd")
|
|
43
|
+
|
|
44
|
+
with pytest.raises(UnsupportedResourceType) as exc_info:
|
|
45
|
+
validate_linked_resources(module)
|
|
46
|
+
|
|
47
|
+
assert "resources/image.png" in str(exc_info.value)
|
|
48
|
+
assert "binary file" in str(exc_info.value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_dry_run_validation_accepts_text_file_with_unusual_extension(tmp_path, monkeypatch):
|
|
52
|
+
module = _make_module(tmp_path, monkeypatch, "notes.xyz123", b"plain text content\n")
|
|
53
|
+
|
|
54
|
+
validate_linked_resources(module)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_dry_run_validation_accepts_empty_file(tmp_path, monkeypatch):
|
|
58
|
+
module = _make_module(tmp_path, monkeypatch, "empty.txt", b"")
|
|
59
|
+
|
|
60
|
+
validate_linked_resources(module)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Tests for handing the conformance tests fixer's implementation changes to the unit tests fixer.
|
|
2
|
+
|
|
3
|
+
When the conformance tests fixer changes implementation code, the unit tests are re-run and fixed next.
|
|
4
|
+
The unit tests fixer must know about that change so it adjusts the unit tests instead of reverting it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import plain_spec
|
|
14
|
+
from memory_management import MemoryManager
|
|
15
|
+
from render_machine.actions import fix_unit_tests as fix_unit_tests_module
|
|
16
|
+
from render_machine.actions.fix_conformance_test import FixConformanceTest
|
|
17
|
+
from render_machine.actions.fix_unit_tests import FixUnitTests
|
|
18
|
+
from render_machine.implementation_code_helpers import ImplementationCodeHelpers
|
|
19
|
+
from render_machine.render_types import ConformanceTestsRunningContext, UnitTestsRunningContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeCodeplainAPI:
|
|
23
|
+
def __init__(self, conformance_fix_response=None, unittests_fix_response=None):
|
|
24
|
+
self.conformance_fix_response = conformance_fix_response
|
|
25
|
+
self.unittests_fix_response = unittests_fix_response or {}
|
|
26
|
+
self.unittests_fix_calls = []
|
|
27
|
+
|
|
28
|
+
def fix_conformance_tests_issue(self, *args, **kwargs):
|
|
29
|
+
return self.conformance_fix_response
|
|
30
|
+
|
|
31
|
+
def fix_unittests_issue(self, *args, **kwargs):
|
|
32
|
+
self.unittests_fix_calls.append(kwargs)
|
|
33
|
+
return self.unittests_fix_response
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeConformanceTests:
|
|
37
|
+
def fetch_existing_conformance_test_files(self, *args):
|
|
38
|
+
return {}, {}
|
|
39
|
+
|
|
40
|
+
def store_conformance_tests_files(self, *args):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def build_folder():
|
|
46
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
47
|
+
with open(os.path.join(folder, "app.py"), "w", encoding="utf-8") as source_file:
|
|
48
|
+
source_file.write("def add(a, b):\n return a + b\n")
|
|
49
|
+
yield folder
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def memory_folder():
|
|
54
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
55
|
+
yield folder
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture(autouse=True)
|
|
59
|
+
def isolate_from_git_and_console(monkeypatch):
|
|
60
|
+
monkeypatch.setattr(ImplementationCodeHelpers, "get_code_diff", staticmethod(lambda *args: {}))
|
|
61
|
+
monkeypatch.setattr(plain_spec, "collect_linked_resources", lambda *args: None)
|
|
62
|
+
monkeypatch.setattr(fix_unit_tests_module.render_utils, "print_inputs", lambda *args: None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def make_conformance_context():
|
|
66
|
+
return ConformanceTestsRunningContext(
|
|
67
|
+
current_testing_module_name="mod",
|
|
68
|
+
current_testing_frid="1",
|
|
69
|
+
fix_attempts=0,
|
|
70
|
+
conformance_tests_json={"1": {"folder_name": "conformance_1"}},
|
|
71
|
+
conformance_tests_render_attempts=0,
|
|
72
|
+
current_testing_frid_specifications={},
|
|
73
|
+
should_prepare_testing_environment=False,
|
|
74
|
+
frid_being_implemented="1",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_render_context(api, build_folder, memory_folder, conformance_tests_running_context):
|
|
79
|
+
return SimpleNamespace(
|
|
80
|
+
codeplain_api=api,
|
|
81
|
+
build_folder=build_folder,
|
|
82
|
+
memory_manager=MemoryManager(api, memory_folder),
|
|
83
|
+
conformance_tests=FakeConformanceTests(),
|
|
84
|
+
conformance_tests_running_context=conformance_tests_running_context,
|
|
85
|
+
unit_tests_running_context=UnitTestsRunningContext(fix_attempts=1),
|
|
86
|
+
plain_source_tree={},
|
|
87
|
+
module_name="mod",
|
|
88
|
+
required_modules=None,
|
|
89
|
+
frid_context=SimpleNamespace(frid="1", linked_resources={}),
|
|
90
|
+
get_required_modules_functionalities=lambda: {},
|
|
91
|
+
run_state=SimpleNamespace(render_id="test-render-id", unittest_batch_id=1),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_conformance_fix(render_context):
|
|
96
|
+
return FixConformanceTest().execute(render_context, {"previous_conformance_tests_issue": "tests failed"})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_conformance_fix_of_implementation_code_is_remembered(build_folder, memory_folder):
|
|
100
|
+
api = FakeCodeplainAPI(
|
|
101
|
+
conformance_fix_response=[
|
|
102
|
+
FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE,
|
|
103
|
+
{"app.py": "def add(a, b):\n return a + b + 1\n"},
|
|
104
|
+
{"hypothesis": "off by one", "approach": "add one"},
|
|
105
|
+
"prepared issue",
|
|
106
|
+
]
|
|
107
|
+
)
|
|
108
|
+
ctx = make_conformance_context()
|
|
109
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
110
|
+
|
|
111
|
+
outcome, _ = run_conformance_fix(render_context)
|
|
112
|
+
|
|
113
|
+
assert outcome == FixConformanceTest.IMPLEMENTATION_CODE_UPDATED
|
|
114
|
+
assert len(ctx.implementation_code_fixes) == 1
|
|
115
|
+
recorded_fix = ctx.implementation_code_fixes[0]
|
|
116
|
+
assert recorded_fix["hypothesis"] == "off by one"
|
|
117
|
+
assert recorded_fix["approach"] == "add one"
|
|
118
|
+
assert list(recorded_fix["code_diff"]) == ["app.py"]
|
|
119
|
+
assert "+ return a + b + 1" in recorded_fix["code_diff"]["app.py"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_consecutive_implementation_fixes_accumulate(build_folder, memory_folder):
|
|
123
|
+
ctx = make_conformance_context()
|
|
124
|
+
for attempt in range(2):
|
|
125
|
+
api = FakeCodeplainAPI(
|
|
126
|
+
conformance_fix_response=[
|
|
127
|
+
FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE,
|
|
128
|
+
{"app.py": f"def add(a, b):\n return a + b + {attempt + 1}\n"},
|
|
129
|
+
{"hypothesis": f"hypothesis {attempt}", "approach": f"approach {attempt}"},
|
|
130
|
+
"prepared issue",
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
134
|
+
run_conformance_fix(render_context)
|
|
135
|
+
|
|
136
|
+
assert [fix["hypothesis"] for fix in ctx.implementation_code_fixes] == ["hypothesis 0", "hypothesis 1"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_conformance_fix_without_summary_is_still_remembered(build_folder, memory_folder):
|
|
140
|
+
api = FakeCodeplainAPI(
|
|
141
|
+
conformance_fix_response=[
|
|
142
|
+
FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE,
|
|
143
|
+
{"app.py": "def add(a, b):\n return a + b + 1\n"},
|
|
144
|
+
None,
|
|
145
|
+
"prepared issue",
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
ctx = make_conformance_context()
|
|
149
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
150
|
+
|
|
151
|
+
run_conformance_fix(render_context)
|
|
152
|
+
|
|
153
|
+
assert ctx.implementation_code_fixes[0]["hypothesis"] is None
|
|
154
|
+
assert ctx.implementation_code_fixes[0]["approach"] is None
|
|
155
|
+
assert "app.py" in ctx.implementation_code_fixes[0]["code_diff"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_conformance_fix_of_conformance_tests_is_not_remembered(build_folder, memory_folder):
|
|
159
|
+
api = FakeCodeplainAPI(
|
|
160
|
+
conformance_fix_response=[
|
|
161
|
+
FixConformanceTest.ISSUE_REASON_CODE_CONFORMANCE_TESTS,
|
|
162
|
+
{"test_app.py": "def test_add(): pass\n"},
|
|
163
|
+
{"hypothesis": "wrong assertion", "approach": "fix test"},
|
|
164
|
+
"prepared issue",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
ctx = make_conformance_context()
|
|
168
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
169
|
+
|
|
170
|
+
outcome, _ = run_conformance_fix(render_context)
|
|
171
|
+
|
|
172
|
+
assert outcome == FixConformanceTest.IMPLEMENTATION_CODE_NOT_UPDATED
|
|
173
|
+
assert ctx.implementation_code_fixes == []
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_implementation_fix_with_no_files_is_not_remembered(build_folder, memory_folder):
|
|
177
|
+
api = FakeCodeplainAPI(
|
|
178
|
+
conformance_fix_response=[
|
|
179
|
+
FixConformanceTest.ISSUE_REASON_CODE_IMPLEMENTATION_CODE,
|
|
180
|
+
{},
|
|
181
|
+
{"hypothesis": "nothing", "approach": "nothing"},
|
|
182
|
+
"prepared issue",
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
ctx = make_conformance_context()
|
|
186
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
187
|
+
|
|
188
|
+
outcome, _ = run_conformance_fix(render_context)
|
|
189
|
+
|
|
190
|
+
assert outcome == FixConformanceTest.IMPLEMENTATION_CODE_NOT_UPDATED
|
|
191
|
+
assert ctx.implementation_code_fixes == []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_unit_tests_fix(render_context):
|
|
195
|
+
return FixUnitTests().execute(render_context, {"previous_unittests_issue": "1 failed"})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_unit_tests_fix_forwards_conformance_fixes(build_folder, memory_folder):
|
|
199
|
+
api = FakeCodeplainAPI(unittests_fix_response={"test_app.py": "def test_add(): pass\n"})
|
|
200
|
+
ctx = make_conformance_context()
|
|
201
|
+
ctx.implementation_code_fixes.append(
|
|
202
|
+
{"hypothesis": "off by one", "approach": "add one", "code_diff": {"app.py": "+ return a + b + 1"}}
|
|
203
|
+
)
|
|
204
|
+
render_context = make_render_context(api, build_folder, memory_folder, ctx)
|
|
205
|
+
|
|
206
|
+
outcome, _ = run_unit_tests_fix(render_context)
|
|
207
|
+
|
|
208
|
+
assert outcome == FixUnitTests.SUCCESSFUL_OUTCOME
|
|
209
|
+
assert len(api.unittests_fix_calls) == 1
|
|
210
|
+
assert api.unittests_fix_calls[0]["conformance_tests_fixes"] == ctx.implementation_code_fixes
|
|
211
|
+
# The forwarded list is a copy, so later conformance fixes do not mutate what was sent.
|
|
212
|
+
assert api.unittests_fix_calls[0]["conformance_tests_fixes"] is not ctx.implementation_code_fixes
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_unit_tests_fix_outside_conformance_phase_sends_no_fixes(build_folder, memory_folder):
|
|
216
|
+
api = FakeCodeplainAPI()
|
|
217
|
+
render_context = make_render_context(api, build_folder, memory_folder, None)
|
|
218
|
+
|
|
219
|
+
run_unit_tests_fix(render_context)
|
|
220
|
+
|
|
221
|
+
assert api.unittests_fix_calls[0]["conformance_tests_fixes"] is None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_unit_tests_fix_in_conformance_phase_without_implementation_changes_sends_no_fixes(build_folder, memory_folder):
|
|
225
|
+
api = FakeCodeplainAPI()
|
|
226
|
+
render_context = make_render_context(api, build_folder, memory_folder, make_conformance_context())
|
|
227
|
+
|
|
228
|
+
run_unit_tests_fix(render_context)
|
|
229
|
+
|
|
230
|
+
assert api.unittests_fix_calls[0]["conformance_tests_fixes"] is None
|