|
| 1 | +# Sub-project A — Python codegen engine + Pydantic entity generator |
| 2 | + |
| 3 | +- **Date:** 2026-05-23 |
| 4 | +- **Status:** Design — approved; implement TDD. |
| 5 | +- **Part of:** the Python codegen + persistence foundation (decomposition roadmap `2026-05-23-python-codegen-persistence-foundation-roadmap.md`). This is the first sub-project (A); it unblocks B–E and the pinned FR-004 Phase B. |
| 6 | +- **Reference implementation:** `server/typescript/packages/codegen-ts/` (`generator.ts`, `runner.ts`, `render-context.ts`, `overwrite-policy.ts`, `generators/entity-file.ts`, `column-mapper.ts`). Mirror its orchestration; emit is idiomatic Python. |
| 7 | + |
| 8 | +## Goal |
| 9 | + |
| 10 | +Stand up the Python codegen substrate (the plugin engine) plus the first generator — |
| 11 | +`object.entity` → a **Pydantic v2 model** — so that `run_gen(config, metadata)` produces a |
| 12 | +formatted, `@generated`-headed Python module per entity. Engine orchestration mirrors TS; |
| 13 | +output is idiomatic Python validated by per-language golden tests (codegen is |
| 14 | +idiomatic-divergent, NOT byte-identical-cross-language — so it is **not** added to the shared |
| 15 | +`fixtures/conformance/` corpus). |
| 16 | + |
| 17 | +## Scope |
| 18 | + |
| 19 | +**In:** the engine (`Generator` protocol, `GenContext`/`RenderContext`, `per_entity`/ |
| 20 | +`once_per_run`, `run_gen`, `@generated`-header overwrite/refuse write policy, naming + output-path |
| 21 | ++ field→type-mapping helpers) and one generator emitting a Pydantic v2 model per entity. |
| 22 | + |
| 23 | +**Out (deferred):** SQLAlchemy table emit (→ sub-project C); `git merge-file --diff3` three-way |
| 24 | +merge (later enhancement; baseline is header overwrite/refuse); multi-target output dirs; |
| 25 | +projection/dbView read-only path; queries/routes/barrel generators (→ B); FR-004 payload codegen |
| 26 | +(pinned). |
| 27 | + |
| 28 | +## Package layout |
| 29 | + |
| 30 | +A new subpackage in the existing distribution (Python idiom — fewer distributions, subpackages): |
| 31 | + |
| 32 | +``` |
| 33 | +server/python/src/metaobjects/codegen/ |
| 34 | +├── __init__.py |
| 35 | +├── generator.py # Generator protocol, EmittedFile, GenContext, per_entity/once_per_run |
| 36 | +├── render_context.py # precomputed shared render state (packages, pk map, relation map) |
| 37 | +├── config.py # GenConfig (out_dir, output_layout, ...) — the run_gen config surface |
| 38 | +├── naming.py # module/class/field name helpers + output-path resolution |
| 39 | +├── type_map.py # field-subtype → Python/Pydantic type (the mapping table) |
| 40 | +├── overwrite_policy.py # decide_and_write: @generated-header overwrite/refuse |
| 41 | +├── runner.py # run_gen orchestration |
| 42 | +├── format.py # ruff-format pass over emitted source |
| 43 | +└── generators/ |
| 44 | + ├── __init__.py |
| 45 | + └── entity_model.py # object.entity → Pydantic v2 model |
| 46 | +``` |
| 47 | + |
| 48 | +Tests: `server/python/tests/codegen/` (unit + golden). Golden fixtures: |
| 49 | +`server/python/tests/codegen/golden/<case>/{meta.json, expected/*.py}`. |
| 50 | + |
| 51 | +## The engine (mirrors TS, idiomatic Python) |
| 52 | + |
| 53 | +```python |
| 54 | +@dataclass |
| 55 | +class EmittedFile: |
| 56 | + path: str # relative to config.out_dir |
| 57 | + content: str # final, formatted Python source |
| 58 | + generated_by: str = "" # set by the runner from Generator.name |
| 59 | + |
| 60 | +@dataclass |
| 61 | +class GenContext: |
| 62 | + entities: list[MetaObject] |
| 63 | + loaded_root: MetaData |
| 64 | + matches: Callable[[MetaObject], bool] |
| 65 | + config: GenConfig |
| 66 | + render_context: RenderContext |
| 67 | + warn: Callable[[str], None] |
| 68 | + |
| 69 | +class Generator(Protocol): |
| 70 | + name: str # kebab-case; surfaces in diagnostics |
| 71 | + def generate(self, ctx: GenContext) -> list[EmittedFile]: ... |
| 72 | + # optional: filter(entity) -> bool ; emits_entity_module: bool |
| 73 | +``` |
| 74 | + |
| 75 | +- `per_entity(fn)` / `once_per_run(fn)` — convenience wrappers (filter via `ctx.matches`). |
| 76 | +- `run_gen(config, metadata, entity_filter=None, merge_strategy="overwrite")`: |
| 77 | + 1. validate `metadata` is a loaded `MetaRoot`; |
| 78 | + 2. resolve entities (apply `entity_filter`; **safe-name guard** `^[A-Za-z_]\w*$` — skip + warn on unsafe names, matching the TS guard against filesystem traversal from untrusted metadata); |
| 79 | + 3. build shared render state once (`RenderContext`: packages-by-name, pk map, relation map — only the parts the entity generator needs this slice); |
| 80 | + 4. run each generator, collecting `EmittedFile`s with full paths; **error on output-path collisions**; |
| 81 | + 5. write phase via `decide_and_write`. |
| 82 | +- `decide_and_write(path, content, strategy)`: new → write (`"new"`); existing **with** `@generated` header → overwrite (or `"skipped"` under `skip-existing`); existing **without** header → **`"refused"`** (never clobber hand-written code). Mirrors `overwrite-policy.ts`. |
| 83 | +- `@generated` header constant (line 1 of every emitted file) + a `<Entity>_extra.py` customization-convention pointer (the Python analog of `.extra.ts`). |
| 84 | + |
| 85 | +## The entity generator → Pydantic v2 model |
| 86 | + |
| 87 | +Per `object.entity`, emit a `@generated`-headed module with a `BaseModel` subclass: |
| 88 | + |
| 89 | +- **Inheritance:** `extends`/`BaseEntity` → the Pydantic model subclasses the base model (import from the base's module); the generator emits only the entity's **own** fields, inheriting the rest. Effective-field walks use the loader's typed accessors. |
| 90 | +- **Abstract:** `@isAbstract` entities emit a base model intended for subclassing (still a `BaseModel`). |
| 91 | +- **Fields:** each `field.*` → a typed attribute. Required (`@required` / `validator.required`) → a plain typed field; not required → `T | None = None`. `@maxLength` → `Field(max_length=N)`. |
| 92 | +- **Nested objects:** `field.object` with `@objectRef` → the referenced model type; `@isArray: true` → `list[<T>]`. |
| 93 | + |
| 94 | +### Field-subtype → Python type mapping (`type_map.py`) |
| 95 | + |
| 96 | +| field subtype | Python type | notes | |
| 97 | +|---|---|---| |
| 98 | +| `string` | `str` | `@maxLength` → `Field(max_length=N)` | |
| 99 | +| `int` / `long` | `int` | | |
| 100 | +| `double` / `float` | `float` | | |
| 101 | +| `decimal` | `decimal.Decimal` | | |
| 102 | +| `currency` | `int` | **integer minor units** — preserves the wire contract | |
| 103 | +| `boolean` | `bool` | | |
| 104 | +| `date` / `time` / `timestamp` | `datetime.date` / `datetime.time` / `datetime.datetime` | | |
| 105 | +| `object` (`@objectRef`) | referenced model type | nested import | |
| 106 | +| `class` | `str` | fallback + a leading comment noting the fallback | |
| 107 | +| `@isArray: true` | `list[<base>]` | wraps the base type | |
| 108 | +| not required | `<T> | None = None` | Optional with default | |
| 109 | + |
| 110 | +## Substrate |
| 111 | + |
| 112 | +- **Emit:** plain string building (f-strings / small helpers) — controllable + byte-stable for |
| 113 | + greenfield; no ts-poet/ts-morph equivalent needed. Imports collected and emitted deterministically |
| 114 | + (sorted). |
| 115 | +- **Format:** a `ruff format` pass over the emitted source (subprocess; `ruff` added as a dev |
| 116 | + dependency). Keeps output canonical and stable across runs. |
| 117 | +- **Merge:** baseline is the header overwrite/refuse policy; three-way merge is a later enhancement. |
| 118 | + |
| 119 | +## Validation (TDD) |
| 120 | + |
| 121 | +- **Engine unit tests:** `per_entity`/`once_per_run` selection; `run_gen` safe-name skip+warn; |
| 122 | + path-collision error; `decide_and_write` new/overwrite/refused/skipped matrix. |
| 123 | +- **Type-map unit tests:** every field subtype → expected Python type; `isArray`; optional; |
| 124 | + `@maxLength`; currency → `int`. |
| 125 | +- **Golden tests:** a small set of `meta.json` inputs (a vanilla entity; a `BaseEntity` + `extends` |
| 126 | + child; an entity with a nested `field.object` array; optional/required mix) → committed expected |
| 127 | + `.py`. Assert emitted == golden, and that the golden is **`ruff`-clean and imports without error** |
| 128 | + (so generated code is valid Python, not just a string match). |
| 129 | +- **Determinism:** generating twice yields identical bytes. |
| 130 | + |
| 131 | +## Verification |
| 132 | + |
| 133 | +``` |
| 134 | +cd server/python |
| 135 | +uv run pytest -q # full suite green (211 existing + new codegen tests) |
| 136 | +uv run pytest -q tests/codegen # the new sub-project A tests |
| 137 | +uv run ruff format --check <golden> # generated goldens are canonical |
| 138 | +``` |
| 139 | + |
| 140 | +Done when: `run_gen` emits a formatted, `@generated`-headed Pydantic model per entity; the engine + |
| 141 | +type-map + golden tests are green; generated goldens import cleanly and are `ruff`-clean; the full |
| 142 | +Python suite stays green. |
| 143 | + |
| 144 | +## Review follow-ups |
| 145 | + |
| 146 | +Addressed before merge (code review): generated output is now isort-clean (a `ruff check |
| 147 | +--select I --fix` pass precedes `ruff format` in `format.py`); the `@generated` header shows |
| 148 | +the effective FQN (resolved from the nearest ancestor package); `run_gen` warns when there are |
| 149 | +no entities; added a `scalars` golden (datetime/decimal/currency + nested ref) that locks import |
| 150 | +ordering, plus refused-write and no-entities runner tests. |
| 151 | + |
| 152 | +Deferred (Minor, tracked): the `class`-subtype fallback maps to `str` without a leading |
| 153 | +explanatory comment (spec table mentioned one — emitting per-field comments adds emitter |
| 154 | +complexity for little value; revisit if `field.class` usage grows). Separately, a metadata-layer |
| 155 | +observation surfaced in review — `@isArray` JSON input loads as an attr while the canonical |
| 156 | +serializer emits the `is_array` *node property*, so `@isArray` may not round-trip through |
| 157 | +canonical serialization; codegen's dual-form `field_is_array` papers over it correctly, but the |
| 158 | +metadata layer should confirm/repair this independently (out of scope here). |
| 159 | + |
| 160 | +## Open items resolved here |
| 161 | + |
| 162 | +- **Package layout:** subpackage `metaobjects.codegen` (not a separate distribution). |
| 163 | +- **Emitter substrate:** plain string emit + `ruff format`. |
| 164 | +- **Field→Python-type table:** defined above (the long-standing CLAUDE.md open question, Python flavor). |
| 165 | +- **Write policy:** `@generated`-header overwrite/refuse (three-way merge deferred). |
0 commit comments