Skip to content

Commit aad4a0e

Browse files
dmealingclaude
andcommitted
feat(python): MetaDataLoader class with from_directory/from_uris/from_string factories
Replaces the free `load_directory` function with a source-polymorphic MetaDataLoader class — the Tier-1 invariant API matching the other three ports. `loader.load(sources)` dispatches per-source on declared format (json -> parse_document; yaml -> parse_yaml), so callers can mix file / directory / URI / in-memory sources in one call. Three class-method factories cover the 99% case: - MetaDataLoader.from_directory(dir, recurse=True) - MetaDataLoader.from_uris([...]) - MetaDataLoader.from_string(content, format=JSON) Existing callsites still import `load_directory` from this module and will break in collection. They are migrated to the new API in the next commit (Task 4.4); the cross-port `__init__` shortcuts arrive in Task 4.3. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e9cd838 commit aad4a0e

2 files changed

Lines changed: 218 additions & 58 deletions

File tree

Lines changed: 135 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
"""Filesystem loader: discover -> parse -> merge -> freeze."""
1+
"""MetaDataLoader: source-polymorphic loader.
2+
3+
The cross-language Tier-1 loader: takes a list of MetaDataSource impls,
4+
runs parse -> merge -> super-resolve -> validate -> freeze, and returns a
5+
LoadResult { root, errors, warnings }.
6+
7+
Source format (JSON vs YAML) is declared by the source, not sniffed by the
8+
loader. This replaces both the per-file extension switch from the old
9+
free function and the TS FileMetaDataLoader.parseSource override.
10+
11+
The three class-method factories (from_directory / from_uris / from_string)
12+
cover the 99% case; module-level shortcuts in `metaobjects.__init__` wrap
13+
them with Pythonic ergonomics.
14+
"""
215
from __future__ import annotations
316

417
import json
@@ -16,74 +29,138 @@
1629
from ..shared.base_types import SUBTYPE_ROOT, TYPE_METADATA
1730
from ..super_resolve import resolve_supers
1831
from .merge import merge_roots
32+
from .sources import (
33+
DirectorySource,
34+
InMemoryStringSource,
35+
MetaDataFormat,
36+
MetaDataSource,
37+
UriSource,
38+
)
1939
from .validation_passes import run_validations
2040

21-
# Authoring file formats this loader recognises. JSON is canonical interchange;
22-
# YAML is the sigil-free authoring front-end (ADR-0006).
23-
_JSON_SUFFIXES = (".json",)
24-
_YAML_SUFFIXES = (".yaml", ".yml")
25-
2641

2742
@dataclass
2843
class LoadResult:
44+
"""The Tier-1 load result. Same field shape across all four ports."""
45+
2946
root: MetaData
3047
errors: list[MetaError] = field(default_factory=list)
3148
warnings: list[str] = field(default_factory=list)
3249

3350

34-
def _parse_file(path: Path, registry: TypeRegistry, errors: list[MetaError]) -> ParseResult | None:
35-
"""Dispatch by file extension to the JSON or YAML front-end.
51+
def _empty_root() -> MetaRoot:
52+
"""Construct the canonical empty root (matches parse_document's seed)."""
53+
return MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, "")
54+
55+
56+
class MetaDataLoader:
57+
"""Source-polymorphic metadata loader.
3658
37-
Returns None if the file could not even be read/parsed at the syntax level
38-
(e.g. malformed JSON/YAML); the caller adds the appropriate error to
39-
*errors* and skips this file from the merge set.
59+
Construct once with a provider list (defaults to ``[core_provider]``);
60+
call ``.load(sources)`` for arbitrary source combinations, or use the
61+
``from_*`` class-method factories for the common cases.
4062
"""
41-
suffix = path.suffix.lower()
42-
text = path.read_text(encoding="utf-8-sig")
43-
if suffix in _YAML_SUFFIXES:
63+
64+
def __init__(self, providers: list[Provider] | None = None) -> None:
65+
self._registry: TypeRegistry = compose_registry(
66+
providers if providers is not None else [core_provider]
67+
)
68+
69+
@property
70+
def registry(self) -> TypeRegistry:
71+
return self._registry
72+
73+
# --- core API ------------------------------------------------------
74+
75+
def load(self, sources: list[MetaDataSource]) -> LoadResult:
76+
"""Parse -> merge -> super-resolve -> validate -> freeze."""
77+
result = LoadResult(root=_empty_root())
78+
roots: list[MetaData] = []
79+
80+
for src in sources:
81+
parsed = self._parse_source(src, result.errors)
82+
if parsed is None:
83+
continue
84+
result.errors.extend(parsed.errors)
85+
result.warnings.extend(parsed.warnings)
86+
if not parsed.errors:
87+
roots.append(parsed.root)
88+
89+
if roots:
90+
result.root = merge_roots(roots, result.errors)
91+
resolve_supers(result.root, result.errors)
92+
93+
run_validations(result.root, self._registry, result.errors, result.warnings)
94+
result.root.freeze()
95+
return result
96+
97+
# --- static factories (the 99% case, cross-language consistent) ----
98+
99+
@classmethod
100+
def from_directory(
101+
cls,
102+
directory: Path | str,
103+
providers: list[Provider] | None = None,
104+
exclude: list[str] | None = None,
105+
recurse: bool = True,
106+
) -> LoadResult:
107+
"""Load every JSON/YAML file under ``directory`` (recursive by default)."""
108+
loader = cls(providers=providers)
109+
sources = list(
110+
DirectorySource(directory, exclude=exclude, recurse=recurse).expand()
111+
)
112+
return loader.load(sources)
113+
114+
@classmethod
115+
def from_uris(
116+
cls,
117+
uris: list[str],
118+
providers: list[Provider] | None = None,
119+
) -> LoadResult:
120+
"""Load metadata from a list of URIs (file:// or http(s)://)."""
121+
loader = cls(providers=providers)
122+
return loader.load([UriSource(u) for u in uris])
123+
124+
@classmethod
125+
def from_string(
126+
cls,
127+
content: str,
128+
format: MetaDataFormat = MetaDataFormat.JSON,
129+
providers: list[Provider] | None = None,
130+
) -> LoadResult:
131+
"""Load metadata from an in-memory string (defaults to JSON)."""
132+
loader = cls(providers=providers)
133+
return loader.load([InMemoryStringSource(content, format=format)])
134+
135+
# --- internals -----------------------------------------------------
136+
137+
def _parse_source(
138+
self, src: MetaDataSource, errors: list[MetaError]
139+
) -> ParseResult | None:
140+
"""Read a source's content and dispatch to the JSON or YAML parser.
141+
142+
Returns ``None`` if the source could not be read or its syntax was
143+
malformed; the caller appends an error and skips this source from
144+
the merge set.
145+
"""
146+
try:
147+
text = src.read()
148+
except OSError as exc:
149+
errors.append(MetaError(str(exc), ErrorCode.ERR_UNKNOWN, src.id))
150+
return None
151+
152+
if src.format is MetaDataFormat.YAML:
153+
try:
154+
return parse_yaml(text, self._registry, source=src.id)
155+
except Exception as exc: # ParseError carries a code
156+
code = getattr(exc, "code", ErrorCode.ERR_MALFORMED_YAML)
157+
errors.append(MetaError(str(exc), code, src.id))
158+
return None
159+
160+
# Default: JSON.
44161
try:
45-
return parse_yaml(text, registry, source=path.name)
46-
except Exception as exc: # ParseError from parse_yaml carries a code
47-
code = getattr(exc, "code", ErrorCode.ERR_MALFORMED_YAML)
48-
errors.append(MetaError(str(exc), code, path.name))
162+
doc = json.loads(text)
163+
except json.JSONDecodeError as exc:
164+
errors.append(MetaError(str(exc), ErrorCode.ERR_MALFORMED_JSON, src.id))
49165
return None
50-
# Default: JSON.
51-
try:
52-
doc = json.loads(text)
53-
except json.JSONDecodeError as exc:
54-
errors.append(MetaError(str(exc), ErrorCode.ERR_MALFORMED_JSON, path.name))
55-
return None
56-
return parse_document(doc, registry, source=path.name)
57-
58-
59-
def load_directory(input_dir: str, providers: list[Provider] | None = None) -> LoadResult:
60-
registry = compose_registry(providers if providers is not None else [core_provider])
61-
result = LoadResult(root=MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, ""))
62-
63-
# Deterministic ordinal-filename order over JSON + YAML / YML.
64-
# Overlay merge is order-sensitive (last-writer-wins on attr conflicts), so
65-
# the scan must not depend on filesystem readdir order. Matches the TS
66-
# FileMetaDataLoader.loadDirectory contract.
67-
accepted = _JSON_SUFFIXES + _YAML_SUFFIXES
68-
files = sorted(
69-
(p for p in Path(input_dir).iterdir() if p.is_file() and p.suffix.lower() in accepted),
70-
key=lambda p: p.name,
71-
)
72-
73-
roots: list[MetaData] = []
74-
for path in files:
75-
parsed = _parse_file(path, registry, result.errors)
76-
if parsed is None:
77-
continue
78-
result.errors.extend(parsed.errors)
79-
result.warnings.extend(parsed.warnings)
80-
if not parsed.errors:
81-
roots.append(parsed.root)
82-
83-
if roots:
84-
result.root = merge_roots(roots, result.errors)
85-
resolve_supers(result.root, result.errors)
86-
87-
run_validations(result.root, registry, result.errors, result.warnings)
88-
result.root.freeze()
89-
return result
166+
return parse_document(doc, self._registry, source=src.id)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Unit tests for the MetaDataLoader class + factory methods.
2+
3+
Covers the Tier-1 invariant: loader.load(sources) -> LoadResult with the
4+
{root, errors, warnings} shape, plus the three class-method factories
5+
(from_directory / from_uris / from_string) for the 99% case.
6+
"""
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
from metaobjects.core_types import core_provider
12+
from metaobjects.loader.meta_data_loader import LoadResult, MetaDataLoader
13+
from metaobjects.loader.sources import (
14+
DirectorySource,
15+
InMemoryStringSource,
16+
MetaDataFormat,
17+
)
18+
19+
20+
_TINY_JSON = '{"metadata.root":{"package":"x","children":[]}}'
21+
22+
23+
def test_load_from_directory_source(tmp_path: Path) -> None:
24+
(tmp_path / "meta.tiny.json").write_text(_TINY_JSON, encoding="utf-8")
25+
loader = MetaDataLoader(providers=[core_provider])
26+
result = loader.load(list(DirectorySource(tmp_path).expand()))
27+
assert isinstance(result, LoadResult)
28+
assert result.errors == []
29+
assert result.root is not None
30+
31+
32+
def test_load_returns_empty_root_on_no_sources() -> None:
33+
loader = MetaDataLoader()
34+
result = loader.load([])
35+
assert result.root is not None
36+
assert result.errors == []
37+
38+
39+
def test_load_from_inline_yaml() -> None:
40+
loader = MetaDataLoader(providers=[core_provider])
41+
result = loader.load([
42+
InMemoryStringSource(
43+
"metadata.root:\n package: x\n children: []\n",
44+
format=MetaDataFormat.YAML,
45+
)
46+
])
47+
assert result.errors == []
48+
assert result.root is not None
49+
50+
51+
def test_load_from_inline_json() -> None:
52+
loader = MetaDataLoader(providers=[core_provider])
53+
result = loader.load([InMemoryStringSource(_TINY_JSON)])
54+
assert result.errors == []
55+
56+
57+
def test_load_from_inline_malformed_json_reports_error() -> None:
58+
loader = MetaDataLoader(providers=[core_provider])
59+
result = loader.load([InMemoryStringSource("not json", id="<bad>")])
60+
assert any(e.code.name == "ERR_MALFORMED_JSON" for e in result.errors)
61+
62+
63+
def test_from_directory_classmethod(tmp_path: Path) -> None:
64+
(tmp_path / "meta.tiny.json").write_text(_TINY_JSON, encoding="utf-8")
65+
result = MetaDataLoader.from_directory(tmp_path)
66+
assert result.errors == []
67+
68+
69+
def test_from_directory_defaults_to_core_provider_when_none(tmp_path: Path) -> None:
70+
(tmp_path / "meta.tiny.json").write_text(_TINY_JSON, encoding="utf-8")
71+
# Pass providers=None explicitly to assert the default is applied.
72+
result = MetaDataLoader.from_directory(tmp_path, providers=None)
73+
assert result.errors == []
74+
75+
76+
def test_from_string_classmethod() -> None:
77+
result = MetaDataLoader.from_string(_TINY_JSON, format=MetaDataFormat.JSON)
78+
assert result.errors == []
79+
80+
81+
def test_from_string_default_format_is_json() -> None:
82+
result = MetaDataLoader.from_string(_TINY_JSON)
83+
assert result.errors == []

0 commit comments

Comments
 (0)