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
plain_modules.py
CHANGED
|
@@ -2,15 +2,24 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import zipfile
|
|
5
7
|
from functools import cached_property
|
|
6
8
|
|
|
7
|
-
from plain2code_exceptions import
|
|
9
|
+
from plain2code_exceptions import (
|
|
10
|
+
GitNotInstalledError,
|
|
11
|
+
InvalidModuleArchiveError,
|
|
12
|
+
MissingPreviousFunctionalitiesError,
|
|
13
|
+
ModuleDoesNotExistError,
|
|
14
|
+
)
|
|
8
15
|
|
|
9
16
|
try:
|
|
17
|
+
from git import Repo
|
|
10
18
|
from git.exc import NoSuchPathError
|
|
11
19
|
except ImportError:
|
|
12
20
|
raise GitNotInstalledError("git is not installed. Please install git and try again.")
|
|
13
21
|
|
|
22
|
+
import file_utils
|
|
14
23
|
import git_utils
|
|
15
24
|
import metadata_utils
|
|
16
25
|
import plain_file
|
|
@@ -28,6 +37,11 @@ CODEPLAIN_METADATA_FOLDER = ".codeplain"
|
|
|
28
37
|
MODULE_CODE_SUBFOLDER = "code"
|
|
29
38
|
MODULE_TESTS_SUBFOLDER = "tests"
|
|
30
39
|
|
|
40
|
+
# A module's build output may be shipped as a single zip archive named
|
|
41
|
+
# "<module>.module" instead of an unpacked "<module>/" folder. See PlainModule.materialize
|
|
42
|
+
# (read/consume) and PlainModule.ensure_module_unpacked (unpack-on-change).
|
|
43
|
+
MODULE_ARCHIVE_EXTENSION = ".module"
|
|
44
|
+
|
|
31
45
|
|
|
32
46
|
def get_module_code_folder(modules_base_folder: str, module_name: str) -> str:
|
|
33
47
|
return os.path.join(modules_base_folder, module_name, MODULE_CODE_SUBFOLDER)
|
|
@@ -37,6 +51,81 @@ def get_module_tests_folder(modules_base_folder: str, module_name: str) -> str:
|
|
|
37
51
|
return os.path.join(modules_base_folder, module_name, MODULE_TESTS_SUBFOLDER)
|
|
38
52
|
|
|
39
53
|
|
|
54
|
+
def _validate_module_repo(repo_path: str, subfolder: str, archive_path: str) -> None:
|
|
55
|
+
"""Validate one extracted git repo (code/ or tests/) inside a module archive."""
|
|
56
|
+
if not os.path.isdir(os.path.join(repo_path, ".git")):
|
|
57
|
+
raise InvalidModuleArchiveError(
|
|
58
|
+
f"Module archive '{archive_path}' has no git repository in '{subfolder}/' "
|
|
59
|
+
"(the archive must include the '.git' directory)."
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
repo = Repo(repo_path)
|
|
63
|
+
if repo.bare or repo.head.is_detached:
|
|
64
|
+
raise InvalidModuleArchiveError(
|
|
65
|
+
f"The git repository in '{subfolder}/' of module archive '{archive_path}' "
|
|
66
|
+
"must be a working tree checked out on a branch."
|
|
67
|
+
)
|
|
68
|
+
except InvalidModuleArchiveError:
|
|
69
|
+
raise
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise InvalidModuleArchiveError(
|
|
72
|
+
f"The git repository in '{subfolder}/' of module archive '{archive_path}' is invalid: {e}"
|
|
73
|
+
) from e
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _validate_module_tree(root: str, archive_path: str) -> None:
|
|
77
|
+
"""Verify an extracted module tree. ``code/`` is required. ``tests/`` is optional: a module
|
|
78
|
+
rendered without a conformance-tests script has no tests folder, so its archive has none either.
|
|
79
|
+
When ``tests/`` is present it must be a valid git repo."""
|
|
80
|
+
code_path = os.path.join(root, MODULE_CODE_SUBFOLDER)
|
|
81
|
+
if not os.path.isdir(code_path):
|
|
82
|
+
raise InvalidModuleArchiveError(
|
|
83
|
+
f"Module archive '{archive_path}' is missing the '{MODULE_CODE_SUBFOLDER}/' folder at its root. "
|
|
84
|
+
"The archive must contain the module folder's contents (code/, optionally tests/, ...) at its "
|
|
85
|
+
"root, not nested under a top-level directory."
|
|
86
|
+
)
|
|
87
|
+
_validate_module_repo(code_path, MODULE_CODE_SUBFOLDER, archive_path)
|
|
88
|
+
|
|
89
|
+
tests_path = os.path.join(root, MODULE_TESTS_SUBFOLDER)
|
|
90
|
+
if os.path.isdir(tests_path):
|
|
91
|
+
_validate_module_repo(tests_path, MODULE_TESTS_SUBFOLDER, archive_path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_module_archive(archive_path: str, dest: str) -> None:
|
|
95
|
+
"""Extract a "<module>.module" zip into dest and validate the module layout.
|
|
96
|
+
|
|
97
|
+
Guards against zip-slip, restores unix mode bits (zipfile drops them), and validates
|
|
98
|
+
that dest contains valid code/ and tests/ git repositories. Raises
|
|
99
|
+
InvalidModuleArchiveError on any problem.
|
|
100
|
+
"""
|
|
101
|
+
if not zipfile.is_zipfile(archive_path):
|
|
102
|
+
raise InvalidModuleArchiveError(f"Module archive '{archive_path}' is not a valid zip file.")
|
|
103
|
+
|
|
104
|
+
os.makedirs(dest, exist_ok=True)
|
|
105
|
+
dest_root = os.path.realpath(dest)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
with zipfile.ZipFile(archive_path) as zf:
|
|
109
|
+
for member in zf.namelist():
|
|
110
|
+
target = os.path.realpath(os.path.join(dest, member))
|
|
111
|
+
if target != dest_root and not target.startswith(dest_root + os.sep):
|
|
112
|
+
raise InvalidModuleArchiveError(
|
|
113
|
+
f"Module archive '{archive_path}' contains an unsafe path: {member}"
|
|
114
|
+
)
|
|
115
|
+
zf.extractall(dest)
|
|
116
|
+
for info in zf.infolist():
|
|
117
|
+
mode = (info.external_attr >> 16) & 0o777
|
|
118
|
+
if not mode:
|
|
119
|
+
continue
|
|
120
|
+
member_path = os.path.join(dest, info.filename)
|
|
121
|
+
if os.path.exists(member_path) and not os.path.islink(member_path):
|
|
122
|
+
os.chmod(member_path, mode)
|
|
123
|
+
except zipfile.BadZipFile as e:
|
|
124
|
+
raise InvalidModuleArchiveError(f"Module archive '{archive_path}' is corrupt: {e}") from e
|
|
125
|
+
|
|
126
|
+
_validate_module_tree(dest, archive_path)
|
|
127
|
+
|
|
128
|
+
|
|
40
129
|
def _strip_functional_requirements(plain_source_tree: dict) -> dict:
|
|
41
130
|
stripped = {k: v for k, v in plain_source_tree.items() if k != plain_spec.FUNCTIONAL_REQUIREMENTS}
|
|
42
131
|
if "sections" in stripped:
|
|
@@ -49,6 +138,10 @@ class PlainModule:
|
|
|
49
138
|
self.filename = filename
|
|
50
139
|
self.build_folder = build_folder
|
|
51
140
|
self.template_dirs = template_dirs
|
|
141
|
+
# When the module exists only as a "<module>.module" archive, these hold the
|
|
142
|
+
# scratch extraction used for read-only consumption. See materialize().
|
|
143
|
+
self._resolved_module_folder: str | None = None
|
|
144
|
+
self._scratch_dir: str | None = None
|
|
52
145
|
module_name, plain_source, required_modules_names = plain_file.plain_file_parser(
|
|
53
146
|
self.filename, self.template_dirs
|
|
54
147
|
)
|
|
@@ -81,16 +174,28 @@ class PlainModule:
|
|
|
81
174
|
return all_required_modules
|
|
82
175
|
|
|
83
176
|
@property
|
|
84
|
-
def
|
|
177
|
+
def _default_module_folder(self) -> str:
|
|
85
178
|
return os.path.join(self.build_folder, self.module_name)
|
|
86
179
|
|
|
180
|
+
@property
|
|
181
|
+
def module_folder(self):
|
|
182
|
+
# When the module was materialized from a "<module>.module" archive, all subpaths
|
|
183
|
+
# resolve against the scratch extraction; otherwise against the real folder.
|
|
184
|
+
if self._resolved_module_folder is not None:
|
|
185
|
+
return self._resolved_module_folder
|
|
186
|
+
return self._default_module_folder
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def module_archive_path(self) -> str:
|
|
190
|
+
return self._default_module_folder + MODULE_ARCHIVE_EXTENSION
|
|
191
|
+
|
|
87
192
|
@property
|
|
88
193
|
def module_conformance_tests_folder(self):
|
|
89
|
-
return
|
|
194
|
+
return os.path.join(self.module_folder, MODULE_TESTS_SUBFOLDER)
|
|
90
195
|
|
|
91
196
|
@property
|
|
92
197
|
def module_build_folder(self):
|
|
93
|
-
return
|
|
198
|
+
return os.path.join(self.module_folder, MODULE_CODE_SUBFOLDER)
|
|
94
199
|
|
|
95
200
|
@property
|
|
96
201
|
def module_memory_folder(self):
|
|
@@ -99,6 +204,84 @@ class PlainModule:
|
|
|
99
204
|
def get_codeplain_folder(self):
|
|
100
205
|
return os.path.join(self.module_folder, CODEPLAIN_METADATA_FOLDER)
|
|
101
206
|
|
|
207
|
+
def has_module_archive(self) -> bool:
|
|
208
|
+
return os.path.isfile(self.module_archive_path)
|
|
209
|
+
|
|
210
|
+
def is_archived_only(self) -> bool:
|
|
211
|
+
return not os.path.isdir(self._default_module_folder) and self.has_module_archive()
|
|
212
|
+
|
|
213
|
+
def archive_has_conformance_tests(self) -> bool:
|
|
214
|
+
"""True if the "<module>.module" archive contains a tests/ folder. A module rendered without
|
|
215
|
+
a conformance-tests script has no tests/, so its archive has none either."""
|
|
216
|
+
if not self.has_module_archive() or not zipfile.is_zipfile(self.module_archive_path):
|
|
217
|
+
return False
|
|
218
|
+
prefix = MODULE_TESTS_SUBFOLDER + "/"
|
|
219
|
+
with zipfile.ZipFile(self.module_archive_path) as zf:
|
|
220
|
+
return any(name.startswith(prefix) for name in zf.namelist())
|
|
221
|
+
|
|
222
|
+
def materialize(self) -> None:
|
|
223
|
+
"""Make an archive-only module readable without populating plain_modules/<module>/.
|
|
224
|
+
|
|
225
|
+
If the real folder is absent but a "<module>.module" archive exists, extract it to a
|
|
226
|
+
scratch directory and point this module's paths there. No-op if the real folder exists
|
|
227
|
+
or the module is already materialized. The archive file is preserved.
|
|
228
|
+
"""
|
|
229
|
+
if self._resolved_module_folder is not None:
|
|
230
|
+
return
|
|
231
|
+
if os.path.isdir(self._default_module_folder):
|
|
232
|
+
return
|
|
233
|
+
if not self.has_module_archive():
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
scratch_dir = tempfile.mkdtemp(prefix=f"codeplain-module-{self.module_name}-")
|
|
237
|
+
try:
|
|
238
|
+
_extract_module_archive(self.module_archive_path, scratch_dir)
|
|
239
|
+
except BaseException:
|
|
240
|
+
shutil.rmtree(scratch_dir, ignore_errors=True)
|
|
241
|
+
raise
|
|
242
|
+
self._scratch_dir = scratch_dir
|
|
243
|
+
self._resolved_module_folder = scratch_dir
|
|
244
|
+
|
|
245
|
+
def ensure_module_unpacked(self) -> None:
|
|
246
|
+
"""Unpack an archive-only module into the real plain_modules/<module>/ in place.
|
|
247
|
+
|
|
248
|
+
Called before a module is (re)rendered. Idempotent. If the real folder already exists,
|
|
249
|
+
only a stray archive is removed. Extraction is atomic (extract to a temp sibling under
|
|
250
|
+
the build folder, then os.replace), and the archive is deleted only after success.
|
|
251
|
+
"""
|
|
252
|
+
if os.path.isdir(self._default_module_folder):
|
|
253
|
+
if self.has_module_archive():
|
|
254
|
+
os.remove(self.module_archive_path)
|
|
255
|
+
self._reset_scratch()
|
|
256
|
+
self._resolved_module_folder = None
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
if not self.has_module_archive():
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
os.makedirs(self.build_folder, exist_ok=True)
|
|
263
|
+
staging_dir = tempfile.mkdtemp(prefix=f".{self.module_name}-unpacking-", dir=self.build_folder)
|
|
264
|
+
try:
|
|
265
|
+
_extract_module_archive(self.module_archive_path, staging_dir)
|
|
266
|
+
os.replace(staging_dir, self._default_module_folder)
|
|
267
|
+
except BaseException:
|
|
268
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
os.remove(self.module_archive_path)
|
|
272
|
+
self._reset_scratch()
|
|
273
|
+
self._resolved_module_folder = None
|
|
274
|
+
|
|
275
|
+
def _reset_scratch(self) -> None:
|
|
276
|
+
if self._scratch_dir is not None:
|
|
277
|
+
shutil.rmtree(self._scratch_dir, ignore_errors=True)
|
|
278
|
+
self._scratch_dir = None
|
|
279
|
+
|
|
280
|
+
def cleanup_scratch(self) -> None:
|
|
281
|
+
"""Remove any scratch extraction created by materialize(). Safe to call multiple times."""
|
|
282
|
+
self._reset_scratch()
|
|
283
|
+
self._resolved_module_folder = None
|
|
284
|
+
|
|
102
285
|
def get_module_render_status(self) -> tuple[str | None, str | None]:
|
|
103
286
|
module_name, frid = git_utils.get_last_rendered_functionality(self.module_build_folder)
|
|
104
287
|
if module_name is not None and module_name == self.module_name:
|
|
@@ -149,6 +332,9 @@ class PlainModule:
|
|
|
149
332
|
return plain_spec.get_hash_value([stripped] + self.resources_list)
|
|
150
333
|
|
|
151
334
|
def get_module_code_hash(self) -> str:
|
|
335
|
+
# Content-only hash (see calculate_build_folder_hash): reading from the resolved (possibly
|
|
336
|
+
# scratch) folder yields the same hash as the in-place folder and the same hash across
|
|
337
|
+
# locations, so archived modules stay portable.
|
|
152
338
|
return ImplementationCodeHelpers.calculate_build_folder_hash(self.module_build_folder)
|
|
153
339
|
|
|
154
340
|
def has_required_modules_code_changed(
|
|
@@ -408,6 +594,10 @@ class PlainModule:
|
|
|
408
594
|
return False
|
|
409
595
|
|
|
410
596
|
def wipe_module(self) -> None:
|
|
411
|
-
if os.path.
|
|
412
|
-
console.warning(f"Wiping module {self.
|
|
413
|
-
|
|
597
|
+
if os.path.isdir(self._default_module_folder):
|
|
598
|
+
console.warning(f"Wiping module {self._default_module_folder}...")
|
|
599
|
+
file_utils.delete_folder(self._default_module_folder)
|
|
600
|
+
if self.has_module_archive():
|
|
601
|
+
os.remove(self.module_archive_path)
|
|
602
|
+
self._reset_scratch()
|
|
603
|
+
self._resolved_module_folder = None
|
|
@@ -32,6 +32,11 @@ class PrepareRepositories(BaseAction):
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
else:
|
|
35
|
+
if render_context.required_modules:
|
|
36
|
+
# If the required module exists only as a "<module>.module" archive, extract it to scratch first
|
|
37
|
+
# (no-op if it is already an unpacked folder or already materialized).
|
|
38
|
+
render_context.required_modules[-1].materialize()
|
|
39
|
+
|
|
35
40
|
file_utils.delete_folder(render_context.plain_module.module_folder)
|
|
36
41
|
render_context.plain_module.seed_module_metadata()
|
|
37
42
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
from typing import Callable, Optional
|
|
3
4
|
|
|
4
5
|
import file_utils
|
|
5
6
|
from plain2code_console import console
|
|
@@ -16,11 +17,20 @@ class ConformanceTests:
|
|
|
16
17
|
self,
|
|
17
18
|
modules_base_folder: str,
|
|
18
19
|
conformance_tests_definition_file_name: str,
|
|
20
|
+
resolve_module_tests_folder: Optional[Callable[[str], Optional[str]]] = None,
|
|
19
21
|
):
|
|
20
22
|
self.modules_base_folder = modules_base_folder
|
|
21
23
|
self.conformance_tests_definition_file_name = conformance_tests_definition_file_name
|
|
24
|
+
# Optional resolver mapping a module name to its resolved tests folder. Lets an
|
|
25
|
+
# archive-only ("<module>.module") required module resolve to its scratch extraction
|
|
26
|
+
# instead of the (non-existent) default plain_modules/<module>/tests path.
|
|
27
|
+
self._resolve_module_tests_folder = resolve_module_tests_folder
|
|
22
28
|
|
|
23
29
|
def get_module_conformance_tests_folder(self, module_name: str) -> str:
|
|
30
|
+
if self._resolve_module_tests_folder is not None:
|
|
31
|
+
resolved = self._resolve_module_tests_folder(module_name)
|
|
32
|
+
if resolved is not None:
|
|
33
|
+
return resolved
|
|
24
34
|
return get_module_tests_folder(self.modules_base_folder, module_name)
|
|
25
35
|
|
|
26
36
|
def _get_full_conformance_tests_definition_file_name(self, module_name: str) -> str:
|
|
@@ -29,21 +39,48 @@ class ConformanceTests:
|
|
|
29
39
|
self.conformance_tests_definition_file_name,
|
|
30
40
|
)
|
|
31
41
|
|
|
42
|
+
def _resolve_folder_names(self, module_name: str, conformance_tests_json: dict) -> dict:
|
|
43
|
+
"""Turn each entry's on-disk relative ``folder_name`` into an absolute path rooted at the
|
|
44
|
+
module's (resolved) tests folder. An already-absolute value is left as-is, so archives
|
|
45
|
+
written by older builds still load."""
|
|
46
|
+
base = self.get_module_conformance_tests_folder(module_name)
|
|
47
|
+
resolved: dict = {}
|
|
48
|
+
for frid, entry in conformance_tests_json.items():
|
|
49
|
+
if isinstance(entry, dict) and "folder_name" in entry:
|
|
50
|
+
entry = {**entry, "folder_name": os.path.join(base, entry["folder_name"])}
|
|
51
|
+
resolved[frid] = entry
|
|
52
|
+
return resolved
|
|
53
|
+
|
|
54
|
+
def _relativize_folder_names(self, module_name: str, conformance_tests_json: dict) -> dict:
|
|
55
|
+
"""Turn each entry's absolute in-memory ``folder_name`` into a path relative to the module's
|
|
56
|
+
tests folder, so the stored definition is location-independent (portable across projects and
|
|
57
|
+
usable from a "<module>.module" archive). Does not mutate the input dict."""
|
|
58
|
+
base = self.get_module_conformance_tests_folder(module_name)
|
|
59
|
+
serializable: dict = {}
|
|
60
|
+
for frid, entry in conformance_tests_json.items():
|
|
61
|
+
if isinstance(entry, dict) and "folder_name" in entry:
|
|
62
|
+
entry = {**entry, "folder_name": os.path.relpath(entry["folder_name"], base)}
|
|
63
|
+
serializable[frid] = entry
|
|
64
|
+
return serializable
|
|
65
|
+
|
|
32
66
|
def get_conformance_tests_json(self, module_name: str) -> dict:
|
|
33
67
|
try:
|
|
34
68
|
with open(self._get_full_conformance_tests_definition_file_name(module_name), "r") as f:
|
|
35
|
-
|
|
69
|
+
conformance_tests_json = json.load(f)
|
|
36
70
|
except FileNotFoundError:
|
|
37
71
|
return {}
|
|
72
|
+
return self._resolve_folder_names(module_name, conformance_tests_json)
|
|
38
73
|
|
|
39
74
|
def dump_conformance_tests_json(self, module_name: str, conformance_tests_json: dict) -> None:
|
|
40
|
-
"""Dump the conformance tests definition to the file.
|
|
75
|
+
"""Dump the conformance tests definition to the file. Folder names are stored relative to the
|
|
76
|
+
module's tests folder so the definition is portable and works from a scratch extraction."""
|
|
41
77
|
if os.path.exists(self.get_module_conformance_tests_folder(module_name)):
|
|
42
78
|
console.debug(
|
|
43
79
|
f"Storing conformance tests definition to {self._get_full_conformance_tests_definition_file_name(module_name)}"
|
|
44
80
|
)
|
|
81
|
+
serializable = self._relativize_folder_names(module_name, conformance_tests_json)
|
|
45
82
|
with open(self._get_full_conformance_tests_definition_file_name(module_name), "w") as f:
|
|
46
|
-
json.dump(
|
|
83
|
+
json.dump(serializable, f, indent=4)
|
|
47
84
|
|
|
48
85
|
def fetch_existing_conformance_test_folder_names(self, module_name: str) -> list[str]:
|
|
49
86
|
if os.path.isdir(self.get_module_conformance_tests_folder(module_name)):
|
|
@@ -8,8 +8,12 @@ import plain_spec
|
|
|
8
8
|
class ImplementationCodeHelpers:
|
|
9
9
|
@staticmethod
|
|
10
10
|
def calculate_build_folder_hash(build_folder: str) -> str:
|
|
11
|
+
# Hash the code content only (relative paths + file contents), NOT the build folder's
|
|
12
|
+
# absolute path, so the hash is stable across directories and machines. This is required for
|
|
13
|
+
# distributable "<module>.module" archives: a module's code hash must match regardless of
|
|
14
|
+
# where plain_modules/ lives, or consuming an archive elsewhere falsely reports a code change.
|
|
11
15
|
_, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(build_folder)
|
|
12
|
-
return plain_spec.hash_text(
|
|
16
|
+
return plain_spec.hash_text(json.dumps(existing_files_content))
|
|
13
17
|
|
|
14
18
|
@staticmethod
|
|
15
19
|
def fetch_existing_files(build_folder: str):
|
render_machine/render_context.py
CHANGED
|
@@ -87,10 +87,22 @@ class RenderContext:
|
|
|
87
87
|
self.conformance_tests_running_context: Optional[ConformanceTestsRunningContext] = None
|
|
88
88
|
# Constants that should remain for a single frid, but possible over multiple rerenderings of the same frid
|
|
89
89
|
self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0
|
|
90
|
-
|
|
90
|
+
|
|
91
|
+
# Initialize conformance tests utilities. The resolver lets a required module that ships
|
|
92
|
+
# as a "<module>.module" archive resolve to its scratch extraction (via materialize())
|
|
93
|
+
# rather than the non-existent default plain_modules/<module>/tests path.
|
|
94
|
+
def _resolve_module_tests_folder(module_name: str) -> Optional[str]:
|
|
95
|
+
if module_name == plain_module.module_name:
|
|
96
|
+
return plain_module.module_conformance_tests_folder
|
|
97
|
+
for required_module in plain_module.all_required_modules:
|
|
98
|
+
if required_module.module_name == module_name:
|
|
99
|
+
return required_module.module_conformance_tests_folder
|
|
100
|
+
return None
|
|
101
|
+
|
|
91
102
|
self.conformance_tests = ConformanceTests(
|
|
92
103
|
modules_base_folder=plain_module.build_folder,
|
|
93
104
|
conformance_tests_definition_file_name=CONFORMANCE_TESTS_DEFINITION_FILE_NAME,
|
|
105
|
+
resolve_module_tests_folder=_resolve_module_tests_folder,
|
|
94
106
|
)
|
|
95
107
|
|
|
96
108
|
self.machine = None
|
system_config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import importlib.resources
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import sys
|
|
4
5
|
|
|
5
6
|
import yaml
|
|
@@ -10,6 +11,15 @@ ENVIRONMENT_VAR = "CODEPLAIN_ENV"
|
|
|
10
11
|
PRODUCTION_ENV = "production"
|
|
11
12
|
DEVELOPMENT_ENV = "development"
|
|
12
13
|
|
|
14
|
+
PRODUCTION_API_URL = "https://api.codeplain.ai"
|
|
15
|
+
TEST_API_URL = "https://api.test.codeplain.ai"
|
|
16
|
+
|
|
17
|
+
# PEP 440 pre-release markers, matched against the end of the release segment:
|
|
18
|
+
# alpha/beta/release-candidate (0.4.0a1, 0.4.0b2, 0.4.0rc1) or a dev release
|
|
19
|
+
# (0.3.10.dev7, published on every merge to main). ".postN" alone is NOT a
|
|
20
|
+
# pre-release and must not match.
|
|
21
|
+
_PRERELEASE_RE = re.compile(r"(?:a|b|rc)\d+$|\.dev\d+$", re.IGNORECASE)
|
|
22
|
+
|
|
13
23
|
|
|
14
24
|
def _resolve_version() -> str:
|
|
15
25
|
"""Resolve the client version.
|
|
@@ -79,9 +89,33 @@ def _resolve_environment(installed: bool) -> str:
|
|
|
79
89
|
return PRODUCTION_ENV if installed else DEVELOPMENT_ENV
|
|
80
90
|
|
|
81
91
|
|
|
92
|
+
def _is_prerelease(version: str) -> bool:
|
|
93
|
+
"""Return True if ``version`` is a PEP 440 pre-release (alpha, beta, rc or dev).
|
|
94
|
+
|
|
95
|
+
Deliberately dependency-free rather than using ``packaging``: that library
|
|
96
|
+
is not a runtime dependency (it only reaches this checkout via dev tools),
|
|
97
|
+
so it is absent from a real user install. The only versions that reach here
|
|
98
|
+
are the ones our own build pipeline produces.
|
|
99
|
+
"""
|
|
100
|
+
# Drop any local version segment ("+gb710c298") before inspecting.
|
|
101
|
+
return _PRERELEASE_RE.search(version.split("+", 1)[0]) is not None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_default_api_url(version: str) -> str:
|
|
105
|
+
"""Resolve the API this client talks to when ``--api`` is not given.
|
|
106
|
+
|
|
107
|
+
Pre-release builds — the dev build published on every merge to main, and
|
|
108
|
+
any alpha/beta/rc release — default to the test API, so an unreleased
|
|
109
|
+
client is exercised against the unreleased backend. Stable releases default
|
|
110
|
+
to production. ``--api`` always wins over this.
|
|
111
|
+
"""
|
|
112
|
+
return TEST_API_URL if _is_prerelease(version) else PRODUCTION_API_URL
|
|
113
|
+
|
|
114
|
+
|
|
82
115
|
__version__ = _resolve_version()
|
|
83
116
|
__installed__ = _resolve_installed()
|
|
84
117
|
__environment__ = _resolve_environment(__installed__)
|
|
118
|
+
__default_api_url__ = _resolve_default_api_url(__version__)
|
|
85
119
|
|
|
86
120
|
|
|
87
121
|
class SystemConfig:
|
|
@@ -95,6 +129,7 @@ class SystemConfig:
|
|
|
95
129
|
self.client_version = __version__
|
|
96
130
|
self.installed = __installed__
|
|
97
131
|
self.environment = __environment__
|
|
132
|
+
self.default_api_url = __default_api_url__
|
|
98
133
|
self.error_messages = self.config["error_messages"]
|
|
99
134
|
|
|
100
135
|
def _load_config(self):
|
tests/test_cli_output.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Unit tests for cli_output module."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime, timezone
|
|
3
4
|
from unittest.mock import Mock, patch
|
|
4
5
|
|
|
5
6
|
from cli_output.status import (
|
|
@@ -10,6 +11,14 @@ from cli_output.status import (
|
|
|
10
11
|
print_status,
|
|
11
12
|
)
|
|
12
13
|
|
|
14
|
+
FROZEN_NOW = datetime(2025, 6, 15, 12, 0, tzinfo=timezone.utc)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FrozenDatetime(datetime):
|
|
18
|
+
@classmethod
|
|
19
|
+
def now(cls, tz=None):
|
|
20
|
+
return FROZEN_NOW if tz is None else FROZEN_NOW.astimezone(tz)
|
|
21
|
+
|
|
13
22
|
|
|
14
23
|
class TestProgressBar:
|
|
15
24
|
"""Tests for _create_progress_bar function."""
|
|
@@ -52,6 +61,7 @@ class TestProgressBar:
|
|
|
52
61
|
assert bar == "█" * 5 + "░" * 5
|
|
53
62
|
|
|
54
63
|
|
|
64
|
+
@patch("cli_output.status.datetime", FrozenDatetime)
|
|
55
65
|
class TestDisplayCreditLine:
|
|
56
66
|
"""Tests for _display_credit_line function."""
|
|
57
67
|
|
|
@@ -132,6 +142,7 @@ class TestDisplayCreditLine:
|
|
|
132
142
|
mock_console.print.assert_called_once()
|
|
133
143
|
|
|
134
144
|
|
|
145
|
+
@patch("cli_output.status.datetime", FrozenDatetime)
|
|
135
146
|
class TestDisplayBucketCreditLine:
|
|
136
147
|
"""Tests for _display_bucket_credit_line function."""
|
|
137
148
|
|
|
@@ -192,6 +203,7 @@ class TestDisplayBucketCreditLine:
|
|
|
192
203
|
mock_console.print.assert_called_once()
|
|
193
204
|
|
|
194
205
|
|
|
206
|
+
@patch("cli_output.status.datetime", FrozenDatetime)
|
|
195
207
|
class TestDisplayStatusMessage:
|
|
196
208
|
"""Tests for _display_status_message function."""
|
|
197
209
|
|
|
@@ -271,6 +283,7 @@ class TestDisplayStatusMessage:
|
|
|
271
283
|
assert "No rendering credits remaining" in call_args
|
|
272
284
|
|
|
273
285
|
|
|
286
|
+
@patch("cli_output.status.datetime", FrozenDatetime)
|
|
274
287
|
class TestPrintStatus:
|
|
275
288
|
"""Tests for print_status function."""
|
|
276
289
|
|