Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
48 changes: 41 additions & 7 deletions server/python/src/metaobjects/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, []

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 34 additions & 2 deletions server/python/src/metaobjects/codegen/generators/entity_model.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.<sub_type>`` children (effective, supers included)."""
return [
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions server/python/tests/codegen/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 54 additions & 0 deletions server/python/tests/codegen/test_entity_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion server/python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading