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.
Files changed (39) hide show
  1. serenecode/__init__.py +281 -0
  2. serenecode/adapters/__init__.py +6 -0
  3. serenecode/adapters/coverage_adapter.py +1173 -0
  4. serenecode/adapters/crosshair_adapter.py +1069 -0
  5. serenecode/adapters/hypothesis_adapter.py +1824 -0
  6. serenecode/adapters/local_fs.py +169 -0
  7. serenecode/adapters/module_loader.py +492 -0
  8. serenecode/adapters/mypy_adapter.py +161 -0
  9. serenecode/checker/__init__.py +6 -0
  10. serenecode/checker/compositional.py +2216 -0
  11. serenecode/checker/coverage.py +186 -0
  12. serenecode/checker/properties.py +154 -0
  13. serenecode/checker/structural.py +1504 -0
  14. serenecode/checker/symbolic.py +178 -0
  15. serenecode/checker/types.py +148 -0
  16. serenecode/cli.py +478 -0
  17. serenecode/config.py +711 -0
  18. serenecode/contracts/__init__.py +6 -0
  19. serenecode/contracts/predicates.py +176 -0
  20. serenecode/core/__init__.py +6 -0
  21. serenecode/core/exceptions.py +38 -0
  22. serenecode/core/pipeline.py +807 -0
  23. serenecode/init.py +307 -0
  24. serenecode/models.py +308 -0
  25. serenecode/ports/__init__.py +6 -0
  26. serenecode/ports/coverage_analyzer.py +124 -0
  27. serenecode/ports/file_system.py +95 -0
  28. serenecode/ports/property_tester.py +69 -0
  29. serenecode/ports/symbolic_checker.py +70 -0
  30. serenecode/ports/type_checker.py +66 -0
  31. serenecode/reporter.py +346 -0
  32. serenecode/source_discovery.py +319 -0
  33. serenecode/templates/__init__.py +5 -0
  34. serenecode/templates/content.py +337 -0
  35. serenecode-0.1.0.dist-info/METADATA +298 -0
  36. serenecode-0.1.0.dist-info/RECORD +39 -0
  37. serenecode-0.1.0.dist-info/WHEEL +4 -0
  38. serenecode-0.1.0.dist-info/entry_points.txt +2 -0
  39. 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