Skip to content

feat: add mypy plugin for partial models - #79

Merged
ddanier merged 3 commits into
ddanier:mainfrom
shivayseth:feat/mypy-plugin-partial
Aug 8, 2026
Merged

feat: add mypy plugin for partial models#79
ddanier merged 3 commits into
ddanier:mainfrom
shivayseth:feat/mypy-plugin-partial

Conversation

@shivayseth

@shivayseth shivayseth commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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
@ddanier ddanier mentioned this pull request Aug 8, 2026

@ddanier ddanier left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very good! Thanks again for putting the effort into this!

I have one question about one of the tests. Besides this I'm completely fine with merging the change and adding the mypy plugin.

I will create a new release when this is merged for sure ;-)

Comment thread tests/test_mypy.py Outdated
Comment thread tests/test_mypy.py
def test_non_literal_field_arg_degrades_gracefully(mypy):
result = mypy.run(MODEL + """
field = "age"
Partial = User.model_as_partial(field) # non-literal: cannot be resolved statically

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Comment thread pyproject.toml Outdated
Comment thread README.md
Comment on lines +184 to +189
Without the `mypy` plugin described above (for example under `pyright`, or for the
partial variants the plugin does not cover yet), `pydantic-partial` cannot generate new
class types that are supported by the Python typing system rules. In those cases the
partial models will only be recognized as the same as their original model classes -
type checkers will not know about the partial model changes and thus will think all
those partial fields are still required.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice you updated the docs! Thanks!

Comment thread pydantic_partial/mypy.py
@@ -0,0 +1,195 @@
"""mypy plugin that teaches type checkers about ``model_as_partial()``.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be frank I don't know enough about the mypy internals to fully understand this. Still it all looks reasonable and works as expected. So I am very willing to merge it ;-)

@shivayseth

Copy link
Copy Markdown
Contributor Author

Thanks a lot for reviewing this!

One heads-up before finalising the next release: I have another completed local commit building on this PR. It adds mypy support for:

  • recursive=True;
  • dotted selectors such as "items.name" and "items.*";
  • nested partials through list, Optional, dict, and tuple wrappers;
  • self-referential models;
  • creating a partial from an existing partial;
  • incremental mypy cache handling;
  • graceful fallback for non-literal arguments;
  • updated documentation and 11 additional tests.

Because it is a substantial follow-up, I think it would be cleaner to merge #79 after these small review fixes and submit the recursive support as a separate PR immediately afterward. If possible, could we hold the release until you’ve had a chance to review that follow-up too?
Let me know your thoughts!

@ddanier

ddanier commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Sure, let’s finalize the changes here, then I’ll look at the next PR and decide when to release. I like the idea of having this completely ready 👍

@ddanier
ddanier merged commit 68aa830 into ddanier:main Aug 8, 2026
6 checks passed
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.

2 participants