Skip to content

Commit 315dcc0

Browse files
authored
Merge pull request #19 from metaobjectsdev/fix/py-codegen-package-init
feat(python-codegen): emit package __init__.py so the out dir is importable
2 parents 03afe5c + f5a504d commit 315dcc0

7 files changed

Lines changed: 182 additions & 15 deletions

File tree

docs/features/templates-and-payloads.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,22 +296,28 @@ string output = Renderer.Render(new RenderRequest(
296296
### Python
297297

298298
`metaobjects.render` ships the Mustache engine + `Verify`. The Python loader
299-
recognizes `template.*` + `origin.*`. Payload-VO codegen is not yet emitted —
300-
consumers pass a `dict` (or any pystache-compatible mapping).
299+
recognizes `template.*` + `origin.*`. Payload-VO codegen **is** emitted (the
300+
`payload` generator emits a Pydantic `BaseModel` per template, origin-aware — see
301+
[Output parsing (FR-006)](#output-parsing-fr-006)), so a consumer can render from
302+
the generated payload type or from a plain `dict`.
303+
304+
`render` takes a `RenderRequest` (only `payload` + `provider` are required; `ref`
305+
defaults to `None`, `format` to `"text"`):
301306

302307
```python
303-
from metaobjects.render import render, FilesystemProvider
308+
from metaobjects.render import FilesystemProvider
309+
from metaobjects.render.renderer import render, RenderRequest
304310
305-
out = render(
306-
ref="lobby/welcome",
311+
out = render(RenderRequest(
307312
payload={
308313
"displayName": "Ada",
309314
"postCount": 12,
310315
"posts": [{"title": "Hello"}],
311316
},
312317
provider=FilesystemProvider("./prompts"),
318+
ref="lobby/welcome",
313319
format="xml",
314-
)
320+
))
315321
```
316322

317323
## Output parsing (FR-006)

docs/ports/python.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
Targets Python 3.11+ on the SQLAlchemy + FastAPI / Pydantic stack. Ships the
44
metadata loader (canonical JSON + sigil-free YAML), conformance runner over the
5-
shared corpora, the FR-004 render engine, and the entity-model codegen. The
6-
persistence + migration tier is in progress.
5+
shared corpora, the FR-004 render engine, and the entity / payload / router
6+
codegen. Schema migrations are owned by the Node `meta` CLI (ADR-0015) — the
7+
Python port has no `migrate` command by design; it consumes the canonical
8+
`schema.postgres.sql` artifact verbatim.
79

810
## Install
911

@@ -168,19 +170,23 @@ print(name_field.attr_string_or_none("maxLength")) # → "200"
168170

169171
## FR-004 — render
170172

173+
`render` takes a `RenderRequest` (only `payload` + `provider` are required; `ref`
174+
defaults to `None`, `format` to `"text"`):
175+
171176
```python
172-
from metaobjects.render import render, FilesystemProvider
177+
from metaobjects.render import FilesystemProvider
178+
from metaobjects.render.renderer import render, RenderRequest
173179
174-
out = render(
175-
ref="lobby/welcome",
180+
out = render(RenderRequest(
176181
payload={
177182
"displayName": "Ada",
178183
"postCount": 12,
179184
"posts": [{"title": "Hello"}],
180185
},
181186
provider=FilesystemProvider("./prompts"),
187+
ref="lobby/welcome",
182188
format="xml",
183-
)
189+
))
184190
```
185191

186192
`metaobjects.render.verify` drift-checks every `template.*` against its
@@ -284,7 +290,7 @@ import lines are stable.
284290
| Templates + render (FR-004) | Yes (`metaobjects.render`) |
285291
| Payload-VO codegen | Yes (`payload_vo_generator` — Pydantic v2 `BaseModel` per template, origin-aware) |
286292
| Output parser codegen (FR-006) | Yes (`output_parser_generator` — Pydantic throw-only; imports the payload class from the sibling payload module) |
287-
| Migrations | In progress (`python -m metaobjects.migrate` planned) |
293+
| Migrations | TS-only by design (ADR-0015) — no Python `migrate` command; consume the canonical `schema.postgres.sql` |
288294
| Drift verify | Yes — template / payload drift (`metaobjects.render.verify`) |
289295
| Runtime metadata | Loader API + render engine; SQLAlchemy ObjectManager-equivalent on the roadmap |
290296

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Python codegen emits a package `__init__.py` so the out dir is importable
2+
3+
_Status: DELIVERED (Python port). Date: 2026-06-13._
4+
5+
## Problem
6+
7+
The Python codegen suite emits modules that use **package-relative imports** — e.g.
8+
the output-parser imports its payload sibling with `from .relevance_verdict_payload
9+
import RelevanceVerdictPayload`. A relative import only resolves when the module is
10+
imported as part of a package, which requires an `__init__.py` in the output
11+
directory.
12+
13+
The `gen` command does **not** emit that `__init__.py`. So a downstream Python
14+
consumer that runs `metaobjects gen --out <dir>` and then tries to
15+
`from <pkg>.<dir>.<name>_output_parser import parse_<name>` hits
16+
`ImportError: attempted relative import with no known parent package`.
17+
18+
The obvious workaround — the consumer hand-adds an `__init__.py` to the out dir —
19+
collides with the **codegen-drift gate**: `verify --codegen` regenerates into a temp
20+
dir and diffs file-for-file, so the hand-added `__init__.py` is reported as
21+
`extra: __init__.py` and the gate fails (exit 1). The consumer is stuck between
22+
"can't import" and "drift gate fails".
23+
24+
This is the same friction whether the generated artifacts are entities, payloads, or
25+
output parsers — anything that emits a package-relative import.
26+
27+
## Decision
28+
29+
`run_gen` emits an **`@generated` `__init__.py`** in every directory that received a
30+
generated file (and the intermediate directories up to `out_dir`). Chosen over the
31+
alternative (teach `verify --codegen` to ignore a consumer-managed `__init__.py`)
32+
because it keeps the out dir **fully codegen-owned** — the repo's standing principle
33+
that generated output is disposable and the consumer hand-edits nothing. As a
34+
generated file, the `__init__.py` is part of the regenerated set, so
35+
`verify --codegen` passes naturally.
36+
37+
`__init__.py` is Python-specific, so this is a Python-port behavior; no cross-port
38+
contract change. (Open question for maintainers: whether the sibling ports want an
39+
analogous "package marker" concept — none is needed for the flat-module ports today.)
40+
41+
## Design
42+
43+
- New `GenConfig.emit_package_init: bool = True` (mirrors the existing
44+
`emit_abstract_shapes` toggle). Default-on for the ergonomic path; a consumer that
45+
manages its own package layout sets it `False`.
46+
- In `run_gen`, after the generator loop builds the emitted-file map, compute the set
47+
of output-relative directories that received a file plus their ancestors down to
48+
`out_dir`, and add an `__init__.py` for each that a generator did not already emit.
49+
- The marker file carries the standard `@generated by metaobjects` header
50+
(`constants.generated_package_init()`), so it flows through the existing
51+
`decide_and_write` overwrite policy: regen overwrites it, but a **hand-authored**
52+
`__init__.py` (no marker) is **refused**, never clobbered.
53+
54+
## Tests (`tests/codegen/test_runner.py`)
55+
56+
- `test_run_gen_emits_importable_package_init` — out dir gets an `@generated`
57+
`__init__.py`.
58+
- `test_run_gen_package_init_can_be_disabled``emit_package_init=False` suppresses it.
59+
- `test_run_gen_does_not_clobber_handwritten_package_init` — a marker-less hand-authored
60+
`__init__.py` is `refused` and left untouched.
61+
- Existing `test_run_gen_writes_a_model_per_entity` / `_refuses_handwritten_file` updated
62+
to assert per-file status (the file map now also contains the package marker).
63+
64+
Full non-integration suite green (the one unrelated failure,
65+
`ERR_RELATIVE_REF_IN_CANONICAL` missing from the Python `ErrorCode` enum, is FR-026
66+
Python-port follow-up, independent of this change).
67+
68+
## Follow-ups (not in this change)
69+
70+
- The `__init__.py` is a bare marker. A future enhancement could re-export each
71+
module's public symbols for flatter consumer imports (`from <dir> import
72+
parse_<name>`), but that needs the generator to know the public surface across
73+
files — deferred.

server/python/src/metaobjects/codegen/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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+
emit_package_init: bool = True # emit an @generated __init__.py so the out dir imports as a package
1213
# FR-019 (ADR-0026): per-port resolution of a @provided enum's import module. The module
1314
# never lives in metadata (ADR-0001) — it is codegen config. ``provided_enum_packages``
1415
# maps a declaring metadata package ("acme::shop") to the Python import module; with a

server/python/src/metaobjects/codegen/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,18 @@ def generated_header(entity_name: str, fqn: str) -> str:
1111
f"# Source metadata: {entity_name} ({fqn})\n"
1212
f"# Customize via {entity_name}_extra.py in this directory.\n"
1313
)
14+
15+
16+
def generated_package_init() -> str:
17+
"""Content for a generated package ``__init__.py``.
18+
19+
A bare marker file that makes the codegen out dir an importable Python package —
20+
the emitted modules use package-relative imports (``from .x import ...``), so the
21+
consumer needs the package marker to import them. Carries the @generated marker so
22+
the overwrite policy re-writes it on regen and refuses to clobber a hand-authored
23+
``__init__.py`` (which lacks the marker).
24+
"""
25+
return (
26+
f"# {GENERATED_MARKER} — DO NOT EDIT.\n"
27+
f"# Package marker so the generated output directory is importable.\n"
28+
)

server/python/src/metaobjects/codegen/runner.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from metaobjects.meta.core.object.meta_object import MetaObject
1212
from metaobjects.shared.base_types import TYPE_OBJECT
1313
from .config import GenConfig
14+
from .constants import generated_package_init
1415
from .generator import GenContext, Generator
1516
from .overwrite_policy import decide_and_write
1617

@@ -100,6 +101,25 @@ def run_gen(
100101
)
101102
emitted[full] = (f.content, gen.name)
102103

104+
# Make the out dir an importable package: emit an @generated __init__.py in
105+
# every directory that received a generated file (and the intermediate dirs up
106+
# to out_dir). The generated modules use package-relative imports, so without
107+
# this a consumer can't import them. A generator-emitted __init__.py wins; a
108+
# hand-authored one is left untouched by the overwrite policy below.
109+
if config.emit_package_init:
110+
pkg_rel_dirs: set[str] = set()
111+
for full in list(emitted):
112+
d = os.path.dirname(os.path.relpath(full, config.out_dir))
113+
while True:
114+
pkg_rel_dirs.add(d)
115+
if d == "":
116+
break
117+
d = os.path.dirname(d)
118+
for d in pkg_rel_dirs:
119+
init_full = os.path.join(config.out_dir, d, "__init__.py")
120+
if init_full not in emitted:
121+
emitted[init_full] = (generated_package_init(), "package-init")
122+
103123
for full, (content, _by) in emitted.items():
104124
status = decide_and_write(full, content, merge_strategy)
105125
result.files.append((full, status))

server/python/tests/codegen/test_runner.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from metaobjects import MetaDataLoader
77
from metaobjects.meta.meta_data import MetaData
88
from metaobjects.codegen.config import GenConfig
9+
from metaobjects.codegen.constants import GENERATED_MARKER
910
from metaobjects.codegen.runner import run_gen
1011
from metaobjects.codegen.generators.entity_model import entity_model
1112

@@ -26,7 +27,51 @@ def test_run_gen_writes_a_model_per_entity(tmp_path: Path) -> None:
2627
result = run_gen(GenConfig(out_dir=str(out)), root, generators=[entity_model()])
2728
assert (out / "Subscriber.py").exists()
2829
assert "class Subscriber(BaseModel):" in (out / "Subscriber.py").read_text()
29-
assert [w[1] for w in result.files] == ["new"]
30+
statuses = {Path(p).name: s for p, s in result.files}
31+
assert statuses["Subscriber.py"] == "new"
32+
assert statuses["__init__.py"] == "new" # package marker emitted alongside
33+
34+
35+
def test_run_gen_emits_importable_package_init(tmp_path: Path) -> None:
36+
"""The codegen out dir is a self-contained package: run_gen emits an
37+
``@generated`` ``__init__.py`` so a consumer can import the generated modules
38+
(which use package-relative imports) without hand-adding a marker file that
39+
``verify --codegen`` would then flag as ``extra``."""
40+
root = _load(tmp_path / "meta", {"metadata.root": {"package": "acme", "children": [
41+
{"object.entity": {"name": "Subscriber", "children": [
42+
{"field.string": {"name": "email", "@required": True}},
43+
]}},
44+
]}})
45+
out = tmp_path / "out"
46+
run_gen(GenConfig(out_dir=str(out)), root, generators=[entity_model()])
47+
init = out / "__init__.py"
48+
assert init.exists(), "expected a generated __init__.py in the out dir"
49+
assert GENERATED_MARKER in init.read_text(), "__init__.py must carry the @generated marker"
50+
51+
52+
def test_run_gen_package_init_can_be_disabled(tmp_path: Path) -> None:
53+
"""``emit_package_init=False`` suppresses the package marker (consumer owns it)."""
54+
root = _load(tmp_path / "meta", {"metadata.root": {"package": "acme", "children": [
55+
{"object.entity": {"name": "Subscriber", "children": [{"field.string": {"name": "x"}}]}},
56+
]}})
57+
out = tmp_path / "out"
58+
run_gen(GenConfig(out_dir=str(out), emit_package_init=False), root,
59+
generators=[entity_model()])
60+
assert not (out / "__init__.py").exists()
61+
62+
63+
def test_run_gen_does_not_clobber_handwritten_package_init(tmp_path: Path) -> None:
64+
"""A hand-authored ``__init__.py`` (no @generated marker) is left untouched."""
65+
root = _load(tmp_path / "meta", {"metadata.root": {"package": "acme", "children": [
66+
{"object.entity": {"name": "Subscriber", "children": [{"field.string": {"name": "x"}}]}},
67+
]}})
68+
out = tmp_path / "out"
69+
out.mkdir()
70+
(out / "__init__.py").write_text("# hand written package init\nVERSION = '1'\n")
71+
result = run_gen(GenConfig(out_dir=str(out)), root, generators=[entity_model()])
72+
statuses = {Path(p).name: s for p, s in result.files}
73+
assert statuses["__init__.py"] == "refused"
74+
assert "hand written package init" in (out / "__init__.py").read_text() # untouched
3075

3176

3277
def test_run_gen_skips_unsafe_names_with_warning(tmp_path: Path) -> None:
@@ -49,7 +94,8 @@ def test_run_gen_refuses_handwritten_file(tmp_path: Path) -> None:
4994
out.mkdir()
5095
(out / "Subscriber.py").write_text("# hand written, no marker\nx = 1\n")
5196
result = run_gen(GenConfig(out_dir=str(out)), root, generators=[entity_model()])
52-
assert [w[1] for w in result.files] == ["refused"]
97+
statuses = {Path(p).name: s for p, s in result.files}
98+
assert statuses["Subscriber.py"] == "refused"
5399
assert any("Refused to overwrite" in w for w in result.warnings)
54100
assert "hand written" in (out / "Subscriber.py").read_text() # untouched
55101

0 commit comments

Comments
 (0)