From 28a049d831b1bcfc4ddce8d8915aa75a3962b80b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 26 Jun 2026 05:26:17 -0400 Subject: [PATCH 1/2] feat(python-codegen): @column field naming, server-default exprs, --entities allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consumer-facing gaps surfaced by generating Python record models from the SAME entity metadata that drives the TypeScript Drizzle layer: 1. @column-aware Pydantic field naming. The entity/value generator emitted `field.name` verbatim, so a model authored camelCase for the TS port (`callPurpose` + `@column: call_purpose`) produced camelCase Python fields that don't match the DB columns. The Pydantic field name is now `@column` when present, else `field.name` — one entity feeds both languages idiomatically (camelCase TS property, snake_case Python field = the physical column). Backward-compatible: no @column emits identically. 2. Server-side default EXPRESSIONS are no longer rendered as Python literal defaults. A string @default matching the SQL-expression patterns (now, now(), current_timestamp/date/time, anything ending in `()` such as gen_random_uuid()) is DB-filled, so emitting `id: uuid.UUID = "gen_random_uuid()"` was wrong. Such a field now carries no Python default (keeps its required/optional shape); literal defaults (bool/int/str/enum) are unchanged. Mirrors the TS column-mapper's SQL_EXPR_PATTERNS so both ports agree on what counts as an expression. 3. `metaobjects gen --entities ` allowlist. The whole model is still loaded (so `extends` bases and @objectRef VOs resolve), but only the named entities are emitted — generate a subset of a shared model without splitting the metadata. Wired through `gen` and `verify --codegen` (run_gen already supported entity_filter). Co-Authored-By: Claude Opus 4.8 --- server/python/src/metaobjects/cli.py | 48 ++++++++++++++--- .../codegen/generators/entity_model.py | 36 ++++++++++++- server/python/tests/codegen/test_cli.py | 24 +++++++++ .../python/tests/codegen/test_entity_model.py | 54 +++++++++++++++++++ 4 files changed, 153 insertions(+), 9 deletions(-) diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index d5a28b483..46e6bd855 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -129,21 +129,37 @@ def _resolve_generators(names: str) -> tuple[list[Generator], list[str]]: return gens, errors +def _parse_entities(value: str | None) -> list[str] | None: + """Parse a comma-separated ``--entities`` value into a name list (``None`` = + no filter = generate every entity). Blank names are dropped.""" + if not value: + return None + names = [n.strip() for n in value.split(",") if n.strip()] + return names or None + + def _generate( - metadata_dir: str, out_dir: str, generators: list[Generator] | None = None + metadata_dir: str, + out_dir: str, + generators: list[Generator] | None = None, + entity_filter: list[str] | None = None, ) -> tuple[list[str], list[str]]: """Run the generator suite into ``out_dir``. ``generators`` defaults to the zero-config default suite; pass a registry- - resolved subset for ``--generators``. Returns ``(written_paths, errors)``. On - a load error, ``errors`` is non-empty and no files are written. + resolved subset for ``--generators``. ``entity_filter`` (the ``--entities`` + allowlist) restricts which entities are EMITTED while the WHOLE model is still + loaded, so cross-entity references (``extends`` bases, ``@objectRef`` VOs) + resolve — emit a subset without splitting the metadata. Returns + ``(written_paths, errors)``. On a load error, ``errors`` is non-empty and no + files are written. """ root, errors = _load_root(metadata_dir) if root is None: return [], errors config = GenConfig(out_dir=out_dir) suite = generators if generators is not None else _default_generators() - result = run_gen(config, root, generators=suite) + result = run_gen(config, root, generators=suite, entity_filter=entity_filter) written = [path for path, status in result.files if status != "refused"] return written, [] @@ -277,7 +293,8 @@ def _cmd_gen(args: argparse.Namespace) -> int: print(f" {msg}", file=sys.stderr) return 1 - written, errors = _generate(args.metadata_dir, args.out, generators) + entities = _parse_entities(getattr(args, "entities", None)) + written, errors = _generate(args.metadata_dir, args.out, generators, entities) if errors: print("error: failed to load metadata:", file=sys.stderr) for msg in errors: @@ -317,9 +334,12 @@ def _verify_codegen(args: argparse.Namespace) -> int: ) return 2 - # Reuse the exact gen code path — regenerate into a throwaway temp dir. + # Reuse the exact gen code path — regenerate into a throwaway temp dir. The + # --entities filter must match the `gen` that produced --out, or the diff + # reports the un-emitted entities as spurious drift. with tempfile.TemporaryDirectory() as tmp: - written, errors = _generate(args.metadata_dir, tmp) + entities = _parse_entities(getattr(args, "entities", None)) + written, errors = _generate(args.metadata_dir, tmp, None, entities) if errors: print("error: failed to load metadata:", file=sys.stderr) for msg in errors: @@ -566,6 +586,15 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="(reserved) package hint; Python derives package from metadata", ) + gen.add_argument( + "--entities", + default=None, + help=( + "comma-separated entity NAMES to emit (allowlist). The whole model is " + "still loaded so `extends` bases and @objectRef VOs resolve; only the " + "named entities are written. Omit to emit every entity." + ), + ) gen.set_defaults(func=_cmd_gen) docs = sub.add_parser( @@ -638,6 +667,11 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="on-disk template/prompt dir the --templates gate resolves refs against", ) + verify.add_argument( + "--entities", + default=None, + help="comma-separated entity allowlist for --codegen drift (match `gen --entities`)", + ) verify.set_defaults(func=_cmd_verify) agent_docs = sub.add_parser( diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index 32385a67b..b1ad15ccb 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -1,6 +1,8 @@ """object.* → Pydantic v2 model module (sub-project A).""" from __future__ import annotations +import re + from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.core.field import field_constants as fc @@ -25,6 +27,27 @@ def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) +# SQL expression defaults: anything matching is a SERVER-side default (the DB fills +# it), NOT a Python literal default — so `@default: gen_random_uuid()` must not emit +# `id: uuid.UUID = "gen_random_uuid()"`. Mirrors the TS column-mapper's +# SQL_EXPR_PATTERNS (and migrate-ts EXPR_DEFAULT_PATTERNS) so every port agrees on +# what counts as an expression. +_SQL_EXPR_DEFAULT_PATTERNS = ( + re.compile(r"^now$", re.I), + re.compile(r"^now\(\)$", re.I), + re.compile(r"^current_timestamp$", re.I), + re.compile(r"^current_date$", re.I), + re.compile(r"^current_time$", re.I), + re.compile(r"\(\)$"), # anything function-like, e.g. gen_random_uuid() +) + + +def _is_sql_expr_default(value: object) -> bool: + """True iff *value* is a server-side SQL expression default (DB-filled), not a + Python literal. Only string defaults can be expressions.""" + return isinstance(value, str) and any(p.search(value) for p in _SQL_EXPR_DEFAULT_PATTERNS) + + def _validators(field: MetaField, sub_type: str) -> list[MetaField]: """The field's own ``validator.`` children (effective, supers included).""" return [ @@ -121,7 +144,10 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple imports.add(f"from .{ref_name} import {ref_name}") required = field.attrs().get(fc.FIELD_ATTR_REQUIRED) is True default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT) - has_default = default_raw is not None + # A server-side expression default (now(), gen_random_uuid(), CURRENT_TIMESTAMP) + # is filled by the DB — the Python model carries no default for it (the field keeps + # its required/optional shape). Only literal defaults become Pydantic defaults. + has_default = default_raw is not None and not _is_sql_expr_default(default_raw) enum_type_name = type_name if shared is not None else None constraints = _validator_constraints(field) @@ -154,7 +180,13 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple else: assignment = " = None" - return f" {field.name}: {annotation}{assignment}", uses_field + # Pydantic field name = @column when present, else field.name. A cross-port + # entity carries the camelCase field.name (the TS property) AND the snake_case + # @column (the physical DB column); the Python model field IS the column, so it + # binds straight to the row. Backward-compatible: snake-authored models with no + # @column emit field.name unchanged. + py_name = field.attrs().get(fc.FIELD_ATTR_COLUMN) or field.name + return f" {py_name}: {annotation}{assignment}", uses_field def _effective_fqn(entity: MetaObject) -> str: diff --git a/server/python/tests/codegen/test_cli.py b/server/python/tests/codegen/test_cli.py index ee3992acc..34fe8ab35 100644 --- a/server/python/tests/codegen/test_cli.py +++ b/server/python/tests/codegen/test_cli.py @@ -39,6 +39,30 @@ def test_gen_writes_files(tmp_path: Path) -> None: assert (out / "Program.py").exists() +def test_gen_entities_allowlist_emits_only_named(tmp_path: Path) -> None: + """`gen --entities` emits only the named entities (the whole model is still + loaded, so references resolve); the others are not written.""" + meta_dir = _meta_dir(tmp_path) + out = tmp_path / "out" + rc = main(["gen", meta_dir, "--out", str(out), "--entities", "Program,Week"]) + assert rc == 0 + assert (out / "Program.py").exists() + assert (out / "Week.py").exists() + # Other fixture entities are excluded by the allowlist. + assert not (out / "Node.py").exists() + assert not (out / "Measurement.py").exists() + assert not (out / "Asset.py").exists() + + +def test_verify_entities_allowlist_in_sync(tmp_path: Path) -> None: + """verify --codegen with the SAME --entities as the gen reports no drift + (without the filter it would flag the un-emitted entities as missing).""" + meta_dir = _meta_dir(tmp_path) + out = tmp_path / "out" + assert main(["gen", meta_dir, "--out", str(out), "--entities", "Program,Week"]) == 0 + assert main(["verify", meta_dir, "--out", str(out), "--entities", "Program,Week"]) == 0 + + def test_verify_in_sync_returns_zero(tmp_path: Path) -> None: meta_dir = _meta_dir(tmp_path) out = tmp_path / "out" diff --git a/server/python/tests/codegen/test_entity_model.py b/server/python/tests/codegen/test_entity_model.py index 1c02ae7c0..1165ea5b7 100644 --- a/server/python/tests/codegen/test_entity_model.py +++ b/server/python/tests/codegen/test_entity_model.py @@ -74,6 +74,60 @@ def test_nested_object_array_imports_ref() -> None: assert "posts: list[PostBrief] | None = None" in out +def test_column_attr_overrides_python_field_name() -> None: + """A field's ``@column`` (the physical DB column) is the Pydantic field name + when present, falling back to ``field.name``. This lets one entity carry the + camelCase ``field.name`` the TS port emits as a property AND the snake_case + ``@column`` the Python port emits as the model field (= DB column), so a single + cross-port entity feeds both languages idiomatically.""" + f = _f("callPurpose", fc.FIELD_SUBTYPE_STRING, required=True) + f.set_attr(fc.FIELD_ATTR_COLUMN, "call_purpose") + e = _entity("LlmCall", [f], package="app::telemetry") + out = render_entity_model(e) + assert "call_purpose: str" in out + assert "callPurpose" not in out + + +def test_field_name_used_when_no_column_attr() -> None: + """Backward-compat: with no ``@column``, the Pydantic field name is ``field.name`` + verbatim (so snake-authored models regenerate byte-identically).""" + e = _entity("Subscriber", [_f("email_address", fc.FIELD_SUBTYPE_STRING, required=True)]) + out = render_entity_model(e) + assert "email_address: str" in out + + +def test_server_default_expression_is_not_a_python_default() -> None: + """A server-side default EXPRESSION (gen_random_uuid(), now(), CURRENT_TIMESTAMP) + is filled by the database, not the Python model — so it must NOT become a Pydantic + field default (``id: uuid.UUID = "gen_random_uuid()"`` is invalid). The field keeps + its required/optional shape with no Python default. Mirrors the TS column-mapper's + SQL_EXPR_PATTERNS so both ports agree on what's an expression.""" + fid = _f("id", fc.FIELD_SUBTYPE_UUID, required=True) + fid.set_attr(fc.FIELD_ATTR_DEFAULT, "gen_random_uuid()") + fts = _f("started_at", fc.FIELD_SUBTYPE_TIMESTAMP, required=True) + fts.set_attr(fc.FIELD_ATTR_DEFAULT, "now()") + fcur = _f("logged_at", fc.FIELD_SUBTYPE_TIMESTAMP) + fcur.set_attr(fc.FIELD_ATTR_DEFAULT, "CURRENT_TIMESTAMP") + out = render_entity_model(_entity("Row", [fid, fts, fcur])) + assert "id: uuid.UUID" in out + assert "gen_random_uuid" not in out + assert "started_at: datetime.datetime" in out + assert "now()" not in out + assert "logged_at: datetime.datetime | None = None" in out + assert "CURRENT_TIMESTAMP" not in out + + +def test_literal_default_is_still_emitted() -> None: + """A non-expression default (a string literal, bool, number) stays a Python default.""" + fstatus = _f("status", fc.FIELD_SUBTYPE_STRING) + fstatus.set_attr(fc.FIELD_ATTR_DEFAULT, "active") + fflag = _f("enabled", fc.FIELD_SUBTYPE_BOOLEAN) + fflag.set_attr(fc.FIELD_ATTR_DEFAULT, True) + out = render_entity_model(_entity("Row", [fstatus, fflag])) + assert "status: str = 'active'" in out + assert "enabled: bool = True" in out + + def test_header_fqn_folds_file_default_package_after_merge() -> None: """After a multi-file merge an object hangs under one package-less merged root, so the naive nearest-ancestor walk would fold every object onto the From 491816050a6f60be4406be9397be5d75b8f53940 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 26 Jun 2026 05:26:24 -0400 Subject: [PATCH 2/2] chore(release): bump python port to 0.12.0 Minor bump for the backward-compatible codegen features in the previous commit (@column field naming, server-default expressions, --entities allowlist) and to align the Python port with the 0.12.x line. pyproject + uv.lock self-version only. Co-Authored-By: Claude Opus 4.8 --- server/python/pyproject.toml | 2 +- server/python/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/python/pyproject.toml b/server/python/pyproject.toml index 00ea96550..2148a2dd3 100644 --- a/server/python/pyproject.toml +++ b/server/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "metaobjects" -version = "0.11.1" +version = "0.12.0" description = "Cross-language metadata standard: declare typed entities once, generate idiomatic drift-checked code across languages — Python port." readme = "README.md" requires-python = ">=3.11" diff --git a/server/python/uv.lock b/server/python/uv.lock index 793328944..624765621 100644 --- a/server/python/uv.lock +++ b/server/python/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "metaobjects" -version = "0.11.1" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "pyyaml" },