Skip to content

Commit ba56af1

Browse files
merge: the loader reads the sidecar and refuses a broken one
The reduction moved the entry union's discriminator into the manifest sidecar while the loader had no reference to that file, so every four-field entry failed discrimination on the real load path. A present-but-unparseable sidecar now raises naming the file rather than reading as absent, which would send the loader looking for an inline discriminator and hide the cause behind the same opaque union error.
2 parents 49bc2d1 + e28d8cc commit ba56af1

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)