From f740b8967aae0f55ef315df4ae0f089aa21fa089 Mon Sep 17 00:00:00 2001 From: "Gregory S. Tucker" Date: Wed, 21 Oct 2026 04:45:06 +0000 Subject: [PATCH 1/3] Invalidate cached components when an INHERIT/COPY parent changes The component cache was keyed on a .comp file's own mtime only, so editing the parent of `DEFINE COMPONENT b INHERIT a` left b's cached IR -- in memory and in its sidecar -- reporting a's old contents until b itself was touched. Each cache entry now records the mtime of every source file it was built from: itself plus every component resolved through Reader.get_component while it was parsed, transitively. A hit requires all of them to match exactly, which also rejects a .comp whose mtime moved backwards (rsync -a of an older file over a newer sidecar). Sidecars store these sources relative to their own directory, so a tree copied with mtimes preserved keeps its cache. The sidecar layout changes, so the cache format is now 5. `cache ir-build` and `ir-clean --stale` use the same freshness check. --- src/mccode_antlr/cli/cache.py | 37 +++--- src/mccode_antlr/reader/reader.py | 152 +++++++++++++++++++----- tests/reader/test_component_ir_cache.py | 54 +++++++++ 3 files changed, 189 insertions(+), 54 deletions(-) diff --git a/src/mccode_antlr/cli/cache.py b/src/mccode_antlr/cli/cache.py index 10ab87cf..6f1420ea 100644 --- a/src/mccode_antlr/cli/cache.py +++ b/src/mccode_antlr/cli/cache.py @@ -472,16 +472,10 @@ def _iter_ir_files(root: Path): def _is_stale(json_path: Path) -> bool: - """True if *json_path* is unusable: sibling ``.comp`` newer, or it was - written by a different mccode-antlr build.""" - from mccode_antlr.reader.reader import component_ir_comp_path, component_ir_is_current - if not component_ir_is_current(json_path): - return True - comp_path = component_ir_comp_path(json_path) - try: - return comp_path.stat().st_mtime_ns > json_path.stat().st_mtime_ns - except OSError: - return False + """True if *json_path* is unusable: its ``.comp`` (or an INHERIT/COPY parent) + changed, or it was written by a different mccode-antlr build.""" + from mccode_antlr.reader.reader import component_ir_is_fresh + return not component_ir_is_fresh(json_path) def cache_ir_list(long: bool): @@ -570,21 +564,17 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str]: ``'hit'``, ``'built'``, or ``'error: '``. """ from pathlib import Path - from mccode_antlr.reader.reader import component_cache, make_reader_error_listener + from mccode_antlr.reader.reader import ( + component_cache, component_ir_is_fresh, component_ir_path, + make_reader_error_listener, recording_dependencies, + ) from mccode_antlr.comp import Comp from mccode_antlr.grammar import McComp_ErrorListener - from mccode_antlr.reader.reader import component_ir_path - comp_path = Path(comp_path_str) - json_path = component_ir_path(comp_path) - if not force: - try: - if json_path.exists() and json_path.stat().st_mtime_ns >= comp_path.stat().st_mtime_ns: - return (comp_path_str, 'hit') - except OSError: - pass + if not force and component_ir_is_fresh(component_ir_path(comp_path)): + return (comp_path_str, 'hit') try: from mccode_antlr.common.encoding import read_source_text @@ -592,8 +582,9 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str]: error_listener = make_reader_error_listener( McComp_ErrorListener, 'Component', comp_path.stem, source ) - comp = Comp.from_source(_IR_READER, error_listener, source, str(comp_path), str(comp_path)) - component_cache.put(comp_path, comp) + with recording_dependencies() as depends: + comp = Comp.from_source(_IR_READER, error_listener, source, str(comp_path), str(comp_path)) + component_cache.put(comp_path, comp, depends) return (comp_path_str, 'built') except Exception as exc: return (comp_path_str, f'error: {exc}') @@ -907,7 +898,7 @@ def add_cache_management_parser(modes): ) ic.add_argument( '--stale', action='store_true', - help='Only remove sidecars whose sibling .comp is newer, or that a different build wrote', + help='Only remove sidecars whose .comp (or an INHERIT/COPY parent) changed, or that a different build wrote', ) ic.add_argument('-f', '--force', action='store_true', help='Skip confirmation prompt') diff --git a/src/mccode_antlr/reader/reader.py b/src/mccode_antlr/reader/reader.py index fd639850..cca7e2ae 100644 --- a/src/mccode_antlr/reader/reader.py +++ b/src/mccode_antlr/reader/reader.py @@ -1,8 +1,11 @@ from __future__ import annotations +import os import re +from contextlib import contextmanager from functools import lru_cache from pathlib import Path -from msgspec import Struct, field +import msgspec +from msgspec import Raw, Struct, field from .registry import (Registry, registries_match, registry_from_specification, resolve_from_registries, origin_label) @@ -29,7 +32,9 @@ # sidecars parsed on e.g. a cp1252 Windows system may hold garbled text. # 4: that decoding also kept CRLF line endings, so sidecars parsed from a CRLF # checkout hold carriage returns in their C blocks. -_COMPONENT_CACHE_FORMAT = 4 +# 5: sidecars record the mtime of every source file the Comp was built from +# (INHERIT/COPY parents included), as {"sources": {...}, "ir": }. +_COMPONENT_CACHE_FORMAT = 5 @lru_cache(maxsize=1) @@ -39,20 +44,86 @@ def _component_cache_salt() -> str: return sha1(f'{_COMPONENT_CACHE_FORMAT}:{version()}'.encode()).hexdigest()[:12] +# Sources (path -> mtime_ns) of the components resolved while each in-progress +# parse runs; innermost parse last. A `DEFINE COMPONENT b INHERIT a` reaches +# `a` through Reader.get_component, which records it here, so b's cache entry +# goes stale when a's file changes, not only when b's does. +# ponytail: process-global, so concurrent parses in threads would mix their +# dependencies; make it a ContextVar if Readers ever parse in parallel threads. +_dependency_frames: list[dict[str, int]] = [] + + +@contextmanager +def recording_dependencies(): + """Collect the sources of every component resolved inside the block.""" + frame: dict[str, int] = {} + _dependency_frames.append(frame) + try: + yield frame + finally: + _dependency_frames.pop() + + +def _note_dependency(path: Path) -> None: + if not _dependency_frames: + return + sources = component_cache.sources(path) + if sources is None: # in-memory level flushed since it was parsed + try: + sources = {str(path): path.stat().st_mtime_ns} + except OSError: + return + _dependency_frames[-1].update(sources) + + +def _sources_are_current(sources: dict[str, int]) -> bool: + try: + return all(Path(p).stat().st_mtime_ns == m for p, m in sources.items()) + except OSError: + return False + + +class _Sidecar(Struct): + sources: dict[str, int] # relative to the sidecar's directory where possible + ir: Raw + + +def _read_sidecar(json_path: Path) -> tuple[dict[str, int], Raw] | None: + """The sidecar's sources (made absolute) and raw Comp JSON, if it is current.""" + try: + sidecar = msgspec.json.decode(json_path.read_bytes(), type=_Sidecar) + except Exception: + return None # missing, corrupt, or an older layout + sources = {str(json_path.parent / p): m for p, m in sidecar.sources.items()} + return (sources, sidecar.ir) if _sources_are_current(sources) else None + + +def _relative_sources(sources: dict[str, int], directory: Path) -> dict[str, int]: + """Relative paths keep a sidecar valid when its whole tree is copied (cp -a).""" + def rel(p): + try: + return os.path.relpath(p, directory) + except ValueError: # different drive on Windows + return p + return {rel(p): m for p, m in sources.items()} + + class _ComponentCache: """Singleton two-level cache for parsed :class:`~mccode_antlr.comp.Comp` objects. **Level 1 – in-memory dict** (per process): - Maps absolute ``.comp`` path → ``(mtime_ns, Comp)``. Hits cost nothing - beyond a dict lookup and a ``stat()`` call. + Maps absolute ``.comp`` path → ``(sources, Comp)``, where *sources* maps + every file the Comp was built from -- itself and any INHERIT/COPY parents, + transitively -- to its mtime. A hit costs a dict lookup and one ``stat()`` + per source. **Level 2 – disk JSON cache** (persists across process restarts): A ``{name}.comp..json`` file is written alongside every ``.comp`` file the first time it is parsed. On subsequent loads the JSON is decoded by :func:`mccode_antlr.io.json.from_json` in ~0.1 ms instead of running the - ANTLR parser (~10–25 ms per component). The JSON file's mtime is compared - to the ``.comp`` file's mtime; a stale JSON is discarded and the component - is re-parsed. The ```` encodes the mccode-antlr version and an + ANTLR parser (~10–25 ms per component). The JSON records the same + *sources* mtimes; if any differs the JSON is ignored and the component is + re-parsed. The ```` encodes the mccode-antlr version and an internal cache-format number (:data:`_COMPONENT_CACHE_FORMAT`), so sidecars written by a different build are ignored and pruned rather than trusted. If the cache directory is not writable the disk level is silently skipped. @@ -65,7 +136,7 @@ class _ComponentCache: def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance._store: dict[str, tuple[int, Comp]] = {} + cls._instance._store: dict[str, tuple[dict[str, int], Comp]] = {} cls._instance._source_overrides: dict[str, str] = {} return cls._instance @@ -93,43 +164,49 @@ def _foreign_json_paths(comp_path: Path): def get(self, path: Path) -> Comp | None: key = str(path) - try: - comp_mtime = path.stat().st_mtime_ns - except OSError: - return None # Level 1: in-memory if key in self._store: - cached_mtime, comp = self._store[key] - if cached_mtime == comp_mtime: + sources, comp = self._store[key] + if _sources_are_current(sources): return comp del self._store[key] # Level 2: disk JSON - json_path = self._json_path(path) - try: - if json_path.exists() and json_path.stat().st_mtime_ns >= comp_mtime: + if (found := _read_sidecar(self._json_path(path))) is not None: + sources, ir = found + try: from mccode_antlr.io.json import from_json - comp = from_json(json_path.read_bytes()) - if isinstance(comp, Comp): - self._store[key] = (comp_mtime, comp) - return comp - except Exception: - pass # corrupt or unreadable JSON — fall through to ANTLR parse + comp = from_json(bytes(ir)) + except Exception: + return None # corrupt JSON — fall through to ANTLR parse + if isinstance(comp, Comp): + self._store[key] = (sources, comp) + return comp return None - def put(self, path: Path, comp: Comp) -> None: + def sources(self, path: Path) -> dict[str, int] | None: + """The files (path -> mtime_ns) the in-memory entry for *path* was built from.""" + entry = self._store.get(str(path)) + return None if entry is None else entry[0] + + def put(self, path: Path, comp: Comp, depends: dict[str, int] | None = None) -> None: + """Cache *comp*, parsed from *path* using the components in *depends* + (as collected by :func:`recording_dependencies`).""" try: - mtime = path.stat().st_mtime_ns - self._store[str(path)] = (mtime, comp) + sources = {str(path): path.stat().st_mtime_ns, **(depends or {})} + self._store[str(path)] = (sources, comp) except OSError: return # Write disk JSON cache alongside the .comp file (best-effort) json_path = self._json_path(path) try: from mccode_antlr.io.json import to_json - json_path.write_bytes(to_json(comp)) + json_path.write_bytes(msgspec.json.encode(_Sidecar( + sources=_relative_sources(sources, json_path.parent), + ir=Raw(to_json(comp)), + ))) for stale in self._foreign_json_paths(path): stale.unlink(missing_ok=True) except Exception: @@ -202,6 +279,11 @@ def iter_component_ir_paths(root: Path): yield path +def component_ir_is_fresh(json_path: Path) -> bool: + """True when this build wrote *json_path* and none of its sources has changed.""" + return component_ir_is_current(json_path) and _read_sidecar(json_path) is not None + + def component_ir_is_current(json_path: Path) -> bool: """True when *json_path* is the sidecar name this build would write (right salt).""" return json_path.name == component_ir_path(component_ir_comp_path(json_path)).name @@ -252,6 +334,8 @@ class Reader(Struct): registries: list[Registry] = field(default_factory=list) components: dict[str, Comp] = field(default_factory=dict) flavor: Flavor | None = None + # The resolved .comp file of each component in `components` read from disk. + component_paths: dict[str, Path] = field(default_factory=dict) def __hash__(self): return hash((tuple(self.registries), tuple(self.components.items()), self.flavor)) @@ -336,10 +420,12 @@ def add_component(self, name: str, current_instance_name=None): error_listener = make_reader_error_listener( McComp_ErrorListener,'Component', name, source ) - res = Comp.from_source( - self, error_listener, source, filename, fullname - ) - component_cache.put(abs_path, res) + with recording_dependencies() as depends: + res = Comp.from_source( + self, error_listener, source, filename, fullname + ) + component_cache.put(abs_path, res, depends) + self.component_paths[name] = abs_path # Reflect *this* resolution's registry even on a cache hit -- the cache # is keyed by filesystem path, not by registry, so in principle a # different registry could resolve the same abs_path for another Reader. @@ -350,6 +436,8 @@ def add_component(self, name: str, current_instance_name=None): def get_component(self, name: str, current_instance_name=None): if name not in self.components: self.add_component(name, current_instance_name=current_instance_name) + if name in self.component_paths: # not for an inject_source'd component + _note_dependency(self.component_paths[name]) return self.components[name] def inject_source(self, name: str, source: str, filename: str | None = None) -> None: @@ -374,6 +462,7 @@ def inject_source(self, name: str, source: str, filename: str | None = None) -> return component_cache.override_source(name, source) self.components[name] = res + self.component_paths.pop(name, None) def evict(self, name: str) -> None: """Remove *name* from ``self.components`` and the source-override dict. @@ -382,6 +471,7 @@ def evict(self, name: str) -> None: (which uses mtime) or re-parse from disk — useful after a file save. """ self.components.pop(name, None) + self.component_paths.pop(name, None) component_cache.clear_override(name) def get_instrument(self, name: str | None | Path, destination=None): diff --git a/tests/reader/test_component_ir_cache.py b/tests/reader/test_component_ir_cache.py index a7803f68..4dcff9a7 100644 --- a/tests/reader/test_component_ir_cache.py +++ b/tests/reader/test_component_ir_cache.py @@ -87,3 +87,57 @@ def test_iter_component_ir_paths_finds_every_scheme(tmp_path): (tmp_path / "notes.json").write_text("{}") # unrelated assert set(iter_component_ir_paths(tmp_path)) == made + + +PARENT = "DEFINE COMPONENT Pa\nSETTING PARAMETERS (width=1.0)\nTRACE %{ %}\nEND\n" +CHILD = "DEFINE COMPONENT Ch INHERIT Pa\nEND\n" + + +@pytest.fixture +def inherit_dir(tmp_path) -> Path: + (tmp_path / "Pa.comp").write_text(PARENT) + (tmp_path / "Ch.comp").write_text(CHILD) + component_cache.clear() + return tmp_path + + +def _child_width(directory: Path, *load_first: str) -> str: + from mccode_antlr.reader.reader import Reader + from mccode_antlr.reader.registry import LocalRegistry + reader = Reader(registries=[LocalRegistry("t", str(directory))]) + for name in load_first: + reader.get_component(name) + return str(reader.get_component("Ch").setting[0].value) + + +def _edit_parent(directory: Path): + import os + parent = directory / "Pa.comp" + stat = parent.stat() + parent.write_text(PARENT.replace("1.0", "2.0")) + # a distinct mtime even on filesystems with coarse timestamps + os.utime(parent, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000)) + + +@pytest.mark.parametrize("load_first", [(), ("Pa",)]) +def test_editing_an_inherit_parent_invalidates_the_child_in_memory(inherit_dir, load_first): + assert _child_width(inherit_dir, *load_first) == "1.0" + _edit_parent(inherit_dir) + assert _child_width(inherit_dir, *load_first) == "2.0" + + +@pytest.mark.parametrize("load_first", [(), ("Pa",)]) +def test_editing_an_inherit_parent_invalidates_the_child_sidecar(inherit_dir, load_first): + assert _child_width(inherit_dir, *load_first) == "1.0" + assert component_ir_path(inherit_dir / "Ch.comp").is_file() + _edit_parent(inherit_dir) + component_cache.clear() # as a new process would start + assert _child_width(inherit_dir, *load_first) == "2.0" + + +def test_a_copied_tree_keeps_its_sidecars(inherit_dir, tmp_path_factory): + import shutil + _child_width(inherit_dir) + copy = tmp_path_factory.mktemp("copy") / "tree" + shutil.copytree(inherit_dir, copy) # copy2: mtimes preserved, like cp -a + assert component_cache.get(copy / "Ch.comp") is not None # sidecar hit, no parse From 58cff1325ba33ae565e148058f30f5500a4e85c4 Mon Sep 17 00:00:00 2001 From: "Gregory S. Tucker" Date: Wed, 21 Oct 2026 05:22:05 +0000 Subject: [PATCH 2/3] Warm the C typedef cache in `cache ir-build` Translation parses every component's SHARE, DECLARE and USERVARS blocks, and each runtime library they %include, to find C typedefs and declarations. That parse is cached by content hash, but nothing pre-built the cache, so a read-only image (stacktainer-splitrun) re-parsed them in every process: 1.3 s for ILL_H142 and 2.5 s for cryostat_example, against 0.34 s warm. ir-build now constructs the C translator for a one-instance instrument of each component it builds or finds up to date, which parses exactly the blocks a real translation would. Only an instrument's own USERVARS are left for translation time. A component that cannot be translated (e.g. a missing %include file) is reported separately and does not count as an IR error. --- docs/api/cli.md | 2 +- src/mccode_antlr/cli/cache.py | 84 ++++++++++++++++++++++++----------- tests/test_cli_management.py | 37 +++++++++++++++ 3 files changed, 97 insertions(+), 26 deletions(-) diff --git a/docs/api/cli.md b/docs/api/cli.md index 675758aa..f8f39184 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -265,7 +265,7 @@ mccode-antlr SUBCOMMAND [OPTIONS] | `register` | Mint a pooch registry file (path + sha256 hash per line) from a local directory tree | | `ir-list` | List component IR cache files (`*.comp..json`) (`-l` for paths, sizes, stale status) | | `ir-clean` | Delete component IR cache files (`--stale` for stale-only; `-f` to skip confirmation) | -| `ir-build` | Pre-build component IR cache files for all known registries | +| `ir-build` | Pre-build component IR cache files, and warm the C typedef cache, for all known registries | **`cache populate` options:** diff --git a/src/mccode_antlr/cli/cache.py b/src/mccode_antlr/cli/cache.py index 6f1420ea..848b124a 100644 --- a/src/mccode_antlr/cli/cache.py +++ b/src/mccode_antlr/cli/cache.py @@ -555,13 +555,41 @@ def _ir_worker_init(registries, flavor, tag=None) -> None: _IR_READER = Reader(registries=list(registries), flavor=flavor) -def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str]: - """Parse one ``.comp`` file and write its ``.comp.json``. +def _warm_typedefs(comp) -> str | None: + """Fill the C typedef cache for *comp*; return an error message on failure. + + Constructing the C translator for a one-instance instrument parses exactly + what a real translation would for this component -- its SHARE, DECLARE and + USERVARS blocks and every library they ``%include`` -- and the cache is keyed + by the hash of that text, so any instrument using the component then hits. + Only an instrument's own USERVARS are left to parse at translation time. + """ + if _IR_READER is None: + return None + from mccode_antlr import Flavor + from mccode_antlr.assembler import Assembler + from mccode_antlr.translators.c import CTargetVisitor + flavor = _IR_READER.flavor or Flavor.MCSTAS + try: + assembler = Assembler('typedefs', registries=list(_IR_READER.registries), flavor=flavor) + # this exact Comp, not whichever same-named one the registries rank first + assembler.reader.components[comp.name] = comp + assembler.component('c', comp.name) + CTargetVisitor(assembler.instrument, flavor=flavor) + except Exception as exc: + return f'{type(exc).__name__}: {exc}'.split('\n')[0] + return None + + +def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str, str | None]: + """Parse one ``.comp`` file, write its ``.comp.json``, and warm the C + typedef cache for it (see :func:`_warm_typedefs`). Module-level so it is picklable by ``ProcessPoolExecutor``. - Returns ``(comp_path_str, status)`` where *status* is one of - ``'hit'``, ``'built'``, or ``'error: '``. + Returns ``(comp_path_str, status, typedef_error)`` where *status* is one of + ``'hit'``, ``'built'``, or ``'error: '``, and *typedef_error* is + None unless warming the typedef cache failed. """ from pathlib import Path from mccode_antlr.reader.reader import ( @@ -574,7 +602,9 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str]: comp_path = Path(comp_path_str) if not force and component_ir_is_fresh(component_ir_path(comp_path)): - return (comp_path_str, 'hit') + comp = component_cache.get(comp_path) + if comp is not None: + return (comp_path_str, 'hit', _warm_typedefs(comp)) try: from mccode_antlr.common.encoding import read_source_text @@ -585,9 +615,9 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str]: with recording_dependencies() as depends: comp = Comp.from_source(_IR_READER, error_listener, source, str(comp_path), str(comp_path)) component_cache.put(comp_path, comp, depends) - return (comp_path_str, 'built') except Exception as exc: - return (comp_path_str, f'error: {exc}') + return (comp_path_str, f'error: {exc}', None) + return (comp_path_str, 'built', _warm_typedefs(comp)) _REGISTRY_SPEC_FORMS = ( @@ -708,7 +738,8 @@ def _registries_for_flavors(flavor: str, registry: list[str] | None) -> tuple[li def cache_ir_build(flavor: str, jobs: int, force: bool, registry: list[str] | None, tag: str | None = None): - """Pre-build component IR (``.comp.json``) files for all known registries. + """Pre-build component IR (``.comp.json``) files for all known registries, + and warm the C typedef cache with each component's C blocks and libraries. Builds IR from files already on disk and downloads nothing: ``cache populate`` is the command that fills the caches, including any ``--registry`` extras. @@ -752,6 +783,19 @@ def cache_ir_build(flavor: str, jobs: int, force: bool, registry: list[str] | No worker = partial(_build_one_ir, force=force) built = hits = errors = 0 + typedef_errors = [] + + def tally(path, status, typedef_error): + nonlocal built, hits, errors + if status == 'built': + built += 1 + elif status == 'hit': + hits += 1 + else: + errors += 1 + print(f' {path.name}: {status}', flush=True) + if typedef_error is not None: + typedef_errors.append(f' {path.name}: {typedef_error}') # The Reader that resolves INHERIT/COPY parents is built per process: once # here for the serial path, once per worker via the pool's initializer. @@ -776,33 +820,23 @@ def cache_ir_build(flavor: str, jobs: int, force: bool, registry: list[str] | No if jobs == 1: _ir_worker_init(*reader_args) for path in comp_paths: - _, status = worker(str(path)) - if status == 'built': - built += 1 - elif status == 'hit': - hits += 1 - else: - errors += 1 - print(f' {path.name}: {status}', flush=True) + tally(path, *worker(str(path))[1:]) else: with ProcessPoolExecutor( max_workers=jobs, initializer=_ir_worker_init, initargs=reader_args, ) as pool: futures = {pool.submit(_build_one_ir, str(p), force): p for p in comp_paths} for future in as_completed(futures): - _, status = future.result() - if status == 'built': - built += 1 - elif status == 'hit': - hits += 1 - else: - errors += 1 - print(f' {futures[future].name}: {status}', flush=True) + tally(futures[future], *future.result()[1:]) print( f'\nDone. {built} built, {hits} already up-to-date, {errors} error(s).', flush=True, ) + if typedef_errors: + print(f'{len(typedef_errors)} component(s) could not warm the C typedef cache ' + '(their instruments will parse those blocks at translation time):', flush=True) + print('\n'.join(typedef_errors), flush=True) def add_cache_management_parser(modes): @@ -908,7 +942,7 @@ def add_cache_management_parser(modes): ib = actions.add_parser( name='ir-build', help=( - 'Pre-build component IR cache sidecars from the .comp files already on ' + 'Pre-build component IR cache sidecars, and warm the C typedef cache, from the .comp files already on ' 'disk (downloads nothing — see `cache populate`)' ), ) diff --git a/tests/test_cli_management.py b/tests/test_cli_management.py index 479e888e..b1e31e0c 100644 --- a/tests/test_cli_management.py +++ b/tests/test_cli_management.py @@ -746,3 +746,40 @@ def test_ir_build_no_comps_when_nothing_on_disk(self, tmp_path, capsys, mock_def if __name__ == '__main__': pytest.main([__file__, '-v']) + + +def test_ir_build_warms_the_c_typedef_cache(tmp_path, monkeypatch): + """A translation after ir-build re-parses none of the component's C blocks.""" + from mccode_antlr import Flavor + from mccode_antlr.cli.cache import _build_one_ir, _ir_worker_init + from mccode_antlr.reader.registry import LocalRegistry, default_registries + from mccode_antlr.translators import c_listener + + comps = tmp_path / 'comps' + comps.mkdir() + (comps / 'TypedefProbe.comp').write_text( + 'DEFINE COMPONENT TypedefProbe\n' + 'SHARE %{\n%include "read_table-lib"\ntypedef struct { double x; } probe_t;\n%}\n' + 'DECLARE %{\nprobe_t probe;\n%}\n' + 'TRACE %{ %}\nEND\n' + ) + cache = c_listener._TypedefsCache() + cache._disk_dir = tmp_path / 'typedefs' + cache._disk_dir.mkdir() + monkeypatch.setattr(c_listener, '_typedefs_cache', cache) + + registries = [LocalRegistry('probe', str(comps)), *default_registries(Flavor.MCSTAS)] + _ir_worker_init(registries, Flavor.MCSTAS) + _, status, typedef_error = _build_one_ir(str(comps / 'TypedefProbe.comp'), force=True) + assert status == 'built' and typedef_error is None + warmed = set(cache._memory) + assert warmed and {p.stem for p in cache._disk_dir.glob('*.json')} == warmed + + # A real translation of an instrument using the component adds nothing. + from mccode_antlr.assembler import Assembler + from mccode_antlr.translators.c import CTargetVisitor + assembler = Assembler('uses_probe', registries=registries, flavor=Flavor.MCSTAS) + assembler.component('p', 'TypedefProbe') + CTargetVisitor(assembler.instrument, flavor=Flavor.MCSTAS) + assert set(cache._memory) == warmed + assert any('t_Table' in types for _, types in cache._memory.values()) # from read_table-lib From ed83241a45595893fb9494344a64b2ecff28f803 Mon Sep 17 00:00:00 2001 From: "Gregory S. Tucker" Date: Wed, 21 Oct 2026 09:46:02 +0000 Subject: [PATCH 3/3] Rename `cache ir-build` to `cache build`; make IR sidecars opt-in `cache build` pre-builds both translation caches, so the IR-specific name no longer fits. `cache ir-build` remains as a deprecated spelling that warns and then runs `cache build`. Component IR sidecars are now written and read only when the new `cache.component_ir` configuration value is true (e.g. MCCODEANTLR_CACHE__COMPONENT_IR=true); it defaults to false. Re-parsing a component costs ~0.3 s per translation process, while sidecars written into source trees have repeatedly served stale IR and leaked into published registries. They remain worthwhile for a prebuilt, read-only cache such as a container image, which can opt in. The in-memory level is unchanged, so long-lived processes lose nothing. String values, as stored by `mccode-antlr config set`, are parsed rather than taken as truthy. With sidecars disabled, `cache build` says so and only warms the C typedef cache. The CLI docs also drop a nonexistent `--download` option and list `--tag` and `--flavor none`. --- docs/api/cli.md | 11 +- docs/getting-started.md | 2 +- docs/how-to/registry-components.md | 2 +- src/mccode_antlr/cli/cache.py | 130 ++++++++++++-------- src/mccode_antlr/config/config_default.yaml | 6 + src/mccode_antlr/reader/reader.py | 16 ++- tests/reader/test_component_ir_cache.py | 44 +++++++ tests/test_cli_management.py | 94 +++++++++----- 8 files changed, 211 insertions(+), 94 deletions(-) diff --git a/docs/api/cli.md b/docs/api/cli.md index f8f39184..d309b00a 100644 --- a/docs/api/cli.md +++ b/docs/api/cli.md @@ -265,7 +265,8 @@ mccode-antlr SUBCOMMAND [OPTIONS] | `register` | Mint a pooch registry file (path + sha256 hash per line) from a local directory tree | | `ir-list` | List component IR cache files (`*.comp..json`) (`-l` for paths, sizes, stale status) | | `ir-clean` | Delete component IR cache files (`--stale` for stale-only; `-f` to skip confirmation) | -| `ir-build` | Pre-build component IR cache files, and warm the C typedef cache, for all known registries | +| `build` | Pre-build the C typedef cache, and component IR cache files when enabled, for all known registries | +| `ir-build` | Deprecated spelling of `build` | **`cache populate` options:** @@ -289,14 +290,16 @@ mccode-antlr SUBCOMMAND [OPTIONS] | `--ext SUFFIX` | Only include files whose name ends with this suffix (repeatable); default includes every file | | `--recursive` / `--no-recursive` | Recurse into subdirectories of each `DIR` (default) | -**`cache ir-build` options:** +**`cache build` options:** + +`cache build` parses only files already on disk; run `cache populate` first. It always warms the C typedef cache. It writes component IR sidecars (`*.comp..json`) only when the `cache.component_ir` configuration value is true, e.g. `MCCODEANTLR_CACHE__COMPONENT_IR=true`. They are off by default; turn them on for a prebuilt, shared or read-only cache. | Flag | Description | |---|---| -| `--flavor {mcstas,mcxtrace,both}` | Which flavor's registries to build IR for (default: `both`) | +| `--tag TAG` | McCode version whose cached components to build (default: the configured version) | +| `--flavor {mcstas,mcxtrace,both,none}` | Which flavor's registries to build (default: `both`); `none` builds only `--registry` ones | | `-j N`, `--jobs N` | Number of parallel workers (default: `os.cpu_count()`) | | `--force` | Rebuild even if the IR sidecar is already up-to-date | -| `--download` | Bulk-populate the pooch cache via git clone before building (faster than individual fetches) | | `-R SPEC`, `--registry SPEC` | Extra registry to include (repeatable); same `SPEC` formats as `registry_from_specification` | --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 413cbb32..54bcc595 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -54,7 +54,7 @@ The `mccode-antlr` management command controls caches and configuration: ```bash mccode-antlr cache list # list cached component registries -mccode-antlr cache ir-build # pre-build component intermediate representations +mccode-antlr cache build # pre-build the translation caches mccode-antlr config list # display active configuration ``` diff --git a/docs/how-to/registry-components.md b/docs/how-to/registry-components.md index a1067151..a6c143a4 100644 --- a/docs/how-to/registry-components.md +++ b/docs/how-to/registry-components.md @@ -104,6 +104,6 @@ automatically so the component definitions are available. ```bash mccode-antlr cache list # show all cached registries mccode-antlr cache populate # download default McStas + McXtrace registries -mccode-antlr cache ir-build # pre-build component IR (speeds up first parse) +mccode-antlr cache build # pre-build translation caches (speeds up first translation) mccode-antlr cache ir-clean # remove stale IR cache entries ``` diff --git a/src/mccode_antlr/cli/cache.py b/src/mccode_antlr/cli/cache.py index 848b124a..04ba03ce 100644 --- a/src/mccode_antlr/cli/cache.py +++ b/src/mccode_antlr/cli/cache.py @@ -9,12 +9,16 @@ - ``register`` – mint a pooch registry file (path + sha256 hash per line) from a local directory tree. -Component intermediate-representation (IR) cache -------------------------------------------------- -The reader writes a ``{name}.comp..json`` file alongside every ``.comp`` -file it parses (the ```` binds the sidecar to the mccode-antlr build that -wrote it). These files persist across process restarts (acting as a fast disk -cache), but they accumulate and users may wish to inspect or clean them up. +Translation caches +------------------ +- ``build`` – pre-build the C typedef cache and, when enabled, the component + IR sidecars (``ir-build`` is its deprecated spelling). + +When the ``cache.component_ir`` configuration value is true, the reader writes a +``{name}.comp..json`` file alongside every ``.comp`` file it parses (the +```` binds the sidecar to the mccode-antlr build that wrote it). These +files persist across process restarts (acting as a fast disk cache), but they +accumulate and users may wish to inspect or clean them up. - ``ir-list`` – find and list all component IR sidecars under the cache root. - ``ir-clean`` – delete all (or only stale) component IR sidecars; a sidecar @@ -348,7 +352,7 @@ def cache_populate( Registries named with ``--registry`` are populated too, file by file through pooch — they have no git-clone shortcut. This is the only command that - downloads; ``cache ir-build`` parses whatever is already on disk. + downloads; ``cache build`` parses whatever is already on disk. ``flavor='none'`` skips the McCode side entirely — no tag resolution, no clone, no checkout — so populating only ``--registry`` extras costs nothing @@ -593,7 +597,7 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str, str | None """ from pathlib import Path from mccode_antlr.reader.reader import ( - component_cache, component_ir_is_fresh, component_ir_path, + component_cache, component_ir_enabled, component_ir_is_fresh, component_ir_path, make_reader_error_listener, recording_dependencies, ) from mccode_antlr.comp import Comp @@ -601,7 +605,7 @@ def _build_one_ir(comp_path_str: str, force: bool) -> tuple[str, str, str | None comp_path = Path(comp_path_str) - if not force and component_ir_is_fresh(component_ir_path(comp_path)): + if not force and component_ir_enabled() and component_ir_is_fresh(component_ir_path(comp_path)): comp = component_cache.get(comp_path) if comp is not None: return (comp_path_str, 'hit', _warm_typedefs(comp)) @@ -708,10 +712,19 @@ def _collect_comp_paths(registries) -> tuple[list[Path], int]: return paths, missing +def cache_ir_build(**kwargs): + """Deprecated spelling of :func:`cache_build`.""" + import warnings + from mccode_antlr.utils import McCodeAntlrDeprecationWarning + warnings.warn('`mccode-antlr cache ir-build` is deprecated since 0.36.0; ' + 'use `mccode-antlr cache build`', McCodeAntlrDeprecationWarning, stacklevel=2) + cache_build(**kwargs) + + def _registries_for_flavors(flavor: str, registry: list[str] | None) -> tuple[list, list]: """Default registries for *flavor* plus any ``--registry`` extras. - Returns ``(flavors, registries)``; both ``populate`` and ``ir-build`` need the + Returns ``(flavors, registries)``; both ``populate`` and ``build`` need the same set, so they resolve it the same way. """ from mccode_antlr import Flavor @@ -736,8 +749,8 @@ def _registries_for_flavors(flavor: str, registry: list[str] | None) -> tuple[li return flavors, registries -def cache_ir_build(flavor: str, jobs: int, force: bool, registry: list[str] | None, - tag: str | None = None): +def cache_build(flavor: str, jobs: int, force: bool, registry: list[str] | None, + tag: str | None = None): """Pre-build component IR (``.comp.json``) files for all known registries, and warm the C typedef cache with each component's C blocks and libraries. @@ -777,6 +790,11 @@ def cache_ir_build(flavor: str, jobs: int, force: bool, registry: list[str] | No n = len(comp_paths) print(f'Found {n} .comp file(s). Building IR cache …', flush=True) + from mccode_antlr.reader.reader import component_ir_enabled + if not component_ir_enabled(): + print(' Component IR sidecars are disabled (set cache.component_ir, e.g. ' + 'MCCODEANTLR_CACHE__COMPONENT_IR=true, to write them); warming the C ' + 'typedef cache only.', flush=True) if jobs is None: jobs = os.cpu_count() or 1 @@ -839,6 +857,43 @@ def tally(path, status, typedef_error): print('\n'.join(typedef_errors), flush=True) +def _add_build_parser(actions, name, action, help_text): + ib = actions.add_parser(name=name, help=help_text) + ib.add_argument( + '--tag', default=None, + help=( + 'McCode version tag whose cached components to build IR for ' + '(e.g. v3.5.31, latest); defaults to the currently-configured version. ' + 'The pooch caches are per version, so this picks between them when ' + 'several have been populated.' + ), + ) + ib.add_argument( + '--flavor', default='both', choices=['mcstas', 'mcxtrace', 'both', 'none'], + help=( + "Which flavor's registries to build IR for (default: both). Use 'none' " + "to build only the registries given with --registry." + ), + ) + ib.add_argument( + '-j', '--jobs', type=int, nargs='?', default=None, const=None, metavar='N', + help='Number of parallel workers; bare -j or omitted means os.cpu_count()', + ) + ib.add_argument( + '--force', action='store_true', + help='Rebuild even if the IR sidecar is already up-to-date', + ) + ib.add_argument( + '-R', '--registry', action='append', default=None, metavar='SPEC', + help=( + 'Extra registry to build IR for (repeatable). Only files already on disk ' + 'are built: pass the same specification to `cache populate` first to ' + 'download them. ' + _REGISTRY_SPEC_FORMS.replace('\n', ' ') + ), + ) + ib.set_defaults(action=action) + + def add_cache_management_parser(modes): parser = modes.add_parser(name='cache', help='Manage the mccode-antlr cache') actions = parser.add_subparsers(help='Action to perform', metavar='ACTION', required=True) @@ -938,47 +993,16 @@ def add_cache_management_parser(modes): help='Skip confirmation prompt') ic.set_defaults(action=cache_ir_clean) - # -- ir-build -- - ib = actions.add_parser( - name='ir-build', - help=( - 'Pre-build component IR cache sidecars, and warm the C typedef cache, from the .comp files already on ' - 'disk (downloads nothing — see `cache populate`)' - ), - ) - ib.add_argument( - '--tag', default=None, - help=( - 'McCode version tag whose cached components to build IR for ' - '(e.g. v3.5.31, latest); defaults to the currently-configured version. ' - 'The pooch caches are per version, so this picks between them when ' - 'several have been populated.' - ), - ) - ib.add_argument( - '--flavor', default='both', choices=['mcstas', 'mcxtrace', 'both', 'none'], - help=( - "Which flavor's registries to build IR for (default: both). Use 'none' " - "to build only the registries given with --registry." - ), - ) - ib.add_argument( - '-j', '--jobs', type=int, nargs='?', default=None, const=None, metavar='N', - help='Number of parallel workers; bare -j or omitted means os.cpu_count()', - ) - ib.add_argument( - '--force', action='store_true', - help='Rebuild even if the IR sidecar is already up-to-date', - ) - ib.add_argument( - '-R', '--registry', action='append', default=None, metavar='SPEC', - help=( - 'Extra registry to build IR for (repeatable). Only files already on disk ' - 'are built: pass the same specification to `cache populate` first to ' - 'download them. ' + _REGISTRY_SPEC_FORMS.replace('\n', ' ') - ), - ) - ib.set_defaults(action=cache_ir_build) + # -- build (and its deprecated spelling, ir-build) -- + for name, action, help_text in ( + ('build', cache_build, + 'Pre-build the translation caches -- the C typedef cache, and component IR ' + 'sidecars when cache.component_ir is enabled -- from the .comp files already ' + 'on disk (downloads nothing — see `cache populate`)'), + ('ir-build', cache_ir_build, 'Deprecated spelling of `cache build`'), + ): + _add_build_parser(actions, name, action, help_text) + # -- register -- reg = actions.add_parser( diff --git a/src/mccode_antlr/config/config_default.yaml b/src/mccode_antlr/config/config_default.yaml index 26a3b96a..17a5a7b5 100644 --- a/src/mccode_antlr/config/config_default.yaml +++ b/src/mccode_antlr/config/config_default.yaml @@ -23,3 +23,9 @@ serialization: trust_local_registries: false embed_local_files: true embed_size_limit_mb: 32 +cache: + # Persist parsed components as `{name}.comp..json` sidecars beside each + # .comp file, for later processes to reuse. Saves roughly 0.3 s per translation; + # worth it for a prebuilt, read-only cache (`mccode-antlr cache build`), e.g. + # with MCCODEANTLR_CACHE__COMPONENT_IR=true. + component_ir: false diff --git a/src/mccode_antlr/reader/reader.py b/src/mccode_antlr/reader/reader.py index cca7e2ae..a4b896d0 100644 --- a/src/mccode_antlr/reader/reader.py +++ b/src/mccode_antlr/reader/reader.py @@ -108,6 +108,15 @@ def rel(p): return {rel(p): m for p, m in sources.items()} +def component_ir_enabled() -> bool: + """Whether component IR sidecars are read and written (``cache.component_ir``).""" + from mccode_antlr.config import config + value = config['cache']['component_ir'].get() + if isinstance(value, str): # `mccode-antlr config set` stores the text it is given + return value.strip().lower() in ('1', 'true', 'yes', 'on') + return bool(value) + + class _ComponentCache: """Singleton two-level cache for parsed :class:`~mccode_antlr.comp.Comp` objects. @@ -117,7 +126,8 @@ class _ComponentCache: transitively -- to its mtime. A hit costs a dict lookup and one ``stat()`` per source. - **Level 2 – disk JSON cache** (persists across process restarts): + **Level 2 – disk JSON cache** (persists across process restarts; only when + the ``cache.component_ir`` configuration value is true): A ``{name}.comp..json`` file is written alongside every ``.comp`` file the first time it is parsed. On subsequent loads the JSON is decoded by :func:`mccode_antlr.io.json.from_json` in ~0.1 ms instead of running the @@ -173,7 +183,7 @@ def get(self, path: Path) -> Comp | None: del self._store[key] # Level 2: disk JSON - if (found := _read_sidecar(self._json_path(path))) is not None: + if component_ir_enabled() and (found := _read_sidecar(self._json_path(path))) is not None: sources, ir = found try: from mccode_antlr.io.json import from_json @@ -199,6 +209,8 @@ def put(self, path: Path, comp: Comp, depends: dict[str, int] | None = None) -> self._store[str(path)] = (sources, comp) except OSError: return + if not component_ir_enabled(): + return # Write disk JSON cache alongside the .comp file (best-effort) json_path = self._json_path(path) try: diff --git a/tests/reader/test_component_ir_cache.py b/tests/reader/test_component_ir_cache.py index 4dcff9a7..5a7ffc5b 100644 --- a/tests/reader/test_component_ir_cache.py +++ b/tests/reader/test_component_ir_cache.py @@ -20,6 +20,12 @@ MINIMAL = "DEFINE COMPONENT IrCacheProbe\nTRACE %{ %}\nEND\n" +@pytest.fixture(autouse=True) +def sidecars_enabled(monkeypatch): + """The disk level is opt-in (cache.component_ir); these tests exercise it.""" + monkeypatch.setattr('mccode_antlr.reader.reader.component_ir_enabled', lambda: True) + + @pytest.fixture def comp_file(tmp_path) -> Path: p = tmp_path / "IrCacheProbe.comp" @@ -141,3 +147,41 @@ def test_a_copied_tree_keeps_its_sidecars(inherit_dir, tmp_path_factory): copy = tmp_path_factory.mktemp("copy") / "tree" shutil.copytree(inherit_dir, copy) # copy2: mtimes preserved, like cp -a assert component_cache.get(copy / "Ch.comp") is not None # sidecar hit, no parse + + +def test_sidecars_are_off_by_default(): + from mccode_antlr.config import config + assert not config['cache']['component_ir'].get() + + +@pytest.mark.parametrize('value, enabled', [ + (True, True), (False, False), (1, True), (0, False), (None, False), + ('true', True), ('false', False), ('1', True), ('0', False), ('no', False), +]) +def test_component_ir_enabled_reads_the_config(value, enabled, monkeypatch): + from mccode_antlr.config import config + from mccode_antlr.reader import reader + monkeypatch.undo() # drop the autouse override; read the real configuration + config.resolve() # a LazyConfig loads its sources on first use + monkeypatch.setattr(config, 'sources', list(config.sources)) # restored afterwards + config.set({'cache': {'component_ir': value}}) + assert reader.component_ir_enabled() is enabled + + +def test_disabled_sidecars_are_neither_written_nor_read(comp_file, monkeypatch): + monkeypatch.setattr('mccode_antlr.reader.reader.component_ir_enabled', lambda: False) + sidecar = component_ir_path(comp_file) + component_cache.put(comp_file, _parse(comp_file)) + assert not sidecar.exists() + assert component_cache.get(comp_file) is not None # the in-memory level still works + + # A sidecar left by an enabled run is not trusted while disabled. + import msgspec + from mccode_antlr.io.json import to_json + from mccode_antlr.reader.reader import _Sidecar + sidecar.write_bytes(msgspec.json.encode(_Sidecar( + sources={comp_file.name: comp_file.stat().st_mtime_ns}, + ir=msgspec.Raw(to_json(_parse(comp_file))), + ))) + component_cache.clear() + assert component_cache.get(comp_file) is None diff --git a/tests/test_cli_management.py b/tests/test_cli_management.py index b1e31e0c..a6cec526 100644 --- a/tests/test_cli_management.py +++ b/tests/test_cli_management.py @@ -593,7 +593,11 @@ def test_cache_register_no_recursive_flag(self): class TestCacheIR: - """Tests for cache ir-list, ir-clean, and ir-build sub-commands.""" + """Tests for cache ir-list, ir-clean, and build sub-commands.""" + + @pytest.fixture(autouse=True) + def sidecars_enabled(self, monkeypatch): + monkeypatch.setattr('mccode_antlr.reader.reader.component_ir_enabled', lambda: True) @pytest.fixture def comp_dir(self, tmp_path): @@ -676,80 +680,104 @@ def test_ir_clean_confirmation_accepted(self, ir_root, monkeypatch): assert not list(ir_root.glob('*.comp*.json')) # ------------------------------------------------------------------ - # ir-build + # build # ------------------------------------------------------------------ @pytest.fixture def mock_default_registries(self, monkeypatch): - """Prevent network calls from default_registries during ir-build tests.""" + """Prevent network calls from default_registries during build tests.""" monkeypatch.setattr( 'mccode_antlr.reader.registry.default_registries', lambda flavor: [] ) - def test_ir_build_creates_json(self, comp_dir, capsys, mock_default_registries): - from mccode_antlr.cli.cache import cache_ir_build + def test_build_creates_json(self, comp_dir, capsys, mock_default_registries): + from mccode_antlr.cli.cache import cache_build assert not list(comp_dir.glob('*.comp*.json')) - cache_ir_build( + cache_build( flavor='mcstas', jobs=1, force=False, registry=[str(comp_dir)], ) assert list(comp_dir.glob('*.comp*.json')) - def test_ir_build_hits_on_second_run(self, comp_dir, capsys, mock_default_registries): - from mccode_antlr.cli.cache import cache_ir_build + def test_build_hits_on_second_run(self, comp_dir, capsys, mock_default_registries): + from mccode_antlr.cli.cache import cache_build # First run builds - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=[str(comp_dir)]) + cache_build(flavor='mcstas', jobs=1, force=False, + registry=[str(comp_dir)]) capsys.readouterr() # Second run should report hits - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=[str(comp_dir)]) + cache_build(flavor='mcstas', jobs=1, force=False, + registry=[str(comp_dir)]) out = capsys.readouterr().out assert 'already up-to-date' in out - def test_ir_build_force_rebuilds(self, comp_dir, capsys, mock_default_registries): - from mccode_antlr.cli.cache import cache_ir_build + def test_build_force_rebuilds(self, comp_dir, capsys, mock_default_registries): + from mccode_antlr.cli.cache import cache_build # Build once, then force rebuild - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=[str(comp_dir)]) + cache_build(flavor='mcstas', jobs=1, force=False, + registry=[str(comp_dir)]) capsys.readouterr() - cache_ir_build(flavor='mcstas', jobs=1, force=True, - registry=[str(comp_dir)]) + cache_build(flavor='mcstas', jobs=1, force=True, + registry=[str(comp_dir)]) out = capsys.readouterr().out assert '0 already up-to-date' in out or 'built' in out - def test_ir_build_registry_spec_local_dir(self, comp_dir, capsys, mock_default_registries): + def test_build_registry_spec_local_dir(self, comp_dir, capsys, mock_default_registries): """--registry accepts a bare local path.""" - from mccode_antlr.cli.cache import cache_ir_build - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=[str(comp_dir)]) + from mccode_antlr.cli.cache import cache_build + cache_build(flavor='mcstas', jobs=1, force=False, + registry=[str(comp_dir)]) out = capsys.readouterr().out assert 'built' in out or 'already up-to-date' in out - def test_ir_build_bad_registry_spec_warns(self, comp_dir, capsys, mock_default_registries): + def test_build_bad_registry_spec_warns(self, comp_dir, capsys, mock_default_registries): """An un-parseable registry spec should warn and continue.""" - from mccode_antlr.cli.cache import cache_ir_build - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=['not-a-valid-spec://xyz']) + from mccode_antlr.cli.cache import cache_build + cache_build(flavor='mcstas', jobs=1, force=False, + registry=['not-a-valid-spec://xyz']) out = capsys.readouterr().out assert 'WARNING' in out or 'warning' in out.lower() or 'No .comp files found' in out - def test_ir_build_no_comps_when_nothing_on_disk(self, tmp_path, capsys, mock_default_registries): - """ir-build downloads nothing, so an empty registry builds nothing.""" - from mccode_antlr.cli.cache import cache_ir_build + def test_build_no_comps_when_nothing_on_disk(self, tmp_path, capsys, mock_default_registries): + """build downloads nothing, so an empty registry builds nothing.""" + from mccode_antlr.cli.cache import cache_build # Empty local dir → no .comp files → should print "No .comp files found" - cache_ir_build(flavor='mcstas', jobs=1, force=False, - registry=[str(tmp_path)]) + cache_build(flavor='mcstas', jobs=1, force=False, + registry=[str(tmp_path)]) out = capsys.readouterr().out assert 'No .comp files found' in out +def test_build_without_sidecars_only_warms_typedefs(tmp_path, capsys, monkeypatch): + from mccode_antlr.cli.cache import cache_build + from mccode_antlr.translators import c_listener + monkeypatch.setattr('mccode_antlr.reader.reader.component_ir_enabled', lambda: False) + monkeypatch.setattr('mccode_antlr.reader.registry.default_registries', lambda flavor: []) + monkeypatch.setattr(c_listener, '_typedefs_cache', c_listener._TypedefsCache()) + (tmp_path / 'Plain.comp').write_text(_MINIMAL_COMP.format(name='Plain')) + cache_build(flavor='mcstas', jobs=1, force=False, registry=[str(tmp_path)]) + assert 'Component IR sidecars are disabled' in capsys.readouterr().out + assert not list(tmp_path.glob('*.comp*.json')) + + +def test_ir_build_is_a_deprecated_build(monkeypatch): + import mccode_antlr.cli.cache as cache_module + from mccode_antlr.cli.management import mccode_management_parser + from mccode_antlr.utils import McCodeAntlrDeprecationWarning + calls = [] + monkeypatch.setattr(cache_module, 'cache_build', lambda **kwargs: calls.append(kwargs)) + args = vars(mccode_management_parser().parse_args(['cache', 'ir-build', '--flavor', 'mcstas'])) + with pytest.warns(McCodeAntlrDeprecationWarning, match='cache build'): + args.pop('action')(**args) + assert calls and calls[0]['flavor'] == 'mcstas' + + if __name__ == '__main__': pytest.main([__file__, '-v']) -def test_ir_build_warms_the_c_typedef_cache(tmp_path, monkeypatch): - """A translation after ir-build re-parses none of the component's C blocks.""" +def test_build_warms_the_c_typedef_cache(tmp_path, monkeypatch): + """A translation after `cache build` re-parses none of the component's C blocks.""" from mccode_antlr import Flavor from mccode_antlr.cli.cache import _build_one_ir, _ir_worker_init from mccode_antlr.reader.registry import LocalRegistry, default_registries