|
89 | 89 | scan_requirements, |
90 | 90 | summarise_requirements, |
91 | 91 | ) |
92 | | -from metaobjects.codegen.config import GenConfig |
| 92 | +from metaobjects.codegen.config import DEFAULT_API_PREFIX, GenConfig |
93 | 93 | from metaobjects.codegen.overwrite_policy import ( |
94 | 94 | has_hash_manifest, |
95 | 95 | is_pristine_generated, |
@@ -608,12 +608,13 @@ def _load_root_from_collection( |
608 | 608 | return result.root, [] |
609 | 609 |
|
610 | 610 |
|
611 | | -#: Trailing advice after ``gen``'s unknown-attribute warnings. ``gen`` has no strict |
612 | | -#: flag of its own; ``verify`` is the strict door (ADR-0023), so that is what it names. |
| 611 | +#: Trailing advice after ``gen``'s unknown-attribute warnings. ``gen`` loads leniently |
| 612 | +#: unless ``--strict``; ``verify`` is strict by default (ADR-0023), so the line names both. |
613 | 613 | UNKNOWN_ATTR_GEN_ADVICE = ( |
614 | 614 | "metaobjects gen loads leniently and generated anyway, but `metaobjects verify` " |
615 | 615 | "rejects this metadata (ADR-0023): fix or remove the attribute - a typo'd one " |
616 | | - "(`isAbstrakt`, `requird`) silently changes what is generated." |
| 616 | + "(`isAbstrakt`, `requird`) silently changes what is generated. " |
| 617 | + "Pass `--strict` to make `gen` refuse it too." |
617 | 618 | ) |
618 | 619 |
|
619 | 620 |
|
@@ -647,13 +648,35 @@ def _print_unknown_attr_warnings(findings: list[str]) -> None: |
647 | 648 | print(f"warning: {UNKNOWN_ATTR_GEN_ADVICE}", file=sys.stderr) |
648 | 649 |
|
649 | 650 |
|
650 | | -def _strict_load_hint() -> str: |
651 | | - """Actionable next-steps when strict verify rejects an undeclared @attr.""" |
| 651 | +def _print_identical_owned_notice(root: Path) -> None: |
| 652 | + """ONE advisory line when owned generator copies are identical to the shipped ones: an |
| 653 | + owned copy that was never edited only costs the adopter upgrades. Advisory: the exit |
| 654 | + code is unchanged, and a project with no owned copies reads no file.""" |
| 655 | + try: |
| 656 | + names = [e.name for e in owned.identical_owned(root)] |
| 657 | + except Exception: # noqa: BLE001 — advisory; a packaged module that cannot be read says nothing |
| 658 | + return |
| 659 | + if names: |
| 660 | + print( |
| 661 | + f"note: owned generator copy identical to the shipped one: {', '.join(names)}. " |
| 662 | + "Run `metaobjects eject --undo` to drop the copies and track the shipped " |
| 663 | + "generators again.", |
| 664 | + file=sys.stderr, |
| 665 | + ) |
| 666 | + |
| 667 | + |
| 668 | +def _strict_load_hint(strict_flag: str = "verify") -> str: |
| 669 | + """Actionable next-steps when a strict load rejects an undeclared @attr.""" |
| 670 | + opt_out = ( |
| 671 | + "OR drop `--strict` to keep the lenient load" |
| 672 | + if strict_flag == "--strict" |
| 673 | + else "OR re-run with `--lax` to keep the legacy open-attr load" |
| 674 | + ) |
| 675 | + who = "gen --strict" if strict_flag == "--strict" else "verify" |
652 | 676 | return ( |
653 | | - "verify is strict (ADR-0023): every authored @attr must be declared. Fix: " |
| 677 | + f"{who} is strict (ADR-0023): every authored @attr must be declared. Fix: " |
654 | 678 | "register the attr on a metadata provider, OR move arbitrary author-supplied " |
655 | | - "properties into an `attr.properties` bag, OR re-run with `--lax` to keep the " |
656 | | - "legacy open-attr load." |
| 679 | + f"properties into an `attr.properties` bag, {opt_out}." |
657 | 680 | ) |
658 | 681 |
|
659 | 682 |
|
@@ -875,6 +898,7 @@ def _run_suite( |
875 | 898 | baseline: str = "default", |
876 | 899 | refused_out: list[str] | None = None, |
877 | 900 | select: Callable[[str], bool] | None = None, |
| 901 | + api_prefix: str = DEFAULT_API_PREFIX, |
878 | 902 | overwrite_hand_edits: frozenset[str] = frozenset(), |
879 | 903 | overwrite_paths_out: set[str] | None = None, |
880 | 904 | ) -> list[str]: |
@@ -917,6 +941,7 @@ def _run_suite( |
917 | 941 | emit_package_init=emit_package_init, |
918 | 942 | gen_state_dir=gen_state_dir, |
919 | 943 | column_naming=column_naming, |
| 944 | + api_prefix=api_prefix, |
920 | 945 | # Manifest keys are relative to the PROJECT, which is what the manifest is |
921 | 946 | # anchored on — not to ``--out``, which varies per run while the manifest does |
922 | 947 | # not. Passed alongside gen_state_dir because the two are one decision: state |
@@ -1248,16 +1273,75 @@ def _cmd_fmt(args: argparse.Namespace) -> int: |
1248 | 1273 |
|
1249 | 1274 |
|
1250 | 1275 | def _cmd_eject(args: argparse.Namespace) -> int: |
1251 | | - """Copy reference generators into ``codegen/generators/`` to own (ADR-0034 Am. 3).""" |
| 1276 | + """Copy reference generators into ``codegen/generators/`` to own (ADR-0034 Am. 3), or |
| 1277 | + with ``--undo`` drop the copies that are identical to the shipped ones.""" |
1252 | 1278 | root = Path(args.root) if args.root else Path.cwd() |
1253 | | - result = owned.eject(args.names, root, force=args.force) |
| 1279 | + if args.undo: |
| 1280 | + result = owned.undo(args.names, root, force=args.force) |
| 1281 | + elif not args.names: |
| 1282 | + print("error: name at least one generator to eject (`metaobjects gen --list` shows " |
| 1283 | + "them), or pass --undo", file=sys.stderr) |
| 1284 | + return 2 |
| 1285 | + else: |
| 1286 | + result = owned.eject(args.names, root, force=args.force) |
1254 | 1287 | for line in result.out: |
1255 | 1288 | print(line) |
1256 | 1289 | for line in result.err: |
1257 | 1290 | print(f"error: {line}", file=sys.stderr) |
1258 | 1291 | return result.code |
1259 | 1292 |
|
1260 | 1293 |
|
| 1294 | +#: The metadata a fresh ``init`` writes: a loadable, empty model to author into. |
| 1295 | +_INIT_METADATA = "metadata:\n package: app\n children: []\n" |
| 1296 | + |
| 1297 | +_INIT_CONFIG = """\ |
| 1298 | +# What `metaobjects gen` generates: each target names its outDir and its generators. |
| 1299 | +# `metaobjects gen --list` shows the catalog. |
| 1300 | +metadata: metaobjects |
| 1301 | +targets: |
| 1302 | + app: |
| 1303 | + outDir: generated |
| 1304 | + generators: [] |
| 1305 | +""" |
| 1306 | + |
| 1307 | +_INIT_DOCS_CONFIG = """\ |
| 1308 | + docs: |
| 1309 | + outDir: docs |
| 1310 | + generators: [api-docs] |
| 1311 | +""" |
| 1312 | + |
| 1313 | + |
| 1314 | +def _cmd_init(args: argparse.Namespace) -> int: |
| 1315 | + """Scaffold ``metaobjects.config.yaml`` and the ``metaobjects/`` metadata directory. |
| 1316 | +
|
| 1317 | + Never overwrites: a file that exists is reported and left alone. ``--with-docs`` adds a |
| 1318 | + ``docs`` target running ``api-docs``; without it no documentation generator is wired. |
| 1319 | + """ |
| 1320 | + root = Path(args.root) if args.root else Path.cwd() |
| 1321 | + config = _INIT_CONFIG + (_INIT_DOCS_CONFIG if args.with_docs else "") |
| 1322 | + wrote: list[str] = [] |
| 1323 | + kept: list[str] = [] |
| 1324 | + for rel, text in ( |
| 1325 | + ("metaobjects.config.yaml", config), |
| 1326 | + ("metaobjects/meta.app.yaml", _INIT_METADATA), |
| 1327 | + ): |
| 1328 | + path = root / rel |
| 1329 | + if path.exists(): |
| 1330 | + kept.append(rel) |
| 1331 | + continue |
| 1332 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 1333 | + path.write_text(text, encoding="utf-8") |
| 1334 | + wrote.append(rel) |
| 1335 | + for rel in wrote: |
| 1336 | + print(f"wrote {rel}") |
| 1337 | + for rel in kept: |
| 1338 | + print(f"kept {rel} (already exists)") |
| 1339 | + if args.with_docs and "metaobjects.config.yaml" in kept: |
| 1340 | + print("note: add the docs target yourself: `docs: {outDir: docs, generators: [api-docs]}`") |
| 1341 | + print("next: describe your entities in metaobjects/, then run `metaobjects gen`.") |
| 1342 | + return 0 |
| 1343 | + |
| 1344 | + |
1261 | 1345 | def _warn_if_agent_context_stale() -> None: |
1262 | 1346 | """Print ONE advisory line to stderr if the scaffolded agent context is stale. |
1263 | 1347 |
|
@@ -1534,15 +1618,15 @@ def selected(gen: Generator, token: str) -> bool: |
1534 | 1618 | written = _run_suite( |
1535 | 1619 | root, out_dir, gens, t.entities, gen_state_dir=gen_state_dir, |
1536 | 1620 | column_naming=column_naming, project_root=project_root, baseline=baseline, |
1537 | | - refused_out=refused_out, select=select, |
| 1621 | + refused_out=refused_out, select=select, api_prefix=config.api_prefix, |
1538 | 1622 | overwrite_hand_edits=overwrite, overwrite_paths_out=overwrite_paths_out, |
1539 | 1623 | ) |
1540 | 1624 | if spec_gens: |
1541 | 1625 | written = written + _run_suite( |
1542 | 1626 | root, out_dir, spec_gens, t.entities, emit_package_init=False, |
1543 | 1627 | gen_state_dir=gen_state_dir, column_naming=column_naming, |
1544 | 1628 | project_root=project_root, baseline=baseline, refused_out=refused_out, |
1545 | | - select=select, |
| 1629 | + select=select, api_prefix=config.api_prefix, |
1546 | 1630 | ) |
1547 | 1631 | except ValueError as exc: # intra-target run_gen collision → clean error |
1548 | 1632 | errors.append(f"target '{t.name}': {exc}") |
@@ -1608,17 +1692,23 @@ def _cmd_gen_config(args: argparse.Namespace) -> int: |
1608 | 1692 | if collection is None: |
1609 | 1693 | return 1 |
1610 | 1694 |
|
| 1695 | + # `--strict` is the load `verify` uses (ADR-0023): an unknown attribute is an error and |
| 1696 | + # nothing is written. Without it `gen` loads leniently and only warns. |
| 1697 | + strict = bool(getattr(args, "strict", False)) |
1611 | 1698 | unknown_attrs: list[str] = [] |
1612 | 1699 | root, load_errors = _load_root_from_collection( |
1613 | | - collection, providers=providers, libraries=config.libraries, |
| 1700 | + collection, strict=strict, providers=providers, libraries=config.libraries, |
1614 | 1701 | unknown_attr_out=unknown_attrs, |
1615 | 1702 | ) |
1616 | 1703 | if root is None: |
1617 | 1704 | print("error: failed to load metadata:", file=sys.stderr) |
1618 | 1705 | for msg in load_errors: |
1619 | 1706 | print(f" {msg}", file=sys.stderr) |
| 1707 | + if strict and any("ERR_UNKNOWN_ATTR" in m for m in load_errors): |
| 1708 | + print(_strict_load_hint(strict_flag="--strict"), file=sys.stderr) |
1620 | 1709 | return 1 |
1621 | 1710 | _print_unknown_attr_warnings(unknown_attrs) |
| 1711 | + _print_identical_owned_notice(config.config_dir) |
1622 | 1712 |
|
1623 | 1713 | template_root, templates_refusal = _template_root_for_config(args, config) |
1624 | 1714 | if templates_refusal is not None: |
@@ -2813,6 +2903,14 @@ def _build_parser() -> argparse.ArgumentParser: |
2813 | 2903 | "as `templateSpec: <path>` in metaobjects.config.yaml" |
2814 | 2904 | ), |
2815 | 2905 | ) |
| 2906 | + gen.add_argument( |
| 2907 | + "--strict", |
| 2908 | + action="store_true", |
| 2909 | + help=( |
| 2910 | + "strict-attr load, the one `verify` uses (ADR-0023): an undeclared attribute is " |
| 2911 | + "an error and nothing is written. Without it gen loads leniently and warns." |
| 2912 | + ), |
| 2913 | + ) |
2816 | 2914 | gen.add_argument( |
2817 | 2915 | "--templates", |
2818 | 2916 | default=None, |
@@ -3023,12 +3121,28 @@ def _build_parser() -> argparse.ArgumentParser: |
3023 | 3121 | "eject", |
3024 | 3122 | help="copy reference generators into codegen/generators/ to own and edit", |
3025 | 3123 | ) |
3026 | | - eject_p.add_argument("names", nargs="+", metavar="NAME", help="stable generator names") |
| 3124 | + eject_p.add_argument("names", nargs="*", metavar="NAME", help="stable generator names") |
3027 | 3125 | eject_p.add_argument("--force", action="store_true", |
3028 | | - help="replace an existing owned copy with the reference") |
| 3126 | + help="replace an existing owned copy with the reference " |
| 3127 | + "(with --undo: remove a copy that differs from it)") |
| 3128 | + eject_p.add_argument( |
| 3129 | + "--undo", action="store_true", |
| 3130 | + help="drop owned copies identical to the shipped generator (all of them, or the " |
| 3131 | + "names given); a copy that differs is refused unless --force; prints the " |
| 3132 | + "config edit, never makes it", |
| 3133 | + ) |
3029 | 3134 | eject_p.add_argument("--root", default=None, help="project root (default: cwd)") |
3030 | 3135 | eject_p.set_defaults(func=_cmd_eject) |
3031 | 3136 |
|
| 3137 | + init_p = sub.add_parser( |
| 3138 | + "init", |
| 3139 | + help="scaffold metaobjects.config.yaml and metaobjects/ (never overwrites a file)", |
| 3140 | + ) |
| 3141 | + init_p.add_argument("--with-docs", action="store_true", |
| 3142 | + help="also wire the api-docs generator (a `docs` target)") |
| 3143 | + init_p.add_argument("--root", default=None, help="project root (default: cwd)") |
| 3144 | + init_p.set_defaults(func=_cmd_init) |
| 3145 | + |
3032 | 3146 | agent_docs = sub.add_parser( |
3033 | 3147 | "agent-docs", |
3034 | 3148 | help=( |
|
0 commit comments