Enable support for typed `model_construct` besides mypy (incl. example implementation)
Initial Checks
- I have searched Google & GitHub for similar requests and couldn't find anything
- I have read and followed the docs and still think this feature is missing
Description
Hi, guys! I really love pydantic, but there is the following issue that i really want to see resolved and i hope i can help:
TL;DR: should model_construct(...) be typed (similar to __init__) and if so, what do you think about my solution of "copying" the __init__ method signature? (If not, is there any official way of instantiating a model with type-checking but without validation?)
Consider this simple model:
class User(BaseModel):
id: int = Field(default_factory=lambda: randint(1, 1000))
name: strIf one wants to create model instances with data where the types are known at type-check time, they have to choose either User(id=7, name="John Doe") or User.model_construct(id=7, name="John Doe")
The issue for me is, that the first option performs redundant (assuming type-checker correctness) validation at runtime and the second cannot perform any type-checks, because the signature is currently model_construct(**kwargs: Any).
According to your docs, the mypy plugin enables this behaviour for mypy, but a lot of users rely on other type-checking tools and it would be great if this functionality could be supported for them as well.
Here is a simple (purely types-based) solution i came up with that seems to work for this use-case and i wanted to get some feedback before making it a proper MR. Essentially it simply copies the ParamSpec of the __init__(...) function into the model_construct method:
from typing_extensions import Protocol, TypeVar, ParamSpec, Callable, TYPE_CHECKING
from pydantic import BaseModel
P = ParamSpec("P")
T = TypeVar("T")
class model_construct_decorator:
def __init__(self, f): pass
def __get__(self, instance: None, owner: Callable[P, T]) -> Callable[P, T]:
return owner
class User(BaseModel):
id: int = Field(default_factory=lambda: randint(1, 1000))
name: str
if TYPE_CHECKING:
@model_construct_decorator
def model_construct_typed(self): ...
else:
@classmethod
def model_construct_typed(cls, *args, **kwargs):
return cls.model_construct(*args, **kwargs)
User.model_construct_typed(id="7") # -> now also shows a type error in pyright(The idea would be that what i currently named model_construct_typed(...) would be implemented as model_construct(...) on BaseModel)
Now there are multiple solvable issues that ive left out for brevity, but here are their solutions for anyone interested:
1. What about the_fields_set argument of the current model_construct(...)?The model_construct_decorator can be updated as follows to reestablish that argument:
class ModelConstructCallable(Protocol[P, T]):
def __call__(self, _fields_set: set[str]|None=None, *args: P.args, **kwargs: P.kwargs) -> T: ...
class model_construct_decorator:
def __init__(self, f): pass
def __get__(self, instance: None, owner: Callable[P, T]) -> ModelConstructCallable[P, T]:
return lambda _fields_set=None, *args, **kwargs: owner(*args, **kwargs) # could also be a simple pass statement with type-ignore comment, because this should never actually be executedSince users currently have the **kwarg option available, this switch may break their existing code, especially if the pass extra data to model construct, as now their type-checker will complain about the extra data. There are two solutions for that issue:
a) either keep the model_construct(...) as is and add a new model_construct_typed(...)
b) add a new argument (e.g. _type_check: bool=False, after _fields_set) that optionally enables this behaviour
Here is an example implementation of solution b) from a type-checking perspective (at runtime there should be no difference):
class ModelConstructCallable(Protocol[P, T]):
@overload
def __call__(self, _fields_set: set[str]|None=None, _type_check: Literal[False]=False, **kwargs: Any) -> T: ...
@overload
def __call__(self, _fields_set: set[str]|None=None, _type_check: Literal[True], *args: P.args, **kwargs: P.kwargs) -> T: ...
class model_construct_decorator:
def __init__(self, f): pass
def __get__(self, instance: None, owner: Callable[P, T]) -> ModelConstructCallable[P, T]:
return lambda _fields_set=None, _type_check=False, *args, **kwargs: owner(*args, **kwargs) # could also be a simple pass statement with type-ignore comment, because this should never actually be executed
...
User.model_construct(id="7", some_extra_value=...) # still no type error as before
User.model_construct(_type_check=True, id="7", some_extra_value="...") # type error as intendedmodel_construct(...)?I am not 100% sure what the behaviour is supposed to be when calling model_construct(...) on an instance of BaseModel, but i assume that it acts as a classmethod, so it should be the exact same method both on class- and instance-level. If thats the case, then the model_construct_decorator can be updated as follows to let type-checkers know that this is a valid use-case as well:
class model_construct_decorator:
def __init__(self, f): pass
def __get__(self, instance: Any, owner: Callable[P, T]) -> Callable[P, T]: # `instance: None` -> `instance: Any` stops type-checking errors when calling `User(...).model_construct_typed(...)`
return ownerThe proposed solution is solely based on typing concepts and as such, any fully-fledged type-checker should be able to infer the data correctly. That being said, mypy does not have this capability (yet?), so the mypy plugin would still be required. Beyond that, i did initial tests of the change and here is the compatibility with common type checkers:
| mypy (no plugin)¹ | mypy (with plugin)¹ | pyright | ty | basedpyright | pyrefly | |
|---|---|---|---|---|---|---|
model_construct(...) |
❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
model_construct_typed(...) |
❌ | ✅ | ✅ | ✅⚠️² | ✅ | ❌ |
(I couldnt get the legacy pyre working and pytype is outdated and also supports neither)
¹ Note that while using model_construct_typed(...) does not enable the desired functionality in mypy without plugin, it also does not break the existing functionality in mypy with plugin.
² Note that ty has some kind of builtin support for pydantic and internally replaces id: int with id: LaxInt, so this error would be caught User.model_construct_typed(id=7) (name argument missing), but this wouldnt User.model_construct_typed(id="7", name="John Doe"), because from the perspective of ty, str is assignable to LaxInt. An update from their side would be required, but i think it is a reasonable assumption that they would add support for that change. See here for more info on the pydantic-in-ty support
Finally lets get to my main questions about all of this:
- Is there general interest in this functionality or is it just me? If there is no interest, then how do you handle this use-case?
- Are there any issues with my solutions that i have not addressed yet?
- Is there anything else i should consider before starting on the actual implementation/MR?
Affected Components
- Compatibility between releases
- Data validation/parsing
- Data serialization -
.model_dump()and.model_dump_json() - JSON Schema
- Dataclasses
- Model Config
- Field Types - adding or changing a particular data type
- Function validation decorator
- Generic Models
- Other Model behaviour -
model_construct(), pickling, private attributes, ORM mode - Plugins and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.
Source: pydantic/pydantic