Search code examples
pythonpython-typingpydanticpyright

How to make Pylance and Pydantic understand each other when instantiating BaseModel class from external data?


I am trying to instantiate user = User(**external_data), where User is Pydantic BaseModel, but I am getting error from Pylance, which don't like my external_data dictionary and unable to figure out that data in the dict is actually correct (see first screenshot).

Type error

I found a workaround by creating TypedDict with the same declaration as for User(BaseModel). Now Pylance is happy, but I am not, because I need to repeat myself (see second screenshot).

Repetition

Any ideas on how to make Pylance and Pydantic understand each other without repetition?

from datetime import datetime
from pydantic import BaseModel
from typing import TypedDict

class UserDict(TypedDict, total=False):
    id: int
    name: str
    signup_ts: datetime
    friends: list[int]


class User(BaseModel):
    id: int
    name: str = "John Doe"
    signup_ts: datetime | None = None
    friends: list[int] = []


external_data: UserDict = {
    'id': 123,
    'name': 'Vlad',
    'signup_ts': datetime.now(),
    'friends': [1, 2, 3],
}

user = User(**external_data)
print(user)
print(user.id)

Pylance error for the case with no UserDict:

Argument of type "int | str | datetime | list[int]" cannot be assigned to parameter "id" of type "int" in function "__init__"
  Type "int | str | datetime | list[int]" cannot be assigned to type "int"
    "datetime" is incompatible with "int"PylancereportGeneralTypeIssues
Argument of type "int | str | datetime | list[int]" cannot be assigned to parameter "name" of type "str" in function "__init__"
  Type "int | str | datetime | list[int]" cannot be assigned to type "str"
    "datetime" is incompatible with "str"PylancereportGeneralTypeIssues
Argument of type "int | str | datetime | list[int]" cannot be assigned to parameter "signup_ts" of type "datetime | None" in function "__init__"PylancereportGeneralTypeIssues
Argument of type "int | str | datetime | list[int]" cannot be assigned to parameter "friends" of type "list[int]" in function "__init__"
  Type "int | str | datetime | list[int]" cannot be assigned to type "list[int]"
    "datetime" is incompatible with "list[int]"PylancereportGeneralTypeIssues
(variable) external_data: dict[str, int | str | datetime | list[int]]

Solution

  • Why the errors?

    These warnings are to be expected because the type of your external_data object (without using a TypedDict) will be inferred by Pylance as dict[str, U], where U is the union of all the types it sees in the dictionary values. In your example U = int | str | datetime | list[int].

    Normal dictionaries are homogeneous in their key type as well is their value type and thus have no way to distinguish types between items. This means that every key of external_data will be seen as a str (which is fine) and every value will be seen as int | str | datetime | list[int].

    Since your Pylance is configured to expect distinct types for the keyword arguments passed to the model's __init__ method, this causes a problem. It sees that you are unpacking a dictionary, where the values are all of the aforementioned union type, yet for example the id argument must be of type int and of course int | str | datetime | list[int] is not a subtype of int. Same for all the other __init__ parameters.

    TypedDict (sort of) works

    The main difference between a normal dict and a TypedDict is that the latter can distinguish (value) types between items. But the drawback in this case is the repetition of course.

    Since TypedDict is a construct that exists solely for the benefit of static type checkers like Pylance, it would make no sense to somehow "dynamically" construct it from the field definitions of a model, even though that may get rid of the repetition.

    The other way around, i.e. construct a model class from a TypedDict, is possible, but has the reverse problem. A type checker will have no way to infer the model members and their types.

    Workarounds

    Since you are not using the keyword-argument syntax directly anyway, but constructing a dictionary first, there is really no need to rely on those keyword-argument type checks at all. If you needed/wanted that additional type safety, you would simply initialize the model as User(id=123, ....).

    Therefore I see essentially two ways around your issue:

    1. Use a type: ignore directive.
    2. Use an alternative constructor.

    The first is easy. Simply put a # type: ignore comment next to the constructor call. The second is different depending on the major version of Pydantic you are using, and it is what I usually do in that situation.

    Pydantic 1.x

    You have the BaseModel.parse_obj method for this purpose:

    parse_obj: this is very similar to the __init__ method of the model, except it takes a dict rather than keyword arguments.

    ...
    external_data = {
        'id': 123,
        'name': 'Vlad',
        # ...
    }
    user = User.parse_obj(external_data)
    

    Pydantic 2.x

    You have the BaseModel.model_validate for this purpose:

    model_validate: this is very similar to the __init__ method of the model, except it takes a dict rather than keyword arguments.

    ...
    external_data = {
        'id': 123,
        'name': 'Vlad',
        # ...
    }
    user = User.model_validate(external_data)