Skip to content

Commit c16bf45

Browse files
committed
feat(python): generator registry + metaobjects gen --list + select-by-name; conformance to stable-name manifest (ADR-0021 D3)
1 parent fbe0d2e commit c16bf45

4 files changed

Lines changed: 415 additions & 7 deletions

File tree

server/python/src/metaobjects/cli.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
)
4444
from metaobjects.codegen.generators.payload_vo_generator import payload_vo_generator
4545
from metaobjects.codegen.generators.router_generator import router_generator
46+
from metaobjects.codegen.generator_registry import (
47+
GENERATOR_REGISTRY,
48+
get_generator,
49+
list_generators,
50+
)
4651
from metaobjects.codegen.runner import run_gen
4752

4853

@@ -72,23 +77,78 @@ def _load_root(metadata_dir: str) -> tuple[MetaData | None, list[str]]:
7277
return result.root, []
7378

7479

75-
def _generate(metadata_dir: str, out_dir: str) -> tuple[list[str], list[str]]:
80+
def _resolve_generators(names: str) -> tuple[list[Generator], list[str]]:
81+
"""Resolve a comma-separated list of STABLE generator names via the registry.
82+
83+
Returns ``(generators, errors)``. An unknown name produces a clear error and
84+
no generators (so the caller can fail with exit code != 0).
85+
"""
86+
requested = [n.strip() for n in names.split(",") if n.strip()]
87+
gens: list[Generator] = []
88+
errors: list[str] = []
89+
for n in requested:
90+
entry = get_generator(n)
91+
if entry is None:
92+
known = ", ".join(sorted(GENERATOR_REGISTRY))
93+
errors.append(f"unknown generator {n!r}; known: {known}")
94+
continue
95+
gens.append(entry.factory())
96+
if not errors and not gens:
97+
errors.append("no generators selected (empty --generators list)")
98+
return gens, errors
99+
100+
101+
def _generate(
102+
metadata_dir: str, out_dir: str, generators: list[Generator] | None = None
103+
) -> tuple[list[str], list[str]]:
76104
"""Run the generator suite into ``out_dir``.
77105
78-
Returns ``(written_paths, errors)``. On a load error, ``errors`` is
79-
non-empty and no files are written.
106+
``generators`` defaults to the zero-config default suite; pass a registry-
107+
resolved subset for ``--generators``. Returns ``(written_paths, errors)``. On
108+
a load error, ``errors`` is non-empty and no files are written.
80109
"""
81110
root, errors = _load_root(metadata_dir)
82111
if root is None:
83112
return [], errors
84113
config = GenConfig(out_dir=out_dir)
85-
result = run_gen(config, root, generators=_default_generators())
114+
suite = generators if generators is not None else _default_generators()
115+
result = run_gen(config, root, generators=suite)
86116
written = [path for path, status in result.files if status != "refused"]
87117
return written, []
88118

89119

120+
def _cmd_list(_args: argparse.Namespace) -> int:
121+
"""Print each registered generator ``<stable-name> — <description>`` and exit 0.
122+
123+
Does NOT run codegen — pure discoverability (ADR-0021 D3).
124+
"""
125+
for entry in list_generators():
126+
print(f"{entry.name}{entry.description}")
127+
return 0
128+
129+
90130
def _cmd_gen(args: argparse.Namespace) -> int:
91-
written, errors = _generate(args.metadata_dir, args.out)
131+
# `--list` is a pure discoverability path: print the registry and exit, no codegen.
132+
if getattr(args, "list", False):
133+
return _cmd_list(args)
134+
135+
if args.metadata_dir is None or args.out is None:
136+
print(
137+
"error: gen requires <metadata_dir> and --out (or use --list).",
138+
file=sys.stderr,
139+
)
140+
return 2
141+
142+
generators: list[Generator] | None = None
143+
if args.generators:
144+
generators, gen_errors = _resolve_generators(args.generators)
145+
if gen_errors:
146+
print("error: invalid --generators selection:", file=sys.stderr)
147+
for msg in gen_errors:
148+
print(f" {msg}", file=sys.stderr)
149+
return 1
150+
151+
written, errors = _generate(args.metadata_dir, args.out, generators)
92152
if errors:
93153
print("error: failed to load metadata:", file=sys.stderr)
94154
for msg in errors:
@@ -164,8 +224,30 @@ def _build_parser() -> argparse.ArgumentParser:
164224
sub = parser.add_subparsers(dest="command", required=True)
165225

166226
gen = sub.add_parser("gen", help="run codegen, writing files under --out")
167-
gen.add_argument("metadata_dir", help="directory of metadata JSON/YAML files")
168-
gen.add_argument("--out", required=True, help="output directory for generated code")
227+
# metadata_dir / --out are optional so `gen --list` works without them.
228+
gen.add_argument(
229+
"metadata_dir",
230+
nargs="?",
231+
default=None,
232+
help="directory of metadata JSON/YAML files",
233+
)
234+
gen.add_argument(
235+
"--out", default=None, help="output directory for generated code"
236+
)
237+
gen.add_argument(
238+
"--generators",
239+
default=None,
240+
help=(
241+
"comma-separated STABLE generator names to run (e.g. entity,routes). "
242+
"Resolved via the registry; omit to run the default suite. "
243+
"See `gen --list`."
244+
),
245+
)
246+
gen.add_argument(
247+
"--list",
248+
action="store_true",
249+
help="list registered generators (stable name + description) and exit",
250+
)
169251
gen.add_argument(
170252
"--package",
171253
default=None,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""ADR-0021 D3 — stable-name generator registry (Python port).
2+
3+
Generators are identified by a STABLE string id (e.g. ``entity``, ``routes``,
4+
``render-helper``) rather than by a language-specific factory import. The id is
5+
the cross-port contract: the same logical generator carries the same stable name
6+
in every port. This module is the discoverability + identity surface behind
7+
``metaobjects gen --list`` and the ``--generators a,b`` selection path.
8+
9+
It is ADDITIVE. The default suite in ``cli.py`` (``_default_generators``) and the
10+
``run_gen(..., generators=[...])`` factory-array path keep working unchanged — the
11+
registry powers ``--list`` and stable identity; it does not replace those paths.
12+
13+
The registry's name set is conformance-tested for SET EQUALITY against the Python
14+
slice of the canonical manifest
15+
(``fixtures/generator-registry-conformance/registry.json``): exactly the manifest
16+
entries whose ``ports`` array includes ``python``. All Python entries are
17+
``tier: native``.
18+
"""
19+
from __future__ import annotations
20+
21+
from dataclasses import dataclass
22+
from typing import Callable
23+
24+
from metaobjects.codegen.generator import Generator
25+
from metaobjects.codegen.generators.entity_model import entity_model
26+
from metaobjects.codegen.generators.extractor_generator import extractor_generator
27+
from metaobjects.codegen.generators.filter_allowlist_generator import (
28+
filter_allowlist_generator,
29+
)
30+
from metaobjects.codegen.generators.output_parser_generator import (
31+
output_parser_generator,
32+
)
33+
from metaobjects.codegen.generators.output_prompt_generator import (
34+
output_prompt_generator,
35+
)
36+
from metaobjects.codegen.generators.payload_vo_generator import payload_vo_generator
37+
from metaobjects.codegen.generators.render_helper_generator import (
38+
render_helper_generator,
39+
)
40+
from metaobjects.codegen.generators.router_generator import router_generator
41+
from metaobjects.codegen.generators.template_generator import template_generator
42+
from metaobjects.render.verify import InMemoryProvider
43+
44+
GeneratorTier = str # "native" | "neutral"
45+
46+
47+
@dataclass(frozen=True)
48+
class GeneratorEntry:
49+
"""A registry entry: stable name + one-line description + tier + factory."""
50+
51+
#: Stable, cross-port-consistent id. Equals the registry map key.
52+
name: str
53+
#: One-line (no newline) human description for ``--list``.
54+
description: str
55+
#: "native" = recommended ``metaobjects gen`` suite; "neutral" = ``meta docs``-owned.
56+
tier: GeneratorTier
57+
#: Constructs the generator with sensible defaults. Calling it must not throw.
58+
factory: Callable[[], Generator]
59+
60+
61+
def _template_primitive() -> Generator:
62+
"""A no-op default for the ``template`` PRIMITIVE generator.
63+
64+
``template_generator`` requires caller-supplied ``template`` / ``walk`` /
65+
``provider`` (it is not a zero-config per-entity emitter). For registry
66+
identity + ``--list`` we expose a default that constructs a valid Generator
67+
without throwing and walks to zero outputs; real use passes opts via the
68+
factory-array config path. Mirrors the TS ``templatePrimitive()``.
69+
"""
70+
return template_generator(
71+
name="template",
72+
template="",
73+
walk=lambda _root: [],
74+
provider=InMemoryProvider(),
75+
)
76+
77+
78+
def _render_helper_default() -> Generator:
79+
"""Construct ``render-helper`` with a default ``template_root``.
80+
81+
The factory ctor only requires ``template_root`` to be non-empty (it builds a
82+
``FilesystemProvider`` lazily; no disk access at construction). Real use passes
83+
the caller's on-disk template root via the factory-array config path; this
84+
default exists only so registry identity + ``--list`` construct without throwing.
85+
"""
86+
return render_helper_generator(template_root="templates")
87+
88+
89+
#: Stable name -> GeneratorEntry. The 9 native generators whose manifest `ports`
90+
#: include `python` (ADR-0021 D3). Set-equality conformance-tested vs the manifest.
91+
GENERATOR_REGISTRY: dict[str, GeneratorEntry] = {
92+
"entity": GeneratorEntry(
93+
name="entity",
94+
description="Per-entity model/class — the entity module (table-backed or value object).",
95+
tier="native",
96+
factory=entity_model,
97+
),
98+
"routes": GeneratorEntry(
99+
name="routes",
100+
description="Per-entity REST endpoint surface (controllers / routes / router).",
101+
tier="native",
102+
factory=router_generator,
103+
),
104+
"output-parser": GeneratorEntry(
105+
name="output-parser",
106+
description="Per-template tolerant output parser (recover-on-receipt).",
107+
tier="native",
108+
factory=output_parser_generator,
109+
),
110+
"output-prompt": GeneratorEntry(
111+
name="output-prompt",
112+
description="Per-template output-format prompt fragment generator.",
113+
tier="native",
114+
factory=output_prompt_generator,
115+
),
116+
"render-helper": GeneratorEntry(
117+
name="render-helper",
118+
description="Per-template.output render helper (document/email typed wrappers).",
119+
tier="native",
120+
factory=_render_helper_default,
121+
),
122+
"extractor": GeneratorEntry(
123+
name="extractor",
124+
description="Per-template strict typed extract<Name> helper (strict payload extraction).",
125+
tier="native",
126+
factory=extractor_generator,
127+
),
128+
"template": GeneratorEntry(
129+
name="template",
130+
description="Generic Mustache template primitive (walk + template -> files).",
131+
tier="native",
132+
factory=_template_primitive,
133+
),
134+
"filter-allowlist": GeneratorEntry(
135+
name="filter-allowlist",
136+
description="Per-entity REST filter allowlist (queryable-field guard).",
137+
tier="native",
138+
factory=filter_allowlist_generator,
139+
),
140+
"payload": GeneratorEntry(
141+
name="payload",
142+
description="Per-template payload value object (the strict payload type).",
143+
tier="native",
144+
factory=payload_vo_generator,
145+
),
146+
}
147+
148+
149+
def list_generators() -> list[GeneratorEntry]:
150+
"""All registry entries, sorted by stable name."""
151+
return sorted(GENERATOR_REGISTRY.values(), key=lambda e: e.name)
152+
153+
154+
def get_generator(name: str) -> GeneratorEntry | None:
155+
"""Resolve a generator entry by its stable id, or ``None`` if unknown."""
156+
return GENERATOR_REGISTRY.get(name)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Unit tests for `metaobjects gen --list` and `--generators` (stable-name selection).
2+
3+
ADR-0021 D3. ``gen --list`` prints the registered generators (stable name +
4+
description) and exits 0 without running codegen. ``--generators a,b`` selects a
5+
subset by stable name via the registry; an unknown name is a clear error (exit
6+
non-zero). Omitting ``--generators`` keeps the existing default suite behavior.
7+
"""
8+
from __future__ import annotations
9+
10+
from pathlib import Path
11+
12+
from metaobjects.cli import main
13+
from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, list_generators
14+
15+
FIXTURE = (
16+
Path(__file__).parents[4]
17+
/ "fixtures"
18+
/ "persistence-conformance"
19+
/ "canonical"
20+
/ "meta.fitness.json"
21+
)
22+
23+
24+
def _meta_dir(tmp_path: Path) -> str:
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_list_prints_nine_and_exits_zero(capsys, tmp_path: Path) -> None:
32+
rc = main(["gen", "--list"])
33+
assert rc == 0
34+
out = capsys.readouterr().out
35+
# Every registered stable name appears in the listing.
36+
for name in GENERATOR_REGISTRY:
37+
assert name in out, f"--list omitted {name!r}"
38+
# Exactly the 9 python-slice names are registered.
39+
assert len(GENERATOR_REGISTRY) == 9
40+
# One line per generator, "<name> — <description>".
41+
listed = [e.name for e in list_generators()]
42+
assert sorted(listed) == sorted(GENERATOR_REGISTRY.keys())
43+
44+
45+
def test_gen_list_does_not_run_codegen(capsys, tmp_path: Path) -> None:
46+
out = tmp_path / "out"
47+
rc = main(["gen", "--list", "--out", str(out)])
48+
assert rc == 0
49+
# --list must NOT write any generated files even when --out is given.
50+
assert not out.exists() or not list(out.rglob("*.py"))
51+
52+
53+
def test_generators_selects_subset_by_stable_name(tmp_path: Path) -> None:
54+
meta_dir = _meta_dir(tmp_path)
55+
out = tmp_path / "out"
56+
rc = main(["gen", meta_dir, "--out", str(out), "--generators", "entity"])
57+
assert rc == 0
58+
files = {p.name for p in out.rglob("*.py")}
59+
# entity-only selection emits the Program entity model...
60+
assert "Program.py" in files
61+
62+
63+
def test_template_generator_reachable_via_generators(tmp_path: Path) -> None:
64+
# `template` is NOT in the default suite, but the registry makes it reachable.
65+
assert "template" in GENERATOR_REGISTRY
66+
meta_dir = _meta_dir(tmp_path)
67+
out = tmp_path / "out"
68+
# The registry's `template` primitive walks to zero outputs (no-op default),
69+
# so selecting it alone is a valid, non-throwing run that writes nothing.
70+
rc = main(["gen", meta_dir, "--out", str(out), "--generators", "template"])
71+
assert rc == 0
72+
73+
74+
def test_unknown_generator_name_is_clear_error(capsys, tmp_path: Path) -> None:
75+
meta_dir = _meta_dir(tmp_path)
76+
out = tmp_path / "out"
77+
rc = main(["gen", meta_dir, "--out", str(out), "--generators", "entity,bogus"])
78+
assert rc != 0
79+
err = capsys.readouterr().err
80+
assert "bogus" in err

0 commit comments

Comments
 (0)