Skip to content

Commit 93c233c

Browse files
dmealingclaude
andcommitted
feat(cli-py): metaobjects console-script — gen + verify (codegen drift) (SP-E Unit 2)
Python now ships a `metaobjects` console-script with gen (run codegen to an out dir) and verify (regenerate to temp + diff → fail on codegen drift). Named `metaobjects`, not `meta` (the Node schema CLI), and intentionally has NO migrate subcommand — schema is owned by the Node `meta` per ADR-0015. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 766d02d commit 93c233c

3 files changed

Lines changed: 279 additions & 0 deletions

File tree

server/python/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ dependencies = [
3737
"PyYAML>=6.0",
3838
]
3939

40+
[project.scripts]
41+
# Codegen CLI (gen + verify). Named `metaobjects`, NOT `meta` — `meta` is the
42+
# Node schema CLI. No `migrate` subcommand: schema is Node-owned (ADR-0015).
43+
metaobjects = "metaobjects.cli:main"
44+
4045
[project.urls]
4146
Homepage = "https://metaobjects.dev"
4247
Repository = "https://github.com/metaobjectsdev/metaobjects"
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""The ``metaobjects`` console-script — codegen `gen` + drift `verify` (SP-E Unit 2).
2+
3+
Two subcommands:
4+
5+
metaobjects gen <metadataDir> --out <dir> [--package <pkg>]
6+
Load metadata, run the Python codegen generator suite, and write files
7+
under ``--out`` (guarded by the @generated header). Prints each written
8+
file. Non-zero exit on a load error.
9+
10+
metaobjects verify <metadataDir> --out <dir>
11+
Regenerate into a temp dir and diff against the committed ``--out`` tree
12+
(content + file set). Any drift — changed / missing / extra file — prints
13+
a report plus a "regenerate (metaobjects gen) and commit" message and
14+
returns 1. In sync → 0.
15+
16+
Named ``metaobjects``, NOT ``meta``: ``meta`` is the Node schema CLI. This CLI
17+
intentionally has NO ``migrate`` subcommand — schema is owned by the Node
18+
toolchain (ADR-0015). ``verify`` shares the exact same generation code path as
19+
``gen`` (verify = gen-to-temp + diff), so drift can never be a generator-wiring
20+
divergence between the two commands.
21+
"""
22+
from __future__ import annotations
23+
24+
import argparse
25+
import sys
26+
import tempfile
27+
from pathlib import Path
28+
29+
from metaobjects import MetaDataLoader
30+
from metaobjects.meta.meta_data import MetaData
31+
from metaobjects.codegen.config import GenConfig
32+
from metaobjects.codegen.generator import Generator
33+
from metaobjects.codegen.generators.entity_model import entity_model
34+
from metaobjects.codegen.generators.extractor_generator import extractor_generator
35+
from metaobjects.codegen.generators.filter_allowlist_generator import (
36+
filter_allowlist_generator,
37+
)
38+
from metaobjects.codegen.generators.output_parser_generator import (
39+
output_parser_generator,
40+
)
41+
from metaobjects.codegen.generators.output_prompt_generator import (
42+
output_prompt_generator,
43+
)
44+
from metaobjects.codegen.generators.payload_vo_generator import payload_vo_generator
45+
from metaobjects.codegen.generators.router_generator import router_generator
46+
from metaobjects.codegen.runner import run_gen
47+
48+
49+
def _default_generators() -> list[Generator]:
50+
"""The default codegen suite — the no-config generators every project gets.
51+
52+
``template_generator`` is excluded: it requires a caller-supplied text
53+
provider + Mustache template and is not a zero-config per-entity emitter.
54+
"""
55+
return [
56+
entity_model(),
57+
router_generator(),
58+
filter_allowlist_generator(),
59+
payload_vo_generator(),
60+
output_parser_generator(),
61+
output_prompt_generator(),
62+
extractor_generator(),
63+
]
64+
65+
66+
def _load_root(metadata_dir: str) -> tuple[MetaData | None, list[str]]:
67+
"""Load metadata; return ``(root, error_messages)``. ``root`` is None on error."""
68+
result = MetaDataLoader.from_directory(metadata_dir)
69+
if result.errors:
70+
msgs = [f"{e.code}: {e.message}" for e in result.errors]
71+
return None, msgs
72+
return result.root, []
73+
74+
75+
def _generate(metadata_dir: str, out_dir: str) -> tuple[list[str], list[str]]:
76+
"""Run the generator suite into ``out_dir``.
77+
78+
Returns ``(written_paths, errors)``. On a load error, ``errors`` is
79+
non-empty and no files are written.
80+
"""
81+
root, errors = _load_root(metadata_dir)
82+
if root is None:
83+
return [], errors
84+
config = GenConfig(out_dir=out_dir)
85+
result = run_gen(config, root, generators=_default_generators())
86+
written = [path for path, status in result.files if status != "refused"]
87+
return written, []
88+
89+
90+
def _cmd_gen(args: argparse.Namespace) -> int:
91+
written, errors = _generate(args.metadata_dir, args.out)
92+
if errors:
93+
print("error: failed to load metadata:", file=sys.stderr)
94+
for msg in errors:
95+
print(f" {msg}", file=sys.stderr)
96+
return 1
97+
for path in written:
98+
print(path)
99+
print(f"metaobjects gen: wrote {len(written)} file(s) to {args.out}")
100+
return 0
101+
102+
103+
def _relative_set(root: Path) -> dict[str, str]:
104+
"""Map every ``*.py`` file under ``root`` to its content, keyed by rel path."""
105+
files: dict[str, str] = {}
106+
if root.exists():
107+
for p in sorted(root.rglob("*.py")):
108+
files[str(p.relative_to(root))] = p.read_text()
109+
return files
110+
111+
112+
def _cmd_verify(args: argparse.Namespace) -> int:
113+
# Reuse the exact gen code path — regenerate into a throwaway temp dir.
114+
with tempfile.TemporaryDirectory() as tmp:
115+
written, errors = _generate(args.metadata_dir, tmp)
116+
if errors:
117+
print("error: failed to load metadata:", file=sys.stderr)
118+
for msg in errors:
119+
print(f" {msg}", file=sys.stderr)
120+
return 1
121+
122+
expected = _relative_set(Path(tmp))
123+
committed = _relative_set(Path(args.out))
124+
125+
changed = sorted(
126+
k for k in expected if k in committed and expected[k] != committed[k]
127+
)
128+
missing = sorted(k for k in expected if k not in committed) # not yet committed
129+
extra = sorted(k for k in committed if k not in expected) # stale committed file
130+
131+
if not changed and not missing and not extra:
132+
print(f"metaobjects verify: in sync ({len(expected)} file(s)).")
133+
return 0
134+
135+
print("error: generated code is out of sync with metadata.", file=sys.stderr)
136+
for k in changed:
137+
print(f" drifted: {k}", file=sys.stderr)
138+
for k in missing:
139+
print(f" missing: {k}", file=sys.stderr)
140+
for k in extra:
141+
print(f" extra: {k}", file=sys.stderr)
142+
print(
143+
"regenerate (metaobjects gen) and commit the result.",
144+
file=sys.stderr,
145+
)
146+
return 1
147+
148+
149+
def _build_parser() -> argparse.ArgumentParser:
150+
parser = argparse.ArgumentParser(
151+
prog="metaobjects",
152+
description=(
153+
"MetaObjects Python codegen CLI. Generate idiomatic Python from "
154+
"metadata and verify it has not drifted. Schema migrations are "
155+
"owned by the Node `meta` CLI (ADR-0015) — there is no `migrate` "
156+
"subcommand here."
157+
),
158+
)
159+
sub = parser.add_subparsers(dest="command", required=True)
160+
161+
gen = sub.add_parser("gen", help="run codegen, writing files under --out")
162+
gen.add_argument("metadata_dir", help="directory of metadata JSON/YAML files")
163+
gen.add_argument("--out", required=True, help="output directory for generated code")
164+
gen.add_argument(
165+
"--package",
166+
default=None,
167+
help="(reserved) package hint; Python derives package from metadata",
168+
)
169+
gen.set_defaults(func=_cmd_gen)
170+
171+
verify = sub.add_parser(
172+
"verify",
173+
help="regenerate to a temp dir and fail on drift vs --out",
174+
)
175+
verify.add_argument("metadata_dir", help="directory of metadata JSON/YAML files")
176+
verify.add_argument(
177+
"--out", required=True, help="committed output directory to diff against"
178+
)
179+
verify.set_defaults(func=_cmd_verify)
180+
181+
return parser
182+
183+
184+
def main(argv: list[str] | None = None) -> int:
185+
"""Entry point. Returns the process exit code (does not call ``sys.exit``)."""
186+
parser = _build_parser()
187+
args = parser.parse_args(argv)
188+
return int(args.func(args))
189+
190+
191+
if __name__ == "__main__": # pragma: no cover
192+
sys.exit(main())
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Unit tests for the `metaobjects` console-script (SP-E Unit 2).
2+
3+
The CLI ships `gen` (run codegen to an out dir) and `verify` (regenerate to a
4+
temp dir + diff against the committed out dir → fail on codegen drift). It is
5+
named `metaobjects`, not `meta` (the Node schema CLI), and has NO `migrate`
6+
subcommand — schema is owned by the Node `meta` per ADR-0015.
7+
"""
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
from metaobjects.cli import main
13+
14+
FIXTURE = (
15+
Path(__file__).parents[4]
16+
/ "fixtures"
17+
/ "persistence-conformance"
18+
/ "canonical"
19+
/ "meta.fitness.json"
20+
)
21+
22+
23+
def _meta_dir(tmp_path: Path) -> str:
24+
"""Copy the fitness fixture into a clean metadata directory for the loader."""
25+
d = tmp_path / "meta"
26+
d.mkdir()
27+
(d / "meta.fitness.json").write_text(FIXTURE.read_text())
28+
return str(d)
29+
30+
31+
def test_gen_writes_files(tmp_path: Path) -> None:
32+
meta_dir = _meta_dir(tmp_path)
33+
out = tmp_path / "out"
34+
rc = main(["gen", meta_dir, "--out", str(out)])
35+
assert rc == 0
36+
written = list(out.rglob("*.py"))
37+
assert written, "gen wrote no files"
38+
# The fitness fixture has a Program entity → an entity model file.
39+
assert (out / "Program.py").exists()
40+
41+
42+
def test_verify_in_sync_returns_zero(tmp_path: Path) -> None:
43+
meta_dir = _meta_dir(tmp_path)
44+
out = tmp_path / "out"
45+
assert main(["gen", meta_dir, "--out", str(out)]) == 0
46+
# Freshly generated → verify must report no drift.
47+
assert main(["verify", meta_dir, "--out", str(out)]) == 0
48+
49+
50+
def test_verify_detects_drift(tmp_path: Path) -> None:
51+
meta_dir = _meta_dir(tmp_path)
52+
out = tmp_path / "out"
53+
assert main(["gen", meta_dir, "--out", str(out)]) == 0
54+
# Mutate a generated file → verify must detect codegen drift.
55+
target = out / "Program.py"
56+
target.write_text(target.read_text() + "\n# hand-edited drift\n")
57+
assert main(["verify", meta_dir, "--out", str(out)]) != 0
58+
59+
60+
def test_verify_detects_missing_file(tmp_path: Path) -> None:
61+
meta_dir = _meta_dir(tmp_path)
62+
out = tmp_path / "out"
63+
assert main(["gen", meta_dir, "--out", str(out)]) == 0
64+
(out / "Program.py").unlink()
65+
assert main(["verify", meta_dir, "--out", str(out)]) != 0
66+
67+
68+
def test_gen_load_error_returns_nonzero(tmp_path: Path) -> None:
69+
bad = tmp_path / "meta"
70+
bad.mkdir()
71+
(bad / "broken.json").write_text("{ not valid json")
72+
out = tmp_path / "out"
73+
assert main(["gen", str(bad), "--out", str(out)]) != 0
74+
75+
76+
def test_no_migrate_subcommand(tmp_path: Path) -> None:
77+
# Schema is owned by the Node `meta` CLI (ADR-0015); Python must not ship it.
78+
import pytest
79+
80+
with pytest.raises(SystemExit) as exc:
81+
main(["migrate", str(tmp_path), "--out", str(tmp_path / "out")])
82+
assert exc.value.code != 0

0 commit comments

Comments
 (0)