"""In-process YAML loader with per-path memoisation. Many CLI tools walk the same `roles///...yml` files multiple times during a single invocation (dependency resolution, service resolution, role introspection, ...). Each direct `yaml.safe_load(path.read_text())` re-parses YAML and re-touches disk. This module exposes a single cached `load_yaml(path)` so each absolute path is parsed at most once per Python process. `dump_yaml` writes through and evicts the cached entry. `invalidate(path)` is the explicit escape hatch when a caller knows a file was changed externally. CACHE SEMANTICS - Cache key: `(str(Path(path).resolve()), st_mtime_ns, st_size)`. Symlinks resolve once at the cache boundary so the same target shares an entry regardless of how callers spell the path. The mtime+size signature invalidates entries whenever the file is rewritten by an external process (e.g. Ansible's `copy:` module persisting tokens while the same Python process keeps running across many calls). - Cached value: the **list of documents** ``yaml.safe_load_all`` returns for that file. Single-document YAML files cache as a 1-element list, so a callsite that asks for the single doc (``load_yaml`` / ``load_yaml_any``) and a callsite that asks for the multi-doc list (``load_yaml_all``) share **the same parse**. The same list / dict instances are returned to every caller, so callers that mutate the result MUST `copy.deepcopy()` it first. Treat the value as read-only. - Lifetime: process-wide. CLI tools that never rewrite their inputs see the same hit rate as before; long-lived Ansible processes pick up out-of-band writes on the very next read. """ from __future__ import annotations from collections.abc import Mapping from pathlib import Path from typing import Any import yaml def _construct_compose_merge_tag(loader, node): """Docker-compose merge tags (!override/!reset) parse as their value.""" if isinstance(node, yaml.SequenceNode): return loader.construct_sequence(node) if isinstance(node, yaml.MappingNode): return loader.construct_mapping(node) return loader.construct_scalar(node) _Loader = getattr(yaml, "CSafeLoader", yaml.SafeLoader) for _tag in ("!override", "!reset"): yaml.SafeLoader.add_constructor(_tag, _construct_compose_merge_tag) _Loader.add_constructor(_tag, _construct_compose_merge_tag) def _construct_vault_tag(loader, node): return loader.construct_scalar(node) yaml.SafeLoader.add_constructor("!vault", _construct_vault_tag) _Loader.add_constructor("!vault", _construct_vault_tag) _MISSING = object() _CACHE: dict[tuple[str, int, int], list[Any]] = {} def _path_key(path) -> str: return str(Path(path).resolve()) def _key(path) -> str: return _path_key(path) def _signature(p: Path) -> tuple[str, int, int]: st = p.stat() return (_path_key(p), st.st_mtime_ns, st.st_size) def _load_docs(path) -> list[Any]: """Return the cached list of YAML documents for *path*, parsing on cache miss. The caller must already have verified that the file exists; missing-file handling is the public helper's job (so the synthetic ``default_if_missing`` value never enters the cache and a later ``dump_yaml`` is picked up cleanly). """ p = Path(path) sig = _signature(p) if sig in _CACHE: return _CACHE[sig] path_str = sig[0] for stale_key in [k for k in _CACHE if k[0] == path_str and k != sig]: _CACHE.pop(stale_key, None) with p.open("r", encoding="utf-8") as f: docs = list(yaml.load_all(f, Loader=_Loader)) # nocheck: direct-yaml _CACHE[sig] = docs return docs def _load_raw(path, *, default_if_missing: Any) -> Any: """Cached single-doc parse without root-shape validation. Returns whatever the YAML root produces (dict, list, scalar, or ``{}`` for empty files). Multi-document files raise ``ValueError`` so callers that expect a single doc fail loud, matching the legacy ``yaml.safe_load(...)`` behaviour. """ p = Path(path) if not p.exists(): if default_if_missing is _MISSING: raise FileNotFoundError(p) return default_if_missing docs = _load_docs(p) if not docs: return {} if len(docs) > 1: raise ValueError( f"Expected a single YAML document in {path}, got {len(docs)}; " "use load_yaml_all() for multi-document files." ) data = docs[0] return {} if data is None else data def load_yaml(path, *, default_if_missing: Any = _MISSING) -> dict[str, Any]: """Load a YAML file as a dict, memoised by absolute path. `default_if_missing` controls the missing-file behaviour: - Default (`_MISSING`): raise `FileNotFoundError` for callers that want to fail loud (resolution code). - Pass a value (typically `{}`): return that value when the file does not exist, matching the behaviour expected by `cli.administration.inventory.provision.yaml_io.load_yaml`. Raises `TypeError` when the YAML root is not a mapping; use `load_yaml_any` for files whose root is a list or scalar. """ data = _load_raw(path, default_if_missing=default_if_missing) if not isinstance(data, dict): raise TypeError( f"Expected a YAML mapping at top-level in {path}, got {type(data).__name__}" ) return data def load_yaml_any(path, *, default_if_missing: Any = _MISSING) -> Any: """Memoised YAML load that accepts any root shape (dict, list, scalar). Use this for files where the root is not a mapping (e.g. Ansible task lists, the root list in `meta/variants.yml`). Empty files surface as `{}` (matching `yaml.safe_load(...) or {}` semantics that most call sites already rely on). """ return _load_raw(path, default_if_missing=default_if_missing) def dump_yaml(path, data: Any) -> None: """Write `data` to `path` as YAML and evict the cached entry. Accepts any safe-dumpable Python structure (dict, list of dicts, scalar). The next `load_yaml*` call for `path` will parse the just-written file and refill the cache, which is what callers expect when a CLI tool or test fixture edits a file mid-run. """ p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) payload = dict(data) if isinstance(data, Mapping) else data with p.open("w", encoding="utf-8") as f: yaml.safe_dump( # nocheck: direct-yaml — this module IS the cache. payload, f, sort_keys=False, default_flow_style=False ) _drop_path(p) def dump_yaml_str( data: Any, *, sort_keys: bool = False, default_flow_style: bool = False, width: int | None = None, ) -> str: """Serialise *data* to a YAML string. Use this for stdout output, log messages, or any place a YAML representation is needed without an on-disk file. There is no cache involved (the destination is a string, not a path), but the helper exists so callers don't have to ``import yaml`` just to call ``yaml.safe_dump`` — keeping every YAML touchpoint in ``utils.cache.yaml``. Args: data: the structure to serialise. sort_keys: emit mapping keys in sorted order. default_flow_style: emit collections inline instead of block style. width: column to wrap long scalars at, None for the PyYAML default. """ extra = {} if width is None else {"width": width} return yaml.safe_dump( # nocheck: direct-yaml — this module IS the cache. data, sort_keys=sort_keys, default_flow_style=default_flow_style, **extra ) def load_yaml_str(text: str) -> Any: """Parse a YAML string. Use this when the YAML payload comes from somewhere other than a file (HTTP response body, stdin, an intermediate buffer). The path-keyed cache cannot help here — every call parses afresh — but the helper exists for symmetry with :func:`dump_yaml_str` so callers never have to ``import yaml`` directly. """ return yaml.load( text, Loader=_Loader, # noqa: S506 — _Loader is CSafeLoader/SafeLoader ) # nocheck: direct-yaml — this module IS the cache. def load_yaml_all(path, *, default_if_missing: Any = _MISSING) -> list: """Memoised multi-document YAML load. Returns the list of documents from a multi-doc YAML file (``--- ... --- ...``). Shares the unified per-path cache with :func:`load_yaml_any` / :func:`load_yaml`, so loading a file as multi-doc and then as single-doc (or vice versa) parses it exactly once. """ p = Path(path) if not p.exists(): if default_if_missing is _MISSING: raise FileNotFoundError(p) return default_if_missing return _load_docs(p) def load_yaml_all_str(text: str) -> list: """Parse a multi-document YAML string into a list of documents. No caching (the source is a string, not a path) — provided for symmetry with :func:`load_yaml_str` so callers never need ``import yaml`` for the parse. """ # This module IS the cache; calling itself would recurse. return list(yaml.load_all(text, Loader=_Loader)) # nocheck: direct-yaml def _drop_path(path) -> None: """Drop every cache entry for `path`.""" path_str = _path_key(path) for stale_key in [k for k in _CACHE if k[0] == path_str]: _CACHE.pop(stale_key, None) def invalidate(path) -> None: """Drop one path from the cache. Use when the file was changed by code that does not go through `dump_yaml` (e.g. an external subprocess, a manual file rewrite, a fixture in tests). With the mtime+size cache key, normal out-of-band writes invalidate automatically; this helper is the explicit escape hatch when the timestamps must not be trusted. """ _drop_path(path) def _reset() -> None: """Test-only helper: clear the entire cache. Named ``_reset`` for parity with the per-domain reset helpers in sibling modules (``applications._reset``, ``users._reset``, ``domains._reset``, ``base._reset``); ``utils.cache._reset_cache_for_tests`` orchestrates calls to all of them. """ _CACHE.clear() _reset_cache_for_tests = _reset