Skip to content

Commit dd9b94d

Browse files
dmealingclaude
andcommitted
feat(codegen-python): FR-019 Python — shared + @provided enums
Brings FR-019 (ADR-0026) to the Python port, gated by the shared fixtures/codegen-conformance/shared-provided-enum oracle: - Metamodel: register @provided (optional boolean) on field.enum (core_types + field_constants) — a non-boolean value is rejected at load with ERR_BAD_ATTR_VALUE via the boolean AttrSchema. Inert on a concrete field. - Shared-materialize: a package-level abstract field.enum consumed by >=1 entity field is emitted ONCE as a module-level `class <Name>(str, Enum)` in a shared `enums.py`; consuming models reference it (`from .enums import <Name>`) instead of inlining `Literal[...]`. New fr019_shared_enum helper (Python port of the TS/C#/Java/Kotlin reference). - @provided: emit nothing for the type; consuming models import it from a per-port-configured module — GenConfig.provided_enum_packages (package->module) then the provided_enum_namespace fallback (ADR-0001). Unresolved package => codegen error naming the enum + package. - Inline enums (own @values) stay `Literal[...]` — byte-identical default; the shared enums.py is only emitted when a materialized shared enum exists. - registry_manifest: carve @provided out of the canonical manifest (new PILOT_VOCAB reason, mirrors C#/Java) until Task 4 graduates it. - Tests: new test_fr019_shared_provided_conformance (materialize-once / provided-import / missing-config-error); test_enum_conformance updated (Priority now materialized + referenced, not inlined). codegen + unit + conformance suites: 810 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ffa61aa commit dd9b94d

8 files changed

Lines changed: 315 additions & 22 deletions

File tree

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
"""Codegen run configuration (the run_gen surface)."""
22
from __future__ import annotations
33

4-
from dataclasses import dataclass
4+
from dataclasses import dataclass, field
55

66

77
@dataclass
88
class GenConfig:
99
out_dir: str
1010
output_layout: str = "flat" # "flat" only in sub-project A
1111
emit_abstract_shapes: bool = True # Python concretes subclass the abstract base model
12+
# FR-019 (ADR-0026): per-port resolution of a @provided enum's import module. The module
13+
# never lives in metadata (ADR-0001) — it is codegen config. ``provided_enum_packages``
14+
# maps a declaring metadata package ("acme::shop") to the Python import module; with a
15+
# single ``provided_enum_namespace`` fallback for the one-module case. A referenced
16+
# @provided enum whose package resolves to no module is a codegen-time error.
17+
provided_enum_namespace: str | None = None
18+
provided_enum_packages: dict[str, str] = field(default_factory=dict)

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

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from metaobjects.meta.core.validator import validator_constants as vc
88
from metaobjects.shared.base_types import TYPE_VALIDATOR
99
from metaobjects.shared.separators import PACKAGE_SEP
10+
from metaobjects.codegen.config import GenConfig
1011
from metaobjects.codegen.constants import generated_header
11-
from metaobjects.codegen.type_map import py_type_for
12+
from metaobjects.codegen.type_map import field_is_array, py_type_for
1213
from metaobjects.codegen.format import ruff_format
1314
from metaobjects.codegen.generator import EmittedFile, GenContext, Generator, per_entity
15+
from metaobjects.codegen.generators import fr019_shared_enum as fr019
1416
from metaobjects.codegen.generators.m2m_codegen import (
1517
build_object_index,
1618
resolve_m2m_descriptors,
@@ -89,10 +91,24 @@ def _validator_constraints(field: MetaField) -> dict[str, object]:
8991
return kwargs
9092

9193

92-
def _field_line(field: MetaField, imports: set[str]) -> tuple[str, bool]:
94+
def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple[str, bool]:
9395
"""Return (source line, uses_field). Collects required imports into *imports*."""
94-
pt = py_type_for(field)
95-
imports.update(pt.imports)
96+
# FR-019: a field resolving to a package-level shared enum is typed by the materialized
97+
# standalone class (imported from the shared `enums` module) or — when @provided — an
98+
# external module from per-port config; inline enums keep their `Literal[...]` (py_type_for).
99+
shared = (
100+
fr019.shared_enum_for_field(field)
101+
if field.sub_type == fc.FIELD_SUBTYPE_ENUM
102+
else None
103+
)
104+
if shared is not None:
105+
type_name, import_line = fr019.enum_type_and_import(shared, config)
106+
imports.add(import_line)
107+
type_expr = f"list[{type_name}]" if field_is_array(field) else type_name
108+
else:
109+
pt = py_type_for(field)
110+
imports.update(pt.imports)
111+
type_expr = pt.expr
96112
if field.sub_type == fc.FIELD_SUBTYPE_OBJECT:
97113
ref = field.attr(fc.FIELD_ATTR_OBJECT_REF)
98114
if ref:
@@ -105,7 +121,7 @@ def _field_line(field: MetaField, imports: set[str]) -> tuple[str, bool]:
105121
parts = [f"{k}={constraints[k]!r}" for k in _order if k in constraints]
106122
uses_field = bool(parts)
107123

108-
annotation = pt.expr if required else f"{pt.expr} | None"
124+
annotation = type_expr if required else f"{type_expr} | None"
109125
if required and uses_field:
110126
assignment = f" = Field({', '.join(parts)})"
111127
elif required:
@@ -158,16 +174,19 @@ def _emit_field_lines(
158174
entity: MetaObject,
159175
imports: set[str],
160176
object_index: dict[str, MetaObject] | None,
177+
config: GenConfig | None = None,
161178
) -> tuple[list[str], bool]:
162179
"""The model body: one line per own field, then M:N nested collections when
163180
*object_index* is supplied. Returns ``(lines, uses_field)`` where
164181
``uses_field`` is True iff any line used a pydantic ``Field(...)`` (so the
165182
caller knows to import ``Field``). Required imports are collected into
166-
*imports*. Override to add/transform body lines."""
183+
*imports*. *config* carries the FR-019 @provided-enum resolution. Override to
184+
add/transform body lines."""
185+
cfg = config if config is not None else GenConfig(out_dir="")
167186
uses_field = False
168187
lines: list[str] = []
169188
for f in entity.own_fields():
170-
line, used = _field_line(f, imports)
189+
line, used = _field_line(f, imports, cfg)
171190
uses_field = uses_field or used
172191
lines.append(line)
173192

@@ -184,22 +203,26 @@ def _emit_field_lines(
184203
return lines, uses_field
185204

186205
def render_entity_model(
187-
self, entity: MetaObject, object_index: dict[str, MetaObject] | None = None
206+
self,
207+
entity: MetaObject,
208+
object_index: dict[str, MetaObject] | None = None,
209+
config: GenConfig | None = None,
188210
) -> str:
189211
"""Render an entity as a Pydantic v2 model (pre-format; the generator runs ruff).
190212
191213
When *object_index* is supplied, M:N navigations (``relationship.*``
192214
``@cardinality:"many" + @through``) are emitted as nested Pydantic
193215
collections (``tags: list[Tag] = []``); a self-join uses a forward-ref string
194216
(``following: list["Person"] = []``). Without an index, only scalar/object
195-
fields are emitted (back-compat)."""
217+
fields are emitted (back-compat). *config* carries the FR-019 @provided-enum
218+
resolution; when omitted, only the non-provided shared-materialize path is reachable."""
196219
imports: set[str] = set()
197220
base_class = "BaseModel"
198221
if entity.super_data is not None:
199222
base_class = entity.super_data.name
200223
imports.add(f"from .{base_class} import {base_class}")
201224

202-
lines, uses_field = self._emit_field_lines(entity, imports, object_index)
225+
lines, uses_field = self._emit_field_lines(entity, imports, object_index, config)
203226

204227
# FR-017 TPH: a concrete subtype pins the inherited discriminator field to its
205228
# own value (Literal) so the Pydantic model rejects a foreign-subtype tag —
@@ -237,23 +260,51 @@ def render_entity_model(
237260
parts += ["", self._emit_class_header(entity, base_class), *body, ""]
238261
return "\n".join(parts)
239262

263+
def _render_shared_enums_module(self, entities: list[MetaObject]) -> EmittedFile | None:
264+
"""FR-019: the shared ``enums.py`` module — one module-level
265+
``class <Name>(str, Enum)`` per materialized (package-level abstract, non-``@provided``,
266+
consumed) enum. ``None`` when the model has no materialized shared enum, so the inline
267+
default output is byte-identical (no spurious empty module)."""
268+
shared = fr019.materialized_shared_enums(entities)
269+
if not shared:
270+
return None
271+
parts: list[str] = [
272+
generated_header("enums", "enums"),
273+
"from __future__ import annotations",
274+
"",
275+
"from enum import Enum",
276+
"",
277+
]
278+
for e in shared:
279+
parts += ["", f"class {e.name}(str, Enum):"]
280+
parts += [f' {m} = "{m}"' for m in e.values]
281+
parts.append("")
282+
return EmittedFile(path="enums.py", content=ruff_format("\n".join(parts)))
283+
240284
def generate(self, ctx: GenContext) -> list[EmittedFile]:
241285
index = build_object_index(ctx.entities)
242-
return per_entity(
286+
files = per_entity(
243287
lambda e, _c: EmittedFile(
244288
path=f"{e.name}.py",
245-
content=ruff_format(self.render_entity_model(e, index)),
289+
content=ruff_format(self.render_entity_model(e, index, ctx.config)),
246290
)
247291
)(ctx)
292+
matched = [e for e in ctx.entities if ctx.matches(e)]
293+
enums_module = self._render_shared_enums_module(matched)
294+
if enums_module is not None:
295+
files.append(enums_module)
296+
return files
248297

249298

250299
def render_entity_model(
251-
entity: MetaObject, object_index: dict[str, MetaObject] | None = None
300+
entity: MetaObject,
301+
object_index: dict[str, MetaObject] | None = None,
302+
config: GenConfig | None = None,
252303
) -> str:
253304
"""Module-level back-compat wrapper. Delegates to a default
254305
:class:`EntityModelGenerator` instance so existing callers (and the golden
255306
tests) are unaffected. Subclass :class:`EntityModelGenerator` to customize."""
256-
return EntityModelGenerator().render_entity_model(entity, object_index)
307+
return EntityModelGenerator().render_entity_model(entity, object_index, config)
257308

258309

259310
def entity_model() -> Generator:
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""FR-019 — shared + externally-provided enum resolution (Python port of the TS reference
2+
``enum-shared.ts`` and the C#/Java/Kotlin ``Fr019SharedEnum``).
3+
4+
A reusable enum is a ROOT/package-level abstract ``field.enum`` (a sibling of ``object.entity``)
5+
that concrete entity fields ``extends``. Per ADR-0026 such a declaration is materialized ONCE per
6+
port as a module-level ``class <Name>(str, Enum)`` and referenced, instead of redeclaring the
7+
members inline (a per-entity ``Literal[...]``) in every consumer.
8+
9+
Two cases on the abstract declaration:
10+
11+
* non-``@provided`` → metaobjects MATERIALIZES the type once (a shared ``enums.py`` module), and
12+
consuming fields reference it (``from .enums import <Name>``).
13+
* ``@provided: true`` → metaobjects emits NOTHING for the type; consuming fields import an existing
14+
type from a per-port-configured module (``GenConfig.provided_enum_packages`` keyed by the enum's
15+
declaring metadata package, then the ``provided_enum_namespace`` fallback). The module never lives
16+
in metadata (ADR-0001); a missing config for a referenced provided enum is a codegen-time error
17+
naming the enum + its package.
18+
19+
The common inline case (a concrete ``field.enum`` with own ``@values``, no root-extends) is
20+
UNCHANGED — it stays a per-entity ``Literal[...]`` (byte-identical default).
21+
"""
22+
from __future__ import annotations
23+
24+
from dataclasses import dataclass
25+
26+
from metaobjects.meta.core.field.meta_field import MetaField
27+
from metaobjects.meta.core.field import field_constants as fc
28+
from metaobjects.meta.meta_data import MetaData
29+
from metaobjects.shared.base_types import TYPE_METADATA
30+
from metaobjects.shared.separators import PACKAGE_SEP
31+
from metaobjects.codegen import type_map
32+
from metaobjects.codegen.config import GenConfig
33+
34+
35+
@dataclass(frozen=True)
36+
class SharedEnum:
37+
"""A shared (package-level abstract) enum declaration codegen reasons about."""
38+
39+
name: str #: materialized type name — the decl's PascalCase short name (cross-port id)
40+
values: list[str] #: member symbols (the @values SSOT), verbatim
41+
provided: bool #: True when the decl carries @provided: true (reference, don't materialize)
42+
meta_package: str #: declaring metadata package in "::" form (the provided-config key)
43+
44+
45+
def _pascal(name: str) -> str:
46+
"""``priority`` → ``Priority`` (leading char only; no snake-splitting — cross-port rule)."""
47+
return name[:1].upper() + name[1:] if name else name
48+
49+
50+
def resolve_shared_enum_decl(field: MetaField) -> MetaData | None:
51+
"""The package-level abstract ``field.enum`` *field* resolves to via ``extends``, or ``None``
52+
for an inline enum. Qualifies only when the immediate super is (a) a ``field.enum``,
53+
(b) abstract, AND (c) a direct child of the metadata root — a sibling of ``object.entity``,
54+
not an abstract field nested inside an object."""
55+
if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
56+
return None
57+
sup = field.super_data
58+
if sup is None or sup.sub_type != fc.FIELD_SUBTYPE_ENUM:
59+
return None
60+
if not getattr(sup, "is_abstract", False):
61+
return None
62+
parent = sup.parent
63+
if parent is None or parent.type != TYPE_METADATA:
64+
return None
65+
return sup
66+
67+
68+
def is_provided(decl: MetaData) -> bool:
69+
"""Own ``@provided`` truth of an enum declaration."""
70+
return decl.attr(fc.FIELD_ATTR_PROVIDED) is True
71+
72+
73+
def _meta_package_of(decl: MetaData) -> str:
74+
"""The decl's metadata package in ``::`` form (its resolution key minus the trailing name)."""
75+
key = decl.resolution_key()
76+
idx = key.rfind(PACKAGE_SEP)
77+
return key[:idx] if idx >= 0 else ""
78+
79+
80+
def shared_enum_for_field(field: MetaField) -> SharedEnum | None:
81+
"""The shared-enum descriptor *field* resolves to, or ``None`` for inline enums."""
82+
decl = resolve_shared_enum_decl(field)
83+
if decl is None:
84+
return None
85+
values = type_map.effective_enum_values(field)
86+
if not values:
87+
return None
88+
return SharedEnum(
89+
name=_pascal(decl.name),
90+
values=values,
91+
provided=is_provided(decl),
92+
meta_package=_meta_package_of(decl),
93+
)
94+
95+
96+
def collect_shared_enums(entities: list) -> list[SharedEnum]:
97+
"""All shared (package-level abstract) enum decls CONSUMED by at least one entity field,
98+
keyed by materialized type name, sorted by name (deterministic). A decl nobody extends is
99+
not materialized (no dangling type)."""
100+
out: dict[str, SharedEnum] = {}
101+
for entity in entities:
102+
for f in entity.own_fields():
103+
shared = shared_enum_for_field(f)
104+
if shared is None:
105+
continue
106+
out.setdefault(shared.name, shared)
107+
return sorted(out.values(), key=lambda e: e.name)
108+
109+
110+
def materialized_shared_enums(entities: list) -> list[SharedEnum]:
111+
"""The shared enums metaobjects MATERIALIZES (non-``@provided``, consumed)."""
112+
return [e for e in collect_shared_enums(entities) if not e.provided]
113+
114+
115+
def enum_type_and_import(shared: SharedEnum, config: GenConfig) -> tuple[str, str]:
116+
"""The ``(type_name, import_line)`` a consuming field emits for a shared/provided enum:
117+
118+
* materialized → ``("<Name>", "from .enums import <Name>")`` (the shared module).
119+
* ``@provided`` → ``("<Name>", "from <module> import <Name>")``, *module* resolved from
120+
``config.provided_enum_packages`` (keyed by the enum's metadata package) then the single
121+
``config.provided_enum_namespace`` fallback. A missing config is a codegen-time ``ValueError``
122+
naming the enum + its package (ADR-0026 / ADR-0001)."""
123+
name = shared.name
124+
if not shared.provided:
125+
return name, f"from .enums import {name}"
126+
module = config.provided_enum_packages.get(shared.meta_package) or config.provided_enum_namespace
127+
if not module:
128+
raise ValueError(
129+
f'provided enum "{name}" (declared in package "{shared.meta_package}") is marked '
130+
f"@provided but no Python import module is configured to reference it from. Map its "
131+
f'package in GenConfig.provided_enum_packages["{shared.meta_package}"] = '
132+
f'"your_app.enums", or set the single provided_enum_namespace fallback, so the '
133+
f'generated code can import "{name}".'
134+
)
135+
return name, f"from {module} import {name}"

server/python/src/metaobjects/core_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
FIELD_ATTR_CURRENCY,
2424
FIELD_ATTR_DEFAULT,
2525
FIELD_ATTR_ENUM_ALIAS,
26+
FIELD_ATTR_PROVIDED,
2627
FIELD_ATTR_ENUM_DOC,
2728
FIELD_ATTR_EXAMPLE,
2829
FIELD_ATTR_FILTERABLE,
@@ -372,6 +373,14 @@ def _register_subtypes(
372373
value_type=ATTR_SUBTYPE_PROPERTIES,
373374
required=False,
374375
),
376+
# FR-019 (ADR-0026): @provided boolean — marks an abstract package-level enum
377+
# as externally provided (codegen references it instead of materializing). The
378+
# boolean AttrSchema rejects a non-boolean value (ERR_BAD_ATTR_VALUE).
379+
AttrSchema(
380+
name=FIELD_ATTR_PROVIDED,
381+
value_type=ATTR_SUBTYPE_BOOLEAN,
382+
required=False,
383+
),
375384
AttrSchema(
376385
name=FIELD_ATTR_ENUM_DOC,
377386
value_type=ATTR_SUBTYPE_PROPERTIES,

server/python/src/metaobjects/meta/core/field/field_constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@
6868
# @enumDoc: member -> human-readable description (shown per-member in the 'guide' prompt).
6969
FIELD_ATTR_ENUM_ALIAS = "enumAlias"
7070
FIELD_ATTR_ENUM_DOC = "enumDoc"
71+
# FR-019 (ADR-0026): @provided boolean, own-only on an ABSTRACT package-level field.enum
72+
# declaration. When true, codegen does NOT materialize the enum type; consuming fields
73+
# reference an existing hand-written/third-party type resolved via per-port codegen config
74+
# (never an FQN in metadata — ADR-0001). Default false. Inert on a concrete consuming field.
75+
# Non-boolean → ERR_BAD_ATTR_VALUE (the boolean AttrSchema).
76+
FIELD_ATTR_PROVIDED = "provided"
7177
# FR-011 enum extract-hardening attrs (field.enum only).
7278
# @coerceDefault: present-but-uncoercible extract fallback member → DEFAULTED.
7379
# Member-validated against the effective @values (own or inherited).

server/python/src/metaobjects/registry_manifest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from enum import Enum
2626

2727
from .documentation.doc_constants import DOC_ATTR_DESCRIPTION
28+
from .meta.core.field.field_constants import FIELD_ATTR_PROVIDED
2829
from .meta.core.attr.attr_constants import (
2930
ATTR_SUBTYPE_STRING,
3031
ATTR_SUBTYPE_STRINGARRAY,
@@ -61,6 +62,11 @@ class ExclusionReason(str, Enum):
6162
INHERITANCE_ANCHOR = "inheritance-anchor"
6263
#: TS-web-presentation-only facet (the generic ``view.*`` controls).
6364
PRESENTATION_ONLY = "presentation-only"
65+
#: A pilot/not-yet-graduated cross-port attr: registered + functional here but held out of
66+
#: the canonical manifest until every port registers it (then it graduates into
67+
#: expected-registry.json and the carve-out is removed). Currently FR-019 ``@provided``
68+
#: (ADR-0026). Mirrors the C# ``ExclusionReason.PilotVocab`` / Java ``PILOT_VOCAB``.
69+
PILOT_VOCAB = "pilot-vocab"
6470

6571

6672
_ATTR_NAME_IS_ABSTRACT = "isAbstract"
@@ -84,6 +90,10 @@ class ExclusionReason(str, Enum):
8490
_ATTR_NAME_OBJECT: ExclusionReason.NATIVE_BINDING,
8591
_ATTR_NAME_OBJECT_ADAPTER: ExclusionReason.NATIVE_BINDING,
8692
DOC_ATTR_DESCRIPTION: ExclusionReason.COMMON_ATTR_DUP,
93+
# FR-019 @provided (ADR-0026): registered + functional on field.enum, held out of the
94+
# canonical manifest until all five ports register it (Task 4 graduates it into
95+
# expected-registry.json and removes this carve-out). Mirrors C#/Java.
96+
FIELD_ATTR_PROVIDED: ExclusionReason.PILOT_VOCAB,
8797
}
8898

8999

0 commit comments

Comments
 (0)