Skip to content

feat(models): convert RootModel[str] wrappers to TypeAlias + resolve discriminated-union bases - #5

Open
tbsvttr wants to merge 1 commit into
fix/pydantic-2.13-known-issuesfrom
feat/root-model-type-aliases
Open

feat(models): convert RootModel[str] wrappers to TypeAlias + resolve discriminated-union bases#5
tbsvttr wants to merge 1 commit into
fix/pydantic-2.13-known-issuesfrom
feat/root-model-type-aliases

Conversation

@tbsvttr

@tbsvttr tbsvttr commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #4. Base branch is `fix/pydantic-2.13-known-issues`; will re-target to `main` once #4 merges.

Summary

Extends `scripts/post_process_models.py` with two more rewrite passes that finish the ergonomic story on top of #4:

Pass 3: RootModel[str]TypeAlias (79 wrappers)

Every generated `class (RootModel[str])` becomes a `TypeAlias` with the pattern preserved via `Annotated`. Consumers who had to write `asset.id.root` now write `asset.id` directly — Pydantic still validates the pattern on load because it honors `Annotated[str, Field(pattern=...)]` inside model fields the same as it honored `RootModel[str]`.

-class AssetAbilityId(RootModel[str]):
-    root: Annotated[str, Field(pattern='^asset\\\\.ability:...', title='...')]
+AssetAbilityId: TypeAlias = Annotated[
+    str,
+    Field(pattern='^asset\\\\.ability:...', title='...'),
+]

Pass 4: Empty discriminated-union bases → Annotated[Union[...], Field(discriminator=...)]

The generator emits several discriminated unions as empty base classes with `extra='allow'` holding only the discriminator field. Fields typed as these bases validate against the empty stub — every real field on the concrete subtype ends up in `pydantic_extra` instead of being accessible as an attribute. That's what was breaking `move.id` and `oracle.id` access.

5 bases fixed:

  • `Move` → union of MoveActionRoll, MoveNoRoll, MoveProgressRoll, MoveSpecialTrack
  • `OracleRollable` → union of OracleColumnText/2/3, OracleTableText/2/3
  • `OracleRollableTable` → union of OracleTableText, OracleTableText2, OracleTableText3 (nested inside OracleRollable)
  • `EmbeddedOracleRollable` → union of EmbeddedOracleColumnText/2/3, EmbeddedOracleTableText/2/3
  • `OracleCollection` → union of OracleTablesCollection, OracleTableSharedRolls, OracleTableSharedText/2/3

Uses string forward references (`Union["MoveActionRoll", ...]`) since the concrete subclass definitions come later in the generated file.

Scope not covered

Ten more empty-base classes (`MoveEnhancement`, `EmbeddedMove`, `AssetControlField`, `AssetOptionField`, `AssetAbilityControlField`, `AssetAbilityOptionField`, `AssetConditionMeterControlField`, `AssetControlFieldEnhancement`, `RulesPackage`, `Choices`, `RollableValue`) have the same shape but their concrete subtype classes weren't generated at all. Fixing those needs upstream codegen work or a bigger post-processor. Called out in the README's Status section.

Tests

New `tests/test_type_alias_ergonomics.py` locks in the ergonomic path so future regenerations don't silently regress:

  • `RulesetId` — plain str, `.startswith`, `len()` work
  • `Move.id` — plain str, usable in `.split("/")` (this is the one that failed before Pass 4)
  • `OracleRollable.id` — plain str (this is the one that failed before Pass 4's nested `OracleRollableTable` mapping)
  • `AssetId` — usable in f-strings without `.root`
  • `MarkdownString` — same for text fields

Test plan

  • `uv run pytest -q tests/` — 14 passed, 0 skipped, 0 failed (13 → 14 after unskipping the two tests this PR unlocks)
  • `scripts/post_process_models.py` idempotent: second run reports `no changes (already post-processed)`
  • `grep -c '^class \\w\\+(RootModel\\[str\\])' models.py` → 0
  • `grep -c '^class Move(BaseModel):' models.py` → 0 (was 1 before Pass 4)

@tbsvttr tbsvttr self-assigned this Jul 16, 2026
@tbsvttr
tbsvttr requested a review from scottbenton July 16, 2026 10:04
Extends post_process_models.py with a third rewrite pass that
converts every `class <ThingId>(RootModel[str]): root: Annotated[str,
Field(pattern=…)]` block into `<ThingId>: TypeAlias = Annotated[str,
Field(pattern=…)]`.

## Why

The generator emits every Datasworn ID type as a RootModel wrapper.
Consumers who want the actual string value have to do:

    asset.id.root                        # RootModel unwrap
    if asset.id.root.startswith("asset:"): ...

Instead of the more natural:

    asset.id
    if asset.id.startswith("asset:"): ...

Type-aliasing to `Annotated[str, Field(...)]` gives back the ergonomic
path (`.id` is a plain str) while keeping the pattern validation
intact — Pydantic honors Annotated Field metadata inside model fields
the same way it honors RootModel[str] wrappers.

## Scope

- 79 `RootModel[str]` wrappers converted to TypeAlias in one pass.
  0 remain after the pass; running the post-processor a second time
  reports no changes.
- Other RootModel variants (`[int]`, `[list[X]]`, `[Union[A, B]]`) are
  deliberately left as RootModel — their runtime shape isn't
  representable as a plain type alias.
- `TypeAlias` is auto-imported into `typing` if missing (kept the
  helper simple: sorts the imports for reproducibility).

## Tests

New `tests/test_type_alias_ergonomics.py` locks in the ergonomic path
so a future regeneration doesn't silently regress:

- `RulesetId` reads as plain str (`.startswith`, `len` work directly)
- `AssetId` same (usable in f-strings without .root)
- MarkdownString same

Two tests (move_id, oracle_id) skip with a documented reason: `Move`
and `OracleRollable` are separately broken by an unrelated codegen
bug — they're emitted as empty discriminated-union bases with
extra='allow', so IDs land in `__pydantic_extra__` instead of as
attributes. That's a follow-up post-process, called out in the
README's Status section.

## Depends on

Stacked on top of #4 (fix: reconcile models.py with pydantic 2.13's
stricter validation). Merge #4 first.
@tbsvttr
tbsvttr force-pushed the feat/root-model-type-aliases branch from c9d83e1 to 7f50d70 Compare July 16, 2026 10:19
@tbsvttr tbsvttr changed the title feat(models): convert RootModel[str] ID wrappers to TypeAlias feat(models): convert RootModel[str] wrappers to TypeAlias + resolve discriminated-union bases Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant