pythonfaster 1.8.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.
pythonfaster/hook.py ADDED
@@ -0,0 +1,307 @@
1
+ """Import hook: transparently load compiled extensions for project modules.
2
+
3
+ The finder sits at the front of ``sys.meta_path``. For every import it:
4
+
5
+ 1. Lets the standard PathFinder resolve the module's *source* location.
6
+ 2. Intercepts only ``.py`` files inside the project root (never stdlib,
7
+ site-packages, excluded paths, or pythonfaster itself).
8
+ 3. Serves the cached compiled artifact when available; otherwise compiles
9
+ (cross-process locked) and serves the fresh artifact.
10
+ 4. On ANY failure: records the skip list and returns ``None`` so the
11
+ default loaders import the original ``.py`` -- user code never breaks.
12
+
13
+ Traceback/debugging fidelity: the original source is primed into
14
+ ``linecache`` under the original filename, so tracebacks and
15
+ ``inspect.getsource`` keep working on compiled modules.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import importlib.abc
20
+ import importlib.machinery
21
+ import importlib.util
22
+ import linecache
23
+ import os
24
+ import sys
25
+ from importlib.machinery import PathFinder
26
+ from pathlib import Path
27
+
28
+ from . import cache, compiler
29
+ from .config import SELF_PACKAGE, Config, is_excluded
30
+
31
+ _VERBOSE = os.environ.get("PYTHONFASTER_VERBOSE") == "1"
32
+
33
+
34
+ def _log(msg: str) -> None:
35
+ if _VERBOSE:
36
+ print(f"[pythonfaster] {msg}", file=sys.stderr)
37
+
38
+
39
+ class _PythonFasterLoader(importlib.machinery.ExtensionFileLoader):
40
+ """ExtensionFileLoader that primes linecache after module exec.
41
+
42
+ Cython embeds the build-area .pyx path (possibly *relative* to the
43
+ compile-time cwd) into every code object, and the build area is removed
44
+ after compilation. After exec we scan the module's code objects and
45
+ prime linecache for the exact co_filename values found -- bulletproof
46
+ against any path spelling Cython chose.
47
+
48
+ Runtime fallback: if the .so crashes during module initialisation
49
+ (e.g. a Cython semantic mismatch that static analysis could not catch),
50
+ we transparently re-exec the original .py source into the same module
51
+ object, record the module on the skip list, and delete the broken .so.
52
+ The user program continues running with pure-Python semantics — zero
53
+ breakage, zero manual intervention.
54
+ """
55
+
56
+ def __init__(self, name, path, source_path, source_bytes, key=None,
57
+ source_text=None):
58
+ super().__init__(name, path)
59
+ self._pb_source_path = source_path
60
+ self._pb_source_bytes = source_bytes
61
+ self._pb_key = key
62
+ if source_text is None:
63
+ source_text = source_bytes.decode("utf-8", errors="replace")
64
+ self._pb_entry = (
65
+ len(source_text),
66
+ None,
67
+ source_text.splitlines(keepends=True),
68
+ str(source_path),
69
+ )
70
+
71
+ def exec_module(self, module):
72
+ try:
73
+ super().exec_module(module)
74
+ except Exception as exc:
75
+ # --- Runtime fallback ---
76
+ # The .so module init crashed. Re-exec the original .py source
77
+ # so the import succeeds with pure-Python semantics.
78
+ _log(
79
+ f"runtime error in compiled {module.__name__}, "
80
+ f"falling back to .py: {exc!r}"
81
+ )
82
+ self._fallback_to_py(module, exc)
83
+ return
84
+
85
+ # --- linecache priming (only when .so loaded successfully) ---
86
+ entry = self._pb_entry
87
+ seen = set()
88
+ stack = list(vars(module).values())
89
+ while stack:
90
+ obj = stack.pop()
91
+ code = getattr(obj, "__code__", None)
92
+ if code is None:
93
+ if isinstance(obj, type):
94
+ stack.extend(vars(obj).values())
95
+ continue
96
+ fname = code.co_filename
97
+ if fname in seen:
98
+ continue
99
+ seen.add(fname)
100
+ # Prime ONLY phantom files (the deleted build-area .pyx) that no
101
+ # real on-disk source backs. Always overwrite entries that contain
102
+ # bytes lines (Cython sometimes injects bytes source lines into
103
+ # linecache, which crashes the traceback formatter).
104
+ if not os.path.exists(fname):
105
+ existing = linecache.cache.get(fname)
106
+ if existing is None or (
107
+ existing[2]
108
+ and isinstance(existing[2][0], bytes)
109
+ ):
110
+ linecache.cache[fname] = entry
111
+ if isinstance(obj, type):
112
+ stack.extend(vars(obj).values())
113
+
114
+ def _fallback_to_py(self, module, exc):
115
+ """Re-exec original .py source after .so runtime failure."""
116
+ # Record skip so future imports go straight to .py
117
+ if self._pb_key is not None:
118
+ try:
119
+ cache.CacheIndex().record_skip(
120
+ self._pb_key, module.__name__,
121
+ f"runtime fallback: {exc!r}"[:500],
122
+ )
123
+ except Exception:
124
+ pass
125
+ # Delete the broken .so from cache to prevent re-use
126
+ try:
127
+ Path(self.path).unlink(missing_ok=True)
128
+ except Exception:
129
+ pass
130
+ # Also delete the stored .pyx copy
131
+ if self._pb_key is not None:
132
+ try:
133
+ stored_pyx = cache.pyx_lookup(self._pb_key, module.__name__)
134
+ if stored_pyx is not None:
135
+ stored_pyx.unlink(missing_ok=True)
136
+ except Exception:
137
+ pass
138
+ # Preserve import-machinery attributes, clear partial .so state
139
+ _preserve = {
140
+ "__name__", "__spec__", "__loader__", "__package__",
141
+ "__path__", "__builtins__",
142
+ }
143
+ saved = {k: v for k, v in module.__dict__.items() if k in _preserve}
144
+ module.__dict__.clear()
145
+ module.__dict__.update(saved)
146
+ module.__file__ = str(self._pb_source_path)
147
+ # Execute the original Python source
148
+ code = compile(
149
+ self._pb_source_bytes, str(self._pb_source_path), "exec",
150
+ )
151
+ exec(code, module.__dict__)
152
+
153
+
154
+ class PythonFasterFinder(importlib.abc.MetaPathFinder):
155
+ def __init__(self, config: Config) -> None:
156
+ self.config = config
157
+ self.root: Path = config.root # type: ignore[assignment]
158
+ self.index = cache.CacheIndex()
159
+
160
+ def _matches(self, source_path: Path) -> bool:
161
+ """Check if *source_path* falls under this finder's project root."""
162
+ try:
163
+ source_path.resolve().relative_to(self.root.resolve())
164
+ return True
165
+ except (ValueError, OSError):
166
+ return False
167
+
168
+ # -- MetaPathFinder protocol -------------------------------------------
169
+
170
+ def find_spec(self, fullname, path=None, target=None):
171
+ try:
172
+ return self._find_spec(fullname, path)
173
+ except Exception as exc: # absolute last line of defense
174
+ _log(f"hook error for {fullname!r}, falling back: {exc!r}")
175
+ return None
176
+
177
+ # -- internals ----------------------------------------------------------
178
+
179
+ def _find_spec(self, fullname: str, path):
180
+ if fullname == SELF_PACKAGE or fullname.startswith(SELF_PACKAGE + "."):
181
+ return None
182
+
183
+ # Resolve the original source location via the standard machinery.
184
+ spec = PathFinder.find_spec(fullname, path)
185
+ if spec is None or not spec.origin:
186
+ return None
187
+ origin = spec.origin
188
+ if not origin.endswith(".py"):
189
+ return None
190
+
191
+ source_path = Path(origin)
192
+ try:
193
+ rel = source_path.resolve().relative_to(self.root.resolve())
194
+ except (ValueError, OSError):
195
+ return None # outside the project root
196
+ if is_excluded(rel.as_posix(), self.config.exclude):
197
+ return None
198
+
199
+ try:
200
+ source_bytes = source_path.read_bytes()
201
+ except OSError:
202
+ return None
203
+
204
+ # Decode using PEP 263 encoding detection (honours
205
+ # # -*- coding: xxx -*- declarations and BOM).
206
+ try:
207
+ import tokenize as _tokenize
208
+ with _tokenize.open(str(source_path)) as fh:
209
+ source_text = fh.read()
210
+ except Exception:
211
+ source_text = source_bytes.decode("utf-8", errors="replace")
212
+
213
+ key = cache.cache_key(
214
+ source=source_bytes, module=fullname, level=self.config.level
215
+ )
216
+
217
+ self.index.refresh()
218
+ if self.index.is_skipped(key):
219
+ return None # known uncompilable -> default import
220
+
221
+ so_path = cache.so_lookup(key, fullname)
222
+ if so_path is None:
223
+ try:
224
+ so_path = compiler.compile_module(
225
+ module=fullname,
226
+ source_path=source_path,
227
+ key=key,
228
+ level=self.config.level,
229
+ aggressive=self.config.aggressive,
230
+ )
231
+ _log(f"compiled {fullname} -> {so_path.name}")
232
+ except compiler.CompileError as exc:
233
+ _log(f"compile failed for {fullname}, falling back: {exc}")
234
+ if exc.permanent:
235
+ # Source genuinely cannot compile -> remember it.
236
+ # Transient errors (lock/IO) are retried next time.
237
+ self.index.record_skip(key, fullname, str(exc))
238
+ return None
239
+ except Exception as exc:
240
+ _log(f"unexpected error for {fullname}, falling back: {exc!r}")
241
+ return None
242
+ else:
243
+ _log(f"cache hit {fullname}")
244
+
245
+ return self._compiled_spec(
246
+ fullname, spec, so_path, source_path, source_bytes, key,
247
+ source_text
248
+ )
249
+
250
+ @staticmethod
251
+ def _compiled_spec(fullname, original_spec, so_path, source_path,
252
+ source_bytes, key, source_text=None):
253
+ # Prime linecache so tracebacks and inspect.getsource() keep working.
254
+ # The .pyx alias must map to the *actually compiled* source (which may
255
+ # be transform output with shifted line numbers); the original .py
256
+ # path always maps to the user's own source text.
257
+ if source_text is None:
258
+ source_text = source_bytes.decode("utf-8", errors="replace")
259
+ lines = source_text.splitlines(keepends=True)
260
+ entry = (len(source_text), None, lines, str(source_path))
261
+ linecache.cache[str(source_path)] = entry
262
+
263
+ compiled_bytes = source_bytes
264
+ stored_pyx = cache.pyx_lookup(key, fullname)
265
+ if stored_pyx is not None:
266
+ try:
267
+ compiled_bytes = stored_pyx.read_bytes()
268
+ except OSError:
269
+ pass
270
+ pyx_text = compiled_bytes.decode("utf-8", errors="replace")
271
+ pyx_lines = pyx_text.splitlines(keepends=True)
272
+ pyx_path = cache.build_dir(key) / (fullname.rsplit(".", 1)[-1] + ".pyx")
273
+ linecache.cache[str(pyx_path)] = (
274
+ len(pyx_text), None, pyx_lines, str(pyx_path)
275
+ )
276
+
277
+ loader = _PythonFasterLoader(fullname, str(so_path), source_path,
278
+ compiled_bytes, key=key, source_text=source_text)
279
+ kwargs = {}
280
+ if original_spec.submodule_search_locations is not None:
281
+ # Package __init__: keep the original source dir on __path__ so
282
+ # submodules, data files and relative imports behave identically.
283
+ kwargs["submodule_search_locations"] = list(
284
+ original_spec.submodule_search_locations
285
+ )
286
+ return importlib.util.spec_from_file_location(
287
+ fullname, str(so_path), loader=loader, **kwargs
288
+ )
289
+
290
+
291
+ def install(config: Config) -> PythonFasterFinder:
292
+ """Install the finder at the front of sys.meta_path.
293
+
294
+ Multiple finders can coexist for different project roots.
295
+ If a finder for the same root already exists, its config is updated
296
+ in place (so changed level/exclude/aggressive take effect).
297
+ """
298
+ for finder in sys.meta_path:
299
+ if isinstance(finder, PythonFasterFinder):
300
+ if finder.root == config.root:
301
+ finder.config = config
302
+ _log(f"import hook updated for project root: {config.root}")
303
+ return finder
304
+ finder = PythonFasterFinder(config)
305
+ sys.meta_path.insert(0, finder)
306
+ _log(f"import hook installed for project root: {config.root}")
307
+ return finder
pythonfaster/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ """AST transformation pipeline used before Cython compilation.
2
+
3
+ The engine applies conservative value, object-layout, container, call-overhead
4
+ and recursion/loop-invariant optimizations. A transform is optional: callers
5
+ must preserve Python semantics by falling back when an analysis cannot prove
6
+ its preconditions.
7
+ """
8
+ from .engine import TransformResult, transform_source
9
+
10
+ __all__ = ["TransformResult", "transform_source"]