|
37 | 37 | import logging |
38 | 38 | import marshal |
39 | 39 | import pickle |
| 40 | +import re |
40 | 41 | import struct |
41 | 42 | import sys |
42 | 43 | import time |
| 44 | +from importlib.metadata import PackageNotFoundError, version |
43 | 45 | from pathlib import Path |
44 | 46 | from tempfile import NamedTemporaryFile |
45 | 47 | from typing import TYPE_CHECKING, cast |
|
49 | 51 | logger = logging.getLogger("kida.bytecode_cache") |
50 | 52 |
|
51 | 53 | if TYPE_CHECKING: |
| 54 | + from collections.abc import Iterable |
52 | 55 | from types import CodeType |
53 | 56 |
|
54 | 57 | from kida.nodes.base import Node |
|
66 | 69 | # Python version tag for cache invalidation across Python upgrades |
67 | 70 | _PY_VERSION_TAG = f"py{sys.version_info.major}{sys.version_info.minor}" |
68 | 71 |
|
| 72 | +# Cache ABI tag for Kida compiler/AST changes. Bytecode cache entries contain |
| 73 | +# compiled Python code and, optionally, pickled Kida AST nodes; both are tied to |
| 74 | +# the engine version and to AST dataclass shape. |
| 75 | +try: |
| 76 | + _KIDA_VERSION_TAG = re.sub(r"[^A-Za-z0-9]+", "_", version("kida-templates")).strip("_") |
| 77 | +except PackageNotFoundError: # pragma: no cover - package metadata may be absent in ad-hoc embeds |
| 78 | + _KIDA_VERSION_TAG = "unknown" |
| 79 | +_CACHE_ABI_TAG = f"{_PY_VERSION_TAG}_kida{_KIDA_VERSION_TAG}_ast2" |
| 80 | + |
69 | 81 |
|
70 | 82 | class BytecodeCache: |
71 | 83 | """Persist compiled template bytecode to disk. |
@@ -122,7 +134,7 @@ def _make_path(self, name: str, source_hash: str, context_hash: str | None = Non |
122 | 134 | if context_hash: |
123 | 135 | hash_key = f"{hash_key}_{context_hash[:16]}" |
124 | 136 | filename = self._pattern.format( |
125 | | - version=_PY_VERSION_TAG, |
| 137 | + version=_CACHE_ABI_TAG, |
126 | 138 | name=safe_name, |
127 | 139 | hash=hash_key, |
128 | 140 | ) |
@@ -221,6 +233,11 @@ def get( |
221 | 233 | "Bytecode cache: failed to unpickle AST for '%s': %s", name, exc |
222 | 234 | ) |
223 | 235 | ast = None |
| 236 | + if ast is not None and not _cached_ast_is_compatible(ast): |
| 237 | + logger.debug("Bytecode cache: incompatible cached AST for '%s'", name) |
| 238 | + with contextlib.suppress(OSError): |
| 239 | + path.unlink(missing_ok=True) |
| 240 | + return None, None, None |
224 | 241 |
|
225 | 242 | return code, ast, precomputed |
226 | 243 |
|
@@ -252,6 +269,11 @@ def get( |
252 | 269 | "Bytecode cache: failed to unpickle AST (v2) for '%s': %s", name, exc |
253 | 270 | ) |
254 | 271 | ast = None |
| 272 | + if ast is not None and not _cached_ast_is_compatible(ast): |
| 273 | + logger.debug("Bytecode cache: incompatible cached AST (v2) for '%s'", name) |
| 274 | + with contextlib.suppress(OSError): |
| 275 | + path.unlink(missing_ok=True) |
| 276 | + return None, None, None |
255 | 277 |
|
256 | 278 | return code, ast, None |
257 | 279 | else: |
@@ -408,3 +430,39 @@ def stats(self) -> dict[str, int]: |
408 | 430 | def hash_source(source: str) -> str: |
409 | 431 | """Generate hash of template source for cache key.""" |
410 | 432 | return hashlib.sha256(source.encode()).hexdigest() |
| 433 | + |
| 434 | + |
| 435 | +def _cached_ast_is_compatible(ast: object) -> bool: |
| 436 | + """Return True when an unpickled AST matches the current dataclass shape.""" |
| 437 | + from kida.nodes.base import Node |
| 438 | + |
| 439 | + if not isinstance(ast, Node): |
| 440 | + return False |
| 441 | + |
| 442 | + stack: list[Node] = [ast] |
| 443 | + while stack: |
| 444 | + node = stack.pop() |
| 445 | + for field_name in node.__dataclass_fields__: |
| 446 | + try: |
| 447 | + value = getattr(node, field_name) |
| 448 | + except AttributeError: |
| 449 | + return False |
| 450 | + if isinstance(value, Node): |
| 451 | + stack.append(value) |
| 452 | + elif isinstance(value, (list, tuple)): |
| 453 | + _extend_node_sequence(stack, value) |
| 454 | + elif isinstance(value, dict): |
| 455 | + _extend_node_sequence(stack, value.values()) |
| 456 | + return True |
| 457 | + |
| 458 | + |
| 459 | +def _extend_node_sequence(stack: list[Node], seq: Iterable[object]) -> None: |
| 460 | + from kida.nodes.base import Node |
| 461 | + |
| 462 | + for item in seq: |
| 463 | + if isinstance(item, Node): |
| 464 | + stack.append(item) |
| 465 | + elif isinstance(item, (list, tuple)): |
| 466 | + _extend_node_sequence(stack, item) |
| 467 | + elif isinstance(item, dict): |
| 468 | + _extend_node_sequence(stack, item.values()) |
0 commit comments