Files
computer-playbook/utils/cache/yaml.py
kevinveenbirkenbachandClaude Opus 5 504f627f17 fix(yaml): register the vault tag on the loader that actually parses
2ac6c0878d taught the shared loader to tolerate ansible-vault tags but
registered the constructor only on yaml.SafeLoader. Everything here parses
with _Loader, which is CSafeLoader wherever libyaml is available, so on a
normal system the tag stayed unknown and a file holding vault-encrypted
values still failed to parse.

Register it on _Loader too, the way the compose merge tags two lines above
already do.

This matters because the inventory keeps credentials vault-encrypted, which
is how the gap surfaced while reading meta/secrets.yml values.

Not verified: whether CSafeLoader is the active loader on this machine. If
libyaml were absent the two loaders would be the same object and the line
would be harmless but inert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:02:51 +02:00

281 lines
10 KiB
Python

"""In-process YAML loader with per-path memoisation.
Many CLI tools walk the same `roles/<role>/<config|vars|meta>/...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