Skip to content

Commit 9195d55

Browse files
committed
feat(python): eject --undo, gen --strict, init --with-docs, apiPrefix; drop the dead api-docs model link (#488)
1 parent 3f2bec7 commit 9195d55

16 files changed

Lines changed: 583 additions & 46 deletions

‎docs/ports/python.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,38 @@ config is refused. Column naming is not a Python setting at all: it is `naming`
157157
([physical-naming.md](../features/migrations/physical-naming.md)). See [the migration section](../features/migrations/docs-are-generators.md#python)
158158
and the [config reference](../features/cli.md).
159159

160+
**Start a project.** `metaobjects init` writes `metaobjects.config.yaml` (one `app` target
161+
with no generators) and an empty, loadable `metaobjects/meta.app.yaml`, and reports what it
162+
wrote. It never overwrites a file it finds; it says it kept it. `metaobjects init --with-docs`
163+
also adds a `docs` target running `api-docs`; without the flag no documentation generator is
164+
wired. Nothing writes docs until the config lists `api-docs`. The "Model / metadata" link on
165+
each api page is emitted only when `apiDocs.modelBaseUrl` is set (the Python port writes no
166+
model page, so a relative link would be dead).
167+
168+
**Where routes mount.** `apiPrefix` at the top level of `metaobjects.config.yaml` (the same
169+
key name as the TypeScript config, whose home is `metaobjects.config.ts`) sets the path the
170+
generated FastAPI routers mount under and the api docs name. The default is `/api`, so
171+
existing output is unchanged: `/api/authors`. `apiPrefix: /v1` gives `/v1/authors`;
172+
`apiPrefix: ""` mounts at `/authors`. It must be `""` or start with `/`; a trailing `/` is
173+
dropped. `gen` and `verify --codegen` read the same value.
174+
175+
**Strict load.** `metaobjects gen` loads leniently and warns about an unknown attribute.
176+
`metaobjects gen --strict` uses the load `verify` uses (ADR-0023): an unknown attribute is an
177+
error with `verify`'s message and hint, the exit code is non-zero, and no file is written.
178+
179+
**Owned copies.** When `gen` runs and an owned generator copy (`codegen/generators/`) is
180+
identical to the shipped one, it prints one `note:` line on stderr naming it and
181+
`metaobjects eject --undo`; the exit code is unchanged and a project with no owned copies pays
182+
nothing. `metaobjects eject --undo` removes every owned copy whose file and companion runtime
183+
modules are identical to the shipped ones (formatting is ignored); `--undo <name>...` removes
184+
only those names, and a copy that differs is refused with exit 2, nothing removed, and the
185+
`diff` command, unless `--force`. Names are validated before anything is deleted. A runtime
186+
copy in `codegen/runtime/` goes only when it matches the package and no remaining owned
187+
generator reads it. The config is never edited: `--undo` prints the entry to put back
188+
(`replace codegen.generators.entity:entity_model with "entity"`). The Python port has no
189+
`doctor` command; the `gen` notice and `metaobjects gen --list` (which marks each owned copy
190+
`identical` or `DIFFERS`) are where stale or identical copies show.
191+
160192
### Reports
161193

162194
A concrete `object.report` whose read source is a `source.rdb` of `@kind: view` is served

‎server/python/src/metaobjects/cli.py‎

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
scan_requirements,
9090
summarise_requirements,
9191
)
92-
from metaobjects.codegen.config import GenConfig
92+
from metaobjects.codegen.config import DEFAULT_API_PREFIX, GenConfig
9393
from metaobjects.codegen.overwrite_policy import (
9494
has_hash_manifest,
9595
is_pristine_generated,
@@ -608,12 +608,13 @@ def _load_root_from_collection(
608608
return result.root, []
609609

610610

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.
613613
UNKNOWN_ATTR_GEN_ADVICE = (
614614
"metaobjects gen loads leniently and generated anyway, but `metaobjects verify` "
615615
"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."
617618
)
618619

619620

@@ -647,13 +648,35 @@ def _print_unknown_attr_warnings(findings: list[str]) -> None:
647648
print(f"warning: {UNKNOWN_ATTR_GEN_ADVICE}", file=sys.stderr)
648649

649650

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"
652676
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: "
654678
"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}."
657680
)
658681

659682

@@ -875,6 +898,7 @@ def _run_suite(
875898
baseline: str = "default",
876899
refused_out: list[str] | None = None,
877900
select: Callable[[str], bool] | None = None,
901+
api_prefix: str = DEFAULT_API_PREFIX,
878902
overwrite_hand_edits: frozenset[str] = frozenset(),
879903
overwrite_paths_out: set[str] | None = None,
880904
) -> list[str]:
@@ -917,6 +941,7 @@ def _run_suite(
917941
emit_package_init=emit_package_init,
918942
gen_state_dir=gen_state_dir,
919943
column_naming=column_naming,
944+
api_prefix=api_prefix,
920945
# Manifest keys are relative to the PROJECT, which is what the manifest is
921946
# anchored on — not to ``--out``, which varies per run while the manifest does
922947
# not. Passed alongside gen_state_dir because the two are one decision: state
@@ -1248,16 +1273,75 @@ def _cmd_fmt(args: argparse.Namespace) -> int:
12481273

12491274

12501275
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."""
12521278
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)
12541287
for line in result.out:
12551288
print(line)
12561289
for line in result.err:
12571290
print(f"error: {line}", file=sys.stderr)
12581291
return result.code
12591292

12601293

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+
12611345
def _warn_if_agent_context_stale() -> None:
12621346
"""Print ONE advisory line to stderr if the scaffolded agent context is stale.
12631347
@@ -1534,15 +1618,15 @@ def selected(gen: Generator, token: str) -> bool:
15341618
written = _run_suite(
15351619
root, out_dir, gens, t.entities, gen_state_dir=gen_state_dir,
15361620
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,
15381622
overwrite_hand_edits=overwrite, overwrite_paths_out=overwrite_paths_out,
15391623
)
15401624
if spec_gens:
15411625
written = written + _run_suite(
15421626
root, out_dir, spec_gens, t.entities, emit_package_init=False,
15431627
gen_state_dir=gen_state_dir, column_naming=column_naming,
15441628
project_root=project_root, baseline=baseline, refused_out=refused_out,
1545-
select=select,
1629+
select=select, api_prefix=config.api_prefix,
15461630
)
15471631
except ValueError as exc: # intra-target run_gen collision → clean error
15481632
errors.append(f"target '{t.name}': {exc}")
@@ -1608,17 +1692,23 @@ def _cmd_gen_config(args: argparse.Namespace) -> int:
16081692
if collection is None:
16091693
return 1
16101694

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))
16111698
unknown_attrs: list[str] = []
16121699
root, load_errors = _load_root_from_collection(
1613-
collection, providers=providers, libraries=config.libraries,
1700+
collection, strict=strict, providers=providers, libraries=config.libraries,
16141701
unknown_attr_out=unknown_attrs,
16151702
)
16161703
if root is None:
16171704
print("error: failed to load metadata:", file=sys.stderr)
16181705
for msg in load_errors:
16191706
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)
16201709
return 1
16211710
_print_unknown_attr_warnings(unknown_attrs)
1711+
_print_identical_owned_notice(config.config_dir)
16221712

16231713
template_root, templates_refusal = _template_root_for_config(args, config)
16241714
if templates_refusal is not None:
@@ -2813,6 +2903,14 @@ def _build_parser() -> argparse.ArgumentParser:
28132903
"as `templateSpec: <path>` in metaobjects.config.yaml"
28142904
),
28152905
)
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+
)
28162914
gen.add_argument(
28172915
"--templates",
28182916
default=None,
@@ -3023,12 +3121,28 @@ def _build_parser() -> argparse.ArgumentParser:
30233121
"eject",
30243122
help="copy reference generators into codegen/generators/ to own and edit",
30253123
)
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")
30273125
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+
)
30293134
eject_p.add_argument("--root", default=None, help="project root (default: cwd)")
30303135
eject_p.set_defaults(func=_cmd_eject)
30313136

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+
30323146
agent_docs = sub.add_parser(
30333147
"agent-docs",
30343148
help=(

‎server/python/src/metaobjects/codegen/config.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
from metaobjects.naming import DEFAULT_COLUMN_NAMING
77

8+
#: What generated routers mount under when no ``apiPrefix`` is configured.
9+
DEFAULT_API_PREFIX = "/api"
10+
811

912
@dataclass
1013
class GenConfig:
@@ -54,6 +57,11 @@ class GenConfig:
5457
# constant this generator emits names a different column than the one a row
5558
# actually lands in.
5659
column_naming: str = DEFAULT_COLUMN_NAMING
60+
# The path the generated routers mount under (and the api docs name): ``/api/authors``
61+
# by default, ``/v1/authors`` for ``/v1``, ``/authors`` for ``""``. The CLI fills it from
62+
# ``apiPrefix`` in ``metaobjects.config.yaml``, the Python spelling of the TypeScript
63+
# config's key of the same name.
64+
api_prefix: str = DEFAULT_API_PREFIX
5765
# First-time-on-existing-file behaviour. "default" refuses a file that cannot be
5866
# proved to be generated output; "adopt" records what is on disk as the baseline and
5967
# writes nothing — the one run a project with no committed manifest can perform (the

0 commit comments

Comments
 (0)