serenecode 0.1.0__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.
- serenecode/__init__.py +281 -0
- serenecode/adapters/__init__.py +6 -0
- serenecode/adapters/coverage_adapter.py +1173 -0
- serenecode/adapters/crosshair_adapter.py +1069 -0
- serenecode/adapters/hypothesis_adapter.py +1824 -0
- serenecode/adapters/local_fs.py +169 -0
- serenecode/adapters/module_loader.py +492 -0
- serenecode/adapters/mypy_adapter.py +161 -0
- serenecode/checker/__init__.py +6 -0
- serenecode/checker/compositional.py +2216 -0
- serenecode/checker/coverage.py +186 -0
- serenecode/checker/properties.py +154 -0
- serenecode/checker/structural.py +1504 -0
- serenecode/checker/symbolic.py +178 -0
- serenecode/checker/types.py +148 -0
- serenecode/cli.py +478 -0
- serenecode/config.py +711 -0
- serenecode/contracts/__init__.py +6 -0
- serenecode/contracts/predicates.py +176 -0
- serenecode/core/__init__.py +6 -0
- serenecode/core/exceptions.py +38 -0
- serenecode/core/pipeline.py +807 -0
- serenecode/init.py +307 -0
- serenecode/models.py +308 -0
- serenecode/ports/__init__.py +6 -0
- serenecode/ports/coverage_analyzer.py +124 -0
- serenecode/ports/file_system.py +95 -0
- serenecode/ports/property_tester.py +69 -0
- serenecode/ports/symbolic_checker.py +70 -0
- serenecode/ports/type_checker.py +66 -0
- serenecode/reporter.py +346 -0
- serenecode/source_discovery.py +319 -0
- serenecode/templates/__init__.py +5 -0
- serenecode/templates/content.py +337 -0
- serenecode-0.1.0.dist-info/METADATA +298 -0
- serenecode-0.1.0.dist-info/RECORD +39 -0
- serenecode-0.1.0.dist-info/WHEEL +4 -0
- serenecode-0.1.0.dist-info/entry_points.txt +2 -0
- serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Local file system adapter for Serenecode.
|
|
2
|
+
|
|
3
|
+
This module implements the FileReader and FileWriter protocols using
|
|
4
|
+
pathlib for actual file system operations. It is the only module
|
|
5
|
+
that directly touches the real file system.
|
|
6
|
+
|
|
7
|
+
This is an adapter module — it handles I/O and wraps OS errors
|
|
8
|
+
in domain exceptions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import icontract
|
|
17
|
+
|
|
18
|
+
from serenecode.contracts.predicates import is_non_empty_string
|
|
19
|
+
from serenecode.core.exceptions import ConfigurationError, InitializationError
|
|
20
|
+
|
|
21
|
+
_IGNORED_DIR_NAMES = frozenset({
|
|
22
|
+
".git",
|
|
23
|
+
".hg",
|
|
24
|
+
".svn",
|
|
25
|
+
".mypy_cache",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".ruff_cache",
|
|
28
|
+
".tox",
|
|
29
|
+
".venv",
|
|
30
|
+
".hypothesis",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"build",
|
|
33
|
+
"dist",
|
|
34
|
+
"env",
|
|
35
|
+
"node_modules",
|
|
36
|
+
"venv",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# no-invariant: stateless adapter with no instance fields to constrain
|
|
41
|
+
class LocalFileReader:
|
|
42
|
+
"""File reader implementation using pathlib.
|
|
43
|
+
|
|
44
|
+
Reads files from the local file system and lists Python files
|
|
45
|
+
in directories.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
|
|
49
|
+
@icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
|
|
50
|
+
def read_file(self, path: str) -> str:
|
|
51
|
+
"""Read a file and return its contents as a UTF-8 string.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
path: Path to the file to read.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The full file contents as a string.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ConfigurationError: If the file cannot be read.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
return Path(path).read_text(encoding="utf-8")
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise ConfigurationError(f"Cannot read file '{path}': {exc}") from exc
|
|
66
|
+
|
|
67
|
+
@icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
|
|
68
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a boolean")
|
|
69
|
+
def file_exists(self, path: str) -> bool:
|
|
70
|
+
"""Check whether a file exists at the given path.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Path to check.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if a file exists at path.
|
|
77
|
+
"""
|
|
78
|
+
return Path(path).is_file()
|
|
79
|
+
|
|
80
|
+
@icontract.require(lambda directory: is_non_empty_string(directory), "directory must be a non-empty string")
|
|
81
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
82
|
+
def list_python_files(self, directory: str) -> list[str]:
|
|
83
|
+
"""List all Python (.py) files in a directory recursively.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
directory: Root directory to search.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Sorted list of paths to .py files as strings.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ConfigurationError: If the directory cannot be read.
|
|
93
|
+
"""
|
|
94
|
+
dir_path = Path(directory)
|
|
95
|
+
|
|
96
|
+
if not dir_path.exists():
|
|
97
|
+
raise ConfigurationError(f"Directory does not exist: '{directory}'")
|
|
98
|
+
|
|
99
|
+
if dir_path.is_file():
|
|
100
|
+
if dir_path.suffix == ".py":
|
|
101
|
+
return [str(dir_path)]
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
files: list[str] = []
|
|
106
|
+
# Loop invariant: files contains Python files discovered from prior os.walk entries
|
|
107
|
+
for current_root, dir_names, file_names in os.walk(dir_path, followlinks=False):
|
|
108
|
+
dir_names[:] = sorted(
|
|
109
|
+
d for d in dir_names
|
|
110
|
+
if d not in _IGNORED_DIR_NAMES
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Loop invariant: files contains matching Python files from file_names[0..i]
|
|
114
|
+
for file_name in sorted(file_names):
|
|
115
|
+
if not file_name.endswith(".py"):
|
|
116
|
+
continue
|
|
117
|
+
files.append(str(Path(current_root) / file_name))
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise ConfigurationError(
|
|
120
|
+
f"Cannot list files in '{directory}': {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
return sorted(files)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# no-invariant: stateless adapter with no instance fields to constrain
|
|
127
|
+
class LocalFileWriter:
|
|
128
|
+
"""File writer implementation using pathlib.
|
|
129
|
+
|
|
130
|
+
Writes files to the local file system and creates directories.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
@icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
|
|
134
|
+
@icontract.require(lambda content: isinstance(content, str), "content must be a string")
|
|
135
|
+
@icontract.ensure(lambda result: result is None, "result must be None")
|
|
136
|
+
def write_file(self, path: str, content: str) -> None:
|
|
137
|
+
"""Write content to a file, creating parent directories if needed.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
path: Path to the file to write.
|
|
141
|
+
content: Content to write as UTF-8.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
InitializationError: If the file cannot be written.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
file_path = Path(path)
|
|
148
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
file_path.write_text(content, encoding="utf-8")
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise InitializationError(f"Cannot write file '{path}': {exc}") from exc
|
|
152
|
+
|
|
153
|
+
@icontract.require(lambda path: is_non_empty_string(path), "path must be a non-empty string")
|
|
154
|
+
@icontract.ensure(lambda result: result is None, "result must be None")
|
|
155
|
+
def ensure_directory(self, path: str) -> None:
|
|
156
|
+
"""Ensure a directory exists, creating it if necessary.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Path to the directory.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
InitializationError: If the directory cannot be created.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise InitializationError(
|
|
168
|
+
f"Cannot create directory '{path}': {exc}"
|
|
169
|
+
) from exc
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Helpers for loading Python modules from module names or file paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.abc
|
|
9
|
+
import importlib.util
|
|
10
|
+
import keyword
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from importlib.machinery import ModuleSpec
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
import icontract
|
|
19
|
+
|
|
20
|
+
from serenecode.contracts.predicates import is_non_empty_string, is_valid_file_path_string
|
|
21
|
+
|
|
22
|
+
_MODULE_LOAD_LOCK = threading.RLock()
|
|
23
|
+
_UNSUPPORTED_RELATIVE_MODULE_PREFIXES = (".",)
|
|
24
|
+
_DYNAMIC_MODULE_PREFIX = "serenecode_dynamic_"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@icontract.require(lambda module_ref: is_non_empty_string(module_ref), "module_ref must be a non-empty string")
|
|
28
|
+
@icontract.require(
|
|
29
|
+
lambda module_ref: is_valid_file_path_string(module_ref),
|
|
30
|
+
"module_ref must be a syntactically valid module reference",
|
|
31
|
+
)
|
|
32
|
+
@icontract.require(
|
|
33
|
+
lambda module_ref: not module_ref.startswith(_UNSUPPORTED_RELATIVE_MODULE_PREFIXES),
|
|
34
|
+
"relative module names are not supported",
|
|
35
|
+
)
|
|
36
|
+
@icontract.ensure(lambda result: isinstance(result, ModuleType), "result must be a module")
|
|
37
|
+
def load_python_module(
|
|
38
|
+
module_ref: str,
|
|
39
|
+
search_paths: tuple[str, ...] = (),
|
|
40
|
+
) -> ModuleType:
|
|
41
|
+
"""Load a Python module from a dotted name or an absolute file path.
|
|
42
|
+
|
|
43
|
+
Always refreshes the target module so repeated verification runs see
|
|
44
|
+
the latest source instead of a cached import from an earlier check.
|
|
45
|
+
"""
|
|
46
|
+
with _MODULE_LOAD_LOCK:
|
|
47
|
+
module_file = _as_existing_python_file(module_ref)
|
|
48
|
+
if module_file is None:
|
|
49
|
+
spec = _find_module_spec(module_ref, search_paths)
|
|
50
|
+
if spec is None or spec.origin is None:
|
|
51
|
+
raise ImportError(f"Cannot load module '{module_ref}'")
|
|
52
|
+
return _load_module_from_spec(module_ref, spec, search_paths)
|
|
53
|
+
return _load_module_from_file(module_file, search_paths)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@icontract.require(lambda module_ref: is_non_empty_string(module_ref), "module_ref must be a non-empty string")
|
|
57
|
+
@icontract.ensure(lambda result: result is None or isinstance(result, Path), "result must be a path or None")
|
|
58
|
+
def _as_existing_python_file(module_ref: str) -> Path | None:
|
|
59
|
+
"""Interpret a module reference as an absolute Python file when possible."""
|
|
60
|
+
path = Path(module_ref)
|
|
61
|
+
if not path.is_absolute():
|
|
62
|
+
return None
|
|
63
|
+
if not path.is_file() or path.suffix != ".py":
|
|
64
|
+
return None
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@icontract.require(lambda module_file: isinstance(module_file, Path), "module_file must be a Path")
|
|
69
|
+
@icontract.ensure(lambda result: isinstance(result, ModuleType), "result must be a module")
|
|
70
|
+
def _load_module_from_file(
|
|
71
|
+
module_file: Path,
|
|
72
|
+
search_paths: tuple[str, ...] = (),
|
|
73
|
+
) -> ModuleType:
|
|
74
|
+
"""Load a Python module directly from a source file."""
|
|
75
|
+
resolved = module_file.resolve()
|
|
76
|
+
module_name = (
|
|
77
|
+
_DYNAMIC_MODULE_PREFIX
|
|
78
|
+
+ hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:16]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
module_dir = str(resolved.parent)
|
|
82
|
+
effective_search_paths = _dedupe_search_paths(
|
|
83
|
+
(*search_paths, _infer_import_root(resolved), module_dir)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
spec = importlib.util.spec_from_file_location(module_name, resolved)
|
|
87
|
+
if spec is None:
|
|
88
|
+
raise ImportError(f"Cannot load module from '{resolved}'")
|
|
89
|
+
|
|
90
|
+
return _load_module_from_spec(module_name, spec, effective_search_paths)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@icontract.require(lambda module_ref: is_non_empty_string(module_ref), "module_ref must be a non-empty string")
|
|
94
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
95
|
+
@icontract.ensure(
|
|
96
|
+
lambda result: result is None or isinstance(result, ModuleSpec),
|
|
97
|
+
"result must be a ModuleSpec or None",
|
|
98
|
+
)
|
|
99
|
+
def _find_module_spec(
|
|
100
|
+
module_ref: str,
|
|
101
|
+
search_paths: tuple[str, ...],
|
|
102
|
+
) -> ModuleSpec | None:
|
|
103
|
+
"""Resolve a module spec, preferring explicit search-root lookups."""
|
|
104
|
+
local_spec = _find_local_module_spec(module_ref, search_paths)
|
|
105
|
+
if local_spec is not None:
|
|
106
|
+
return local_spec
|
|
107
|
+
|
|
108
|
+
importlib.invalidate_caches()
|
|
109
|
+
normalized_search_paths = _dedupe_search_paths(search_paths)
|
|
110
|
+
with _temporary_sys_path(normalized_search_paths):
|
|
111
|
+
return importlib.util.find_spec(module_ref)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@icontract.require(lambda module_ref: is_non_empty_string(module_ref), "module_ref must be a non-empty string")
|
|
115
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
116
|
+
@icontract.ensure(
|
|
117
|
+
lambda result: result is None or isinstance(result, ModuleSpec),
|
|
118
|
+
"result must be a ModuleSpec or None",
|
|
119
|
+
)
|
|
120
|
+
def _find_local_module_spec(
|
|
121
|
+
module_ref: str,
|
|
122
|
+
search_paths: tuple[str, ...],
|
|
123
|
+
) -> ModuleSpec | None:
|
|
124
|
+
"""Resolve a local module directly from the provided search roots."""
|
|
125
|
+
relative_parts = module_ref.split(".")
|
|
126
|
+
|
|
127
|
+
# Loop invariant: no prior search path resolved module_ref to a local file-backed spec.
|
|
128
|
+
for search_path in _dedupe_search_paths(search_paths):
|
|
129
|
+
root = Path(search_path).resolve()
|
|
130
|
+
package_init = root.joinpath(*relative_parts, "__init__.py")
|
|
131
|
+
if package_init.is_file():
|
|
132
|
+
return importlib.util.spec_from_file_location(
|
|
133
|
+
module_ref,
|
|
134
|
+
package_init,
|
|
135
|
+
submodule_search_locations=[str(package_init.parent)],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
module_file = root.joinpath(*relative_parts).with_suffix(".py")
|
|
139
|
+
if module_file.is_file():
|
|
140
|
+
return importlib.util.spec_from_file_location(module_ref, module_file)
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@icontract.require(lambda module_name: is_non_empty_string(module_name), "module_name must be a non-empty string")
|
|
146
|
+
@icontract.require(lambda spec: isinstance(spec, ModuleSpec), "spec must be a ModuleSpec")
|
|
147
|
+
@icontract.ensure(lambda result: isinstance(result, ModuleType), "result must be a module")
|
|
148
|
+
def _load_module_from_spec(
|
|
149
|
+
module_name: str,
|
|
150
|
+
spec: ModuleSpec,
|
|
151
|
+
search_paths: tuple[str, ...] = (),
|
|
152
|
+
) -> ModuleType:
|
|
153
|
+
"""Load a module from an import spec using fresh source execution."""
|
|
154
|
+
if spec.origin is None:
|
|
155
|
+
raise ImportError(f"Cannot load module '{module_name}' without origin")
|
|
156
|
+
|
|
157
|
+
importlib.invalidate_caches()
|
|
158
|
+
refresh_prefixes = _module_refresh_prefixes(module_name, spec.origin, search_paths)
|
|
159
|
+
import_roots = _module_import_roots(module_name, spec.origin, search_paths)
|
|
160
|
+
with _temporary_module_refresh(refresh_prefixes):
|
|
161
|
+
with _temporary_fresh_imports(_dedupe_search_paths(search_paths), import_roots):
|
|
162
|
+
module = importlib.util.module_from_spec(spec)
|
|
163
|
+
sys.modules[module_name] = module
|
|
164
|
+
with _temporary_sys_path(_dedupe_search_paths(search_paths)):
|
|
165
|
+
source = Path(spec.origin).read_text(encoding="utf-8")
|
|
166
|
+
code = compile(source, spec.origin, "exec")
|
|
167
|
+
exec(code, module.__dict__)
|
|
168
|
+
|
|
169
|
+
return module
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@icontract.require(lambda module_name: is_non_empty_string(module_name), "module_name must be a non-empty string")
|
|
173
|
+
@icontract.require(lambda origin: is_non_empty_string(origin), "origin must be a non-empty string")
|
|
174
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
175
|
+
@icontract.ensure(lambda result: isinstance(result, tuple), "result must be a tuple")
|
|
176
|
+
def _module_refresh_prefixes(
|
|
177
|
+
module_name: str,
|
|
178
|
+
origin: str,
|
|
179
|
+
search_paths: tuple[str, ...],
|
|
180
|
+
) -> tuple[str, ...]:
|
|
181
|
+
"""Infer module prefixes whose cache entries should be refreshed."""
|
|
182
|
+
prefixes: list[str] = []
|
|
183
|
+
|
|
184
|
+
if not module_name.startswith(_DYNAMIC_MODULE_PREFIX):
|
|
185
|
+
prefixes.append(module_name)
|
|
186
|
+
if "." in module_name:
|
|
187
|
+
prefixes.append(module_name.split(".", 1)[0] + ".")
|
|
188
|
+
|
|
189
|
+
derived_name = _module_name_from_origin(Path(origin), search_paths)
|
|
190
|
+
if derived_name is not None:
|
|
191
|
+
prefixes.append(derived_name)
|
|
192
|
+
if "." in derived_name:
|
|
193
|
+
prefixes.append(derived_name.split(".", 1)[0] + ".")
|
|
194
|
+
|
|
195
|
+
unique: list[str] = []
|
|
196
|
+
# Loop invariant: unique contains the distinct refresh prefixes from prefixes[0..i].
|
|
197
|
+
for prefix in prefixes:
|
|
198
|
+
if prefix and prefix not in unique:
|
|
199
|
+
unique.append(prefix)
|
|
200
|
+
|
|
201
|
+
return tuple(unique)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@icontract.require(lambda module_name: is_non_empty_string(module_name), "module_name must be a non-empty string")
|
|
205
|
+
@icontract.require(lambda origin: is_non_empty_string(origin), "origin must be a non-empty string")
|
|
206
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
207
|
+
@icontract.ensure(lambda result: isinstance(result, tuple), "result must be a tuple")
|
|
208
|
+
def _module_import_roots(
|
|
209
|
+
module_name: str,
|
|
210
|
+
origin: str,
|
|
211
|
+
search_paths: tuple[str, ...],
|
|
212
|
+
) -> tuple[str, ...]:
|
|
213
|
+
"""Infer top-level packages that should be imported from fresh source."""
|
|
214
|
+
roots: list[str] = []
|
|
215
|
+
if not module_name.startswith(_DYNAMIC_MODULE_PREFIX):
|
|
216
|
+
roots.append(module_name.split(".", 1)[0])
|
|
217
|
+
|
|
218
|
+
derived_name = _module_name_from_origin(Path(origin), search_paths)
|
|
219
|
+
if derived_name is not None:
|
|
220
|
+
roots.append(derived_name.split(".", 1)[0])
|
|
221
|
+
|
|
222
|
+
unique: list[str] = []
|
|
223
|
+
# Loop invariant: unique contains the distinct import roots from roots[0..i].
|
|
224
|
+
for root in roots:
|
|
225
|
+
if root and root not in unique:
|
|
226
|
+
unique.append(root)
|
|
227
|
+
return tuple(unique)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@icontract.require(lambda origin: isinstance(origin, Path), "origin must be a Path")
|
|
231
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
232
|
+
@icontract.ensure(lambda result: result is None or is_non_empty_string(result), "result must be a non-empty string when present")
|
|
233
|
+
def _module_name_from_origin(
|
|
234
|
+
origin: Path,
|
|
235
|
+
search_paths: tuple[str, ...],
|
|
236
|
+
) -> str | None:
|
|
237
|
+
"""Derive an importable module name for an origin relative to search paths."""
|
|
238
|
+
resolved_origin = origin.resolve()
|
|
239
|
+
|
|
240
|
+
# Loop invariant: no prior search path resolved origin to an importable module name.
|
|
241
|
+
for search_path in _dedupe_search_paths(search_paths):
|
|
242
|
+
root = Path(search_path).resolve()
|
|
243
|
+
try:
|
|
244
|
+
relative = resolved_origin.relative_to(root)
|
|
245
|
+
except ValueError:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
if relative.suffix != ".py":
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
parts = list(relative.parts)
|
|
252
|
+
if parts[-1] == "__init__.py":
|
|
253
|
+
parts = parts[:-1]
|
|
254
|
+
else:
|
|
255
|
+
parts[-1] = relative.stem
|
|
256
|
+
|
|
257
|
+
if not parts:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
if any((not part.isidentifier()) or keyword.iskeyword(part) for part in parts):
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
return ".".join(parts)
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@icontract.require(lambda prefixes: isinstance(prefixes, tuple), "prefixes must be a tuple")
|
|
269
|
+
@icontract.ensure(
|
|
270
|
+
lambda result: hasattr(result, "__enter__") and hasattr(result, "__exit__"),
|
|
271
|
+
"result must be a context manager",
|
|
272
|
+
)
|
|
273
|
+
@contextmanager
|
|
274
|
+
def _temporary_module_refresh(prefixes: tuple[str, ...]) -> Iterator[None]:
|
|
275
|
+
"""Temporarily clear matching module cache entries while loading a module."""
|
|
276
|
+
if not prefixes:
|
|
277
|
+
yield
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
snapshot: dict[str, ModuleType] = {}
|
|
281
|
+
existing_names = list(sys.modules)
|
|
282
|
+
# Loop invariant: snapshot contains removable cached modules from existing_names[0..i].
|
|
283
|
+
for name in existing_names:
|
|
284
|
+
if _should_refresh_module(name, prefixes):
|
|
285
|
+
module = sys.modules.get(name)
|
|
286
|
+
if module is not None:
|
|
287
|
+
snapshot[name] = module
|
|
288
|
+
del sys.modules[name]
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
yield
|
|
292
|
+
finally:
|
|
293
|
+
# Loop invariant: every refreshed module seen so far has been removed from sys.modules.
|
|
294
|
+
for name in list(sys.modules):
|
|
295
|
+
if _should_refresh_module(name, prefixes):
|
|
296
|
+
del sys.modules[name]
|
|
297
|
+
sys.modules.update(snapshot)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@icontract.require(lambda module_name: is_non_empty_string(module_name), "module_name must be a non-empty string")
|
|
301
|
+
@icontract.require(lambda prefixes: isinstance(prefixes, tuple), "prefixes must be a tuple")
|
|
302
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
303
|
+
def _should_refresh_module(module_name: str, prefixes: tuple[str, ...]) -> bool:
|
|
304
|
+
"""Check whether a cached module should be cleared for a fresh load."""
|
|
305
|
+
if module_name == __name__ or module_name.startswith(__name__ + "."):
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
# Loop invariant: no prefix checked so far matched module_name for refresh.
|
|
309
|
+
for prefix in prefixes:
|
|
310
|
+
if prefix.endswith("."):
|
|
311
|
+
if module_name.startswith(prefix):
|
|
312
|
+
return True
|
|
313
|
+
continue
|
|
314
|
+
if module_name == prefix or module_name.startswith(prefix + "."):
|
|
315
|
+
return True
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
320
|
+
@icontract.require(lambda module_roots: isinstance(module_roots, tuple), "module_roots must be a tuple")
|
|
321
|
+
@icontract.ensure(
|
|
322
|
+
lambda result: hasattr(result, "__enter__") and hasattr(result, "__exit__"),
|
|
323
|
+
"result must be a context manager",
|
|
324
|
+
)
|
|
325
|
+
@contextmanager
|
|
326
|
+
def _temporary_fresh_imports(
|
|
327
|
+
search_paths: tuple[str, ...],
|
|
328
|
+
module_roots: tuple[str, ...],
|
|
329
|
+
) -> Iterator[None]:
|
|
330
|
+
"""Temporarily install a finder that compiles local modules from source."""
|
|
331
|
+
if not search_paths or not module_roots:
|
|
332
|
+
yield
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
finder = _FreshSourceFinder(search_paths, module_roots)
|
|
336
|
+
sys.meta_path.insert(0, finder)
|
|
337
|
+
try:
|
|
338
|
+
yield
|
|
339
|
+
finally:
|
|
340
|
+
try:
|
|
341
|
+
sys.meta_path.remove(finder)
|
|
342
|
+
except ValueError:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@icontract.invariant(
|
|
347
|
+
lambda self: is_non_empty_string(self._fullname) and isinstance(self._source_path, Path),
|
|
348
|
+
"loader must keep a module name and source path",
|
|
349
|
+
)
|
|
350
|
+
class _FreshSourceLoader(importlib.abc.Loader):
|
|
351
|
+
"""Loader that always compiles the current source text from disk."""
|
|
352
|
+
|
|
353
|
+
@icontract.require(lambda fullname: is_non_empty_string(fullname), "fullname must be a non-empty string")
|
|
354
|
+
@icontract.require(lambda source_path: isinstance(source_path, Path), "source_path must be a Path")
|
|
355
|
+
@icontract.ensure(lambda result: result is None, "initialization returns None")
|
|
356
|
+
def __init__(self, fullname: str, source_path: Path) -> None:
|
|
357
|
+
"""Capture the module name and source file that should be loaded freshly."""
|
|
358
|
+
self._fullname = fullname
|
|
359
|
+
self._source_path = source_path
|
|
360
|
+
|
|
361
|
+
@icontract.require(lambda spec: isinstance(spec, ModuleSpec), "spec must be a ModuleSpec")
|
|
362
|
+
@icontract.ensure(
|
|
363
|
+
lambda result: result is None or isinstance(result, ModuleType),
|
|
364
|
+
"result must be a module or None",
|
|
365
|
+
)
|
|
366
|
+
def create_module(self, spec: ModuleSpec) -> ModuleType | None:
|
|
367
|
+
"""Delegate module object creation to Python's default import machinery."""
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
@icontract.require(lambda module: isinstance(module, ModuleType), "module must be a module")
|
|
371
|
+
@icontract.ensure(lambda result: result is None, "exec_module returns None")
|
|
372
|
+
def exec_module(self, module: ModuleType) -> None:
|
|
373
|
+
"""Execute the current source text into the provided module namespace."""
|
|
374
|
+
source = self._source_path.read_text(encoding="utf-8")
|
|
375
|
+
code = compile(source, str(self._source_path), "exec")
|
|
376
|
+
exec(code, module.__dict__)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@icontract.invariant(
|
|
380
|
+
lambda self: isinstance(self._search_paths, tuple) and isinstance(self._module_roots, tuple),
|
|
381
|
+
"finder must keep immutable search paths and module roots",
|
|
382
|
+
)
|
|
383
|
+
class _FreshSourceFinder(importlib.abc.MetaPathFinder):
|
|
384
|
+
"""Finder that resolves selected local modules directly to source files."""
|
|
385
|
+
|
|
386
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
387
|
+
@icontract.require(lambda module_roots: isinstance(module_roots, tuple), "module_roots must be a tuple")
|
|
388
|
+
@icontract.ensure(lambda result: result is None, "initialization returns None")
|
|
389
|
+
def __init__(self, search_paths: tuple[str, ...], module_roots: tuple[str, ...]) -> None:
|
|
390
|
+
"""Store the search roots and package names that should bypass bytecode caches."""
|
|
391
|
+
self._search_paths = tuple(Path(path).resolve() for path in search_paths)
|
|
392
|
+
self._module_roots = module_roots
|
|
393
|
+
|
|
394
|
+
@icontract.require(lambda fullname: is_non_empty_string(fullname), "fullname must be a non-empty string")
|
|
395
|
+
@icontract.ensure(
|
|
396
|
+
lambda result: result is None or isinstance(result, ModuleSpec),
|
|
397
|
+
"result must be a ModuleSpec or None",
|
|
398
|
+
)
|
|
399
|
+
def find_spec(
|
|
400
|
+
self,
|
|
401
|
+
fullname: str,
|
|
402
|
+
path: object = None,
|
|
403
|
+
target: ModuleType | None = None,
|
|
404
|
+
) -> ModuleSpec | None:
|
|
405
|
+
"""Resolve eligible local modules to specs backed by fresh source execution."""
|
|
406
|
+
if not any(fullname == root or fullname.startswith(root + ".") for root in self._module_roots):
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
relative_parts = fullname.split(".")
|
|
410
|
+
# Loop invariant: no prior search path has resolved fullname to a package or module source file.
|
|
411
|
+
for search_path in self._search_paths:
|
|
412
|
+
package_init = search_path.joinpath(*relative_parts, "__init__.py")
|
|
413
|
+
if package_init.is_file():
|
|
414
|
+
loader = _FreshSourceLoader(fullname, package_init)
|
|
415
|
+
return importlib.util.spec_from_file_location(
|
|
416
|
+
fullname,
|
|
417
|
+
package_init,
|
|
418
|
+
loader=loader,
|
|
419
|
+
submodule_search_locations=[str(package_init.parent)],
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
module_file = search_path.joinpath(*relative_parts).with_suffix(".py")
|
|
423
|
+
if module_file.is_file():
|
|
424
|
+
loader = _FreshSourceLoader(fullname, module_file)
|
|
425
|
+
return importlib.util.spec_from_file_location(
|
|
426
|
+
fullname,
|
|
427
|
+
module_file,
|
|
428
|
+
loader=loader,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@icontract.require(lambda module_file: isinstance(module_file, Path), "module_file must be a Path")
|
|
435
|
+
@icontract.ensure(lambda result: is_non_empty_string(result), "result must be a non-empty string")
|
|
436
|
+
def _infer_import_root(module_file: Path) -> str:
|
|
437
|
+
"""Infer the sys.path entry needed for importing sibling packages."""
|
|
438
|
+
current = module_file.parent.resolve()
|
|
439
|
+
|
|
440
|
+
# Loop invariant: current is the directory above the deepest package chain seen so far
|
|
441
|
+
while (current / "__init__.py").is_file():
|
|
442
|
+
parent = current.parent
|
|
443
|
+
if parent == current:
|
|
444
|
+
break
|
|
445
|
+
current = parent
|
|
446
|
+
|
|
447
|
+
return str(current)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
451
|
+
@icontract.ensure(lambda result: isinstance(result, tuple), "result must be a tuple")
|
|
452
|
+
def _dedupe_search_paths(search_paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
453
|
+
"""Normalize and deduplicate search paths while preserving order."""
|
|
454
|
+
unique: list[str] = []
|
|
455
|
+
|
|
456
|
+
# Loop invariant: unique contains normalized, unique paths from search_paths[0..i]
|
|
457
|
+
for path in search_paths:
|
|
458
|
+
if not path:
|
|
459
|
+
continue
|
|
460
|
+
normalized = str(Path(path).resolve())
|
|
461
|
+
if normalized not in unique:
|
|
462
|
+
unique.append(normalized)
|
|
463
|
+
|
|
464
|
+
return tuple(unique)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@icontract.require(lambda search_paths: isinstance(search_paths, tuple), "search_paths must be a tuple")
|
|
468
|
+
@icontract.ensure(
|
|
469
|
+
lambda result: hasattr(result, "__enter__") and hasattr(result, "__exit__"),
|
|
470
|
+
"result must be a context manager",
|
|
471
|
+
)
|
|
472
|
+
@contextmanager
|
|
473
|
+
def _temporary_sys_path(search_paths: tuple[str, ...]) -> Iterator[None]:
|
|
474
|
+
"""Temporarily prepend search paths to sys.path during module loading."""
|
|
475
|
+
inserted: list[str] = []
|
|
476
|
+
|
|
477
|
+
# Loop invariant: inserted contains search paths added from reversed(search_paths)[0..i]
|
|
478
|
+
for path in reversed(search_paths):
|
|
479
|
+
if path in sys.path:
|
|
480
|
+
continue
|
|
481
|
+
sys.path.insert(0, path)
|
|
482
|
+
inserted.append(path)
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
yield
|
|
486
|
+
finally:
|
|
487
|
+
# Loop invariant: sys.path entries added in inserted[0..i] are removed at most once
|
|
488
|
+
for path in inserted:
|
|
489
|
+
try:
|
|
490
|
+
sys.path.remove(path)
|
|
491
|
+
except ValueError:
|
|
492
|
+
pass
|