Skip to content

Commit 8352079

Browse files
dmealingclaude
andauthored
feat(python-codegen): @column field naming, server-default exprs, --entities allowlist (#79)
* feat(python-codegen): @column field naming, server-default exprs, --entities allowlist 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 <names>` 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9b56002 commit 8352079

6 files changed

Lines changed: 155 additions & 11 deletions

File tree

server/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "metaobjects"
3-
version = "0.11.1"
3+
version = "0.12.0"
44
description = "Cross-language metadata standard: declare typed entities once, generate idiomatic drift-checked code across languages — Python port."
55
readme = "README.md"
66
requires-python = ">=3.11"

server/python/src/metaobjects/cli.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,21 +129,37 @@ def _resolve_generators(names: str) -> tuple[list[Generator], list[str]]:
129129
return gens, errors
130130

131131

132+
def _parse_entities(value: str | None) -> list[str] | None:
133+
"""Parse a comma-separated ``--entities`` value into a name list (``None`` =
134+
no filter = generate every entity). Blank names are dropped."""
135+
if not value:
136+
return None
137+
names = [n.strip() for n in value.split(",") if n.strip()]
138+
return names or None
139+
140+
132141
def _generate(
133-
metadata_dir: str, out_dir: str, generators: list[Generator] | None = None
142+
metadata_dir: str,
143+
out_dir: str,
144+
generators: list[Generator] | None = None,
145+
entity_filter: list[str] | None = None,
134146
) -> tuple[list[str], list[str]]:
135147
"""Run the generator suite into ``out_dir``.
136148
137149
``generators`` defaults to the zero-config default suite; pass a registry-
138-
resolved subset for ``--generators``. Returns ``(written_paths, errors)``. On
139-
a load error, ``errors`` is non-empty and no files are written.
150+
resolved subset for ``--generators``. ``entity_filter`` (the ``--entities``
151+
allowlist) restricts which entities are EMITTED while the WHOLE model is still
152+
loaded, so cross-entity references (``extends`` bases, ``@objectRef`` VOs)
153+
resolve — emit a subset without splitting the metadata. Returns
154+
``(written_paths, errors)``. On a load error, ``errors`` is non-empty and no
155+
files are written.
140156
"""
141157
root, errors = _load_root(metadata_dir)
142158
if root is None:
143159
return [], errors
144160
config = GenConfig(out_dir=out_dir)
145161
suite = generators if generators is not None else _default_generators()
146-
result = run_gen(config, root, generators=suite)
162+
result = run_gen(config, root, generators=suite, entity_filter=entity_filter)
147163
written = [path for path, status in result.files if status != "refused"]
148164
return written, []
149165

@@ -277,7 +293,8 @@ def _cmd_gen(args: argparse.Namespace) -> int:
277293
print(f" {msg}", file=sys.stderr)
278294
return 1
279295

280-
written, errors = _generate(args.metadata_dir, args.out, generators)
296+
entities = _parse_entities(getattr(args, "entities", None))
297+
written, errors = _generate(args.metadata_dir, args.out, generators, entities)
281298
if errors:
282299
print("error: failed to load metadata:", file=sys.stderr)
283300
for msg in errors:
@@ -317,9 +334,12 @@ def _verify_codegen(args: argparse.Namespace) -> int:
317334
)
318335
return 2
319336

320-
# Reuse the exact gen code path — regenerate into a throwaway temp dir.
337+
# Reuse the exact gen code path — regenerate into a throwaway temp dir. The
338+
# --entities filter must match the `gen` that produced --out, or the diff
339+
# reports the un-emitted entities as spurious drift.
321340
with tempfile.TemporaryDirectory() as tmp:
322-
written, errors = _generate(args.metadata_dir, tmp)
341+
entities = _parse_entities(getattr(args, "entities", None))
342+
written, errors = _generate(args.metadata_dir, tmp, None, entities)
323343
if errors:
324344
print("error: failed to load metadata:", file=sys.stderr)
325345
for msg in errors:
@@ -566,6 +586,15 @@ def _build_parser() -> argparse.ArgumentParser:
566586
default=None,
567587
help="(reserved) package hint; Python derives package from metadata",
568588
)
589+
gen.add_argument(
590+
"--entities",
591+
default=None,
592+
help=(
593+
"comma-separated entity NAMES to emit (allowlist). The whole model is "
594+
"still loaded so `extends` bases and @objectRef VOs resolve; only the "
595+
"named entities are written. Omit to emit every entity."
596+
),
597+
)
569598
gen.set_defaults(func=_cmd_gen)
570599

571600
docs = sub.add_parser(
@@ -638,6 +667,11 @@ def _build_parser() -> argparse.ArgumentParser:
638667
default=None,
639668
help="on-disk template/prompt dir the --templates gate resolves refs against",
640669
)
670+
verify.add_argument(
671+
"--entities",
672+
default=None,
673+
help="comma-separated entity allowlist for --codegen drift (match `gen --entities`)",
674+
)
641675
verify.set_defaults(func=_cmd_verify)
642676

643677
agent_docs = sub.add_parser(

server/python/src/metaobjects/codegen/generators/entity_model.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""object.* → Pydantic v2 model module (sub-project A)."""
22
from __future__ import annotations
33

4+
import re
5+
46
from metaobjects.meta.core.field.meta_field import MetaField
57
from metaobjects.meta.core.object.meta_object import MetaObject
68
from metaobjects.meta.core.field import field_constants as fc
@@ -25,6 +27,27 @@ def _is_int(value: object) -> bool:
2527
return isinstance(value, int) and not isinstance(value, bool)
2628

2729

30+
# SQL expression defaults: anything matching is a SERVER-side default (the DB fills
31+
# it), NOT a Python literal default — so `@default: gen_random_uuid()` must not emit
32+
# `id: uuid.UUID = "gen_random_uuid()"`. Mirrors the TS column-mapper's
33+
# SQL_EXPR_PATTERNS (and migrate-ts EXPR_DEFAULT_PATTERNS) so every port agrees on
34+
# what counts as an expression.
35+
_SQL_EXPR_DEFAULT_PATTERNS = (
36+
re.compile(r"^now$", re.I),
37+
re.compile(r"^now\(\)$", re.I),
38+
re.compile(r"^current_timestamp$", re.I),
39+
re.compile(r"^current_date$", re.I),
40+
re.compile(r"^current_time$", re.I),
41+
re.compile(r"\(\)$"), # anything function-like, e.g. gen_random_uuid()
42+
)
43+
44+
45+
def _is_sql_expr_default(value: object) -> bool:
46+
"""True iff *value* is a server-side SQL expression default (DB-filled), not a
47+
Python literal. Only string defaults can be expressions."""
48+
return isinstance(value, str) and any(p.search(value) for p in _SQL_EXPR_DEFAULT_PATTERNS)
49+
50+
2851
def _validators(field: MetaField, sub_type: str) -> list[MetaField]:
2952
"""The field's own ``validator.<sub_type>`` children (effective, supers included)."""
3053
return [
@@ -121,7 +144,10 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple
121144
imports.add(f"from .{ref_name} import {ref_name}")
122145
required = field.attrs().get(fc.FIELD_ATTR_REQUIRED) is True
123146
default_raw = field.attrs().get(fc.FIELD_ATTR_DEFAULT)
124-
has_default = default_raw is not None
147+
# A server-side expression default (now(), gen_random_uuid(), CURRENT_TIMESTAMP)
148+
# is filled by the DB — the Python model carries no default for it (the field keeps
149+
# its required/optional shape). Only literal defaults become Pydantic defaults.
150+
has_default = default_raw is not None and not _is_sql_expr_default(default_raw)
125151
enum_type_name = type_name if shared is not None else None
126152

127153
constraints = _validator_constraints(field)
@@ -154,7 +180,13 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple
154180
else:
155181
assignment = " = None"
156182

157-
return f" {field.name}: {annotation}{assignment}", uses_field
183+
# Pydantic field name = @column when present, else field.name. A cross-port
184+
# entity carries the camelCase field.name (the TS property) AND the snake_case
185+
# @column (the physical DB column); the Python model field IS the column, so it
186+
# binds straight to the row. Backward-compatible: snake-authored models with no
187+
# @column emit field.name unchanged.
188+
py_name = field.attrs().get(fc.FIELD_ATTR_COLUMN) or field.name
189+
return f" {py_name}: {annotation}{assignment}", uses_field
158190

159191

160192
def _effective_fqn(entity: MetaObject) -> str:

server/python/tests/codegen/test_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,30 @@ def test_gen_writes_files(tmp_path: Path) -> None:
3939
assert (out / "Program.py").exists()
4040

4141

42+
def test_gen_entities_allowlist_emits_only_named(tmp_path: Path) -> None:
43+
"""`gen --entities` emits only the named entities (the whole model is still
44+
loaded, so references resolve); the others are not written."""
45+
meta_dir = _meta_dir(tmp_path)
46+
out = tmp_path / "out"
47+
rc = main(["gen", meta_dir, "--out", str(out), "--entities", "Program,Week"])
48+
assert rc == 0
49+
assert (out / "Program.py").exists()
50+
assert (out / "Week.py").exists()
51+
# Other fixture entities are excluded by the allowlist.
52+
assert not (out / "Node.py").exists()
53+
assert not (out / "Measurement.py").exists()
54+
assert not (out / "Asset.py").exists()
55+
56+
57+
def test_verify_entities_allowlist_in_sync(tmp_path: Path) -> None:
58+
"""verify --codegen with the SAME --entities as the gen reports no drift
59+
(without the filter it would flag the un-emitted entities as missing)."""
60+
meta_dir = _meta_dir(tmp_path)
61+
out = tmp_path / "out"
62+
assert main(["gen", meta_dir, "--out", str(out), "--entities", "Program,Week"]) == 0
63+
assert main(["verify", meta_dir, "--out", str(out), "--entities", "Program,Week"]) == 0
64+
65+
4266
def test_verify_in_sync_returns_zero(tmp_path: Path) -> None:
4367
meta_dir = _meta_dir(tmp_path)
4468
out = tmp_path / "out"

server/python/tests/codegen/test_entity_model.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,60 @@ def test_nested_object_array_imports_ref() -> None:
7474
assert "posts: list[PostBrief] | None = None" in out
7575

7676

77+
def test_column_attr_overrides_python_field_name() -> None:
78+
"""A field's ``@column`` (the physical DB column) is the Pydantic field name
79+
when present, falling back to ``field.name``. This lets one entity carry the
80+
camelCase ``field.name`` the TS port emits as a property AND the snake_case
81+
``@column`` the Python port emits as the model field (= DB column), so a single
82+
cross-port entity feeds both languages idiomatically."""
83+
f = _f("callPurpose", fc.FIELD_SUBTYPE_STRING, required=True)
84+
f.set_attr(fc.FIELD_ATTR_COLUMN, "call_purpose")
85+
e = _entity("LlmCall", [f], package="app::telemetry")
86+
out = render_entity_model(e)
87+
assert "call_purpose: str" in out
88+
assert "callPurpose" not in out
89+
90+
91+
def test_field_name_used_when_no_column_attr() -> None:
92+
"""Backward-compat: with no ``@column``, the Pydantic field name is ``field.name``
93+
verbatim (so snake-authored models regenerate byte-identically)."""
94+
e = _entity("Subscriber", [_f("email_address", fc.FIELD_SUBTYPE_STRING, required=True)])
95+
out = render_entity_model(e)
96+
assert "email_address: str" in out
97+
98+
99+
def test_server_default_expression_is_not_a_python_default() -> None:
100+
"""A server-side default EXPRESSION (gen_random_uuid(), now(), CURRENT_TIMESTAMP)
101+
is filled by the database, not the Python model — so it must NOT become a Pydantic
102+
field default (``id: uuid.UUID = "gen_random_uuid()"`` is invalid). The field keeps
103+
its required/optional shape with no Python default. Mirrors the TS column-mapper's
104+
SQL_EXPR_PATTERNS so both ports agree on what's an expression."""
105+
fid = _f("id", fc.FIELD_SUBTYPE_UUID, required=True)
106+
fid.set_attr(fc.FIELD_ATTR_DEFAULT, "gen_random_uuid()")
107+
fts = _f("started_at", fc.FIELD_SUBTYPE_TIMESTAMP, required=True)
108+
fts.set_attr(fc.FIELD_ATTR_DEFAULT, "now()")
109+
fcur = _f("logged_at", fc.FIELD_SUBTYPE_TIMESTAMP)
110+
fcur.set_attr(fc.FIELD_ATTR_DEFAULT, "CURRENT_TIMESTAMP")
111+
out = render_entity_model(_entity("Row", [fid, fts, fcur]))
112+
assert "id: uuid.UUID" in out
113+
assert "gen_random_uuid" not in out
114+
assert "started_at: datetime.datetime" in out
115+
assert "now()" not in out
116+
assert "logged_at: datetime.datetime | None = None" in out
117+
assert "CURRENT_TIMESTAMP" not in out
118+
119+
120+
def test_literal_default_is_still_emitted() -> None:
121+
"""A non-expression default (a string literal, bool, number) stays a Python default."""
122+
fstatus = _f("status", fc.FIELD_SUBTYPE_STRING)
123+
fstatus.set_attr(fc.FIELD_ATTR_DEFAULT, "active")
124+
fflag = _f("enabled", fc.FIELD_SUBTYPE_BOOLEAN)
125+
fflag.set_attr(fc.FIELD_ATTR_DEFAULT, True)
126+
out = render_entity_model(_entity("Row", [fstatus, fflag]))
127+
assert "status: str = 'active'" in out
128+
assert "enabled: bool = True" in out
129+
130+
77131
def test_header_fqn_folds_file_default_package_after_merge() -> None:
78132
"""After a multi-file merge an object hangs under one package-less merged
79133
root, so the naive nearest-ancestor walk would fold every object onto the

server/python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)