|
| 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()) |
0 commit comments