Source code for powderline.recipes

"""Recipe files and core blocks (engine-free): load a recipe asset from disk; move a core block between recipes.

``load_recipe_asset`` moved here from ``powderline.kicker`` so
the public API can read a recipe without importing any engine. Interpreting
the recipe is the gateways' job (:func:`powderline.validate`).
"""

from __future__ import annotations

import json
from pathlib import Path

import yaml


[docs] def load_recipe_asset(recipe_path: Path) -> dict: """Read a recipe file and return it as a dict, uninterpreted. ``.json`` is parsed as JSON; ``.yaml`` / ``.yml`` / ``.txt`` as YAML (an empty YAML document gives ``{}``). Files are read as UTF-8 (a leading BOM is accepted). The content is not validated: pass it to :func:`powderline.validate` or :func:`powderline.run`. Raises: ValueError: the suffix is none of the above (``Unsupported recipe format``); the text is not valid JSON / YAML; or the document is not a mapping. The message starts with the file path. OSError: the file cannot be read. """ recipe_path = Path(recipe_path) ext = recipe_path.suffix.lower() if ext not in {".json", ".yaml", ".yml", ".txt"}: raise ValueError(f"Unsupported recipe format: {recipe_path}") text = recipe_path.read_text(encoding="utf-8-sig") if ext == ".json": try: data = json.loads(text) except json.JSONDecodeError as exc: raise ValueError(f"{recipe_path}: not valid JSON ({exc})") from exc else: try: data = yaml.safe_load(text) except yaml.YAMLError as exc: raise ValueError(f"{recipe_path}: not valid YAML ({exc})") from exc if data is None: data = {} if not isinstance(data, dict): raise ValueError( f"{recipe_path}: a recipe must be a mapping with schema_name and payload, " f"got {type(data).__name__}") return data
#: The blocks every engine schema shares by construction (the core schema): the only parts of a recipe that may #: move from one recipe to another, whatever their engines. ``payload.phases.<name>`` is one of them. CORE_BLOCKS = ("payload.xrd_data", "payload.fit_range") def _is_core_block(path: str) -> bool: return path in CORE_BLOCKS or (path.startswith("payload.phases.") and path.count(".") == 2) def _walk(recipe: dict, keys: list, create: bool = False) -> dict: node = recipe for key in keys: if create: node = node.setdefault(key, {}) else: node = node[key] return node
[docs] def block(recipe: dict, path: str) -> dict: """A deep copy of the core block at ``path`` (``payload.xrd_data``, ``payload.fit_range``, ``payload.phases.<name>``) of a recipe dict -- the chaining currency between tools (KI-04). Raises: ValueError: ``path`` is not a core block (engine blocks are never shared between engines). KeyError: the recipe has no such block. """ import copy if not _is_core_block(path): raise ValueError(f"{path!r} is not a core block; only {', '.join(CORE_BLOCKS)} and payload.phases.<name> " "move between recipes (an engine's own blocks are written in that engine's terms)") keys = path.split(".") return copy.deepcopy(_walk(recipe, keys[:-1])[keys[-1]])
[docs] def with_block(recipe: dict, path: str, value: dict) -> dict: """A deep copy of ``recipe`` with the core block at ``path`` replaced by a copy of ``value``. The result is not validated here: the target recipe's own schema is the gate (:func:`powderline.validate`). Raises ``ValueError`` for a path that is not a core block. """ import copy if not _is_core_block(path): raise ValueError(f"{path!r} is not a core block; only {', '.join(CORE_BLOCKS)} and payload.phases.<name> " "move between recipes (an engine's own blocks are written in that engine's terms)") out = copy.deepcopy(recipe) keys = path.split(".") _walk(out, keys[:-1], create=True)[keys[-1]] = copy.deepcopy(value) return out