Skip to content

Generate OpenAPI schemas for unions on Python 3.14 - #157

Merged
davegaeddert merged 1 commit into
masterfrom
api-unions
Sep 22, 2026
Merged

davegaeddert merged 1 commit into
masterfrom
api-unions

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

plain api generate-openapi raises ValueError: Unknown type: str | None on Python 3.14, so any view with an optional field in its response schema produces no document and the served /api/openapi.json 500s. Shipped in 0.36.0; found tonight by plainframework.com's own upgrade, which held its deploy over it.

Cause. Python 3.14 unified types.UnionType with typing.Union, so (str | None).__origin__ now exists and is typing.Union where on 3.13 it raised AttributeError. schema_from_type() probed hasattr(t, "__origin__"), handled list and dict, and raised on anything else — and the branch that handles X | None sat after it. On 3.13 unions fell through to the right branch; on 3.14 they're caught and rejected.

Fix. Match unions explicitly and first, and use typing.get_origin/get_args rather than probing dunder attributes, which is the actual root cause: attribute probing on typing internals is what broke across a Python release. X | None keeps byte-identical output (member schema plus nullable: true), so no document that generated on 3.13 changes. A union of several named members now emits anyOf plus nullable when None is among them, instead of raising — str | int is a legal annotation, and taking down the whole document over one property is the same failure mode this PR exists to fix. A subscripted generic the generator doesn't model (tuple[int, str], set[int], Annotated[...]) still raises, as before.

Audit. __origin__/__args__ appear in exactly one function repo-wide, the one fixed here. Every other union check in the repo (plain-mcp's _type_to_schema, plain-postgres' written.py and preflight, plain's settings parser, plain-api's own typed_dict_from_annotation) already tests get_origin(t) in (Union, UnionType) with the union branch ahead of the generic branches, so nothing else is exposed. plain-mcp's version is the same function written correctly.

Eight tests, all confirmed failing against the pre-fix code, including one that generates a whole document with str | None, list[str | None] and str | int | None fields and validates it against the OpenAPI 3.0.3 schema — the regression at the level where the 500 appears.

Verified: ./scripts/fix clean; plain-api 53 passed; ./scripts/type-check plain-api and ./scripts/type-validate clean.

Python 3.14 unified `types.UnionType` with `typing.Union`, so `str | None`
now reports an origin (`typing.Union`) where on 3.13 it had none. The
`hasattr(t, "__origin__")` branch ran first and raised "Unknown type" for
anything it didn't recognize, so every optional field in an API schema
raised and serving `openapi.json` returned a 500.

Unions are now matched explicitly, before the list/dict branches, via
`get_origin(t) in (Union, UnionType)` — so `X | None`, `Optional[X]`, and
`Union[X, None]` all work. The root cause was probing `__origin__` /
`__args__` directly instead of asking `typing`, so the whole function now
goes through `get_origin`/`get_args`.

A union of several named types used to raise; it now emits `anyOf`, with
`nullable: true` when `None` is a member. `X | None` keeps its existing
shape, so no already-generated document changes.
@pullapprove5

pullapprove5 Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor
PASS: 1 review scope passed
Scope Progress
✅ code 1/1

View in PullApprove

Next steps:

@davegaeddert
davegaeddert merged commit ae347ae into master Sep 22, 2026
8 checks passed
@davegaeddert
davegaeddert deleted the api-unions branch September 22, 2026 02:39
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