Skip to content

Commit e9cd838

Browse files
dmealingclaude
andcommitted
feat(python): add MetaDataSource ABC + FileSource/DirectorySource/UriSource/InMemoryStringSource
Introduces the polymorphic MetaDataSource contract from the cross-language loader-unification spec: identity, declared format, and read() returning decoded UTF-8 text. Four concrete impls land in a new `metaobjects.loader.sources` package — FileSource (extension-derived format, BOM-tolerant), DirectorySource (recursive by default, sorted + exclude-filtered), UriSource (file:// + http(s)://), and InMemoryStringSource (no I/O; default id "<inline>"). The new loader API (MetaDataLoader class) follows in the next commit; this commit purely lands the source abstractions + tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 851e989 commit e9cd838

6 files changed

Lines changed: 341 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Polymorphic MetaDataSource implementations.
2+
3+
The cross-language Tier-1 contract: every source declares an identity (for
4+
error locations), a format (json | yaml), and a read() returning the decoded
5+
text content. File / directory / URI / in-memory sources are all just
6+
implementations of that contract — the loader is source-agnostic.
7+
8+
See `docs/superpowers/specs/2026-05-25-cross-language-loader-architecture-unification.md`.
9+
"""
10+
from __future__ import annotations
11+
12+
from .directory_source import DirectorySource
13+
from .file_source import FileSource
14+
from .meta_data_source import InMemoryStringSource, MetaDataFormat, MetaDataSource
15+
from .uri_source import UriSource
16+
17+
__all__ = [
18+
"MetaDataFormat",
19+
"MetaDataSource",
20+
"InMemoryStringSource",
21+
"FileSource",
22+
"DirectorySource",
23+
"UriSource",
24+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Directory expander -> sorted list of FileSource.
2+
3+
Walks the directory (recursively by default), filters to supported authoring
4+
extensions (``.json`` / ``.yaml`` / ``.yml``), honors a name-based exclude
5+
list, and yields FileSource instances in deterministic ordinal-filename
6+
order. Deterministic order is required because overlay merging is
7+
order-sensitive (last-writer-wins on attr conflicts).
8+
"""
9+
from __future__ import annotations
10+
11+
from collections.abc import Iterable, Iterator
12+
from pathlib import Path
13+
14+
from .file_source import FileSource
15+
16+
_SUPPORTED_SUFFIXES = (".json", ".yaml", ".yml")
17+
18+
19+
class DirectorySource:
20+
"""Expands a directory into a sorted, filtered list of FileSource objects."""
21+
22+
def __init__(
23+
self,
24+
directory: Path | str,
25+
exclude: Iterable[str] | None = None,
26+
recurse: bool = True,
27+
) -> None:
28+
self._directory = Path(directory)
29+
self._exclude = set(exclude or ())
30+
self._recurse = recurse
31+
32+
@property
33+
def directory(self) -> Path:
34+
return self._directory
35+
36+
def expand(self) -> Iterator[FileSource]:
37+
if self._recurse:
38+
candidates: Iterable[Path] = self._directory.rglob("*")
39+
else:
40+
candidates = self._directory.iterdir()
41+
42+
files = [
43+
p
44+
for p in candidates
45+
if p.is_file()
46+
and p.suffix.lower() in _SUPPORTED_SUFFIXES
47+
and p.name not in self._exclude
48+
]
49+
files.sort(key=lambda p: p.name)
50+
for p in files:
51+
yield FileSource(p)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Single-file MetaDataSource.
2+
3+
Format defaults to extension-derived (``.yaml`` / ``.yml`` -> YAML; otherwise
4+
JSON). Reads with ``utf-8-sig`` so a leading UTF-8 BOM is silently stripped —
5+
matches the prior `load_directory` behavior.
6+
"""
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
from .meta_data_source import MetaDataFormat, MetaDataSource
12+
13+
14+
def _infer_format(path: Path) -> MetaDataFormat:
15+
suffix = path.suffix.lower()
16+
if suffix in (".yaml", ".yml"):
17+
return MetaDataFormat.YAML
18+
return MetaDataFormat.JSON
19+
20+
21+
class FileSource(MetaDataSource):
22+
"""A single on-disk file, decoded eagerly via ``utf-8-sig``."""
23+
24+
def __init__(self, path: Path | str, format: MetaDataFormat | None = None) -> None:
25+
self._path = Path(path)
26+
self._format = format if format is not None else _infer_format(self._path)
27+
28+
@property
29+
def path(self) -> Path:
30+
return self._path
31+
32+
@property
33+
def id(self) -> str:
34+
return self._path.name
35+
36+
@property
37+
def format(self) -> MetaDataFormat:
38+
return self._format
39+
40+
def read(self) -> str:
41+
return self._path.read_text(encoding="utf-8-sig")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""MetaDataSource abstract base + InMemoryStringSource impl.
2+
3+
Tier-1 contract: identity (for MetaError.location), declared format, and a
4+
read() that returns decoded UTF-8 content.
5+
"""
6+
from __future__ import annotations
7+
8+
from abc import ABC, abstractmethod
9+
from enum import Enum
10+
11+
12+
class MetaDataFormat(str, Enum):
13+
"""Authoring format vocabulary. Cross-language Tier 1: lowercase strings."""
14+
15+
JSON = "json"
16+
YAML = "yaml"
17+
18+
19+
class MetaDataSource(ABC):
20+
"""A source of metadata content.
21+
22+
Implementations declare identity + format up front; bytes may be read
23+
eagerly or lazily (the loader treats `read()` as decoded text).
24+
"""
25+
26+
@property
27+
@abstractmethod
28+
def id(self) -> str:
29+
"""Stable identity used in MetaError.location (e.g. file name, URI, ``<inline>``)."""
30+
31+
@property
32+
@abstractmethod
33+
def format(self) -> MetaDataFormat:
34+
"""Declared authoring format. Source-declared, never sniffed by the loader."""
35+
36+
@abstractmethod
37+
def read(self) -> str:
38+
"""Return the decoded text content."""
39+
40+
41+
class InMemoryStringSource(MetaDataSource):
42+
"""In-memory string source; no I/O.
43+
44+
Default identity is ``<inline>``; callers may pass a more descriptive id
45+
(e.g. ``<test:foo>``) — it surfaces in MetaError.location.
46+
"""
47+
48+
def __init__(
49+
self,
50+
content: str,
51+
id: str = "<inline>",
52+
format: MetaDataFormat = MetaDataFormat.JSON,
53+
) -> None:
54+
self._content = content
55+
self._id = id
56+
self._format = format
57+
58+
@property
59+
def id(self) -> str:
60+
return self._id
61+
62+
@property
63+
def format(self) -> MetaDataFormat:
64+
return self._format
65+
66+
def read(self) -> str:
67+
return self._content
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""URI-backed MetaDataSource.
2+
3+
Supports ``file://``, ``http://``, and ``https://`` schemes. Format defaults to
4+
extension-derived from the URI path; callers may override.
5+
"""
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
from urllib.parse import urlparse
10+
from urllib.request import urlopen
11+
12+
from .meta_data_source import MetaDataFormat, MetaDataSource
13+
14+
15+
def _infer_format(uri: str) -> MetaDataFormat:
16+
path = urlparse(uri).path
17+
suffix = Path(path).suffix.lower()
18+
if suffix in (".yaml", ".yml"):
19+
return MetaDataFormat.YAML
20+
return MetaDataFormat.JSON
21+
22+
23+
class UriSource(MetaDataSource):
24+
"""A URI-backed source. Lazily fetched on ``read()``."""
25+
26+
def __init__(self, uri: str, format: MetaDataFormat | None = None) -> None:
27+
self._uri = uri
28+
self._format = format if format is not None else _infer_format(uri)
29+
30+
@property
31+
def id(self) -> str:
32+
return self._uri
33+
34+
@property
35+
def format(self) -> MetaDataFormat:
36+
return self._format
37+
38+
def read(self) -> str:
39+
parsed = urlparse(self._uri)
40+
if parsed.scheme == "file":
41+
# urlparse splits the leading slashes off the path on file:// URIs.
42+
return Path(parsed.path).read_text(encoding="utf-8-sig")
43+
if parsed.scheme in ("http", "https"):
44+
with urlopen(self._uri) as resp: # noqa: S310 — caller-supplied URI
45+
return resp.read().decode("utf-8")
46+
raise ValueError(
47+
f"UriSource: unsupported scheme '{parsed.scheme}' on {self._uri}"
48+
)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Unit tests for MetaDataSource implementations.
2+
3+
Mirrors the Tier-1 source contract from the cross-language loader-unification
4+
spec: identity / format / read().
5+
"""
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
10+
from metaobjects.loader.sources import (
11+
DirectorySource,
12+
FileSource,
13+
InMemoryStringSource,
14+
MetaDataFormat,
15+
UriSource,
16+
)
17+
18+
19+
def test_file_source_infers_format_from_extension(tmp_path: Path) -> None:
20+
p = tmp_path / "x.yaml"
21+
p.write_text("k: v", encoding="utf-8")
22+
src = FileSource(p)
23+
assert src.format == MetaDataFormat.YAML
24+
assert src.id == "x.yaml"
25+
assert src.read() == "k: v"
26+
27+
28+
def test_file_source_explicit_format_overrides(tmp_path: Path) -> None:
29+
p = tmp_path / "x.txt"
30+
p.write_text("k: v", encoding="utf-8")
31+
src = FileSource(p, format=MetaDataFormat.YAML)
32+
assert src.format == MetaDataFormat.YAML
33+
34+
35+
def test_file_source_defaults_to_json_for_unknown_extension(tmp_path: Path) -> None:
36+
p = tmp_path / "x.txt"
37+
p.write_text("{}", encoding="utf-8")
38+
assert FileSource(p).format == MetaDataFormat.JSON
39+
40+
41+
def test_file_source_strips_utf8_bom(tmp_path: Path) -> None:
42+
p = tmp_path / "bom.json"
43+
# Write BOM + body; utf-8-sig decoder must strip it.
44+
p.write_bytes(b"\xef\xbb\xbf{}")
45+
assert FileSource(p).read() == "{}"
46+
47+
48+
def test_in_memory_string_source_defaults() -> None:
49+
src = InMemoryStringSource("{}")
50+
assert src.id == "<inline>"
51+
assert src.format == MetaDataFormat.JSON
52+
assert src.read() == "{}"
53+
54+
55+
def test_in_memory_string_source_custom_identity_and_format() -> None:
56+
src = InMemoryStringSource("k: v", id="<test>", format=MetaDataFormat.YAML)
57+
assert src.id == "<test>"
58+
assert src.format == MetaDataFormat.YAML
59+
60+
61+
def test_directory_source_expand_sorted_filtered(tmp_path: Path) -> None:
62+
(tmp_path / "b.json").write_text("{}", encoding="utf-8")
63+
(tmp_path / "a.yaml").write_text("", encoding="utf-8")
64+
(tmp_path / "ignored.txt").write_text("x", encoding="utf-8")
65+
expanded = list(DirectorySource(tmp_path).expand())
66+
assert [s.id for s in expanded] == ["a.yaml", "b.json"]
67+
assert expanded[0].format == MetaDataFormat.YAML
68+
assert expanded[1].format == MetaDataFormat.JSON
69+
70+
71+
def test_directory_source_honors_exclude(tmp_path: Path) -> None:
72+
(tmp_path / "meta.alpha.json").write_text("{}", encoding="utf-8")
73+
(tmp_path / "meta.beta.json").write_text("{}", encoding="utf-8")
74+
src = DirectorySource(tmp_path, exclude=["meta.beta.json"])
75+
expanded = list(src.expand())
76+
assert [s.id for s in expanded] == ["meta.alpha.json"]
77+
78+
79+
def test_directory_source_recurses_by_default(tmp_path: Path) -> None:
80+
sub = tmp_path / "nested"
81+
sub.mkdir()
82+
(sub / "deep.json").write_text("{}", encoding="utf-8")
83+
(tmp_path / "top.json").write_text("{}", encoding="utf-8")
84+
ids = [s.id for s in DirectorySource(tmp_path).expand()]
85+
# Sorted by file name; both files surfaced.
86+
assert ids == ["deep.json", "top.json"]
87+
88+
89+
def test_directory_source_non_recursive(tmp_path: Path) -> None:
90+
sub = tmp_path / "nested"
91+
sub.mkdir()
92+
(sub / "deep.json").write_text("{}", encoding="utf-8")
93+
(tmp_path / "top.json").write_text("{}", encoding="utf-8")
94+
ids = [s.id for s in DirectorySource(tmp_path, recurse=False).expand()]
95+
assert ids == ["top.json"]
96+
97+
98+
def test_uri_source_file_scheme_reads_content(tmp_path: Path) -> None:
99+
p = tmp_path / "x.json"
100+
p.write_text("{}", encoding="utf-8")
101+
src = UriSource(p.as_uri())
102+
assert src.format == MetaDataFormat.JSON
103+
assert src.read() == "{}"
104+
105+
106+
def test_uri_source_format_inferred_from_path(tmp_path: Path) -> None:
107+
p = tmp_path / "x.yaml"
108+
p.write_text("k: v", encoding="utf-8")
109+
src = UriSource(p.as_uri())
110+
assert src.format == MetaDataFormat.YAML

0 commit comments

Comments
 (0)