Skip to content

Commit 12d79bf

Browse files
authored
Merge pull request #202 from thisisthepy/feat/import-gate
TypedPython: Development-mode import gate (N-7b)
2 parents 9c865e0 + a2da58c commit 12d79bf

4 files changed

Lines changed: 252 additions & 1 deletion

File tree

‎docs/SPEC.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
551551
first checks it with the N-7 gate, and a type error stops the import with the gate's diagnostics before
552552
any of the module's code runs. Release builds rely on the build gate (#20) and skip it. Results are cached
553553
by file content, so an unchanged module is not checked again. There are no per-call runtime type checks:
554-
compiled and interpreted code behave exactly as CPython does (N-8). `Status: planned` (#183).
554+
compiled and interpreted code behave exactly as CPython does (N-8). Form: `typedpython.importgate.install(source_roots)`
555+
puts a meta path finder first on `sys.meta_path` (`uninstall()` removes it; `install_from_env()` installs it
556+
when `TYPEDPYTHON_IMPORT_GATE` lists source roots). Only modules whose source file lies under a root are
557+
checked; the stdlib and dependencies never are. A gate error raises `ImportGateError` (an `ImportError`
558+
with `.diagnostics`) before the module runs. Results are cached in memory by file content hash, failures
559+
included. `Status: implemented`, `python-multiplatform-ksp/src/test/python/test_importgate.py`.
555560
- **N-8** TypedPython compilation to C (INTENT §1.4; design §4.3; #41). `@compiled` (a builtin name, no
556561
import, #42) and a first-line `# typedpython: compiled` mark functions; the compiler lowers them
557562
through the typed IR (`typedpython/ir.py`) to C against the CPython C API and builds an extension with
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Development-mode import gate (SPEC N-7b, #183).
2+
3+
`install(source_roots)` puts a meta path finder first on `sys.meta_path`. When a module whose source
4+
file lies under one of the roots is imported, the finder runs the N-7 gate (`gate.check`, the checker
5+
behind `python -m typedpython check`) on that file before the import machinery executes it. A type
6+
error raises `ImportGateError`, an `ImportError` carrying the diagnostics, and none of the module's
7+
code runs. Modules outside the roots (the stdlib, dependencies) are never checked.
8+
9+
Results are cached in memory by the SHA-256 of the file content, so an unchanged module is not
10+
checked again (a failing result is cached too). Release builds never call `install`.
11+
"""
12+
from __future__ import annotations
13+
14+
import hashlib
15+
import os
16+
import sys
17+
from collections.abc import Sequence
18+
from importlib import machinery
19+
from importlib.abc import MetaPathFinder
20+
from pathlib import Path
21+
from types import ModuleType
22+
23+
from typedpython import gate
24+
from typedpython.diagnostic import Diagnostic
25+
26+
ENV_VAR = "TYPEDPYTHON_IMPORT_GATE" # source roots separated by os.pathsep
27+
28+
29+
class ImportGateError(ImportError):
30+
"""A project module failed the type gate; `diagnostics` holds the gate's errors."""
31+
32+
def __init__(self, path: str, diagnostics: Sequence[Diagnostic], name: str | None = None) -> None:
33+
self.diagnostics = list(diagnostics)
34+
lines = [f"{d.path}:{d.line}:{d.column}: {d.message} [{d.rule}]" for d in self.diagnostics]
35+
super().__init__(
36+
f"type gate rejected {path}; the module was not run:\n" + "\n".join(lines),
37+
name=name, path=path,
38+
)
39+
40+
41+
def _run_gate(paths: Sequence[Path], search_paths: Sequence[Path]) -> list[Diagnostic]:
42+
"""The checker seam: tests count its invocations."""
43+
return gate.check(paths, mode="checked", search_paths=search_paths)
44+
45+
46+
class ImportGateFinder(MetaPathFinder):
47+
def __init__(self, source_roots: Sequence[Path]) -> None:
48+
self.roots = [Path(r).resolve() for r in source_roots]
49+
self._cache: dict[str, tuple[str, list[Diagnostic]]] = {}
50+
51+
def find_spec(
52+
self, fullname: str, path: Sequence[str] | None, target: ModuleType | None = None,
53+
) -> machinery.ModuleSpec | None:
54+
spec = machinery.PathFinder.find_spec(fullname, path)
55+
origin = spec.origin if spec is not None else None
56+
if spec is None or origin is None or not origin.endswith(".py"):
57+
return None # not project source: let the normal finders handle it
58+
file = Path(origin).resolve()
59+
if not any(file.is_relative_to(root) for root in self.roots):
60+
return None
61+
errors = self._errors(file)
62+
if errors:
63+
raise ImportGateError(str(file), errors, name=fullname)
64+
return spec
65+
66+
def _errors(self, file: Path) -> list[Diagnostic]:
67+
digest = hashlib.sha256(file.read_bytes()).hexdigest()
68+
key = str(file)
69+
cached = self._cache.get(key)
70+
if cached is not None and cached[0] == digest:
71+
return cached[1]
72+
errors = [d for d in _run_gate([file], self.roots) if d.severity == "error"]
73+
self._cache[key] = (digest, errors)
74+
return errors
75+
76+
77+
def install(source_roots: Sequence[str | Path]) -> ImportGateFinder:
78+
"""Install the gate for modules under `source_roots`, replacing an installed one."""
79+
uninstall()
80+
finder = ImportGateFinder([Path(r) for r in source_roots])
81+
sys.meta_path.insert(0, finder)
82+
return finder
83+
84+
85+
def uninstall() -> None:
86+
"""Remove the gate; imports are then ungated."""
87+
sys.meta_path[:] = [f for f in sys.meta_path if not isinstance(f, ImportGateFinder)]
88+
89+
90+
def install_from_env() -> bool:
91+
"""Development-mode switch: install when `TYPEDPYTHON_IMPORT_GATE` lists source roots."""
92+
roots = [r for r in os.environ.get(ENV_VAR, "").split(os.pathsep) if r]
93+
if not roots:
94+
return False
95+
install(roots)
96+
return True

‎python-multiplatform-ksp/src/main/python/typedpython/runtime/API.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,9 @@ per-interpreter counter that only grows and, once it wraps to 0, assigns nothing
177177
gets at most MAX_VERSIONS_PER_CLASS (1000) tags. So a tag never repeats within an interpreter and a
178178
class whose tags are exhausted keeps 0 (never current). Py_TPFLAGS_VALID_VERSION_TAG is "Unused.
179179
Legacy flag" in 3.14 (never set) and is not consulted.
180+
181+
## Development-mode import gate (Python API, not C; SPEC N-7b)
182+
typedpython.importgate.install(source_roots) # meta path finder; checks project modules before they run
183+
typedpython.importgate.uninstall()
184+
typedpython.importgate.install_from_env() # installs when TYPEDPYTHON_IMPORT_GATE=<roots, os.pathsep-separated>
185+
A type error raises `ImportGateError` (an `ImportError`, `.diagnostics` holds the gate's errors) and the module's code does not run. Only files under the roots are checked; results are cached by content hash. Do not call it in release builds.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Import-time type gate (SPEC N-7b, #183): development-mode import hook."""
2+
from __future__ import annotations
3+
4+
import importlib
5+
import sys
6+
7+
import pytest
8+
9+
from typedpython import gate, importgate
10+
11+
BAD = """\
12+
from pathlib import Path
13+
Path(__file__).with_name("marker.txt").write_text("ran")
14+
15+
def f(x: int) -> int:
16+
return x
17+
18+
f("not an int")
19+
"""
20+
21+
GOOD = "VALUE: int = 1\n"
22+
23+
24+
@pytest.fixture
25+
def project(tmp_path, monkeypatch):
26+
"""A fresh source root on sys.path; the hook and imported modules are cleaned up afterwards."""
27+
root = tmp_path / "src"
28+
root.mkdir()
29+
monkeypatch.syspath_prepend(str(root))
30+
before = set(sys.modules)
31+
yield root
32+
importgate.uninstall()
33+
for name in set(sys.modules) - before:
34+
del sys.modules[name]
35+
importlib.invalidate_caches()
36+
37+
38+
@pytest.fixture
39+
def calls(monkeypatch):
40+
"""Paths handed to the checker, one list per checker invocation (the seam)."""
41+
seen: list[list[str]] = []
42+
real = importgate._run_gate
43+
44+
def counting(paths, search_paths):
45+
seen.append([str(p) for p in paths])
46+
return real(paths, search_paths)
47+
48+
monkeypatch.setattr(importgate, "_run_gate", counting)
49+
return seen
50+
51+
52+
def test_type_error_stops_import_before_module_code_runs(project):
53+
(project / "bad_mod.py").write_text(BAD)
54+
importgate.install([project])
55+
with pytest.raises(ImportError) as info:
56+
importlib.import_module("bad_mod")
57+
assert isinstance(info.value, importgate.ImportGateError)
58+
assert info.value.diagnostics
59+
assert all(d.severity == "error" for d in info.value.diagnostics)
60+
assert "bad_mod.py" in str(info.value)
61+
assert not (project / "marker.txt").exists()
62+
assert "bad_mod" not in sys.modules
63+
64+
65+
def test_same_module_imports_without_the_hook(project):
66+
(project / "bad_mod2.py").write_text(BAD)
67+
importlib.import_module("bad_mod2")
68+
assert (project / "marker.txt").exists()
69+
70+
71+
def test_uninstall_restores_normal_import(project):
72+
(project / "bad_mod3.py").write_text(BAD)
73+
importgate.install([project])
74+
importgate.uninstall()
75+
importlib.import_module("bad_mod3")
76+
assert (project / "marker.txt").exists()
77+
78+
79+
def test_clean_module_imports_and_is_cached_by_content(project, calls):
80+
path = project / "good_mod.py"
81+
path.write_text(GOOD)
82+
importgate.install([project])
83+
mod = importlib.import_module("good_mod")
84+
assert mod.VALUE == 1
85+
assert len(calls) == 1
86+
87+
del sys.modules["good_mod"]
88+
importlib.import_module("good_mod")
89+
assert len(calls) == 1, "an unchanged module must not be checked again"
90+
91+
del sys.modules["good_mod"]
92+
path.write_text("VALUE: int = 2\n")
93+
assert importlib.import_module("good_mod").VALUE == 2
94+
assert len(calls) == 2, "changed content must be re-checked"
95+
96+
97+
def test_failing_result_is_cached_too(project, calls):
98+
(project / "bad_mod4.py").write_text(BAD)
99+
importgate.install([project])
100+
for _ in range(2):
101+
with pytest.raises(importgate.ImportGateError):
102+
importlib.import_module("bad_mod4")
103+
assert len(calls) == 1
104+
105+
106+
def test_modules_outside_source_roots_and_stdlib_are_not_checked(project, tmp_path, monkeypatch, calls):
107+
other = tmp_path / "deps"
108+
other.mkdir()
109+
(other / "dep_mod.py").write_text(BAD.replace("marker.txt", "dep_marker.txt"))
110+
monkeypatch.syspath_prepend(str(other))
111+
importgate.install([project])
112+
importlib.import_module("dep_mod") # a type error, but outside the roots
113+
importlib.import_module("colorsys") # stdlib
114+
assert calls == []
115+
assert (other / "dep_marker.txt").exists()
116+
117+
118+
def test_package_submodule_under_root_is_checked(project, calls):
119+
pkg = project / "pkg"
120+
pkg.mkdir()
121+
(pkg / "__init__.py").write_text("")
122+
(pkg / "sub.py").write_text("def f(x):\n return x\n")
123+
importgate.install([project])
124+
with pytest.raises(importgate.ImportGateError):
125+
importlib.import_module("pkg.sub")
126+
assert any(p.endswith("sub.py") for c in calls for p in c)
127+
128+
129+
def test_install_twice_installs_one_finder(project):
130+
importgate.install([project])
131+
importgate.install([project])
132+
assert sum(isinstance(f, importgate.ImportGateFinder) for f in sys.meta_path) == 1
133+
importgate.uninstall()
134+
assert not any(isinstance(f, importgate.ImportGateFinder) for f in sys.meta_path)
135+
136+
137+
def test_install_from_env(project, monkeypatch):
138+
monkeypatch.delenv(importgate.ENV_VAR, raising=False)
139+
assert importgate.install_from_env() is False
140+
monkeypatch.setenv(importgate.ENV_VAR, str(project))
141+
assert importgate.install_from_env() is True
142+
(project / "bad_mod5.py").write_text(BAD)
143+
with pytest.raises(importgate.ImportGateError):
144+
importlib.import_module("bad_mod5")

0 commit comments

Comments
 (0)