Skip to content

Commit 488c917

Browse files
Merge pull request #29 from Simon-McIntosh/main
Read the catalog sidecar in the loader
2 parents 5f8df72 + ba56af1 commit 488c917

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

imas_standard_names/yaml_store.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import yaml
1010

1111
from .models import (
12+
StandardNameCatalogManifest,
1213
StandardNameEntry,
1314
StandardNameScalarEntry,
1415
create_standard_name_entry,
@@ -213,9 +214,45 @@ def __init__(self, root: str | Path, permissive: bool = False):
213214
def yaml_files(self):
214215
return sorted(list(self.root.rglob("*.yml")) + list(self.root.rglob("*.yaml")))
215216

217+
def load_manifest(self) -> StandardNameCatalogManifest | None:
218+
"""Load the manifest sidecar (``catalog.yml``) beside the entries.
219+
220+
Checked first at ``root.parent/catalog.yml`` (the standard layout —
221+
manifest at repo root, entries under ``standard_names/``), then at
222+
``root/catalog.yml`` (entries under the repo root directly). Returns
223+
``None`` only when no manifest file is present at either location; a
224+
present-but-unparseable manifest is refused rather than treated as
225+
absent, since silently ignoring it would leave entries without an
226+
inline ``kind`` failing discrimination later with an opaque union
227+
error that hides the real cause.
228+
"""
229+
candidates = [self.root.parent / "catalog.yml", self.root / "catalog.yml"]
230+
for candidate in candidates:
231+
if not candidate.exists():
232+
continue
233+
try:
234+
data = yaml.safe_load(candidate.read_text(encoding="utf-8"))
235+
except yaml.YAMLError as e:
236+
raise ValueError(
237+
f"Catalog manifest sidecar at {candidate} is not valid YAML: {e}"
238+
) from e
239+
if not isinstance(data, dict):
240+
raise ValueError(
241+
f"Catalog manifest sidecar at {candidate} must be a mapping, "
242+
f"got {type(data).__name__}"
243+
)
244+
try:
245+
return StandardNameCatalogManifest(**data)
246+
except Exception as e:
247+
raise ValueError(
248+
f"Catalog manifest sidecar at {candidate} failed validation: {e}"
249+
) from e
250+
return None
251+
216252
# Load --------------------------------------------------------------------
217253
def load(self) -> list[StandardNameEntry]:
218254
models: list[StandardNameEntry] = []
255+
manifest = self.load_manifest()
219256
for f in self.yaml_files():
220257
# Detect nested paths (legacy per-file layout)
221258
relative = f.relative_to(self.root)
@@ -269,7 +306,7 @@ def load(self) -> list[StandardNameEntry]:
269306

270307
# Handle Pydantic validation errors in permissive mode
271308
try:
272-
m = create_standard_name_entry(entry_data)
309+
m = create_standard_name_entry(entry_data, manifest=manifest)
273310
models.append(m)
274311
except Exception as e:
275312
if self.permissive:

tests/test_yaml_store.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib.resources as resources
22
from pathlib import Path
33

4+
import pytest
45
import yaml
56

67
from imas_standard_names.models import create_standard_name_entry
@@ -11,6 +12,18 @@
1112
write_catalog_yaml,
1213
)
1314

15+
_MANIFEST_HEADER = """
16+
catalog_name: test_catalog
17+
cocos_convention: 11
18+
grammar_version: "1"
19+
isn_model_version: "1"
20+
dd_version_lineage: ["4.0.0"]
21+
generated_by: test-suite
22+
generated_at: 2026-01-01T00:00:00Z
23+
candidate_count: 1
24+
published_count: 1
25+
"""
26+
1427

1528
def _entry_with_source(source: dict[str, str]) -> dict:
1629
return {
@@ -264,3 +277,58 @@ def test_existing_catalog_rewrite_preserves_data(tmp_path: Path) -> None:
264277
]
265278

266279
assert rewritten_entries == existing
280+
281+
282+
def test_load_resolves_kind_from_valid_manifest_sidecar(tmp_path: Path) -> None:
283+
entries_root = tmp_path / "standard_names"
284+
entries_root.mkdir()
285+
(tmp_path / "catalog.yml").write_text(
286+
_MANIFEST_HEADER
287+
+ "names:\n"
288+
+ " plasma_current:\n"
289+
+ " kind: scalar\n"
290+
+ " physics_domain: core_plasma_physics\n"
291+
)
292+
(entries_root / "plasma_current.yml").write_text(
293+
"name: plasma_current\n"
294+
"description: Plasma current.\n"
295+
"documentation: Total plasma current in the tokamak.\n"
296+
"unit: A\n"
297+
)
298+
299+
loaded = {mm.name: mm for mm in YamlStore(entries_root).load()}
300+
301+
assert loaded["plasma_current"].kind == "scalar"
302+
303+
304+
def test_load_refuses_present_but_unparseable_manifest_sidecar(tmp_path: Path) -> None:
305+
entries_root = tmp_path / "standard_names"
306+
entries_root.mkdir()
307+
manifest_path = tmp_path / "catalog.yml"
308+
manifest_path.write_text("catalog_name: [unterminated\n")
309+
(entries_root / "plasma_current.yml").write_text(
310+
"name: plasma_current\n"
311+
"kind: scalar\n"
312+
"physics_domain: core_plasma_physics\n"
313+
"description: Plasma current.\n"
314+
"documentation: Total plasma current in the tokamak.\n"
315+
"unit: A\n"
316+
)
317+
318+
with pytest.raises(ValueError, match=str(manifest_path)):
319+
YamlStore(entries_root).load()
320+
321+
322+
def test_load_without_manifest_sidecar_uses_inline_kind(tmp_path: Path) -> None:
323+
(tmp_path / "plasma_current.yml").write_text(
324+
"name: plasma_current\n"
325+
"kind: scalar\n"
326+
"physics_domain: core_plasma_physics\n"
327+
"description: Plasma current.\n"
328+
"documentation: Total plasma current in the tokamak.\n"
329+
"unit: A\n"
330+
)
331+
332+
loaded = {mm.name: mm for mm in YamlStore(tmp_path).load()}
333+
334+
assert loaded["plasma_current"].kind == "scalar"

0 commit comments

Comments
 (0)