Skip to content

fix: reconcile models.py with pydantic 2.13's stricter validation - #4

Open
tbsvttr wants to merge 1 commit into
mainfrom
fix/pydantic-2.13-known-issues
Open

fix: reconcile models.py with pydantic 2.13's stricter validation#4
tbsvttr wants to merge 1 commit into
mainfrom
fix/pydantic-2.13-known-issues

Conversation

@tbsvttr

@tbsvttr tbsvttr commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes both known codegen bugs from datamodel-code-generator that were carried over from the fork as `xfail` + `<2.13` pin. Adds scripts/post_process_models.py so future regenerations of models.py don't undo the fix.

Bugs

  1. String pattern constraint on date fields. SourceInfo.date is typed as datetime.date but the schema's YYYY-MM-DD pattern was emitted into the generated Field(). Pydantic 2.13 correctly rejects a string-only pattern on a non-string field.

  2. Empty Features / Dangers / Denizens stubs. Codegen emitted class Denizens(BaseModel): pass and referenced it as denizens: Annotated[Denizens, Field(...)] where the JSON actually contains a list of DelveSiteDenizen items. Same pattern for Features/Dangers on both DelveSiteDomain and DelveSiteTheme.

What the post-processor does

  • Strips pattern='[0-9]{4}-…' from Field() blocks whose type is date_aliased. Only date-shaped patterns; won't touch string field patterns nearby.
  • Walks each class block, identifies the 5 known (class, attr) → stub-name pairs (registered explicitly in a table, so nothing gets rewritten by accident), and replaces attr: Stub or attr: Annotated[Stub, Field(…)] with the correct list[X] type.
  • Deletes the orphaned empty stub classes.
  • Idempotent — running twice reports zero changes on the second pass.

Consequences

  • Pydantic pin widened from <2.13 to >=2.13.4,<2.14. All 10 packages validate cleanly under 2.13.4.
  • EXPECTED_FAILURES in the test is now empty. Delve loads without xfail.
  • README's Status section rewritten to reflect the new baseline.

Test plan

  • uv run python scripts/post_process_models.py on a fresh checkout: strips 0 date patterns (already handled in the initial port), rewrites 8 stub sites (features×2 + dangers×2 for both DelveSiteDomain and DelveSiteTheme, plus denizens for DelveSite, plus 3 stub class deletions).
  • Running the script a second time reports "no changes (already post-processed)".
  • uv run pytest -q tests/ — 9 passed, 0 xfail.

Not fixed here

The RootModel[str]TypeAlias = str conversion for ID types (so rules.id reads as a plain string instead of via .root) is still outstanding — that's an ergonomics improvement, separate from these correctness bugs. Called out in the README.

The two known codegen bugs carried over from the fork are now fixed at
the generator-post-process layer, not just in-place edits on the shipped
`models.py`. This means a future regeneration from schema doesn't undo
the fix.

## Bugs fixed

1. **String `pattern` constraint on `date` fields.** `SourceInfo.date`
   is typed as `datetime.date` but the schema's YYYY-MM-DD `pattern` was
   emitted into the generated `Field()`. Pydantic 2.13 correctly rejects
   a string-only pattern on a non-string field.

2. **Empty `Features` / `Dangers` / `Denizens` stubs.** Codegen emitted
   `class Denizens(BaseModel): pass` and then wrote `denizens: Annotated[
   Denizens, Field(...)]` where the JSON actually contains a list of
   `DelveSiteDenizen` items. Same pattern for Features/Dangers on
   DelveSiteDomain and DelveSiteTheme. The post-processor rewrites the
   field types to the correct `list[X]` and deletes the orphaned stubs.

Both fixes belong upstream in datamodel-code-generator; this script is
the workaround until they land there. Documented in the script's
docstring and in the README so a future regen doesn't accidentally
skip the post-process step.

## Consequences

- Pydantic pin widened from `<2.13` to `>=2.13.4,<2.14`. All 10 packages
  validate cleanly under 2.13.4.
- Test `EXPECTED_FAILURES` set is now empty — delve loads without xfail.
- README's Status section rewritten to reflect the new baseline (both
  bugs are documented as "patched by post_process_models.py" instead of
  "TODO: fix before first PyPI release").

## Not fixed here

The `RootModel[str]` → `TypeAlias = str` conversion for ID types is
still outstanding — that's an ergonomics improvement (letting users read
`rules.id` directly instead of `rules.id.root`), separate from the
correctness bugs above.
@tbsvttr tbsvttr self-assigned this Jul 16, 2026
@tbsvttr
tbsvttr requested a review from scottbenton July 16, 2026 09:54
tbsvttr added a commit that referenced this pull request Jul 16, 2026
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.
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