feat: add mypy plugin for partial models - #79
Conversation
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
a5c58ca to
6e2e15d
Compare
ddanier
left a comment
There was a problem hiding this comment.
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 ;-)
| 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 |
| 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. |
There was a problem hiding this comment.
Very nice you updated the docs! Thanks!
| @@ -0,0 +1,195 @@ | |||
| """mypy plugin that teaches type checkers about ``model_as_partial()``. | |||
There was a problem hiding this comment.
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 ;-)
|
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:
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? |
|
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 👍 |
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 withmodel_as_partial()are understood by mypy instead of being seen as the original (still-required) model.Enable alongside Pydantic's own plugin (both required, since this plugin reads the field metadata
pydantic.mypyproduces):How
PartialFoo = Foo.model_as_partial()is theName = call(...)form, which mypy routes throughget_dynamic_class_hook, the same mechanism SQLAlchemy uses forBase = declarative_base(). The plugin registers a realTypeInfoat 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 fieldsOptional, and synthesises a matching__init__. For field-selecting calls it reads each field'shas_defaultfrom 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; andpydantic.mypywould generate a required__init__for the synthetic class, so the plugin's__init__must win. The hookdefer()s untilpydantic.mypyhas 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 likemodel_as_partial("name")(only those become optional), in thePartial = 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,
*argssplats, or dotted names like"items.name"), and non-assignment uses.pyrightisn'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.pyrun real mypy over fixtures in a subprocess, so a shared.mypy_cacheexercises incremental mode.tox,ruff, andpyrightall pass.