Skip to content

feat: add mypy plugin for partial models#79

Open
shivayseth wants to merge 2 commits into
ddanier:mainfrom
shivayseth:feat/mypy-plugin-partial
Open

feat: add mypy plugin for partial models#79
shivayseth wants to merge 2 commits into
ddanier:mainfrom
shivayseth:feat/mypy-plugin-partial

Conversation

@shivayseth

@shivayseth shivayseth commented Jul 10, 2026

Copy link
Copy Markdown

Closes the long-standing request for static type-checking support (revisits #2, per the discussion in #78).

What

Adds a mypy plugin, pydantic_partial.mypy, so partials created with model_as_partial() are understood by mypy instead of being seen as the original (still-required) model.

from pydantic import BaseModel
from pydantic_partial import PartialModelMixin

class Foo(PartialModelMixin, BaseModel):
    id: int
    name: str

# no arguments: all fields optional
PartialFoo = Foo.model_as_partial()
reveal_type(PartialFoo())  # PartialFoo, id: int | None, name: str | None
PartialFoo()               # no error

# select fields: only the named ones become optional, the rest stay required
PatchName = Foo.model_as_partial("name")
PatchName(id=1)            # no error (name optional)
PatchName()               # error: missing named argument "id"

Enable alongside Pydantic's own plugin (both required, since this plugin reads the field metadata pydantic.mypy produces):

[tool.mypy]
plugins = ["pydantic.mypy", "pydantic_partial.mypy"]

How

PartialFoo = Foo.model_as_partial() is the Name = call(...) form, which mypy routes through get_dynamic_class_hook, the same mechanism SQLAlchemy uses for Base = declarative_base(). The plugin registers a real TypeInfo at the assignment site (so mypy never has to resolve the call's return value as a type, which is the wall #2 hit), makes the relevant fields Optional, and synthesises a matching __init__. For field-selecting calls it reads each field's has_default from pydantic's metadata so unselected fields keep their real requiredness.

Two details have dedicated regression tests: mypy reports the hook fullname as <module>.<Model>.model_as_partial (not the mixin's), so matching is by method-name suffix plus an MRO check; and pydantic.mypy would generate a required __init__ for the synthetic class, so the plugin's __init__ must win. The hook defer()s until pydantic.mypy has populated its field metadata and rebuilds cleanly on incremental passes.

Scope

Supported: model_as_partial() / as_partial() with no arguments (all fields optional) and with literal field names like model_as_partial("name") (only those become optional), in the Partial = Model.model_as_partial(...) assignment form.

recursive= isn't fully supported yet: the call still produces a flat partial (top-level fields become optional), but nested models aren't recursed into, so it's stricter than the runtime behaviour rather than wrong.

These fall back to mypy's default instead (never a crash or a silently wrong type): field lists that can't be resolved statically (non-literal arguments, *args splats, or dotted names like "items.name"), and non-assignment uses. pyright isn't supported yet.

Notes

I originally planned to split the no-argument and field-selecting cases into two PRs, but since neither leaves anything in a broken state and field selection rounds out the feature, I folded both into this one so it is release-ready as a whole. Happy to split it back out if you would rather review or land them separately.

mypy is added to the dev and tox deps (the plugin imports mypy only when mypy runs). New tests in tests/test_mypy.py run real mypy over fixtures in a subprocess, so a shared .mypy_cache exercises incremental mode. tox, ruff, and pyright all pass.

Add a mypy plugin (pydantic_partial.mypy) that teaches type checkers about
model_as_partial(). Using get_dynamic_class_hook, it registers a real TypeInfo
for the partial at the assignment site, makes every field Optional, and
synthesises an all-optional __init__. It runs alongside pydantic.mypy, which
provides the field metadata it reads.

Covers the no-argument model_as_partial()/as_partial() call. Field-selecting and
recursive calls are not handled yet and degrade gracefully to the original type.

Refs ddanier#78
Handle model_as_partial("age") in the mypy plugin: only the named fields
become optional, while unselected fields keep their original requiredness,
read from pydantic's field metadata. Non-literal arguments, *args splats,
and dotted/nested names degrade gracefully to mypy's default.

Refs ddanier#78
@shivayseth shivayseth force-pushed the feat/mypy-plugin-partial branch from a5c58ca to 6e2e15d Compare July 11, 2026 06:22
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