Skip to content

Commit 155ae32

Browse files
committed
fix(python): expand a ::-relative package against the declaring file's root package
The Python parser kept a relative package ("::parts") raw, and later code expanded it against the merged root's package, which is the first file's. In a multi-file collection whose files declare different root packages that gave a different package, resolution key, canonical JSON and field-lint address than TypeScript, C# and Java. The parser now expands at parse time against the declaring file's context package, mirroring TS applyReservedKeys. - field_lint.py drops its own expansion of the loaded model and shares the parser's helper for the raw-file walk. - MetaData.effective_package() exposes the package a node resolves under. - New conformance fixture loader-relative-package-multi-root and field-lint fixture reference-field-missing-multi-root-package gate every port. - The Python corner note in the field-lint docs is removed.
1 parent 5153aa9 commit 155ae32

18 files changed

Lines changed: 339 additions & 25 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
7575
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
7676

7777
**Cross-port conformance corpora** (every port runs the shared corpus):
78-
- Metamodel: `fixtures/conformance/` (363 fixtures; 26 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
78+
- Metamodel: `fixtures/conformance/` (364 fixtures; 26 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
7979
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
8080
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
8181
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Two sub-corpora run the **generated lane only, on all five ports** — `write-through/` and `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived.

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,21 @@ it until 1.1 ships._
146146
used to split it on commas, which left stray quotes in the items; TypeScript and Python
147147
already kept it whole. All three now agree, so a Java model that relied on the split (a
148148
comma-separated string where a list belongs) now reads one item, as the other ports always did.
149+
- **Python: a `::`-relative package is expanded against the declaring file's root package.**
150+
An object that declares `"package": "::parts"` in a file whose root package is `beta::other`
151+
now loads as `beta::other::parts`, the same as in TypeScript, C# and Java. The Python parser
152+
used to keep the raw `::parts`, and the code that expanded it later used the merged root's
153+
package, which is the first file's. The result differed only in a multi-file collection
154+
whose files declare different root packages: there the node's `package`, its
155+
`resolution_key()`, its canonical JSON and the address in a `metaobjects verify` field-lint
156+
warning now carry the declaring file's package (`beta::other::parts::Gadget`, not
157+
`acme::app::parts::Gadget`). Everything that loaded before still loads. A node's `package`
158+
now holds the expanded form in a single-file model too, where it held `::parts` before.
159+
Gated by the new `loader-relative-package-multi-root` conformance fixture and
160+
`reference-field-missing-multi-root-package` in `fixtures/field-lint-conformance/`.
161+
- **Python: `MetaData.effective_package()`** returns the package a node resolves under, where
162+
`package` is `None` for an object that inherits its file's root package. See
163+
`docs/ports/python.md`, "Use".
149164
- **Python: the `@filter` desugar now matches TypeScript.** It recurses into `and` / `or`;
150165
previously it turned each list into an `in` clause on a field named `and` or `or`, so the
151166
clauses inside a composition were never checked. A dangling field reference inside an `or`

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ metaobjects/
261261
├── CLAUDE.md # project instructions for Claude
262262
├── spec/ # canonical metamodel docs, ADRs, roadmap
263263
├── fixtures/ # 25 cross-language conformance corpora — the oracle
264-
│ ├── conformance/ # metamodel (loader + serializer + navigation), 363 fixtures
264+
│ ├── conformance/ # metamodel (loader + serializer + navigation), 364 fixtures
265265
│ ├── yaml-conformance/ # YAML authoring desugar
266266
│ ├── render-conformance/ # FR-004 byte-identical render oracle
267267
│ ├── verify-conformance/ # FR-004 template-drift gate

‎docs/CONFORMANCE.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
2626

2727
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2828
|---|---|---|---|---|---|---|
29-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 363 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
29+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 364 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
3030
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
3131
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3232
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -49,7 +49,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
4949
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
5050
| [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — |
5151
| [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
52-
| [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 14 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
52+
| [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 15 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
5353
| [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ |
5454
| [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary is inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ |
5555

@@ -204,7 +204,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
204204

205205
## Fixture-to-doc mapping
206206

207-
### `fixtures/conformance/` — metamodel loader + canonical serializer (363)
207+
### `fixtures/conformance/` — metamodel loader + canonical serializer (364)
208208

209209
| Fixture prefix | Feature doc |
210210
|---|---|
@@ -399,7 +399,7 @@ own those two functions), and
399399

400400
## Orphaned fixtures (tested but not yet documented)
401401

402-
The fixtures in the nine corpora mapped above (metamodel 363 + yaml 16 + verify 31
402+
The fixtures in the nine corpora mapped above (metamodel 364 + yaml 16 + verify 31
403403
+ render 15 + persistence 39 + api-contract 61 + source-resolution 25 + scope 10 +
404404
dependency 23) each map to a feature doc. None are orphaned today. The remaining
405405
corpora in the totals table gate tooling contracts (registry manifests, provider

‎docs/features/cli.md‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,7 @@ Rules of the contract:
139139
| `metaobjects verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
140140

141141
The codes and message text are identical in every port, gated by
142-
[`fixtures/field-lint-conformance/`](../../fixtures/field-lint-conformance/README.md). In
143-
Python, for a multi-file collection whose roots declare different packages, the address
144-
printed for a `::`-relative package is expanded against the merged root's package and can
145-
differ from the other ports.
142+
[`fixtures/field-lint-conformance/`](../../fixtures/field-lint-conformance/README.md).
146143

147144
### The prompt directory: `--prompts` everywhere (F101)
148145

‎docs/ports/python.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,20 @@ name_field = [f for f in author.children() if f.name == "name"][0]
437437
print(name_field.get_meta_attr("maxLength")) # -> 200
438438
```
439439

440+
To ask which package a node is in, call `effective_package()`. The `package` attribute holds
441+
only a package the node declares itself, so it is `None` on an object that inherits its
442+
file's root package:
443+
444+
```python
445+
author.package # -> None (declared no package of its own)
446+
author.effective_package() # -> "myapp::library"
447+
author.resolution_key() # -> "myapp::library::Author"
448+
```
449+
450+
A `::`-relative package (`"package": "::parts"`) is expanded when the file is parsed, against
451+
that file's root package, so `package` on such a node is already the full
452+
`beta::other::parts`.
453+
440454
### Run-time validation
441455

442456
`run_validators` checks a data mapping against an entity's metadata, with no generated
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::app",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Store",
8+
"children": [
9+
{
10+
"field.long": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"identity.primary": {
16+
"name": "pk",
17+
"@fields": [
18+
"id"
19+
]
20+
}
21+
}
22+
]
23+
}
24+
},
25+
{
26+
"object.entity": {
27+
"name": "Gadget",
28+
"package": "beta::other::parts",
29+
"children": [
30+
{
31+
"field.long": {
32+
"name": "id"
33+
}
34+
},
35+
{
36+
"field.long": {
37+
"name": "storeId"
38+
}
39+
},
40+
{
41+
"identity.primary": {
42+
"name": "pk",
43+
"@fields": [
44+
"id"
45+
]
46+
}
47+
},
48+
{
49+
"identity.reference": {
50+
"name": "store_fk",
51+
"@fields": [
52+
"storeId"
53+
],
54+
"@references": "acme::app::Store"
55+
}
56+
}
57+
]
58+
}
59+
}
60+
]
61+
}
62+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::app",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Store",
8+
"children": [
9+
{
10+
"field.long": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"identity.primary": {
16+
"name": "pk",
17+
"@fields": [
18+
"id"
19+
]
20+
}
21+
}
22+
]
23+
}
24+
}
25+
]
26+
}
27+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"metadata.root": {
3+
"package": "beta::other",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Gadget",
8+
"package": "::parts",
9+
"children": [
10+
{
11+
"field.long": {
12+
"name": "id"
13+
}
14+
},
15+
{
16+
"field.long": {
17+
"name": "storeId"
18+
}
19+
},
20+
{
21+
"identity.primary": {
22+
"name": "pk",
23+
"@fields": [
24+
"id"
25+
]
26+
}
27+
},
28+
{
29+
"identity.reference": {
30+
"name": "store_fk",
31+
"@fields": [
32+
"storeId"
33+
],
34+
"@references": "acme::app::Store"
35+
}
36+
}
37+
]
38+
}
39+
}
40+
]
41+
}
42+
}

‎fixtures/field-lint-conformance/README.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@ A runner does three things, in this order:
3232
`(code, path, message)`.
3333

3434
`path` is the declaring object's resolution key (`<package>::<name>`), a dot, then the
35-
identity name or the field name. In Python, for a multi-file collection whose roots declare
36-
different packages, the address printed for a `::`-relative package is expanded against the
37-
merged root's package and can differ from the other ports.
35+
identity name or the field name. A `::`-relative package is expanded against the declaring
36+
file's root package in every port (`reference-field-missing-multi-root-package`).
3837

3938
## The two halves read different things
4039

0 commit comments

Comments
 (0)