"""Content identities and atomic storage shared by generator cache adapters.""" from __future__ import annotations import ast import contextlib import functools import hashlib import json import os import pathlib import shutil import tempfile ROOT = pathlib.Path(__file__).resolve().parent @functools.lru_cache(maxsize=256) def imports(content): modules = set() for node in ast.walk(ast.parse(content)): if isinstance(node, ast.Import): modules.update(alias.name for alias in node.names if alias.name.startswith('generator.')) elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith('generator'): modules.add(node.module) modules.update(node.module + '.' + alias.name for alias in node.names) return tuple(sorted(modules)) def source_identity(entrypoints): """Conservatively include the transitive imports of explicit stage roots. Read bytes on every call, so edits in a long-lived build process invalidate both code content and newly introduced dependencies. Only parsing is cached. """ pending, sources = list(entrypoints), {} while pending: module = pending.pop() relative = module.split('.')[1:] path = ROOT.joinpath(*relative).with_suffix('.py') if relative else ROOT / '__init__.py' if not path.is_file(): path = ROOT.joinpath(*relative, '__init__.py') if path in sources or not path.is_file(): continue content = path.read_bytes() sources[path] = hashlib.sha256(content).hexdigest() pending.extend(imports(content)) pending.extend('.'.join(module.split('.')[:i]) for i in range(1, len(module.split('.')))) return {path.relative_to(ROOT).as_posix(): sources[path] for path in sorted(sources)} def fingerprint(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def write_json(path, value): path.parent.mkdir(parents=True, exist_ok=True) temporary = None try: with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as output: temporary = pathlib.Path(output.name) json.dump(value, output, sort_keys=True) os.replace(temporary, path) finally: if temporary is not None: temporary.unlink(missing_ok=True) def has_symlink(path): return any(parent.is_symlink() for parent in (path, *path.parents)) @contextlib.contextmanager def stage_entry(entry, prefix): if has_symlink(entry.parent): raise ValueError('cache directories must not be symlinks') entry.parent.mkdir(parents=True, exist_ok=True) if entry.is_symlink() or entry.is_file(): entry.unlink(missing_ok=True) entry.mkdir(exist_ok=True) with tempfile.TemporaryDirectory(prefix=prefix, dir=entry.parent) as temporary: yield pathlib.Path(temporary) def publish_files(entry, staged, names): """Publish complete files in order, with the artifact manifest last.""" for name in names: target = entry / name if target.is_dir() and not target.is_symlink(): shutil.rmtree(target) (staged / name).replace(target)