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/__init__.py +92 -0
- pythonfaster/_bootstrap.py +89 -0
- pythonfaster/_cli.py +232 -0
- pythonfaster/cache.py +285 -0
- pythonfaster/compiler.py +425 -0
- pythonfaster/config.py +165 -0
- pythonfaster/hook.py +307 -0
- pythonfaster/py.typed +0 -0
- pythonfaster/transform/__init__.py +10 -0
- pythonfaster/transform/engine.py +3033 -0
- pythonfaster/transform/recpass.py +522 -0
- pythonfaster-1.8.0.data/data/pythonfaster.pth +1 -0
- pythonfaster-1.8.0.dist-info/METADATA +362 -0
- pythonfaster-1.8.0.dist-info/RECORD +18 -0
- pythonfaster-1.8.0.dist-info/WHEEL +5 -0
- pythonfaster-1.8.0.dist-info/entry_points.txt +2 -0
- pythonfaster-1.8.0.dist-info/licenses/LICENSE +21 -0
- pythonfaster-1.8.0.dist-info/top_level.txt +1 -0
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"]
|