diff --git a/CHANGES.md b/CHANGES.md index 9159e247..587766f5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,10 @@ issue tracker. The initial project development roadmap is documented here: Note any unreleased items inside the comment here. Not visible until release. +### Added + +- `register_geometry_yaml` in-memory registration entry point. (#288) + ### Fixed - Kappa equivalent-Eulerian chi axis now matches fourcv/fourch/psic. (#284) diff --git a/src/ad_hoc_diffractometer/__init__.py b/src/ad_hoc_diffractometer/__init__.py index ea821492..9a954458 100644 --- a/src/ad_hoc_diffractometer/__init__.py +++ b/src/ad_hoc_diffractometer/__init__.py @@ -31,6 +31,7 @@ from .factories import register_geometry from .geometry_loader import load_geometry_file from .geometry_loader import register_geometry_file +from .geometry_loader import register_geometry_yaml from .lattice import Lattice from .mode import REQUIRED from .mode import BisectConstraint @@ -67,6 +68,7 @@ "make_geometry", "register_geometry", "register_geometry_file", + "register_geometry_yaml", "load_geometry_file", # orientation "ub_identity", diff --git a/src/ad_hoc_diffractometer/geometry_loader.py b/src/ad_hoc_diffractometer/geometry_loader.py index 99862620..51bfa718 100644 --- a/src/ad_hoc_diffractometer/geometry_loader.py +++ b/src/ad_hoc_diffractometer/geometry_loader.py @@ -22,6 +22,9 @@ - :func:`register_geometry_file` — parse a YAML file from any path and add it to the geometry registry under its declared name (or an explicit ``name=`` override). +- :func:`register_geometry_yaml` — parse an in-memory YAML string and + add it to the geometry registry under a caller-supplied name (the + in-memory companion to :func:`register_geometry_file`). - :data:`KIND_KEY` — the top-level marker key (``"ad_hoc_diffractometer_geometry"``). - :data:`SUPPORTED_REVISIONS` — the schema revisions this loader @@ -1083,6 +1086,84 @@ def _factory(**kwargs: Any) -> AdHocDiffractometer: return final_name +def register_geometry_yaml( + yaml_text: str, + *, + name: str, +) -> str: + """Parse an in-memory YAML string and add it to the geometry registry. + + The in-memory companion to :func:`register_geometry_file`. Use it + when the YAML definition of a geometry is already available as a + Python ``str`` (for example, persisted inside another configuration + document) and you want it discoverable via :func:`list_geometries` + and :func:`make_geometry` without round-tripping through the + filesystem. + + Parameters + ---------- + yaml_text : str + The YAML document declaring the geometry. Must contain the + :data:`KIND_KEY` marker with a supported + :data:`SUPPORTED_REVISIONS` value. Parsed eagerly so schema + errors surface at registration time rather than at first + :func:`make_geometry` call (matching + :func:`register_geometry_file`). + name : str + Required registry key under which the geometry will be + installed. Unlike :func:`register_geometry_file` there is no + filesystem path from which to derive a default, so this + argument is mandatory. + + Returns + ------- + str + The registry name (the value passed in as ``name``). + + Raises + ------ + ValueError + If the registry already contains an entry under ``name``. + GeometrySchemaError + If ``yaml_text`` is not a well-formed declarative geometry + document. + + Notes + ----- + The registered factory re-parses ``yaml_text`` on every + :func:`make_geometry` call so that each invocation returns a fresh + :class:`AdHocDiffractometer` instance, mirroring the re-read + semantics of :func:`register_geometry_file`. + """ + # Local import to avoid a circular dependency at module load time. + from . import factories as _factories + + source_label = f"" + doc = yaml.safe_load(yaml_text) + # Validate eagerly so problems surface at registration time. + geom = _construct_from_doc(doc, source_label=source_label, overrides={}) + registry = _factories._GEOMETRY_REGISTRY # noqa: SLF001 + if name in registry: + existing = registry[name] + raise ValueError( + f"register_geometry_yaml: a geometry named {name!r} is " + f"already registered ({existing!r}); pass a different " + f"name= to register this YAML under another name." + ) + + # Capture yaml_text in the closure so each call re-parses the same + # source. This mirrors register_geometry_file's re-read semantics + # (every make_geometry() returns a fresh AdHocDiffractometer). + def _factory(**kwargs: Any) -> AdHocDiffractometer: + fresh = yaml.safe_load(yaml_text) + return _construct_from_doc(fresh, source_label=source_label, overrides=kwargs) + + _factory.__name__ = name + _factory.__doc__ = geom.description.splitlines()[0] if geom.description else "" + registry[name] = _factory + return name + + def _register_packaged_geometries() -> None: """Register every ``*.yml`` file shipped under :mod:`ad_hoc_diffractometer.geometries` with the geometry registry. diff --git a/tests/test_geometry_loader.py b/tests/test_geometry_loader.py index b436075a..9dff9c9b 100644 --- a/tests/test_geometry_loader.py +++ b/tests/test_geometry_loader.py @@ -18,6 +18,9 @@ ``detector``, ``reference``) - :func:`register_geometry_file` (registry insertion, name override, duplicate-name guard) +- :func:`register_geometry_yaml` (in-memory companion: registry + insertion, required ``name=``, duplicate-name guard, eager schema + validation, re-parse on every call) - module-level constants ``KIND_KEY``, ``SUPPORTED_REVISIONS``, ``CURRENT_REVISION`` """ @@ -26,12 +29,14 @@ import os import re +from contextlib import nullcontext as does_not_raise import numpy as np import pytest from ad_hoc_diffractometer import load_geometry_file from ad_hoc_diffractometer import register_geometry_file +from ad_hoc_diffractometer import register_geometry_yaml from ad_hoc_diffractometer.factories import _GEOMETRY_REGISTRY from ad_hoc_diffractometer.factories import BASIS_BL from ad_hoc_diffractometer.factories import BASIS_DEFAULT @@ -493,6 +498,93 @@ def test_register_geometry_file_missing(tmp_path, restore_registry): register_geometry_file(tmp_path / "does_not_exist.yml") +# --------------------------------------------------------------------------- +# register_geometry_yaml() (issue #288 — in-memory companion) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kwargs, expected_name, context", + [ + pytest.param( + {"name": "inmem_alpha"}, + "inmem_alpha", + does_not_raise(), + id="registers-under-supplied-name", + ), + pytest.param( + {}, + None, + pytest.raises(TypeError, match="name"), + id="name-is-required", + ), + ], +) +def test_register_geometry_yaml_name_argument( + kwargs, expected_name, context, restore_registry +): + """``name`` is a required keyword-only argument.""" + with context: + returned = register_geometry_yaml(_MINIMAL_YAML, **kwargs) + assert returned == expected_name + assert expected_name in _GEOMETRY_REGISTRY + + +def test_register_geometry_yaml_make_geometry_round_trip(restore_registry): + """make_geometry() on a registered in-memory geometry produces an + AdHocDiffractometer consistent with load_geometry_file(yaml_text).""" + import ad_hoc_diffractometer as ahd + + register_geometry_yaml(_MINIMAL_YAML, name="inmem_round_trip") + g_registry = ahd.make_geometry("inmem_round_trip") + g_direct = load_geometry_file(_MINIMAL_YAML) + assert g_registry.name == g_direct.name == "tiny" + assert list(g_registry._stages) == list(g_direct._stages) + assert list(g_registry.modes) == list(g_direct.modes) + + +def test_register_geometry_yaml_duplicate_raises(restore_registry): + """Second registration under the same name raises ValueError + (same contract as register_geometry_file).""" + register_geometry_yaml(_MINIMAL_YAML, name="inmem_dup") + with pytest.raises(ValueError, match="already registered"): + register_geometry_yaml(_MINIMAL_YAML, name="inmem_dup") + + +def test_register_geometry_yaml_eager_schema_validation(restore_registry): + """Schema errors surface at registration time, not at first + make_geometry() call (matches register_geometry_file behavior).""" + # Drop the required top-level 'stages' key. + bad_yaml = ( + "ad_hoc_diffractometer_geometry:\n" + " schema_revision: 1\n" + "name: broken\n" + "basis: BL\n" + "modes:\n" + " m:\n" + " default: true\n" + " constraints: []\n" + " computed: []\n" + ) + with pytest.raises(GeometrySchemaError, match="stages"): + register_geometry_yaml(bad_yaml, name="inmem_broken") + # Registration was rejected — nothing landed in the registry. + assert "inmem_broken" not in _GEOMETRY_REGISTRY + + +def test_register_geometry_yaml_factory_reparses_text(restore_registry): + """Each make_geometry() call re-parses the captured YAML text, so + every call returns a fresh AdHocDiffractometer instance (mirroring + register_geometry_file's per-call re-read semantics).""" + import ad_hoc_diffractometer as ahd + + register_geometry_yaml(_MINIMAL_YAML, name="inmem_fresh") + g1 = ahd.make_geometry("inmem_fresh") + g2 = ahd.make_geometry("inmem_fresh") + assert g1 is not g2 + assert g1.name == g2.name == "tiny" + + # --------------------------------------------------------------------------- # load_geometry_file rejects unknown kwargs # ---------------------------------------------------------------------------