From 1f2542cadd25b940907e3d1b52bc972adc0e4af0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 08:46:51 -0400 Subject: [PATCH 1/9] =?UTF-8?q?docs(#267):=20design=20=E2=80=94=20Python?= =?UTF-8?q?=20declarative=20codegen=20config=20(targets=20registry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable cross-port config investigation: ship metaobjects.config.yaml (declarative, Python-only) with a targets registry; schema keys identical to TS (outDir/ generators/entities); providers resolved config-relative (kills PYTHONPATH); no-arg gen/verify run all targets; flags stay back-compat. Cross-port doctrine: per-port file surface, one locked shared schema (ADR-0021 D3 / FR-025 / template-spec) — NOT a single physical cross-port YAML. Additive; no coordinated release. #265 interaction noted (config-declared extending-providers need #265). Co-Authored-By: Claude Opus 4.8 Claude-Session: --- ...ython-declarative-codegen-config-design.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md diff --git a/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md b/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md new file mode 100644 index 000000000..4f128824f --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md @@ -0,0 +1,58 @@ +# #267 — Python declarative codegen config (targets registry): design + +_Date: 2026-08-03 · Issue: [#267](https://github.com/metaobjectsdev/metaobjects/issues/267) · Scope: **Python port only** (additive; no metamodel/vocabulary change; NO coordinated release) · Status: designed (Fable cross-port config investigation)_ + +## Problem + +The Python port's codegen is **flags-only** (`server/python/src/metaobjects/cli.py`): output dir, generator selection, entity allowlist, and provider module all pass on the command line, so per-gate they live in **CI shell scripts** where they go stale (an adopter shipped a committed generated dir several emitter versions stale because nothing re-derived it). The `--provider module:symbol` path additionally requires `PYTHONPATH=` to make the provider importable, and the entity list gets duplicated into every gate (local script + CI workflows) so a new entity is silently ungated. `codegen/KNOWN_GAPS.md` already anticipates a targets registry. + +## Decision (Fable-investigated) + +Ship a **declarative `metaobjects.config.yaml`** for Python — NOT a `.py` executable config. Rationale: Python's entire config surface is *data* (targets, stable generator-names, entity lists, `module:symbol` provider strings). Unlike TS's `metaobjects.config.ts`, which **must** be executable because ADR-0034 scaffold-and-own means the consumer config imports its *owned local generator implementations* and passes live `MetaDataTypeProvider` objects, Python's config only needs the provider **string reference** (the code stays in the provider module, per #158). YAML costs zero new deps (PyYAML ≥6.0 is already required by ADR-0006's authoring front-end) and is inspectable by non-Python tooling; a `.py` config would add arbitrary code-exec at gen/verify time for expressiveness Python doesn't use. + +**Cross-port doctrine (answers the maintainer's "does it matter if it differs per language?"):** the **file surface** may differ per port; the **schema (vocabulary) must not**. This is the position the repo already took three times — ADR-0021 D3 (stable generator-name selection, `generator-registry-conformance`), FR-025 milestone-1.1 (*"per-port file naming is at each port's discretion; the shape is locked across ports"*), and the template-spec JSON (one shape + JSON Schema, read by TS/C#/Python). So #267 does NOT introduce a single physical cross-port YAML (that would force TS to dual-support/break scaffold-and-own for zero gain, and split Java's config away from its pom idiom). It ships Python's YAML using **schema keys identical to TS's** so a polyglot adopter learns one vocabulary. + +## Schema (align names with TS — `outDir`, `generators`, `entities`; NOT `out`) + +```yaml +metadata: ./metaobjects # optional; default ./metaobjects +providers: ["my_provider:provider"] # module:symbol, resolved CONFIG-RELATIVE +targets: # named map (like TS `targets`), not a list + models: + outDir: pkg/models/generated + generators: [entity] # stable names via GENERATOR_REGISTRY (ADR-0021 D3) + entities: [Aaa, Bbb] # optional allowlist; omit = all entities + other: + outDir: pkg/other/generated + generators: [entity] + entities: [Ccc, Ddd] +``` + +- Publish a **JSON Schema** beside the loader (the template-spec precedent — `template-spec.schema.json`) for editor autocomplete + non-Python validation. +- One deliberate reconciliation: TS `targets` are *output destinations* (a generator picks a target via a `target?` option; `TargetConfig` carries no `generators`/`entities`), while #267's targets are *run-specs* (each carries its own `generators` + `entities`). Reconcilable — a run-spec target is a destination **plus** a selection — and the shared keys (`outDir`/`generators`/`entities`) stay identical; document that TS attaches selection to generators while the declarative ports attach it to targets. + +## Behavior + +- **Provider resolution:** prepend the **config file's directory** to `sys.path` before the existing `_resolve_providers` importlib path — removes the `PYTHONPATH=` requirement; `module:symbol` strings unchanged. Optional later: a `pythonPath: [./tools]` key for providers living elsewhere (don't block on it). +- **`metaobjects gen` (no positional dir / no `--out`)** → load config, load metadata **once**, run **every** target (per-target generators/entities into its `outDir`). Add a **cross-target duplicate-output-path guard** (TS's runner errors on duplicate full paths; Python's `run_gen` guard is per-pass only). Add `--target ` to scope to one target. +- **`metaobjects verify --codegen` (no args)** → per-target regen-to-temp + diff, aggregating exit codes (the existing `_verify_codegen` loop, per target). `--target ` scopes. +- **Config lookup:** `--config ` else `./metaobjects.config.yaml` in cwd. (Optional secondary `pyproject.toml [tool.metaobjects]` location later — don't block on it.) +- **Back-compat (load-bearing):** an explicit positional `metadata_dir` + `--out` keeps today's flag path **byte-identical** — flags present ⇒ legacy path, config not consulted (simplest, least-surprising rule). Purely additive; existing CI keeps working. + +## Interaction with #265 (note, not a blocker) + +A config-declared provider that `registry.extend()`s a core subtype still hits the strict-scoping prune that **#265** (PR #268, landing) fixes — until #265 merges, such a provider forces `--lax` on strict verify, so the config's "one command, strict gate" promise is hollow for exactly those adopters. #267's own code + tests do NOT depend on #265 (a #267 test provider can register a fresh subtype, or use no provider). Build #267 on a branch off main; the two compose once both land. + +## Non-goals + +- No `.py` executable config; no single physical cross-port config file; no change to the existing flags; no metamodel/vocabulary change; no coordinated release. TS/Java/C# unchanged (the cross-port *schema* convergence is a separate, later, additive step per FR-025 — optionally recorded as an ADR). + +## Testing + +- Config loader unit tests (parse YAML → typed config; missing/invalid → clear error; defaults). +- `gen` no-arg runs all targets into their `outDir`s with the right generators/entities (temp-dir integration); duplicate-output-path guard fires on a colliding config. +- `verify --codegen` no-arg per-target regen+diff, aggregate exit; a stale target fails, a fresh tree passes. +- Provider resolved config-relative WITHOUT `PYTHONPATH=` (a provider module beside the config). +- `--target ` scopes gen + verify. +- Back-compat: the existing positional+`--out` flag path is byte-identical (config ignored). +- No new metamodel conformance fixtures needed (Python-only ergonomics; not a cross-port vocabulary change). From 03b8c8ad55ecb94a73ef1e16b5197caf440b67ab Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:09:43 -0400 Subject: [PATCH 2/9] =?UTF-8?q?docs(#267):=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=20Python=20declarative=20codegen=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bite-sized TDD plan (4 tasks) for the metaobjects.config.yaml targets registry: config loader + JSON Schema, gen config mode (all-targets, --target, cross-target dup guard, config-relative providers), verify --codegen config mode (per-target regen+diff), docs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- ...e-267-python-declarative-codegen-config.md | 1235 +++++++++++++++++ 1 file changed, 1235 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-issue-267-python-declarative-codegen-config.md diff --git a/docs/superpowers/plans/2026-08-03-issue-267-python-declarative-codegen-config.md b/docs/superpowers/plans/2026-08-03-issue-267-python-declarative-codegen-config.md new file mode 100644 index 000000000..9c741f916 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-issue-267-python-declarative-codegen-config.md @@ -0,0 +1,1235 @@ +# Python declarative codegen config (targets registry) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a declarative `metaobjects.config.yaml` for the Python port — a targets registry — so `metaobjects gen` / `verify --codegen` run every target with no flags and provider modules resolve relative to the config (killing the `PYTHONPATH=` requirement and the duplicated CI entity lists), while the existing positional-`metadata_dir` + `--out` flag path stays byte-identical. + +**Architecture:** A new `metaobjects.codegen.project_config` module parses + validates a YAML config into typed dataclasses (`ProjectConfig` / `TargetConfig`). `cli.py` gains a config-mode branch on both `gen` and `verify --codegen`: config mode triggers **only** when no positional `metadata_dir` is given, so the flag path is untouched. Config mode loads metadata once, prepends the config's directory to `sys.path` (config-relative providers), and runs each target's generator/entity selection into its own `outDir`, with a cross-target duplicate-output-path guard the per-pass `run_gen` guard can't provide. + +**Tech Stack:** Python 3.10+, `argparse`, PyYAML (`yaml.safe_load` — already a dependency, ADR-0006), pytest. Hatchling wheel build (ships non-`.py` package data). + +## Global Constraints + +- **Python-only, additive.** No metamodel/vocabulary change, no conformance-fixture change, NO coordinated release. TS/Java/C#/Kotlin unchanged. +- **Schema keys identical to TS** (`metaobjects.config.ts` vocabulary): `targets..{outDir, generators, entities}`, `providers`, `metadata`. Use `outDir` (NOT `out`). The file *surface* differs per port; the *schema* does not (ADR-0021 D3 / FR-025). +- **YAML, not `.py`.** Python's config surface is pure data; the provider CODE stays in its module, referenced by `module:symbol` (#158). +- **Back-compat is load-bearing.** Config mode triggers **only** when the positional `metadata_dir` is absent. An explicit `metadata_dir` + `--out` keeps today's flag path byte-identical; the config is NOT consulted, even if a `metaobjects.config.yaml` sits in cwd. +- **Provider resolution is config-relative.** Prepend the config file's directory to `sys.path` before the existing `_resolve_providers` importlib path; `module:symbol` strings unchanged. +- **Publish a JSON Schema** beside the loader (mirror `template-spec.schema.json`) for editor autocomplete + non-Python validation. +- **Public repo hygiene.** No private/other-project names, no absolute home paths in any committed file, commit message, or fixture. Use generic terms. +- **Named constants / no magic strings** where the codebase already uses them. Reuse existing helpers (`_run_suite`, `_resolve_generators`, `_load_root`, `_relative_set`) — do not re-implement. +- **Commit author** `Doug Mealing ` with the repo's `Co-Authored-By` / `Claude-Session` trailers. TDD throughout (RED before GREEN). Run tests scoped: `cd server/python && uv run pytest -q`. + +--- + +## File Structure + +- **Create** `server/python/src/metaobjects/codegen/project_config.py` — the config loader: `ConfigError`, `TargetConfig`, `ProjectConfig`, `load_project_config()`, `CONFIG_FILENAME`, `DEFAULT_METADATA_DIR`. One responsibility: YAML text → validated typed config, with path-resolution helpers. +- **Create** `server/python/src/metaobjects/codegen/metaobjects-config.schema.json` — the published JSON Schema (tooling/editor artifact; the loader validates in Python and does not read it at runtime). +- **Modify** `server/python/src/metaobjects/cli.py` — add config-mode helpers (`_find_config`, `_config_providers`, `_select_targets`, `_run_gen_targets`, `_cmd_gen_config`, `_verify_codegen_config`, `_verify_one_target`), branch `_cmd_gen` / `_verify_codegen` on config mode, guard `_verify_templates` against a missing `metadata_dir`, and extend `_build_parser` (`--config` + `--target` on `gen` and `verify`; `verify` positional → optional). +- **Create** `server/python/tests/codegen/test_project_config.py` — loader unit tests. +- **Create** `server/python/tests/codegen/test_cli_config_gen.py` — `gen` config-mode integration tests. +- **Create** `server/python/tests/codegen/test_cli_config_verify.py` — `verify --codegen` config-mode integration tests. +- **Modify** `server/python/src/metaobjects/codegen/KNOWN_GAPS.md` — mark the targets-registry gap closed. +- **Modify** `docs/features/cli.md` — document the Python declarative config. + +**Interfaces produced by Task 1 (every later task consumes these exact names):** + +```python +# metaobjects.codegen.project_config +CONFIG_FILENAME: str = "metaobjects.config.yaml" +DEFAULT_METADATA_DIR: str = "metaobjects" + +class ConfigError(ValueError): ... + +@dataclass(frozen=True) +class TargetConfig: + name: str + out_dir: str + generators: list[str] | None # stable registry names; None => default suite + entities: list[str] | None # allowlist; None => all entities + +@dataclass(frozen=True) +class ProjectConfig: + config_dir: Path + metadata: str + providers: list[str] + targets: list[TargetConfig] + def metadata_dir(self) -> str: ... # metadata resolved under config_dir (abs path) + def target(self, name: str) -> TargetConfig | None: ... + def out_dir_for(self, target: TargetConfig) -> str: ... # out_dir resolved under config_dir (abs path) + +def load_project_config(path: Path) -> ProjectConfig: ... # raises ConfigError +``` + +--- + +## Task 1: Config loader module + JSON Schema + +**Files:** +- Create: `server/python/src/metaobjects/codegen/project_config.py` +- Create: `server/python/src/metaobjects/codegen/metaobjects-config.schema.json` +- Test: `server/python/tests/codegen/test_project_config.py` + +**Interfaces:** +- Consumes: nothing (leaf module; `yaml` + stdlib only). +- Produces: the full public surface listed above. + +- [ ] **Step 1: Write the failing loader tests** + +Create `server/python/tests/codegen/test_project_config.py`: + +```python +"""#267 — declarative config loader unit tests.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.codegen.project_config import ( + CONFIG_FILENAME, + DEFAULT_METADATA_DIR, + ConfigError, + ProjectConfig, + TargetConfig, + load_project_config, +) + + +def _write(tmp_path: Path, text: str, name: str = CONFIG_FILENAME) -> Path: + p = tmp_path / name + p.write_text(text, encoding="utf-8") + return p + + +def test_minimal_targets_only_applies_defaults(tmp_path: Path) -> None: + p = _write( + tmp_path, + """ + targets: + models: + outDir: pkg/generated + """, + ) + cfg = load_project_config(p) + assert isinstance(cfg, ProjectConfig) + assert cfg.metadata == DEFAULT_METADATA_DIR + assert cfg.providers == [] + assert len(cfg.targets) == 1 + t = cfg.targets[0] + assert t == TargetConfig(name="models", out_dir="pkg/generated", generators=None, entities=None) + # metadata + outDir resolve relative to the config file's directory. + assert cfg.metadata_dir() == str((tmp_path / DEFAULT_METADATA_DIR).resolve()) + assert cfg.out_dir_for(t) == str((tmp_path / "pkg/generated").resolve()) + + +def test_full_config_parses(tmp_path: Path) -> None: + p = _write( + tmp_path, + """ + metadata: ./meta + providers: ["my_provider:provider", "other:make"] + targets: + models: + outDir: pkg/models/generated + generators: [entity] + entities: [Program, Week] + other: + outDir: pkg/other/generated + generators: [entity, routes] + """, + ) + cfg = load_project_config(p) + assert cfg.metadata == "./meta" + assert cfg.providers == ["my_provider:provider", "other:make"] + assert [t.name for t in cfg.targets] == ["models", "other"] # insertion order preserved + assert cfg.target("models").entities == ["Program", "Week"] + assert cfg.target("other").generators == ["entity", "routes"] + assert cfg.target("nope") is None + + +def test_absolute_paths_are_not_reparented(tmp_path: Path) -> None: + abs_out = tmp_path / "elsewhere" + p = _write( + tmp_path, + f""" + metadata: {abs_out} + targets: + t: + outDir: {abs_out} + """, + ) + cfg = load_project_config(p) + assert cfg.metadata_dir() == str(abs_out.resolve()) + assert cfg.out_dir_for(cfg.targets[0]) == str(abs_out.resolve()) + + +def test_missing_file_raises(tmp_path: Path) -> None: + with pytest.raises(ConfigError, match="not found"): + load_project_config(tmp_path / "nope.yaml") + + +@pytest.mark.parametrize( + "text, match", + [ + ("[]", "must be a mapping"), + ("metadata: 3\ntargets:\n t:\n outDir: x\n", "'metadata' must be a string"), + ("targets: {}\n", "non-empty mapping"), + ("providers: notalist\ntargets:\n t:\n outDir: x\n", "'providers'"), + ("targets:\n t: 5\n", "must be a mapping"), + ("targets:\n t:\n generators: [entity]\n", "'outDir'"), + ("targets:\n t:\n outDir: x\n generators: 3\n", "'generators'"), + ("targets:\n t:\n outDir: x\n entities: [1, 2]\n", "'entities'"), + (": : :\n", "invalid YAML"), + ("", "empty"), + ], +) +def test_invalid_config_raises_configerror(tmp_path: Path, text: str, match: str) -> None: + p = _write(tmp_path, text) + with pytest.raises(ConfigError, match=match): + load_project_config(p) + + +def test_schema_file_is_valid_json_and_matches_shape(tmp_path: Path) -> None: + schema_path = ( + Path(__file__).parents[2] + / "src" + / "metaobjects" + / "codegen" + / "metaobjects-config.schema.json" + ) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + assert schema["type"] == "object" + assert "targets" in schema["required"] + props = schema["properties"] + assert set(props) >= {"metadata", "providers", "targets"} + target_props = schema["properties"]["targets"]["additionalProperties"]["properties"] + assert set(target_props) == {"outDir", "generators", "entities"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd server/python && uv run pytest tests/codegen/test_project_config.py -q` +Expected: FAIL — `ModuleNotFoundError: metaobjects.codegen.project_config`. + +- [ ] **Step 3: Write the loader module** + +Create `server/python/src/metaobjects/codegen/project_config.py`: + +```python +"""Declarative project config for the Python codegen CLI (#267). + +`metaobjects.config.yaml` describes a project's codegen surface once — a targets +registry (per-target ``outDir`` + generator selection + entity allowlist), the +metadata dir, and consumer providers — so ``metaobjects gen`` / ``verify --codegen`` +run every target with no flags and provider modules resolve relative to the config +(no ``PYTHONPATH=``). YAML, not ``.py``: Python's config surface is pure data (the +provider CODE stays in its module, referenced by ``module:symbol`` per #158) — +unlike TS's executable ``metaobjects.config.ts`` (ADR-0034 owned generators / live +providers). + +Schema keys are IDENTICAL to the TS ``metaobjects.config.ts`` vocabulary +(``targets..{outDir, generators, entities}``, ``providers``, ``metadata``) so +a polyglot adopter learns one vocabulary; the file SURFACE differs per port, the +SCHEMA does not (ADR-0021 D3 / FR-025). A JSON Schema ships beside this loader +(``metaobjects-config.schema.json``) for editor autocomplete + non-Python +validation. + +Python-only, additive: no metamodel/vocabulary change; the existing positional +``metadata_dir`` + ``--out`` flag path is untouched and byte-identical. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml # type: ignore[import-untyped] # PyYAML ships no type stubs + +#: Config filename looked up in the cwd when ``--config`` is not given. +CONFIG_FILENAME = "metaobjects.config.yaml" + +#: Default metadata directory (relative to the config file) when ``metadata:`` is omitted. +DEFAULT_METADATA_DIR = "metaobjects" + + +class ConfigError(ValueError): + """A ``metaobjects.config.yaml`` that is missing, malformed, or invalid. + + Carries a single user-facing message (no stack trace) — the CLI prints it and + exits non-zero. + """ + + +@dataclass(frozen=True) +class TargetConfig: + """One named run-spec: where to write + which generators/entities to run. + + NOTE (cross-port reconciliation, design §Schema): TS ``targets`` are pure output + DESTINATIONS (a generator picks a target; ``TargetConfig`` carries no selection), + while a declarative-port target is a destination PLUS a selection (``generators`` + + ``entities``). The shared keys (``outDir``/``generators``/``entities``) stay + identical. + """ + + #: The target's map key (``targets.``). + name: str + #: Output directory, relative to the config file's directory (or absolute). + out_dir: str + #: Stable generator names (registry ids); ``None`` => the default suite. + generators: list[str] | None + #: Entity-name allowlist; ``None`` => every entity. + entities: list[str] | None + + +@dataclass(frozen=True) +class ProjectConfig: + #: Directory containing the config file — the base for resolving ``metadata``, + #: ``providers`` (sys.path), and each target's ``outDir``. + config_dir: Path + #: Metadata directory (relative to ``config_dir`` or absolute). + metadata: str + #: Consumer provider refs (``module:symbol``), resolved config-relative. + providers: list[str] + #: Ordered run-specs (YAML map insertion order preserved). + targets: list[TargetConfig] + + def metadata_dir(self) -> str: + """The metadata dir resolved against ``config_dir`` (absolute path string).""" + return _resolve_under(self.config_dir, self.metadata) + + def target(self, name: str) -> TargetConfig | None: + return next((t for t in self.targets if t.name == name), None) + + def out_dir_for(self, target: TargetConfig) -> str: + """``target.out_dir`` resolved against ``config_dir`` (absolute path string).""" + return _resolve_under(self.config_dir, target.out_dir) + + +def _resolve_under(base: Path, p: str) -> str: + q = Path(p) + return str(q if q.is_absolute() else (base / q).resolve()) + + +def _require_str_list(value: object, ctx: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(x, str) for x in value): + raise ConfigError(f"{ctx} must be a list of strings.") + return list(value) + + +def load_project_config(path: Path) -> ProjectConfig: + """Parse + validate ``metaobjects.config.yaml`` at ``path``. + + Raises :class:`ConfigError` (single user-facing message) on a missing file, + invalid YAML, or any shape violation. Defaults: ``metadata`` => + ``DEFAULT_METADATA_DIR``, ``providers`` => ``[]``, per-target ``generators`` / + ``entities`` => ``None`` (default suite / all entities). + """ + if not path.is_file(): + raise ConfigError(f"config file not found: {path}") + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ConfigError(f"{path}: invalid YAML: {exc}") from exc + + if raw is None: + raise ConfigError(f"{path}: config is empty.") + if not isinstance(raw, dict): + raise ConfigError(f"{path}: top level must be a mapping.") + + metadata = raw.get("metadata", DEFAULT_METADATA_DIR) + if not isinstance(metadata, str): + raise ConfigError(f"{path}: 'metadata' must be a string (a directory path).") + + providers = _require_str_list(raw.get("providers", []), f"{path}: 'providers'") + + targets_raw = raw.get("targets") + if not isinstance(targets_raw, dict) or not targets_raw: + raise ConfigError( + f"{path}: 'targets' must be a non-empty mapping of " + " -> {outDir, generators?, entities?}." + ) + + targets: list[TargetConfig] = [] + for name, spec in targets_raw.items(): + ctx = f"{path}: target '{name}'" + if not isinstance(spec, dict): + raise ConfigError(f"{ctx} must be a mapping.") + out_dir = spec.get("outDir") + if not isinstance(out_dir, str) or not out_dir: + raise ConfigError(f"{ctx} must declare a non-empty 'outDir' string.") + generators = spec.get("generators") + if generators is not None: + generators = _require_str_list(generators, f"{ctx} 'generators'") + entities = spec.get("entities") + if entities is not None: + entities = _require_str_list(entities, f"{ctx} 'entities'") + targets.append( + TargetConfig(name=str(name), out_dir=out_dir, generators=generators, entities=entities) + ) + + return ProjectConfig( + config_dir=path.parent.resolve(), + metadata=metadata, + providers=providers, + targets=targets, + ) +``` + +- [ ] **Step 4: Write the JSON Schema** + +Create `server/python/src/metaobjects/codegen/metaobjects-config.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://metaobjects.dev/schemas/metaobjects-config.schema.json", + "title": "MetaObjects Python codegen config", + "description": "Declarative config for the `metaobjects` Python CLI (#267). Schema keys mirror the TS metaobjects.config.ts vocabulary (outDir/generators/entities/providers/metadata).", + "type": "object", + "additionalProperties": false, + "required": ["targets"], + "properties": { + "metadata": { + "type": "string", + "description": "Metadata directory, relative to this config file. Default: metaobjects", + "default": "metaobjects" + }, + "providers": { + "type": "array", + "items": { "type": "string", "pattern": "^[^:]+:[^:]+$" }, + "description": "Consumer provider refs as 'module:symbol', resolved relative to this config file's directory (no PYTHONPATH needed)." + }, + "targets": { + "type": "object", + "minProperties": 1, + "description": "Named run-specs; each key is a target name.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["outDir"], + "properties": { + "outDir": { + "type": "string", + "description": "Output directory, relative to this config file." + }, + "generators": { + "type": "array", + "items": { "type": "string" }, + "description": "Stable generator names (see `metaobjects gen --list`). Omit to run the default suite." + }, + "entities": { + "type": "array", + "items": { "type": "string" }, + "description": "Entity-name allowlist. Omit to emit every entity." + } + } + } + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd server/python && uv run pytest tests/codegen/test_project_config.py -q` +Expected: PASS (all cases). + +- [ ] **Step 6: Commit** + +```bash +git add server/python/src/metaobjects/codegen/project_config.py \ + server/python/src/metaobjects/codegen/metaobjects-config.schema.json \ + server/python/tests/codegen/test_project_config.py +git commit -m "feat(#267): declarative config loader + JSON Schema (Python)" +``` + +--- + +## Task 2: `gen` config mode (all-targets, `--target`, dup guard, config-relative providers) + +**Files:** +- Modify: `server/python/src/metaobjects/cli.py` +- Test: `server/python/tests/codegen/test_cli_config_gen.py` + +**Interfaces:** +- Consumes: `project_config.{CONFIG_FILENAME, ConfigError, ProjectConfig, TargetConfig, load_project_config}`; existing `cli._run_suite`, `cli._resolve_generators`, `cli._resolve_providers`, `cli._load_root`. +- Produces: `cli._find_config`, `cli._config_providers`, `cli._select_targets`, `cli._run_gen_targets`, `cli._cmd_gen_config`; `gen` gains `--config` / `--target`; `_cmd_gen` branches on config mode. + +- [ ] **Step 1: Write the failing gen-config integration tests** + +Create `server/python/tests/codegen/test_cli_config_gen.py`: + +```python +"""#267 — `metaobjects gen` declarative-config mode (no-arg, targets registry).""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from metaobjects.cli import main + +FITNESS = ( + Path(__file__).parents[4] + / "fixtures" + / "persistence-conformance" + / "canonical" + / "meta.fitness.json" +) + + +def _project(tmp_path: Path, config_text: str, meta_subdir: str = "metaobjects") -> Path: + """Write a config + a metadata dir (fitness fixture) under tmp_path. Return the config path.""" + meta = tmp_path / meta_subdir + meta.mkdir(parents=True) + (meta / "meta.fitness.json").write_text(FITNESS.read_text()) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text(config_text) + return cfg + + +TWO_TARGETS = """ +targets: + models: + outDir: gen/models + generators: [entity] + entities: [Program, Week] + other: + outDir: gen/other + generators: [entity] + entities: [Node, Measurement] +""" + + +def test_gen_no_args_runs_all_targets_via_config_flag(tmp_path: Path) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg)]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + assert (tmp_path / "gen/models/Week.py").exists() + assert (tmp_path / "gen/other/Node.py").exists() + assert (tmp_path / "gen/other/Measurement.py").exists() + # allowlists honored per target + assert not (tmp_path / "gen/models/Node.py").exists() + assert not (tmp_path / "gen/other/Program.py").exists() + + +def test_gen_no_args_discovers_config_in_cwd(tmp_path: Path, monkeypatch) -> None: + _project(tmp_path, TWO_TARGETS) + monkeypatch.chdir(tmp_path) + rc = main(["gen"]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + + +def test_gen_target_scopes_to_one(tmp_path: Path) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg), "--target", "models"]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + assert not (tmp_path / "gen/other").exists() + + +def test_gen_unknown_target_errors(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg), "--target", "nope"]) + assert rc == 1 + assert "unknown --target" in capsys.readouterr().err + + +def test_gen_missing_config_errors(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) # no config here + rc = main(["gen"]) + assert rc == 2 + assert "metaobjects.config.yaml" in capsys.readouterr().err + + +DUP_TARGETS = """ +targets: + a: + outDir: shared/gen + generators: [entity] + entities: [Program] + b: + outDir: shared/gen + generators: [entity] + entities: [Program] +""" + + +def test_gen_cross_target_duplicate_output_path_guard(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path, DUP_TARGETS) + rc = main(["gen", "--config", str(cfg)]) + assert rc == 1 + assert "duplicate output path across targets" in capsys.readouterr().err + + +# --- config-relative provider (no PYTHONPATH) ------------------------------- + +PROVIDER_MODULE = ''' +from metaobjects.provider import Provider +from metaobjects.registry import TypeDefinition +from metaobjects.meta.meta_data import MetaData + +geo_provider = Provider("test-geocheck", ("metaobjects-core-types",)) +geo_provider.add(TypeDefinition( + type="validator", + sub_type="geocheck", + factory=lambda t, s, n: MetaData(t, s, n), + description="A custom validator", +)) +''' + +CUSTOM_META = { + "metadata.root": { + "package": "acme::geo", + "children": [ + { + "object.entity": { + "name": "Place", + "children": [ + {"field.long": {"name": "id"}}, + { + "field.string": { + "name": "name", + "children": [{"validator.geocheck": {"name": "chk"}}], + } + }, + {"source.rdb": {"name": "src", "@table": "places"}}, + { + "identity.primary": { + "name": "pk", + "@fields": ["id"], + "@generation": "increment", + } + }, + ], + } + } + ], + } +} + + +def test_gen_resolves_provider_config_relative_without_pythonpath(tmp_path: Path) -> None: + """A provider module beside the config resolves via the config dir on sys.path, + with NO caller PYTHONPATH / sys.path manipulation.""" + meta = tmp_path / "metaobjects_meta" + meta.mkdir() + (meta / "meta.json").write_text(json.dumps(CUSTOM_META)) + (tmp_path / "geo_conf_prov.py").write_text(PROVIDER_MODULE) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text( + """ + metadata: metaobjects_meta + providers: ["geo_conf_prov:geo_provider"] + targets: + models: + outDir: gen + generators: [entity] + """ + ) + assert str(tmp_path) not in sys.path # precondition: not already importable + try: + rc = main(["gen", "--config", str(cfg)]) + finally: + if str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + sys.modules.pop("geo_conf_prov", None) + assert rc == 0 + assert (tmp_path / "gen/Place.py").exists() + + +def test_gen_flag_path_ignores_config_when_present(tmp_path: Path) -> None: + """Back-compat: an explicit + --out uses the flag path and does + NOT consult a metaobjects.config.yaml sitting in cwd (byte-identical).""" + _project(tmp_path, DUP_TARGETS) # a config that WOULD fail (dup guard) if consulted + out = tmp_path / "flagout" + meta = tmp_path / "metaobjects" # created by _project + # Flag path: metadata_dir + --out present => config ignored, normal gen. + rc = main(["gen", str(meta), "--out", str(out)]) + assert rc == 0 + assert (out / "Program.py").exists() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli_config_gen.py -q` +Expected: FAIL — `gen` with no positional currently errors exit 2 ("gen requires and --out"), and `--config`/`--target` are unknown args. + +- [ ] **Step 3: Add the imports + config helpers to `cli.py`** + +Add to the imports near `from metaobjects.codegen.config import GenConfig` (line ~54): + +```python +from metaobjects.codegen.project_config import ( + CONFIG_FILENAME, + ConfigError, + ProjectConfig, + TargetConfig, + load_project_config, +) +``` + +Add these helpers (place them just after `_providers_from_args`, ~line 181): + +```python +def _find_config(args: argparse.Namespace) -> Path | None: + """The config path: ``--config`` if given (even if missing → clear load error), + else ``./metaobjects.config.yaml`` in cwd when it exists, else ``None``.""" + explicit = getattr(args, "config", None) + if explicit: + return Path(explicit) + default = Path.cwd() / CONFIG_FILENAME + return default if default.is_file() else None + + +def _config_providers(config: ProjectConfig) -> tuple[list[object], bool]: + """Resolve ``config.providers`` with the config file's directory on ``sys.path``. + + #267: prepend the config directory so a ``module:symbol`` provider living beside + the config imports with no ``PYTHONPATH=``. Idempotent; the entry is left in + place (a short-lived CLI process). Prints resolution errors; returns (providers, ok). + """ + config_dir = str(config.config_dir) + if config_dir not in sys.path: + sys.path.insert(0, config_dir) + providers, errors = _resolve_providers(config.providers) + if errors: + print("error: invalid provider in config:", file=sys.stderr) + for msg in errors: + print(f" {msg}", file=sys.stderr) + return providers, False + return providers, True + + +def _select_targets( + config: ProjectConfig, target_name: str | None +) -> tuple[list[TargetConfig], str | None]: + """All targets, or the single ``--target`` (error string when the name is unknown).""" + if target_name is None: + return config.targets, None + t = config.target(target_name) + if t is None: + known = ", ".join(sorted(x.name for x in config.targets)) + return [], f"unknown --target {target_name!r}; known targets: {known}" + return [t], None +``` + +- [ ] **Step 4: Add `_run_gen_targets` + `_cmd_gen_config`** + +Place after `_cmd_gen` (or just before it). `_run_gen_targets` carries the cross-target guard: + +```python +def _run_gen_targets( + config: ProjectConfig, targets: list[TargetConfig], root: MetaData +) -> tuple[list[str], list[str]]: + """Run each target's suite into its ``outDir``. Returns (all_written, errors). + + Cross-target duplicate-output-path guard (#267): ``run_gen``'s collision guard + is per-pass, so two targets writing the same full path would silently clobber + (generated files carry the @generated header). We accumulate every written full + path across targets and record an error when two targets emit the same one. + (Detection is post-write — the colliding file may already be on disk — but the + command still fails, so a misconfigured gate is caught in CI.) + """ + all_written: list[str] = [] + seen: dict[str, str] = {} # full path -> target name + errors: list[str] = [] + for t in targets: + gens: list[Generator] | None = None + if t.generators is not None: + gens, gen_errors = _resolve_generators(",".join(t.generators)) + if gen_errors: + errors.extend(f"target '{t.name}': {m}" for m in gen_errors) + continue + out_dir = config.out_dir_for(t) + try: + written = _run_suite(root, out_dir, gens, t.entities) + except ValueError as exc: # intra-target run_gen collision → clean error + errors.append(f"target '{t.name}': {exc}") + continue + for full in written: + prior = seen.get(full) + if prior is not None and prior != t.name: + errors.append( + f"duplicate output path across targets: {full!r} written by " + f"both '{prior}' and '{t.name}'." + ) + else: + seen[full] = t.name + all_written.extend(written) + return all_written, errors + + +def _cmd_gen_config(args: argparse.Namespace) -> int: + """``gen`` with no positional ``metadata_dir`` → declarative-config mode (#267). + + Load ``metaobjects.config.yaml``, load metadata ONCE, and run every target + (or ``--target``) into its own ``outDir`` with a cross-target duplicate-path + guard. Providers resolve relative to the config file (no ``PYTHONPATH=``). + """ + config_path = _find_config(args) + if config_path is None: + print( + "error: no given and no metaobjects.config.yaml found. " + "Either pass --out (flag mode) or create a " + "metaobjects.config.yaml (or pass --config ).", + file=sys.stderr, + ) + return 2 + try: + config = load_project_config(config_path) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + targets, target_err = _select_targets(config, getattr(args, "target", None)) + if target_err: + print(f"error: {target_err}", file=sys.stderr) + return 1 + + providers, providers_ok = _config_providers(config) + if not providers_ok: + return 1 + + root, load_errors = _load_root(config.metadata_dir(), providers=providers) + if root is None: + print("error: failed to load metadata:", file=sys.stderr) + for msg in load_errors: + print(f" {msg}", file=sys.stderr) + return 1 + + written, errors = _run_gen_targets(config, targets, root) + if errors: + for msg in errors: + print(f"error: {msg}", file=sys.stderr) + return 1 + for path in written: + print(path) + print( + f"metaobjects gen: wrote {len(written)} file(s) across {len(targets)} target(s)." + ) + return 0 +``` + +- [ ] **Step 5: Branch `_cmd_gen` on config mode** + +In `_cmd_gen`, replace the early metadata/out guard. Current (lines ~423-430): + +```python + _warn_if_agent_context_stale() + + if args.metadata_dir is None or args.out is None: + print( + "error: gen requires and --out (or use --list).", + file=sys.stderr, + ) + return 2 +``` + +becomes: + +```python + _warn_if_agent_context_stale() + + # #267: config mode ⇔ no positional . The explicit + # + --out flag path below is untouched (byte-identical). + if args.metadata_dir is None: + return _cmd_gen_config(args) + if args.out is None: + print( + "error: gen requires and --out " + "(or a metaobjects.config.yaml / --list).", + file=sys.stderr, + ) + return 2 +``` + +- [ ] **Step 6: Add `--config` / `--target` to the `gen` parser** + +In `_build_parser`, after the `gen.add_argument("--provider", ...)` block (~line 857), add: + +```python + gen.add_argument( + "--config", + default=None, + help=( + "path to a metaobjects.config.yaml (declarative targets registry). " + "Config mode runs when no is given; default lookup is " + "./metaobjects.config.yaml in cwd." + ), + ) + gen.add_argument( + "--target", + default=None, + help="run only this named target from the config (default: every target)", + ) +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli_config_gen.py -q` +Expected: PASS (all cases, including the config-relative provider and the back-compat flag path). + +- [ ] **Step 8: Run the existing CLI suite (no back-compat regressions)** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli.py tests/codegen/test_cli_providers.py tests/codegen/test_cli_registry.py -q` +Expected: PASS (unchanged behavior). + +- [ ] **Step 9: Commit** + +```bash +git add server/python/src/metaobjects/cli.py \ + server/python/tests/codegen/test_cli_config_gen.py +git commit -m "feat(#267): gen config mode — all-targets, --target, cross-target dup guard, config-relative providers" +``` + +--- + +## Task 3: `verify --codegen` config mode (per-target regen+diff, aggregate exit) + +**Files:** +- Modify: `server/python/src/metaobjects/cli.py` +- Test: `server/python/tests/codegen/test_cli_config_verify.py` + +**Interfaces:** +- Consumes: Task 1 loader + Task 2 helpers (`_find_config`, `_config_providers`, `_select_targets`); existing `cli._run_suite`, `cli._relative_set`, `cli._resolve_generators`, `cli._load_root`, `cli._strict_load_hint`. +- Produces: `cli._verify_codegen_config`, `cli._verify_one_target`; `_verify_codegen` branches on config mode; `verify` positional becomes optional; `verify` gains `--config` / `--target`; `_verify_templates` guards a missing `metadata_dir`. + +- [ ] **Step 1: Write the failing verify-config integration tests** + +Create `server/python/tests/codegen/test_cli_config_verify.py`: + +```python +"""#267 — `metaobjects verify --codegen` declarative-config mode (per-target diff).""" +from __future__ import annotations + +from pathlib import Path + +from metaobjects.cli import main + +FITNESS = ( + Path(__file__).parents[4] + / "fixtures" + / "persistence-conformance" + / "canonical" + / "meta.fitness.json" +) + +TWO_TARGETS = """ +targets: + models: + outDir: gen/models + generators: [entity] + entities: [Program, Week] + other: + outDir: gen/other + generators: [entity] + entities: [Node, Measurement] +""" + + +def _project(tmp_path: Path) -> Path: + meta = tmp_path / "metaobjects" + meta.mkdir() + (meta / "meta.fitness.json").write_text(FITNESS.read_text()) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text(TWO_TARGETS) + return cfg + + +def test_verify_codegen_no_args_in_sync(tmp_path: Path) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + # Fresh gen → no drift across every target. + assert main(["verify", "--codegen", "--config", str(cfg)]) == 0 + + +def test_verify_codegen_bare_defaults_to_codegen(tmp_path: Path, monkeypatch) -> None: + cfg = _project(tmp_path) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + assert main(["verify"]) == 0 # bare verify → --codegen default, config-driven + + +def test_verify_codegen_detects_drift_in_one_target(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + target = tmp_path / "gen/other/Node.py" + target.write_text(target.read_text() + "\n# hand-edited drift\n") + rc = main(["verify", "--codegen", "--config", str(cfg)]) + assert rc == 1 + err = capsys.readouterr().err + assert "[other]" in err and "drifted" in err + + +def test_verify_codegen_target_scopes(tmp_path: Path) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + # Drift in `other`, but scope verify to `models` → clean. + target = tmp_path / "gen/other/Node.py" + target.write_text(target.read_text() + "\n# drift\n") + assert main(["verify", "--codegen", "--config", str(cfg), "--target", "models"]) == 0 + assert main(["verify", "--codegen", "--config", str(cfg), "--target", "other"]) == 1 + + +def test_verify_flag_path_still_works_with_config_present(tmp_path: Path) -> None: + """Back-compat: legacy `verify --out` diff is unchanged when a config exists.""" + cfg = _project(tmp_path) + meta = tmp_path / "metaobjects" + out = tmp_path / "flagout" + assert main(["gen", str(meta), "--out", str(out)]) == 0 + assert main(["verify", str(meta), "--out", str(out)]) == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli_config_verify.py -q` +Expected: FAIL — `verify` currently requires the positional `metadata_dir`; `--config`/`--target` are unknown. + +- [ ] **Step 3: Add `_verify_one_target` + `_verify_codegen_config`** + +Place just after `_verify_codegen` (~line 573): + +```python +def _verify_one_target( + config: ProjectConfig, target: TargetConfig, root: MetaData +) -> int: + """Regenerate one target to a temp dir + diff against its committed ``outDir``. + + Mirrors :func:`_verify_codegen`'s diff, labeled per target. Returns 0 (in sync) + or 1 (drift / bad generator name).""" + gens: list[Generator] | None = None + if target.generators is not None: + gens, gen_errors = _resolve_generators(",".join(target.generators)) + if gen_errors: + for m in gen_errors: + print(f"error: target '{target.name}': {m}", file=sys.stderr) + return 1 + + out_dir = Path(config.out_dir_for(target)) + with tempfile.TemporaryDirectory() as tmp: + _run_suite(root, tmp, gens, target.entities) + expected = _relative_set(Path(tmp)) + committed = _relative_set(out_dir) + + changed = sorted(k for k in expected if k in committed and expected[k] != committed[k]) + missing = sorted(k for k in expected if k not in committed) + extra = sorted(k for k in committed if k not in expected) + + if not changed and not missing and not extra: + print(f"metaobjects verify [{target.name}]: in sync ({len(expected)} file(s)).") + return 0 + + print( + f"error: [{target.name}] generated code is out of sync with metadata.", + file=sys.stderr, + ) + for k in changed: + print(f" drifted: {k}", file=sys.stderr) + for k in missing: + print(f" missing: {k}", file=sys.stderr) + for k in extra: + print(f" extra: {k}", file=sys.stderr) + print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) + return 1 + + +def _verify_codegen_config(args: argparse.Namespace) -> int: + """``verify --codegen`` with no positional ``metadata_dir`` → config mode (#267). + + Load the config + metadata ONCE, then regen+diff each target (or ``--target``) + against its committed ``outDir``, aggregating the exit code (non-zero if ANY + target drifts). Strict-by-default (ADR-0023) unless ``--lax``. + """ + config_path = _find_config(args) + if config_path is None: + print( + "error: verify --codegen with no requires a " + "metaobjects.config.yaml (or --config ).", + file=sys.stderr, + ) + return 2 + try: + config = load_project_config(config_path) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + targets, target_err = _select_targets(config, getattr(args, "target", None)) + if target_err: + print(f"error: {target_err}", file=sys.stderr) + return 1 + + strict = not getattr(args, "lax", False) + providers, providers_ok = _config_providers(config) + if not providers_ok: + return 1 + + root, load_errors = _load_root(config.metadata_dir(), strict=strict, providers=providers) + if root is None: + print("error: failed to load metadata:", file=sys.stderr) + for msg in load_errors: + print(f" {msg}", file=sys.stderr) + if strict and any("ERR_UNKNOWN_ATTR" in m for m in load_errors): + print(_strict_load_hint(), file=sys.stderr) + return 1 + + exit_code = 0 + for t in targets: + exit_code = max(exit_code, _verify_one_target(config, t, root)) + return exit_code +``` + +- [ ] **Step 4: Branch `_verify_codegen` on config mode** + +At the top of `_verify_codegen` (~line 519), before the `if args.out is None:` guard, add: + +```python + # #267: config mode ⇔ no positional . The legacy + # + --out diff below is untouched. + if args.metadata_dir is None: + return _verify_codegen_config(args) +``` + +- [ ] **Step 5: Guard `_verify_templates` against a missing `metadata_dir`** + +`--templates` / `--db` are not config-driven. At the top of `_verify_templates` (~line 610, before the `templates_root` check), add: + +```python + if args.metadata_dir is None: + print( + "error: verify --templates requires (it is not " + "config-driven; the config's targets registry drives --codegen only).", + file=sys.stderr, + ) + return 2 +``` + +- [ ] **Step 6: Make the `verify` positional optional + add `--config` / `--target`** + +In `_build_parser`, change the `verify` positional (~line 905): + +```python + verify.add_argument("metadata_dir", help="directory of metadata JSON/YAML files") +``` + +to: + +```python + verify.add_argument( + "metadata_dir", + nargs="?", + default=None, + help=( + "directory of metadata JSON/YAML files. Omit to use a " + "metaobjects.config.yaml (--codegen config mode, #267)." + ), + ) +``` + +and after the `verify.add_argument("--provider", ...)` block (~line 955) add: + +```python + verify.add_argument( + "--config", + default=None, + help=( + "path to a metaobjects.config.yaml. Config mode runs when no " + " is given; default ./metaobjects.config.yaml in cwd. " + "Drives --codegen per-target regen+diff." + ), + ) + verify.add_argument( + "--target", + default=None, + help="verify only this named target from the config (default: every target)", + ) +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli_config_verify.py -q` +Expected: PASS (all cases). + +- [ ] **Step 8: Run the full CLI + verify suites (no regressions)** + +Run: `cd server/python && uv run pytest tests/codegen/test_cli.py tests/codegen/test_cli_verify_subverbs.py tests/codegen/test_cli_verify_strict.py tests/codegen/test_cli_providers.py -q` +Expected: PASS (bare-verify default, subverbs, strict, providers all unchanged). + +- [ ] **Step 9: Commit** + +```bash +git add server/python/src/metaobjects/cli.py \ + server/python/tests/codegen/test_cli_config_verify.py +git commit -m "feat(#267): verify --codegen config mode — per-target regen+diff, aggregate exit" +``` + +--- + +## Task 4: Docs — close the KNOWN_GAPS note + document the config + +**Files:** +- Modify: `server/python/src/metaobjects/codegen/KNOWN_GAPS.md` +- Modify: `docs/features/cli.md` + +**Interfaces:** +- Consumes: the shipped behavior from Tasks 1–3. +- Produces: doc parity (no code). + +- [ ] **Step 1: Update `KNOWN_GAPS.md`** + +The "When it ships" note at the bottom of `KNOWN_GAPS.md` references the missing targets registry. Update it to record that the per-target output directory / targets registry now exists via `metaobjects.config.yaml` (#267), while noting the router→entity-model import wiring remains the separate deferred piece. Replace the final paragraph (lines ~50-53) with: + +```markdown +**When it ships:** the Python codegen now has a per-target output-directory +targets registry via the declarative `metaobjects.config.yaml` (#267 — mirrors +the TS `targets` registry). The remaining deferred piece is the router→entity-model +import wiring itself (`from ._entity import `), which still needs the +path-resolution/import-base machinery; the config's per-target `outDir` is the +prerequisite that is now in place. +``` + +- [ ] **Step 2: Document the config in `docs/features/cli.md`** + +Find the Python CLI section in `docs/features/cli.md` (grep `metaobjects gen`) and add a short "Declarative config (`metaobjects.config.yaml`)" subsection: the schema (`metadata` / `providers` / `targets..{outDir, generators, entities}`), the no-arg `metaobjects gen` / `metaobjects verify --codegen` behavior, `--config` / `--target`, config-relative provider resolution (no `PYTHONPATH=`), and the back-compat rule (a positional `` + `--out` keeps the flag path). State the schema keys are identical to `metaobjects.config.ts` and point to `server/python/src/metaobjects/codegen/metaobjects-config.schema.json`. Keep it generic (public repo — no private names/paths). + +- [ ] **Step 3: Verify docs render / no leaks** + +Run: `git diff --staged -U0 -- docs/features/cli.md server/python/src/metaobjects/codegen/KNOWN_GAPS.md | grep -nE "/home/|/Users/" || echo clean` +Expected: `clean`. + +- [ ] **Step 4: Commit** + +```bash +git add server/python/src/metaobjects/codegen/KNOWN_GAPS.md docs/features/cli.md +git commit -m "docs(#267): document the Python declarative config; close the targets-registry KNOWN_GAP" +``` + +--- + +## Final verification (after all tasks) + +- [ ] Full scoped Python suite: `cd server/python && uv run pytest -q` — all green. +- [ ] Whole-branch review (subagent-driven-development final stage): code-reviewer + code-simplifier over the branch diff; fix findings in place (no follow-up tickets). +- [ ] no-mistakes gate with a rich `--intent` (ensure `.serena/` + `.worktrees/` are in `.git/info/exclude`). +- [ ] PR `Closes #267`; Doug merges. + +--- + +## Self-Review (author checklist — completed at plan-writing time) + +**Spec coverage:** +- Problem (flags-only, stale CI lists, `PYTHONPATH=`) → Tasks 2/3 config mode + config-relative providers. ✅ +- Decision (YAML not `.py`; TS-identical schema keys; JSON Schema) → Task 1 (`project_config.py` + `metaobjects-config.schema.json`). ✅ +- Schema (`metadata`/`providers`/`targets..{outDir,generators,entities}`; `outDir` not `out`) → Task 1 dataclasses + validation + schema JSON. ✅ +- Behavior: provider config-relative sys.path → Task 2 `_config_providers`. ✅ No-arg `gen` all targets + cross-target dup guard + `--target` → Task 2. ✅ No-arg `verify --codegen` per-target regen+diff aggregate + `--target` → Task 3. ✅ Config lookup `--config` else `./metaobjects.config.yaml` → Task 2/3 `_find_config`. ✅ Back-compat positional+`--out` byte-identical → Tasks 2/3 branch on `metadata_dir is None`, tested with a config present. ✅ +- Non-goals (no `.py` config, no single cross-port file, no vocabulary/registry change, no coordinated release) → respected; no fixtures touched. ✅ +- Testing section items → each has a test in Tasks 1–3 (loader units, no-arg gen/verify, dup guard, provider-without-PYTHONPATH, `--target` scoping, back-compat). ✅ + +**Placeholder scan:** No TBD/TODO; every code step has full content. ✅ + +**Type consistency:** `TargetConfig` / `ProjectConfig` field + method names (`out_dir`, `generators`, `entities`, `metadata_dir()`, `out_dir_for()`, `target()`) are used identically across Tasks 1–3; helper names (`_find_config`, `_config_providers`, `_select_targets`, `_run_gen_targets`, `_verify_one_target`, `_verify_codegen_config`, `_cmd_gen_config`) are consistent. `_resolve_generators` takes a comma-string (joined from the list). ✅ From f3c2d47d1430cfcdccb7849688b2104ed3a6812f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:12:43 -0400 Subject: [PATCH 3/9] feat(#267): declarative config loader + JSON Schema (Python) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- .../codegen/metaobjects-config.schema.json | 47 ++++++ .../src/metaobjects/codegen/project_config.py | 156 ++++++++++++++++++ .../tests/codegen/test_project_config.py | 127 ++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 server/python/src/metaobjects/codegen/metaobjects-config.schema.json create mode 100644 server/python/src/metaobjects/codegen/project_config.py create mode 100644 server/python/tests/codegen/test_project_config.py diff --git a/server/python/src/metaobjects/codegen/metaobjects-config.schema.json b/server/python/src/metaobjects/codegen/metaobjects-config.schema.json new file mode 100644 index 000000000..ab91a952d --- /dev/null +++ b/server/python/src/metaobjects/codegen/metaobjects-config.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://metaobjects.dev/schemas/metaobjects-config.schema.json", + "title": "MetaObjects Python codegen config", + "description": "Declarative config for the `metaobjects` Python CLI (#267). Schema keys mirror the TS metaobjects.config.ts vocabulary (outDir/generators/entities/providers/metadata).", + "type": "object", + "additionalProperties": false, + "required": ["targets"], + "properties": { + "metadata": { + "type": "string", + "description": "Metadata directory, relative to this config file. Default: metaobjects", + "default": "metaobjects" + }, + "providers": { + "type": "array", + "items": { "type": "string", "pattern": "^[^:]+:[^:]+$" }, + "description": "Consumer provider refs as 'module:symbol', resolved relative to this config file's directory (no PYTHONPATH needed)." + }, + "targets": { + "type": "object", + "minProperties": 1, + "description": "Named run-specs; each key is a target name.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["outDir"], + "properties": { + "outDir": { + "type": "string", + "description": "Output directory, relative to this config file." + }, + "generators": { + "type": "array", + "items": { "type": "string" }, + "description": "Stable generator names (see `metaobjects gen --list`). Omit to run the default suite." + }, + "entities": { + "type": "array", + "items": { "type": "string" }, + "description": "Entity-name allowlist. Omit to emit every entity." + } + } + } + } + } +} diff --git a/server/python/src/metaobjects/codegen/project_config.py b/server/python/src/metaobjects/codegen/project_config.py new file mode 100644 index 000000000..0f67543a8 --- /dev/null +++ b/server/python/src/metaobjects/codegen/project_config.py @@ -0,0 +1,156 @@ +"""Declarative project config for the Python codegen CLI (#267). + +`metaobjects.config.yaml` describes a project's codegen surface once — a targets +registry (per-target ``outDir`` + generator selection + entity allowlist), the +metadata dir, and consumer providers — so ``metaobjects gen`` / ``verify --codegen`` +run every target with no flags and provider modules resolve relative to the config +(no ``PYTHONPATH=``). YAML, not ``.py``: Python's config surface is pure data (the +provider CODE stays in its module, referenced by ``module:symbol`` per #158) — +unlike TS's executable ``metaobjects.config.ts`` (ADR-0034 owned generators / live +providers). + +Schema keys are IDENTICAL to the TS ``metaobjects.config.ts`` vocabulary +(``targets..{outDir, generators, entities}``, ``providers``, ``metadata``) so +a polyglot adopter learns one vocabulary; the file SURFACE differs per port, the +SCHEMA does not (ADR-0021 D3 / FR-025). A JSON Schema ships beside this loader +(``metaobjects-config.schema.json``) for editor autocomplete + non-Python +validation. + +Python-only, additive: no metamodel/vocabulary change; the existing positional +``metadata_dir`` + ``--out`` flag path is untouched and byte-identical. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml # type: ignore[import-untyped] # PyYAML ships no type stubs + +#: Config filename looked up in the cwd when ``--config`` is not given. +CONFIG_FILENAME = "metaobjects.config.yaml" + +#: Default metadata directory (relative to the config file) when ``metadata:`` is omitted. +DEFAULT_METADATA_DIR = "metaobjects" + + +class ConfigError(ValueError): + """A ``metaobjects.config.yaml`` that is missing, malformed, or invalid. + + Carries a single user-facing message (no stack trace) — the CLI prints it and + exits non-zero. + """ + + +@dataclass(frozen=True) +class TargetConfig: + """One named run-spec: where to write + which generators/entities to run. + + NOTE (cross-port reconciliation, design §Schema): TS ``targets`` are pure output + DESTINATIONS (a generator picks a target; ``TargetConfig`` carries no selection), + while a declarative-port target is a destination PLUS a selection (``generators`` + + ``entities``). The shared keys (``outDir``/``generators``/``entities``) stay + identical. + """ + + #: The target's map key (``targets.``). + name: str + #: Output directory, relative to the config file's directory (or absolute). + out_dir: str + #: Stable generator names (registry ids); ``None`` => the default suite. + generators: list[str] | None + #: Entity-name allowlist; ``None`` => every entity. + entities: list[str] | None + + +@dataclass(frozen=True) +class ProjectConfig: + #: Directory containing the config file — the base for resolving ``metadata``, + #: ``providers`` (sys.path), and each target's ``outDir``. + config_dir: Path + #: Metadata directory (relative to ``config_dir`` or absolute). + metadata: str + #: Consumer provider refs (``module:symbol``), resolved config-relative. + providers: list[str] + #: Ordered run-specs (YAML map insertion order preserved). + targets: list[TargetConfig] + + def metadata_dir(self) -> str: + """The metadata dir resolved against ``config_dir`` (absolute path string).""" + return _resolve_under(self.config_dir, self.metadata) + + def target(self, name: str) -> TargetConfig | None: + return next((t for t in self.targets if t.name == name), None) + + def out_dir_for(self, target: TargetConfig) -> str: + """``target.out_dir`` resolved against ``config_dir`` (absolute path string).""" + return _resolve_under(self.config_dir, target.out_dir) + + +def _resolve_under(base: Path, p: str) -> str: + q = Path(p) + return str(q if q.is_absolute() else (base / q).resolve()) + + +def _require_str_list(value: object, ctx: str) -> list[str]: + if not isinstance(value, list) or not all(isinstance(x, str) for x in value): + raise ConfigError(f"{ctx} must be a list of strings.") + return list(value) + + +def load_project_config(path: Path) -> ProjectConfig: + """Parse + validate ``metaobjects.config.yaml`` at ``path``. + + Raises :class:`ConfigError` (single user-facing message) on a missing file, + invalid YAML, or any shape violation. Defaults: ``metadata`` => + ``DEFAULT_METADATA_DIR``, ``providers`` => ``[]``, per-target ``generators`` / + ``entities`` => ``None`` (default suite / all entities). + """ + if not path.is_file(): + raise ConfigError(f"config file not found: {path}") + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ConfigError(f"{path}: invalid YAML: {exc}") from exc + + if raw is None: + raise ConfigError(f"{path}: config is empty.") + if not isinstance(raw, dict): + raise ConfigError(f"{path}: top level must be a mapping.") + + metadata = raw.get("metadata", DEFAULT_METADATA_DIR) + if not isinstance(metadata, str): + raise ConfigError(f"{path}: 'metadata' must be a string (a directory path).") + + providers = _require_str_list(raw.get("providers", []), f"{path}: 'providers'") + + targets_raw = raw.get("targets") + if not isinstance(targets_raw, dict) or not targets_raw: + raise ConfigError( + f"{path}: 'targets' must be a non-empty mapping of " + " -> {outDir, generators?, entities?}." + ) + + targets: list[TargetConfig] = [] + for name, spec in targets_raw.items(): + ctx = f"{path}: target '{name}'" + if not isinstance(spec, dict): + raise ConfigError(f"{ctx} must be a mapping.") + out_dir = spec.get("outDir") + if not isinstance(out_dir, str) or not out_dir: + raise ConfigError(f"{ctx} must declare a non-empty 'outDir' string.") + generators = spec.get("generators") + if generators is not None: + generators = _require_str_list(generators, f"{ctx} 'generators'") + entities = spec.get("entities") + if entities is not None: + entities = _require_str_list(entities, f"{ctx} 'entities'") + targets.append( + TargetConfig(name=str(name), out_dir=out_dir, generators=generators, entities=entities) + ) + + return ProjectConfig( + config_dir=path.parent.resolve(), + metadata=metadata, + providers=providers, + targets=targets, + ) diff --git a/server/python/tests/codegen/test_project_config.py b/server/python/tests/codegen/test_project_config.py new file mode 100644 index 000000000..80490d68e --- /dev/null +++ b/server/python/tests/codegen/test_project_config.py @@ -0,0 +1,127 @@ +"""#267 — declarative config loader unit tests.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.codegen.project_config import ( + CONFIG_FILENAME, + DEFAULT_METADATA_DIR, + ConfigError, + ProjectConfig, + TargetConfig, + load_project_config, +) + + +def _write(tmp_path: Path, text: str, name: str = CONFIG_FILENAME) -> Path: + p = tmp_path / name + p.write_text(text, encoding="utf-8") + return p + + +def test_minimal_targets_only_applies_defaults(tmp_path: Path) -> None: + p = _write( + tmp_path, + """ + targets: + models: + outDir: pkg/generated + """, + ) + cfg = load_project_config(p) + assert isinstance(cfg, ProjectConfig) + assert cfg.metadata == DEFAULT_METADATA_DIR + assert cfg.providers == [] + assert len(cfg.targets) == 1 + t = cfg.targets[0] + assert t == TargetConfig(name="models", out_dir="pkg/generated", generators=None, entities=None) + # metadata + outDir resolve relative to the config file's directory. + assert cfg.metadata_dir() == str((tmp_path / DEFAULT_METADATA_DIR).resolve()) + assert cfg.out_dir_for(t) == str((tmp_path / "pkg/generated").resolve()) + + +def test_full_config_parses(tmp_path: Path) -> None: + p = _write( + tmp_path, + """ + metadata: ./meta + providers: ["my_provider:provider", "other:make"] + targets: + models: + outDir: pkg/models/generated + generators: [entity] + entities: [Program, Week] + other: + outDir: pkg/other/generated + generators: [entity, routes] + """, + ) + cfg = load_project_config(p) + assert cfg.metadata == "./meta" + assert cfg.providers == ["my_provider:provider", "other:make"] + assert [t.name for t in cfg.targets] == ["models", "other"] # insertion order preserved + assert cfg.target("models").entities == ["Program", "Week"] + assert cfg.target("other").generators == ["entity", "routes"] + assert cfg.target("nope") is None + + +def test_absolute_paths_are_not_reparented(tmp_path: Path) -> None: + abs_out = tmp_path / "elsewhere" + p = _write( + tmp_path, + f""" + metadata: {abs_out} + targets: + t: + outDir: {abs_out} + """, + ) + cfg = load_project_config(p) + assert cfg.metadata_dir() == str(abs_out.resolve()) + assert cfg.out_dir_for(cfg.targets[0]) == str(abs_out.resolve()) + + +def test_missing_file_raises(tmp_path: Path) -> None: + with pytest.raises(ConfigError, match="not found"): + load_project_config(tmp_path / "nope.yaml") + + +@pytest.mark.parametrize( + "text, match", + [ + ("[]", "must be a mapping"), + ("metadata: 3\ntargets:\n t:\n outDir: x\n", "'metadata' must be a string"), + ("targets: {}\n", "non-empty mapping"), + ("providers: notalist\ntargets:\n t:\n outDir: x\n", "'providers'"), + ("targets:\n t: 5\n", "must be a mapping"), + ("targets:\n t:\n generators: [entity]\n", "'outDir'"), + ("targets:\n t:\n outDir: x\n generators: 3\n", "'generators'"), + ("targets:\n t:\n outDir: x\n entities: [1, 2]\n", "'entities'"), + (": : :\n", "invalid YAML"), + ("", "empty"), + ], +) +def test_invalid_config_raises_configerror(tmp_path: Path, text: str, match: str) -> None: + p = _write(tmp_path, text) + with pytest.raises(ConfigError, match=match): + load_project_config(p) + + +def test_schema_file_is_valid_json_and_matches_shape(tmp_path: Path) -> None: + schema_path = ( + Path(__file__).parents[2] + / "src" + / "metaobjects" + / "codegen" + / "metaobjects-config.schema.json" + ) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + assert schema["type"] == "object" + assert "targets" in schema["required"] + props = schema["properties"] + assert set(props) >= {"metadata", "providers", "targets"} + target_props = schema["properties"]["targets"]["additionalProperties"]["properties"] + assert set(target_props) == {"outDir", "generators", "entities"} From a5c64008e62b02f9c1bd643e168859d630a37217 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:21:33 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(#267):=20gen=20config=20mode=20?= =?UTF-8?q?=E2=80=94=20all-targets,=20--target,=20cross-target=20dup=20gua?= =?UTF-8?q?rd,=20config-relative=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- server/python/src/metaobjects/cli.py | 164 ++++++++++++++- .../tests/codegen/test_cli_config_gen.py | 190 ++++++++++++++++++ 2 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 server/python/tests/codegen/test_cli_config_gen.py diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index b4b45e3a0..809331e7b 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -52,6 +52,13 @@ ) from metaobjects.meta.meta_data import MetaData from metaobjects.codegen.config import GenConfig +from metaobjects.codegen.project_config import ( + CONFIG_FILENAME, + ConfigError, + ProjectConfig, + TargetConfig, + load_project_config, +) from metaobjects.codegen.generator import Generator from metaobjects.codegen.generators.entity_model import entity_model from metaobjects.codegen.generators.extractor_generator import extractor_generator @@ -180,6 +187,48 @@ def _providers_from_args(args: argparse.Namespace) -> tuple[list[object], bool]: return providers, True +def _find_config(args: argparse.Namespace) -> Path | None: + """The config path: ``--config`` if given (even if missing → clear load error), + else ``./metaobjects.config.yaml`` in cwd when it exists, else ``None``.""" + explicit = getattr(args, "config", None) + if explicit: + return Path(explicit) + default = Path.cwd() / CONFIG_FILENAME + return default if default.is_file() else None + + +def _config_providers(config: ProjectConfig) -> tuple[list[object], bool]: + """Resolve ``config.providers`` with the config file's directory on ``sys.path``. + + #267: prepend the config directory so a ``module:symbol`` provider living beside + the config imports with no ``PYTHONPATH=``. Idempotent; the entry is left in + place (a short-lived CLI process). Prints resolution errors; returns (providers, ok). + """ + config_dir = str(config.config_dir) + if config_dir not in sys.path: + sys.path.insert(0, config_dir) + providers, errors = _resolve_providers(config.providers) + if errors: + print("error: invalid provider in config:", file=sys.stderr) + for msg in errors: + print(f" {msg}", file=sys.stderr) + return providers, False + return providers, True + + +def _select_targets( + config: ProjectConfig, target_name: str | None +) -> tuple[list[TargetConfig], str | None]: + """All targets, or the single ``--target`` (error string when the name is unknown).""" + if target_name is None: + return config.targets, None + t = config.target(target_name) + if t is None: + known = ", ".join(sorted(x.name for x in config.targets)) + return [], f"unknown --target {target_name!r}; known targets: {known}" + return [t], None + + def _load_root( metadata_dir: str, strict: bool = False, @@ -422,9 +471,14 @@ def _cmd_gen(args: argparse.Namespace) -> int: _warn_if_agent_context_stale() - if args.metadata_dir is None or args.out is None: + # #267: config mode ⇔ no positional . The explicit + # + --out flag path below is untouched (byte-identical). + if args.metadata_dir is None: + return _cmd_gen_config(args) + if args.out is None: print( - "error: gen requires and --out (or use --list).", + "error: gen requires and --out " + "(or a metaobjects.config.yaml / --list).", file=sys.stderr, ) return 2 @@ -495,6 +549,98 @@ def _cmd_gen(args: argparse.Namespace) -> int: return 0 +def _run_gen_targets( + config: ProjectConfig, targets: list[TargetConfig], root: MetaData +) -> tuple[list[str], list[str]]: + """Run each target's suite into its ``outDir``. Returns (all_written, errors). + + Cross-target duplicate-output-path guard (#267): ``run_gen``'s collision guard + is per-pass, so two targets writing the same full path would silently clobber + (generated files carry the @generated header). We accumulate every written full + path across targets and record an error when two targets emit the same one. + (Detection is post-write — the colliding file may already be on disk — but the + command still fails, so a misconfigured gate is caught in CI.) + """ + all_written: list[str] = [] + seen: dict[str, str] = {} # full path -> target name + errors: list[str] = [] + for t in targets: + gens: list[Generator] | None = None + if t.generators is not None: + gens, gen_errors = _resolve_generators(",".join(t.generators)) + if gen_errors: + errors.extend(f"target '{t.name}': {m}" for m in gen_errors) + continue + out_dir = config.out_dir_for(t) + try: + written = _run_suite(root, out_dir, gens, t.entities) + except ValueError as exc: # intra-target run_gen collision → clean error + errors.append(f"target '{t.name}': {exc}") + continue + for full in written: + prior = seen.get(full) + if prior is not None and prior != t.name: + errors.append( + f"duplicate output path across targets: {full!r} written by " + f"both '{prior}' and '{t.name}'." + ) + else: + seen[full] = t.name + all_written.extend(written) + return all_written, errors + + +def _cmd_gen_config(args: argparse.Namespace) -> int: + """``gen`` with no positional ``metadata_dir`` → declarative-config mode (#267). + + Load ``metaobjects.config.yaml``, load metadata ONCE, and run every target + (or ``--target``) into its own ``outDir`` with a cross-target duplicate-path + guard. Providers resolve relative to the config file (no ``PYTHONPATH=``). + """ + config_path = _find_config(args) + if config_path is None: + print( + "error: no given and no metaobjects.config.yaml found. " + "Either pass --out (flag mode) or create a " + "metaobjects.config.yaml (or pass --config ).", + file=sys.stderr, + ) + return 2 + try: + config = load_project_config(config_path) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + targets, target_err = _select_targets(config, getattr(args, "target", None)) + if target_err: + print(f"error: {target_err}", file=sys.stderr) + return 1 + + providers, providers_ok = _config_providers(config) + if not providers_ok: + return 1 + + root, load_errors = _load_root(config.metadata_dir(), providers=providers) + if root is None: + print("error: failed to load metadata:", file=sys.stderr) + for msg in load_errors: + print(f" {msg}", file=sys.stderr) + return 1 + + written, errors = _run_gen_targets(config, targets, root) + if errors: + for msg in errors: + print(f"error: {msg}", file=sys.stderr) + return 1 + for path in written: + print(path) + print( + f"metaobjects gen: wrote {len(written)} file(s) across {len(targets)} target(s)." + ) + return 0 + + def _relative_set(root: Path) -> dict[str, str]: """Map every ``*.py`` file under ``root`` to its content, keyed by rel path. @@ -855,6 +1001,20 @@ def _build_parser() -> argparse.ArgumentParser: "resolves through the standalone CLI (#158)" ), ) + gen.add_argument( + "--config", + default=None, + help=( + "path to a metaobjects.config.yaml (declarative targets registry). " + "Config mode runs when no is given; default lookup is " + "./metaobjects.config.yaml in cwd." + ), + ) + gen.add_argument( + "--target", + default=None, + help="run only this named target from the config (default: every target)", + ) gen.set_defaults(func=_cmd_gen) docs = sub.add_parser( diff --git a/server/python/tests/codegen/test_cli_config_gen.py b/server/python/tests/codegen/test_cli_config_gen.py new file mode 100644 index 000000000..2a29bc7ea --- /dev/null +++ b/server/python/tests/codegen/test_cli_config_gen.py @@ -0,0 +1,190 @@ +"""#267 — `metaobjects gen` declarative-config mode (no-arg, targets registry).""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from metaobjects.cli import main + +FITNESS = ( + Path(__file__).parents[4] + / "fixtures" + / "persistence-conformance" + / "canonical" + / "meta.fitness.json" +) + + +def _project(tmp_path: Path, config_text: str, meta_subdir: str = "metaobjects") -> Path: + """Write a config + a metadata dir (fitness fixture) under tmp_path. Return the config path.""" + meta = tmp_path / meta_subdir + meta.mkdir(parents=True) + (meta / "meta.fitness.json").write_text(FITNESS.read_text()) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text(config_text) + return cfg + + +TWO_TARGETS = """ +targets: + models: + outDir: gen/models + generators: [entity] + entities: [Program, Week] + other: + outDir: gen/other + generators: [entity] + entities: [Node, Measurement] +""" + + +def test_gen_no_args_runs_all_targets_via_config_flag(tmp_path: Path) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg)]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + assert (tmp_path / "gen/models/Week.py").exists() + assert (tmp_path / "gen/other/Node.py").exists() + assert (tmp_path / "gen/other/Measurement.py").exists() + # allowlists honored per target + assert not (tmp_path / "gen/models/Node.py").exists() + assert not (tmp_path / "gen/other/Program.py").exists() + + +def test_gen_no_args_discovers_config_in_cwd(tmp_path: Path, monkeypatch) -> None: + _project(tmp_path, TWO_TARGETS) + monkeypatch.chdir(tmp_path) + rc = main(["gen"]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + + +def test_gen_target_scopes_to_one(tmp_path: Path) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg), "--target", "models"]) + assert rc == 0 + assert (tmp_path / "gen/models/Program.py").exists() + assert not (tmp_path / "gen/other").exists() + + +def test_gen_unknown_target_errors(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path, TWO_TARGETS) + rc = main(["gen", "--config", str(cfg), "--target", "nope"]) + assert rc == 1 + assert "unknown --target" in capsys.readouterr().err + + +def test_gen_missing_config_errors(tmp_path: Path, monkeypatch, capsys) -> None: + monkeypatch.chdir(tmp_path) # no config here + rc = main(["gen"]) + assert rc == 2 + assert "metaobjects.config.yaml" in capsys.readouterr().err + + +DUP_TARGETS = """ +targets: + a: + outDir: shared/gen + generators: [entity] + entities: [Program] + b: + outDir: shared/gen + generators: [entity] + entities: [Program] +""" + + +def test_gen_cross_target_duplicate_output_path_guard(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path, DUP_TARGETS) + rc = main(["gen", "--config", str(cfg)]) + assert rc == 1 + assert "duplicate output path across targets" in capsys.readouterr().err + + +# --- config-relative provider (no PYTHONPATH) ------------------------------- + +PROVIDER_MODULE = ''' +from metaobjects.provider import Provider +from metaobjects.registry import TypeDefinition +from metaobjects.meta.meta_data import MetaData + +geo_provider = Provider("test-geocheck", ("metaobjects-core-types",)) +geo_provider.add(TypeDefinition( + type="validator", + sub_type="geocheck", + factory=lambda t, s, n: MetaData(t, s, n), + description="A custom validator", +)) +''' + +CUSTOM_META = { + "metadata.root": { + "package": "acme::geo", + "children": [ + { + "object.entity": { + "name": "Place", + "children": [ + {"field.long": {"name": "id"}}, + { + "field.string": { + "name": "name", + "children": [{"validator.geocheck": {"name": "chk"}}], + } + }, + {"source.rdb": {"name": "src", "@table": "places"}}, + { + "identity.primary": { + "name": "pk", + "@fields": ["id"], + "@generation": "increment", + } + }, + ], + } + } + ], + } +} + + +def test_gen_resolves_provider_config_relative_without_pythonpath(tmp_path: Path) -> None: + """A provider module beside the config resolves via the config dir on sys.path, + with NO caller PYTHONPATH / sys.path manipulation.""" + meta = tmp_path / "metaobjects_meta" + meta.mkdir() + (meta / "meta.json").write_text(json.dumps(CUSTOM_META)) + (tmp_path / "geo_conf_prov.py").write_text(PROVIDER_MODULE) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text( + """ + metadata: metaobjects_meta + providers: ["geo_conf_prov:geo_provider"] + targets: + models: + outDir: gen + generators: [entity] + """ + ) + assert str(tmp_path) not in sys.path # precondition: not already importable + try: + rc = main(["gen", "--config", str(cfg)]) + finally: + if str(tmp_path) in sys.path: + sys.path.remove(str(tmp_path)) + sys.modules.pop("geo_conf_prov", None) + assert rc == 0 + assert (tmp_path / "gen/Place.py").exists() + + +def test_gen_flag_path_ignores_config_when_present(tmp_path: Path) -> None: + """Back-compat: an explicit + --out uses the flag path and does + NOT consult a metaobjects.config.yaml sitting in cwd (byte-identical).""" + _project(tmp_path, DUP_TARGETS) # a config that WOULD fail (dup guard) if consulted + out = tmp_path / "flagout" + meta = tmp_path / "metaobjects" # created by _project + # Flag path: metadata_dir + --out present => config ignored, normal gen. + rc = main(["gen", str(meta), "--out", str(out)]) + assert rc == 0 + assert (out / "Program.py").exists() From 604311ef0eea0b18de8041c49a6c79348c522cab Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:30:21 -0400 Subject: [PATCH 5/9] =?UTF-8?q?feat(#267):=20verify=20--codegen=20config?= =?UTF-8?q?=20mode=20=E2=80=94=20per-target=20regen+diff,=20aggregate=20ex?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- server/python/src/metaobjects/cli.py | 125 +++++++++++++++++- .../tests/codegen/test_cli_config_verify.py | 79 +++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 server/python/tests/codegen/test_cli_config_verify.py diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 809331e7b..04cdc00d1 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -662,6 +662,10 @@ def _verify_codegen(args: argparse.Namespace) -> int: code path (gen-to-temp + diff), so drift can never be a generator-wiring divergence between the two commands. """ + # #267: config mode ⇔ no positional . The legacy + # + --out diff below is untouched. + if args.metadata_dir is None: + return _verify_codegen_config(args) if args.out is None: print( "error: verify --codegen requires --out (the committed output dir).", @@ -718,6 +722,95 @@ def _verify_codegen(args: argparse.Namespace) -> int: return 1 +def _verify_one_target( + config: ProjectConfig, target: TargetConfig, root: MetaData +) -> int: + """Regenerate one target to a temp dir + diff against its committed ``outDir``. + + Mirrors :func:`_verify_codegen`'s diff, labeled per target. Returns 0 (in sync) + or 1 (drift / bad generator name).""" + gens: list[Generator] | None = None + if target.generators is not None: + gens, gen_errors = _resolve_generators(",".join(target.generators)) + if gen_errors: + for m in gen_errors: + print(f"error: target '{target.name}': {m}", file=sys.stderr) + return 1 + + out_dir = Path(config.out_dir_for(target)) + with tempfile.TemporaryDirectory() as tmp: + _run_suite(root, tmp, gens, target.entities) + expected = _relative_set(Path(tmp)) + committed = _relative_set(out_dir) + + changed = sorted(k for k in expected if k in committed and expected[k] != committed[k]) + missing = sorted(k for k in expected if k not in committed) + extra = sorted(k for k in committed if k not in expected) + + if not changed and not missing and not extra: + print(f"metaobjects verify [{target.name}]: in sync ({len(expected)} file(s)).") + return 0 + + print( + f"error: [{target.name}] generated code is out of sync with metadata.", + file=sys.stderr, + ) + for k in changed: + print(f" drifted: {k}", file=sys.stderr) + for k in missing: + print(f" missing: {k}", file=sys.stderr) + for k in extra: + print(f" extra: {k}", file=sys.stderr) + print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) + return 1 + + +def _verify_codegen_config(args: argparse.Namespace) -> int: + """``verify --codegen`` with no positional ``metadata_dir`` → config mode (#267). + + Load the config + metadata ONCE, then regen+diff each target (or ``--target``) + against its committed ``outDir``, aggregating the exit code (non-zero if ANY + target drifts). Strict-by-default (ADR-0023) unless ``--lax``. + """ + config_path = _find_config(args) + if config_path is None: + print( + "error: verify --codegen with no requires a " + "metaobjects.config.yaml (or --config ).", + file=sys.stderr, + ) + return 2 + try: + config = load_project_config(config_path) + except ConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + targets, target_err = _select_targets(config, getattr(args, "target", None)) + if target_err: + print(f"error: {target_err}", file=sys.stderr) + return 1 + + strict = not getattr(args, "lax", False) + providers, providers_ok = _config_providers(config) + if not providers_ok: + return 1 + + root, load_errors = _load_root(config.metadata_dir(), strict=strict, providers=providers) + if root is None: + print("error: failed to load metadata:", file=sys.stderr) + for msg in load_errors: + print(f" {msg}", file=sys.stderr) + if strict and any("ERR_UNKNOWN_ATTR" in m for m in load_errors): + print(_strict_load_hint(), file=sys.stderr) + return 1 + + exit_code = 0 + for t in targets: + exit_code = max(exit_code, _verify_one_target(config, t, root)) + return exit_code + + #: The text-ref attrs a ``template.*`` node may carry. Each is resolved through #: the filesystem provider and run through the render ``verify()`` gate. A prompt #: / document uses ``@textRef``; an email uses subject / html / (optional) text @@ -753,6 +846,14 @@ def _verify_templates(args: argparse.Namespace) -> int: (exit 1). Clean → 0. Reuses the render engine + field-tree walk; nothing is reimplemented here. """ + if args.metadata_dir is None: + print( + "error: verify --templates requires (it is not " + "config-driven; the config's targets registry drives --codegen only).", + file=sys.stderr, + ) + return 2 + template_root = getattr(args, "templates_root", None) if not template_root: print( @@ -1062,7 +1163,15 @@ def _build_parser() -> argparse.ArgumentParser: "(ADR-0021 D2); bare verify defaults to --codegen" ), ) - verify.add_argument("metadata_dir", help="directory of metadata JSON/YAML files") + verify.add_argument( + "metadata_dir", + nargs="?", + default=None, + help=( + "directory of metadata JSON/YAML files. Omit to use a " + "metaobjects.config.yaml (--codegen config mode, #267)." + ), + ) verify.add_argument( "--codegen", action="store_true", @@ -1113,6 +1222,20 @@ def _build_parser() -> argparse.ArgumentParser: metavar="module:symbol", help="load a consumer metadata Provider as 'module:symbol' (repeatable; #158)", ) + verify.add_argument( + "--config", + default=None, + help=( + "path to a metaobjects.config.yaml. Config mode runs when no " + " is given; default ./metaobjects.config.yaml in cwd. " + "Drives --codegen per-target regen+diff." + ), + ) + verify.add_argument( + "--target", + default=None, + help="verify only this named target from the config (default: every target)", + ) verify.set_defaults(func=_cmd_verify) agent_docs = sub.add_parser( diff --git a/server/python/tests/codegen/test_cli_config_verify.py b/server/python/tests/codegen/test_cli_config_verify.py new file mode 100644 index 000000000..1d7fb1e40 --- /dev/null +++ b/server/python/tests/codegen/test_cli_config_verify.py @@ -0,0 +1,79 @@ +"""#267 — `metaobjects verify --codegen` declarative-config mode (per-target diff).""" +from __future__ import annotations + +from pathlib import Path + +from metaobjects.cli import main + +FITNESS = ( + Path(__file__).parents[4] + / "fixtures" + / "persistence-conformance" + / "canonical" + / "meta.fitness.json" +) + +TWO_TARGETS = """ +targets: + models: + outDir: gen/models + generators: [entity] + entities: [Program, Week] + other: + outDir: gen/other + generators: [entity] + entities: [Node, Measurement] +""" + + +def _project(tmp_path: Path) -> Path: + meta = tmp_path / "metaobjects" + meta.mkdir() + (meta / "meta.fitness.json").write_text(FITNESS.read_text()) + cfg = tmp_path / "metaobjects.config.yaml" + cfg.write_text(TWO_TARGETS) + return cfg + + +def test_verify_codegen_no_args_in_sync(tmp_path: Path) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + # Fresh gen → no drift across every target. + assert main(["verify", "--codegen", "--config", str(cfg)]) == 0 + + +def test_verify_codegen_bare_defaults_to_codegen(tmp_path: Path, monkeypatch) -> None: + cfg = _project(tmp_path) + monkeypatch.chdir(tmp_path) + assert main(["gen"]) == 0 + assert main(["verify"]) == 0 # bare verify → --codegen default, config-driven + + +def test_verify_codegen_detects_drift_in_one_target(tmp_path: Path, capsys) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + target = tmp_path / "gen/other/Node.py" + target.write_text(target.read_text() + "\n# hand-edited drift\n") + rc = main(["verify", "--codegen", "--config", str(cfg)]) + assert rc == 1 + err = capsys.readouterr().err + assert "[other]" in err and "drifted" in err + + +def test_verify_codegen_target_scopes(tmp_path: Path) -> None: + cfg = _project(tmp_path) + assert main(["gen", "--config", str(cfg)]) == 0 + # Drift in `other`, but scope verify to `models` → clean. + target = tmp_path / "gen/other/Node.py" + target.write_text(target.read_text() + "\n# drift\n") + assert main(["verify", "--codegen", "--config", str(cfg), "--target", "models"]) == 0 + assert main(["verify", "--codegen", "--config", str(cfg), "--target", "other"]) == 1 + + +def test_verify_flag_path_still_works_with_config_present(tmp_path: Path) -> None: + """Back-compat: legacy `verify --out` diff is unchanged when a config exists.""" + cfg = _project(tmp_path) + meta = tmp_path / "metaobjects" + out = tmp_path / "flagout" + assert main(["gen", str(meta), "--out", str(out)]) == 0 + assert main(["verify", str(meta), "--out", str(out)]) == 0 From ba5b516fbfc2ac5dbfc8464249c17d679d68d553 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:36:56 -0400 Subject: [PATCH 6/9] docs(#267): document the Python declarative config; close the targets-registry KNOWN_GAP Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- docs/features/cli.md | 48 +++++++++++++++++++ .../src/metaobjects/codegen/KNOWN_GAPS.md | 10 ++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/features/cli.md b/docs/features/cli.md index cb066619c..034933c39 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -84,6 +84,54 @@ at `-Dmeta.verify.templateRoot`). The one goal covers BOTH Java (`codegen-spring migrate engine, ADR-0015"); an unknown mode fails listing the valid ones. (Schema `--db` remains Node-only by the ADR-0015 design — see below.) +## Declarative config (`metaobjects.config.yaml`) — Python codegen + +Alongside its flag-only mode (`metaobjects gen --out `), the +Python `metaobjects` CLI supports a declarative project config, +`metaobjects.config.yaml` (#267). The **schema keys are identical to the TS +`metaobjects.config.ts` vocabulary** — a polyglot adopter learns one +targets-registry shape regardless of port. A JSON Schema ships at +[`server/python/src/metaobjects/codegen/metaobjects-config.schema.json`](../../server/python/src/metaobjects/codegen/metaobjects-config.schema.json) +for editor autocomplete and non-Python validation. + +```yaml +metadata: metaobjects # optional, default "metaobjects" — relative to this file +providers: # optional; "module:symbol" refs, resolved config-relative (no PYTHONPATH=) + - my_project.providers:register_custom_types +targets: + api: + outDir: src/generated/api + generators: [entity, routes] # optional; stable names from `metaobjects gen --list`; omit = default suite + admin: + outDir: src/generated/admin + entities: [Author, Book] # optional allowlist; omit = every entity +``` + +- **`metaobjects gen`** with no positional `` runs config mode: + it loads the config and metadata once and runs every target into its own + `outDir`, with a cross-target guard against two targets writing the same + output path. `--target ` scopes the run to a single target. +- **`metaobjects verify --codegen`** — including bare `verify`, since + `--codegen` is the Python default (see above) — runs the matching config + mode with no positional ``: it regenerates each target to a + temp dir, diffs it against that target's committed `outDir`, and + aggregates the exit code (non-zero if *any* target has drifted). `--target` + scopes it the same way. Strict-attr loading (ADR-0023) still applies unless + `--lax` is passed. +- **`--config `** picks the config file explicitly on either command; + with no positional metadata dir and no `--config`, both commands default to + looking for `./metaobjects.config.yaml` in the current directory. +- **Providers resolve config-relative.** A `providers:` entry is imported + with the config file's own directory prepended to `sys.path`, so a + consumer provider module living beside the config resolves with no + `PYTHONPATH=` needed (unlike the flag-only `--provider module:symbol` + path, which relies on the ambient environment). +- **Back-compat is load-bearing.** Passing an explicit positional + `` (with `--out` on `gen`, or `--out` on `verify --codegen`) + keeps the original flag-only path byte-identical — the config file is never + consulted. Config mode activates only when no positional `` + is given. + ## `meta gen` / `meta verify` run an advisory anti-pattern pass (Node `meta`) Both `meta verify` and a real `meta gen` write run (not `--dry-run`) end with a diff --git a/server/python/src/metaobjects/codegen/KNOWN_GAPS.md b/server/python/src/metaobjects/codegen/KNOWN_GAPS.md index 4fb0bd091..703bd5017 100644 --- a/server/python/src/metaobjects/codegen/KNOWN_GAPS.md +++ b/server/python/src/metaobjects/codegen/KNOWN_GAPS.md @@ -47,7 +47,9 @@ the path-resolution and import-base machinery that is not yet on the Python codegen surface). Consumers wanting strong typing can hand-edit the generated router to import their preferred entity shape. -**When it ships:** once the Python codegen grows per-target output -directories (mirroring the TS `targets` registry — see -`@metaobjectsdev/cli` README), the router generator will emit -`from ._entity import ` and use the entity type throughout. +**When it ships:** the Python codegen now has a per-target output-directory +targets registry via the declarative `metaobjects.config.yaml` (#267 — mirrors +the TS `targets` registry). The remaining deferred piece is the router→entity-model +import wiring itself (`from ._entity import `), which still needs the +path-resolution/import-base machinery; the config's per-target `outDir` is the +prerequisite that is now in place. From 5cc26903885b905c0cf46a1ea96338a216d7180b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 19:49:58 -0400 Subject: [PATCH 7/9] fix(#267): wrap config read errors + exempt package __init__.py from cross-target dup guard Final-review fixes: load_project_config now raises ConfigError (not a raw UnicodeDecodeError/OSError traceback) on an unreadable/non-UTF-8 config; the gen cross-target duplicate-output-path guard exempts the auto-emitted package __init__.py so two targets may share an outDir with disjoint entities. Adds a regression test for the verify --templates no-metadata_dir guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- server/python/src/metaobjects/cli.py | 2 ++ .../src/metaobjects/codegen/project_config.py | 6 ++++- .../tests/codegen/test_cli_config_gen.py | 24 +++++++++++++++++++ .../tests/codegen/test_cli_config_verify.py | 7 ++++++ .../tests/codegen/test_project_config.py | 9 +++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 04cdc00d1..d343955e5 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -578,6 +578,8 @@ def _run_gen_targets( errors.append(f"target '{t.name}': {exc}") continue for full in written: + if Path(full).name == "__init__.py": + continue # auto-emitted package marker — byte-identical across targets sharing an outDir; not a real collision prior = seen.get(full) if prior is not None and prior != t.name: errors.append( diff --git a/server/python/src/metaobjects/codegen/project_config.py b/server/python/src/metaobjects/codegen/project_config.py index 0f67543a8..c9b418557 100644 --- a/server/python/src/metaobjects/codegen/project_config.py +++ b/server/python/src/metaobjects/codegen/project_config.py @@ -108,7 +108,11 @@ def load_project_config(path: Path) -> ProjectConfig: if not path.is_file(): raise ConfigError(f"config file not found: {path}") try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise ConfigError(f"{path}: cannot read config file: {exc}") from exc + try: + raw = yaml.safe_load(text) except yaml.YAMLError as exc: raise ConfigError(f"{path}: invalid YAML: {exc}") from exc diff --git a/server/python/tests/codegen/test_cli_config_gen.py b/server/python/tests/codegen/test_cli_config_gen.py index 2a29bc7ea..d14e50770 100644 --- a/server/python/tests/codegen/test_cli_config_gen.py +++ b/server/python/tests/codegen/test_cli_config_gen.py @@ -102,6 +102,30 @@ def test_gen_cross_target_duplicate_output_path_guard(tmp_path: Path, capsys) -> assert "duplicate output path across targets" in capsys.readouterr().err +SHARED_OUTDIR_DISJOINT_ENTITIES = """ +targets: + a: + outDir: shared + generators: [entity] + entities: [Program] + b: + outDir: shared + generators: [entity] + entities: [Week] +""" + + +def test_gen_cross_target_shared_outdir_disjoint_entities_not_flagged(tmp_path: Path) -> None: + """Two targets sharing an outDir with DISJOINT entities must succeed: the + auto-emitted package-marker __init__.py is byte-identical across both targets + and must not trip the cross-target duplicate-output-path guard.""" + cfg = _project(tmp_path, SHARED_OUTDIR_DISJOINT_ENTITIES) + rc = main(["gen", "--config", str(cfg)]) + assert rc == 0 + assert (tmp_path / "shared/Program.py").exists() + assert (tmp_path / "shared/Week.py").exists() + + # --- config-relative provider (no PYTHONPATH) ------------------------------- PROVIDER_MODULE = ''' diff --git a/server/python/tests/codegen/test_cli_config_verify.py b/server/python/tests/codegen/test_cli_config_verify.py index 1d7fb1e40..14384a05f 100644 --- a/server/python/tests/codegen/test_cli_config_verify.py +++ b/server/python/tests/codegen/test_cli_config_verify.py @@ -77,3 +77,10 @@ def test_verify_flag_path_still_works_with_config_present(tmp_path: Path) -> Non out = tmp_path / "flagout" assert main(["gen", str(meta), "--out", str(out)]) == 0 assert main(["verify", str(meta), "--out", str(out)]) == 0 + + +def test_verify_templates_config_mode_requires_metadata_dir(tmp_path: Path) -> None: + """`verify --templates` is not config-driven — the guard returns exit 2 when + no positional metadata_dir is given (config mode / --templates only drives + --codegen).""" + assert main(["verify", "--templates"]) == 2 diff --git a/server/python/tests/codegen/test_project_config.py b/server/python/tests/codegen/test_project_config.py index 80490d68e..df31bac10 100644 --- a/server/python/tests/codegen/test_project_config.py +++ b/server/python/tests/codegen/test_project_config.py @@ -89,6 +89,15 @@ def test_missing_file_raises(tmp_path: Path) -> None: load_project_config(tmp_path / "nope.yaml") +def test_unreadable_non_utf8_config_raises_configerror_not_traceback(tmp_path: Path) -> None: + """A non-UTF-8 config file must fail with a ConfigError ("cannot read"), not a + raw UnicodeDecodeError escaping the ConfigError "no stack trace" contract.""" + p = tmp_path / CONFIG_FILENAME + p.write_bytes(b"\xff\xfe\x00 targets: bad") + with pytest.raises(ConfigError, match="cannot read"): + load_project_config(p) + + @pytest.mark.parametrize( "text, match", [ From 9a5124b0a6173c1c14b0b718f39ecd58ad48999d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 21:05:48 -0400 Subject: [PATCH 8/9] no-mistakes(review): fix(#267): verify --codegen diffs per unique outDir, not per target --- docs/features/cli.md | 13 +- ...ython-declarative-codegen-config-design.md | 4 +- server/python/src/metaobjects/cli.py | 142 ++++++++++++------ .../tests/codegen/test_cli_config_gen.py | 10 ++ .../tests/codegen/test_cli_config_verify.py | 72 ++++++++- 5 files changed, 184 insertions(+), 57 deletions(-) diff --git a/docs/features/cli.md b/docs/features/cli.md index 034933c39..0a5e4db49 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -113,11 +113,14 @@ targets: output path. `--target ` scopes the run to a single target. - **`metaobjects verify --codegen`** — including bare `verify`, since `--codegen` is the Python default (see above) — runs the matching config - mode with no positional ``: it regenerates each target to a - temp dir, diffs it against that target's committed `outDir`, and - aggregates the exit code (non-zero if *any* target has drifted). `--target` - scopes it the same way. Strict-attr loading (ADR-0023) still applies unless - `--lax` is passed. + mode with no positional ``: it regenerates the whole selection + into a temp tree (the exact `gen` pipeline, including the cross-target + duplicate-output-path guard) and diffs each **unique `outDir`** against the + union of the co-resident targets' regen, aggregating the exit code (non-zero + if *any* outDir has drifted). Targets sharing an `outDir` are verified + together, so a shared `outDir` is never a false-positive `extra`. `--target` + widens to the `outDir`-sharing closure (an `outDir` is verified as a unit). + Strict-attr loading (ADR-0023) still applies unless `--lax` is passed. - **`--config `** picks the config file explicitly on either command; with no positional metadata dir and no `--config`, both commands default to looking for `./metaobjects.config.yaml` in the current directory. diff --git a/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md b/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md index 4f128824f..8536259e3 100644 --- a/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md +++ b/docs/superpowers/specs/2026-08-03-issue-267-python-declarative-codegen-config-design.md @@ -35,7 +35,7 @@ targets: # named map (like TS `targets`), not a list - **Provider resolution:** prepend the **config file's directory** to `sys.path` before the existing `_resolve_providers` importlib path — removes the `PYTHONPATH=` requirement; `module:symbol` strings unchanged. Optional later: a `pythonPath: [./tools]` key for providers living elsewhere (don't block on it). - **`metaobjects gen` (no positional dir / no `--out`)** → load config, load metadata **once**, run **every** target (per-target generators/entities into its `outDir`). Add a **cross-target duplicate-output-path guard** (TS's runner errors on duplicate full paths; Python's `run_gen` guard is per-pass only). Add `--target ` to scope to one target. -- **`metaobjects verify --codegen` (no args)** → per-target regen-to-temp + diff, aggregating exit codes (the existing `_verify_codegen` loop, per target). `--target ` scopes. +- **`metaobjects verify --codegen` (no args)** → one whole-selection regen into a temp tree (the exact `gen` pipeline, including the cross-target duplicate-output-path guard), then a diff per **unique outDir** — the union of co-resident targets' regen vs the shared committed dir — so two targets sharing an outDir are verified *together* (no false `extra` drift). `--target ` widens to the outDir-sharing closure (an outDir is verified as a unit). - **Config lookup:** `--config ` else `./metaobjects.config.yaml` in cwd. (Optional secondary `pyproject.toml [tool.metaobjects]` location later — don't block on it.) - **Back-compat (load-bearing):** an explicit positional `metadata_dir` + `--out` keeps today's flag path **byte-identical** — flags present ⇒ legacy path, config not consulted (simplest, least-surprising rule). Purely additive; existing CI keeps working. @@ -51,7 +51,7 @@ A config-declared provider that `registry.extend()`s a core subtype still hits t - Config loader unit tests (parse YAML → typed config; missing/invalid → clear error; defaults). - `gen` no-arg runs all targets into their `outDir`s with the right generators/entities (temp-dir integration); duplicate-output-path guard fires on a colliding config. -- `verify --codegen` no-arg per-target regen+diff, aggregate exit; a stale target fails, a fresh tree passes. +- `verify --codegen` no-arg one whole-selection regen + diff per unique outDir, aggregate exit; a stale target fails, a fresh tree passes; a shared outDir with disjoint entities verifies clean, and real drift/extra is still detected under sharing. - Provider resolved config-relative WITHOUT `PYTHONPATH=` (a provider module beside the config). - `--target ` scopes gen + verify. - Back-compat: the existing positional+`--out` flag path is byte-identical (config ignored). diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index d343955e5..5f9153ea7 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -39,7 +39,9 @@ from __future__ import annotations import argparse +import dataclasses import json +import re import sys import tempfile from pathlib import Path @@ -724,55 +726,38 @@ def _verify_codegen(args: argparse.Namespace) -> int: return 1 -def _verify_one_target( - config: ProjectConfig, target: TargetConfig, root: MetaData -) -> int: - """Regenerate one target to a temp dir + diff against its committed ``outDir``. +def _temp_slot_for(temp_root: Path, real_outdir: str, config_dir: Path) -> str: + """The temp mirror of a committed ``outDir``. - Mirrors :func:`_verify_codegen`'s diff, labeled per target. Returns 0 (in sync) - or 1 (drift / bad generator name).""" - gens: list[Generator] | None = None - if target.generators is not None: - gens, gen_errors = _resolve_generators(",".join(target.generators)) - if gen_errors: - for m in gen_errors: - print(f"error: target '{target.name}': {m}", file=sys.stderr) - return 1 - - out_dir = Path(config.out_dir_for(target)) - with tempfile.TemporaryDirectory() as tmp: - _run_suite(root, tmp, gens, target.entities) - expected = _relative_set(Path(tmp)) - committed = _relative_set(out_dir) - - changed = sorted(k for k in expected if k in committed and expected[k] != committed[k]) - missing = sorted(k for k in expected if k not in committed) - extra = sorted(k for k in committed if k not in expected) - - if not changed and not missing and not extra: - print(f"metaobjects verify [{target.name}]: in sync ({len(expected)} file(s)).") - return 0 - - print( - f"error: [{target.name}] generated code is out of sync with metadata.", - file=sys.stderr, - ) - for k in changed: - print(f" drifted: {k}", file=sys.stderr) - for k in missing: - print(f" missing: {k}", file=sys.stderr) - for k in extra: - print(f" extra: {k}", file=sys.stderr) - print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) - return 1 + Keyed by the real outDir's path relative to ``config_dir`` when it lives under + it, else a sanitized flat name — mirroring the TS ``computeCodegenDrift`` + ``tempFor``. Two targets sharing a real outDir map to the SAME slot so their + co-resident regen accumulates into one tree: the diff is then union-of- + co-resident-regen vs the shared committed dir, so the other target's files are + never false ``extra`` drift. + """ + try: + rel = Path(real_outdir).relative_to(config_dir) + except ValueError: + rel = None + if rel is None: + safe = re.sub(r"[^A-Za-z0-9]+", "_", real_outdir) + return str(temp_root / safe) + return str(temp_root / rel) def _verify_codegen_config(args: argparse.Namespace) -> int: """``verify --codegen`` with no positional ``metadata_dir`` → config mode (#267). - Load the config + metadata ONCE, then regen+diff each target (or ``--target``) - against its committed ``outDir``, aggregating the exit code (non-zero if ANY - target drifts). Strict-by-default (ADR-0023) unless ``--lax``. + Symmetric with ``gen`` config mode: ONE whole-selection regen into a temp tree + (reusing :func:`_run_gen_targets` — the exact gen pipeline, so verify rejects + exactly what gen rejects, including the cross-target duplicate-output-path + guard), then a diff per UNIQUE real outDir. Targets that share an outDir are + verified TOGETHER — the diff is the union of their co-resident regen vs the + shared committed dir, so a shared outDir is never a false-positive ``extra`` + drift (mirrors the TS ``computeCodegenDrift`` unit = unique outDir). + ``--target`` widens to the outDir-sharing closure (an outDir is verified as a + unit). Strict-by-default (ADR-0023) unless ``--lax``. """ config_path = _find_config(args) if config_path is None: @@ -788,11 +773,24 @@ def _verify_codegen_config(args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) return 1 - targets, target_err = _select_targets(config, getattr(args, "target", None)) + selected, target_err = _select_targets(config, getattr(args, "target", None)) if target_err: print(f"error: {target_err}", file=sys.stderr) return 1 + # Widen to the outDir-sharing closure: a shared outDir is verified as a unit, + # so --target a (whose outDir b also writes) pulls b in. + selected_outdirs = {config.out_dir_for(t) for t in selected} + closure = [t for t in config.targets if config.out_dir_for(t) in selected_outdirs] + added = [t.name for t in closure if t not in selected] + if added: + target_arg = getattr(args, "target", None) + print( + f"note: --target {target_arg!r} shares an outDir with " + f"{', '.join(added)}; verifying the shared outDir as a unit.", + file=sys.stderr, + ) + strict = not getattr(args, "lax", False) providers, providers_ok = _config_providers(config) if not providers_ok: @@ -807,10 +805,58 @@ def _verify_codegen_config(args: argparse.Namespace) -> int: print(_strict_load_hint(), file=sys.stderr) return 1 - exit_code = 0 - for t in targets: - exit_code = max(exit_code, _verify_one_target(config, t, root)) - return exit_code + with tempfile.TemporaryDirectory() as temp_root: + # Map each unique real outDir to ONE temp slot (shared real dir => shared slot). + temp_for: dict[str, str] = {} + for t in closure: + real = config.out_dir_for(t) + if real not in temp_for: + temp_for[real] = _temp_slot_for(Path(temp_root), real, config.config_dir) + remapped = [ + dataclasses.replace(t, out_dir=temp_for[config.out_dir_for(t)]) for t in closure + ] + + # The exact gen pipeline into the temp tree — verify now rejects exactly + # what gen rejects (incl. the cross-target duplicate-output-path guard). + _written, errors = _run_gen_targets(config, remapped, root) + if errors: + for msg in errors: + print(f"error: {msg}", file=sys.stderr) + return 1 + + exit_code = 0 + for real_outdir in sorted(temp_for): + tmp_slot = temp_for[real_outdir] + expected = _relative_set(Path(tmp_slot)) + committed = _relative_set(Path(real_outdir)) + + changed = sorted( + k for k in expected if k in committed and expected[k] != committed[k] + ) + missing = sorted(k for k in expected if k not in committed) + extra = sorted(k for k in committed if k not in expected) + + names = "+".join( + t.name for t in closure if config.out_dir_for(t) == real_outdir + ) + if not changed and not missing and not extra: + print( + f"metaobjects verify [{names}]: in sync ({len(expected)} file(s))." + ) + continue + exit_code = max(exit_code, 1) + print( + f"error: [{names}] generated code is out of sync with metadata.", + file=sys.stderr, + ) + for k in changed: + print(f" drifted: {k}", file=sys.stderr) + for k in missing: + print(f" missing: {k}", file=sys.stderr) + for k in extra: + print(f" extra: {k}", file=sys.stderr) + print("regenerate (metaobjects gen) and commit the result.", file=sys.stderr) + return exit_code #: The text-ref attrs a ``template.*`` node may carry. Each is resolved through diff --git a/server/python/tests/codegen/test_cli_config_gen.py b/server/python/tests/codegen/test_cli_config_gen.py index d14e50770..fbba3572a 100644 --- a/server/python/tests/codegen/test_cli_config_gen.py +++ b/server/python/tests/codegen/test_cli_config_gen.py @@ -102,6 +102,16 @@ def test_gen_cross_target_duplicate_output_path_guard(tmp_path: Path, capsys) -> assert "duplicate output path across targets" in capsys.readouterr().err +def test_verify_dup_targets_config_rejected(tmp_path: Path, capsys) -> None: + """verify --codegen runs the SAME cross-target duplicate-output-path guard as + gen (verify is symmetric with gen): the DUP_TARGETS config — two targets emit + the same Program.py into the same outDir — is rejected with exit 1.""" + cfg = _project(tmp_path, DUP_TARGETS) + rc = main(["verify", "--codegen", "--config", str(cfg)]) + assert rc == 1 + assert "duplicate output path across targets" in capsys.readouterr().err + + SHARED_OUTDIR_DISJOINT_ENTITIES = """ targets: a: diff --git a/server/python/tests/codegen/test_cli_config_verify.py b/server/python/tests/codegen/test_cli_config_verify.py index 14384a05f..44a93f5c5 100644 --- a/server/python/tests/codegen/test_cli_config_verify.py +++ b/server/python/tests/codegen/test_cli_config_verify.py @@ -25,13 +25,25 @@ entities: [Node, Measurement] """ +SHARED_OUTDIR = """ +targets: + a: + outDir: shared + generators: [entity] + entities: [Program] + b: + outDir: shared + generators: [entity] + entities: [Week] +""" -def _project(tmp_path: Path) -> Path: + +def _project(tmp_path: Path, config_text: str = TWO_TARGETS) -> Path: meta = tmp_path / "metaobjects" meta.mkdir() (meta / "meta.fitness.json").write_text(FITNESS.read_text()) cfg = tmp_path / "metaobjects.config.yaml" - cfg.write_text(TWO_TARGETS) + cfg.write_text(config_text) return cfg @@ -84,3 +96,59 @@ def test_verify_templates_config_mode_requires_metadata_dir(tmp_path: Path) -> N no positional metadata_dir is given (config mode / --templates only drives --codegen).""" assert main(["verify", "--templates"]) == 2 + + +def test_verify_codegen_shared_outdir_disjoint_entities_in_sync(tmp_path: Path) -> None: + """Two targets sharing an outDir with DISJOINT entities: gen succeeds, and + verify --codegen must NOT report the co-resident target's files as false + `extra` drift. The diff is union-of-co-resident-regen vs the shared dir.""" + cfg = _project(tmp_path, SHARED_OUTDIR) + assert main(["gen", "--config", str(cfg)]) == 0 + assert (tmp_path / "shared/Program.py").exists() + assert (tmp_path / "shared/Week.py").exists() + # Bug repro: this exits 1 today with a false `extra` on both targets. + assert main(["verify", "--codegen", "--config", str(cfg)]) == 0 + + +def test_verify_codegen_shared_outdir_detects_real_drift(tmp_path: Path, capsys) -> None: + """Real drift is still detected under a shared outDir: hand-editing one + co-resident target's file flags it as `drifted` (labeled for the shared unit).""" + cfg = _project(tmp_path, SHARED_OUTDIR) + assert main(["gen", "--config", str(cfg)]) == 0 + target = tmp_path / "shared/Week.py" + target.write_text(target.read_text() + "\n# hand-edited drift\n") + rc = main(["verify", "--codegen", "--config", str(cfg)]) + assert rc == 1 + err = capsys.readouterr().err + assert "drifted" in err and "Week.py" in err + + +def test_verify_codegen_shared_outdir_detects_stale_extra(tmp_path: Path, capsys) -> None: + """A genuinely stale committed file (produced by no target) is still flagged + `extra` under a shared outDir — stale detection is preserved under sharing.""" + cfg = _project(tmp_path, SHARED_OUTDIR) + assert main(["gen", "--config", str(cfg)]) == 0 + (tmp_path / "shared/Orphan.py").write_text("# not produced by any target\n") + rc = main(["verify", "--codegen", "--config", str(cfg)]) + assert rc == 1 + err = capsys.readouterr().err + assert "extra" in err and "Orphan.py" in err + + +def test_verify_target_scoping_widens_to_shared_outdir(tmp_path: Path, capsys) -> None: + """`verify --target a` on a shared-outDir config widens to the shared outDir + as a unit: no false positive on a clean tree (with a widening note), and a + drift in target b's file IS caught because the shared dir is verified together.""" + cfg = _project(tmp_path, SHARED_OUTDIR) + assert main(["gen", "--config", str(cfg)]) == 0 + # Clean tree: --target a widens to cover the shared dir (b co-resident) → no false positive. + rc = main(["verify", "--codegen", "--config", str(cfg), "--target", "a"]) + assert rc == 0 + note = capsys.readouterr().err + assert "note:" in note and "shares an outDir" in note and "b" in note + # Drift target b's file; --target a still catches it (shared dir verified as a unit). + week = tmp_path / "shared/Week.py" + week.write_text(week.read_text() + "\n# drift\n") + rc = main(["verify", "--codegen", "--config", str(cfg), "--target", "a"]) + assert rc == 1 + assert "Week.py" in capsys.readouterr().err From 606437b5cb625b35bb4da74746eb74886818d5f1 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 3 Aug 2026 21:21:41 -0400 Subject: [PATCH 9/9] no-mistakes(document): Add Python config doc pointers; fix unused-var lint --- docs/features/own-your-codegen.md | 2 +- docs/ports/python.md | 6 ++++++ server/python/tests/codegen/test_cli_config_verify.py | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index a42a484af..84500435f 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -62,7 +62,7 @@ provider class** in each language: a JSON file could express the declarative sur | Port | How the CLI / build tool loads your provider | |---|---| | **TypeScript** | `metaobjects.config.ts` → `providers: [myProvider]`. Threaded into `meta gen`, `meta verify`, `meta docs`, **and** the offline `meta migrate` paths (baseline + generate). | -| **Python** | `metaobjects gen \| verify \| docs --provider module:symbol` (repeatable). The symbol resolves to a `Provider` (or a list, or a zero-arg factory returning one) and is composed **on top of** the core providers — parity with TS `config.providers`. E.g. `metaobjects gen ./metaobjects --out ./gen --provider myapp.providers:view_provider`. | +| **Python** | `metaobjects gen \| verify \| docs --provider module:symbol` (repeatable). The symbol resolves to a `Provider` (or a list, or a zero-arg factory returning one) and is composed **on top of** the core providers — parity with TS `config.providers`. E.g. `metaobjects gen ./metaobjects --out ./gen --provider myapp.providers:view_provider`. A declarative `metaobjects.config.yaml` `providers:` list is also supported (resolved config-relative, no `PYTHONPATH=` — mirroring TS's config-file providers); see [`cli.md`](cli.md). | | **Java / Kotlin** | Put your compiled `MetaDataTypeProvider` on the **project classpath** with a `META-INF/services/com.metaobjects.registry.MetaDataTypeProvider` entry. `metaobjects-maven-plugin` builds the loader with the project classloader, so **Java ServiceLoader auto-discovers it** — no plugin config needed. | | **C#** | The loader accepts providers like every port; a first-class `dotnet meta` consumer-provider hook is tracked for a future release ([#158](https://github.com/metaobjectsdev/metaobjects/issues/158)). Today, extend the metamodel from an app that constructs the loader directly. | diff --git a/docs/ports/python.md b/docs/ports/python.md index f5a4a692d..7d71f1049 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -95,6 +95,12 @@ metaobjects gen ./metadata --out ./generated # codegen → write metaobjects verify ./metadata --out ./generated # drift gate; bare verify defaults to --codegen ``` +For multi-target projects (several `outDir`s, per-target generator/entity +selection, config-relative provider resolution), `metaobjects gen`/`verify` also +read a declarative [`metaobjects.config.yaml`](../features/cli.md) (#267) — run +either with no positional `` to use it; the flag path above stays +byte-identical. + **Both `pydantic` and `fastapi` are consumer-installed, not transitive deps of `metaobjects` itself** — the entity/router files this emits `import pydantic` and `import fastapi`, so add them before importing the generated code: diff --git a/server/python/tests/codegen/test_cli_config_verify.py b/server/python/tests/codegen/test_cli_config_verify.py index 44a93f5c5..6b7acb817 100644 --- a/server/python/tests/codegen/test_cli_config_verify.py +++ b/server/python/tests/codegen/test_cli_config_verify.py @@ -55,7 +55,7 @@ def test_verify_codegen_no_args_in_sync(tmp_path: Path) -> None: def test_verify_codegen_bare_defaults_to_codegen(tmp_path: Path, monkeypatch) -> None: - cfg = _project(tmp_path) + _project(tmp_path) monkeypatch.chdir(tmp_path) assert main(["gen"]) == 0 assert main(["verify"]) == 0 # bare verify → --codegen default, config-driven @@ -84,7 +84,7 @@ def test_verify_codegen_target_scopes(tmp_path: Path) -> None: def test_verify_flag_path_still_works_with_config_present(tmp_path: Path) -> None: """Back-compat: legacy `verify --out` diff is unchanged when a config exists.""" - cfg = _project(tmp_path) + _project(tmp_path) meta = tmp_path / "metaobjects" out = tmp_path / "flagout" assert main(["gen", str(meta), "--out", str(out)]) == 0