pydantic icon indicating copy to clipboard operation
pydantic copied to clipboard

'Partial' Equivalent

Open kalzoo opened this issue 4 years ago • 14 comments

Question

Output of python -c "import pydantic.utils; print(pydantic.utils.version_info())":

             pydantic version: 1.5.1
            pydantic compiled: True
                 install path: /.../venv/lib/python3.7/site-packages/pydantic
               python version: 3.7.4 (default, Sep  7 2019, 18:27:02)  [Clang 10.0.1 (clang-1001.0.46.4)]
                     platform: Darwin-19.4.0-x86_64-i386-64bit
     optional deps. installed: ['typing-extensions']

Hey Samuel & team,

First of all, huge fan of Pydantic, it makes working in Python so much better. I've been through the docs and many of the issues several times, and until this point I've found all the answers I was looking for. If I missed one in this case - sorry in advance!

Looking to be able to transform a model at runtime to another with the same fields but where all are Optional, akin to Typescript's Partial (hence the issue title). Something similar is discussed in part in at least one FastAPI issue, but since the answer there is basically "duplicate your model" and since this is inherently a pydantic thing, I thought I'd ask here.

By way of a little bit of hacking on model internals, I've been able to get the functionality I need for a relatively trivial use case:

from __future__ import annotations

from pydantic import BaseModel, create_model
from typing import Optional

class BaseSchemaModel(BaseModel):
    # ...

    @classmethod
    def to_partial(cls) -> BaseSchemaModel:
        return get_partial_model(cls)


def get_partial_model(model: BaseModel) -> BaseModel:
    """
    Return a model similar to the original, but where all fields are Optional.

    Note: this minimal implementation means that many Pydantic features will be discarded
    (such as alias). This is not by design and could stand to improve.
    """
    new_fields = {
        name: (Optional[model.__annotations__.get(name)], model.__fields__[name].default or None)
        for name in model.__fields__
    }
    return create_model("GeneratedPartialModel", **new_fields, __config__=model.__config__)

verified with a simple test:

import pytest

from pydantic import ValidationError
from typing import Optional

from app.utilities.pydantic import BaseSchemaModel


def test_partial_model():
    class TestModel(BaseSchemaModel):
        a: int
        b: int = 2
        c: str
        d: Optional[str]

    with pytest.raises(ValidationError):
        TestModel()

    model = TestModel(a=1, c='hello')
    assert model.dict() == {'a': 1, 'b': 2, 'c': 'hello', 'd': None}

    PartialTestModel = TestModel.to_partial()

    partial_model = PartialTestModel()
    assert partial_model.dict() == {'a': None, 'b': 2, 'c': None, 'd': None}

However this doesn't support much of Pydantic's functionality (aliases, for a start). In the interest of a better issue report and possibly a PR, I tried a couple other things, but in the time I allocated did not get them to work:

from pydantic import BaseModel, create_model
from pydantic.fields import ModelField

class Original(BaseModel):
  a: int
  b: str
  c: Optional[str]

class PartialModel(BaseModel):
  a: Optional[int]
  b: Optional[str]
  c: Optional[str]

def copy_model_field(field: ModelField, **kwargs) -> ModelField:
    params = (
      'name',
      'type_',
      'class_validators',
      'model_config',
      'default',
      'default_factory',
      'required',
      'alias',
      'field_info'
    )
    return ModelField(**{param: kwargs.get(param, getattr(field, param)) for param in params})

# Doesn't work - ModelField not acceptable in place of FieldInfo
GeneratedPartialModel = create_model('GeneratedPartialModel', **{name: copy_model_field(field, required=False) for name, field in Original.__fields__.items()})

# Doesn't work - field_info doesn't contain all the necessary information
GeneratedPartialModel = create_model('GeneratedPartialModel', **{name: copy_model_field(field, required=False).field_info for name, field in Original.__fields__.items()})

# This works for my use case - but without aliases and probably without some other functionality as well
new_fields = {name: (Optional[Original.__annotations__.get(name)], Original.__fields__[name].default or None) for name in Original.__fields__}
GeneratedPartialModel = create_model('GeneratedPartialModel', **new_fields)

Would be happy to put in a PR for this, if

a. it doesn't exist already b. it would be useful c. I knew where that would best fit - on ModelMetaclass? BaseModel? A utility function to_partial?

Thanks!

kalzoo avatar Jun 30 '20 21:06 kalzoo

Generally I would make the Partial model a subclass of the Original:

from typing import Optional
from pydantic import BaseModel

class Original(BaseModel):
  a: int
  b: str
  c: Optional[str]

class PartialModel(Original):
  a: Optional[int]
  b: Optional[str]
  c: Optional[str]

which retains the validation and everything else about the original, but makes all the fields it defines Optional.

In : Original(a=1, b="two")
Out: Original(a=1, b='two', c=None)

In : PartialModel(b="three")
Out: PartialModel(a=None, b='three', c=None)

Sinc you already have some optional fields in your original, you don't need to redefine them, though it does help with explicitness:

In : class PartialModel(Original):
...:     a: Optional[int]
...:     b: Optional[str]
...:

In : PartialModel(b="four")
Out: PartialModel(a=None, b='four', c=None)

StephenBrown2 avatar Jun 30 '20 22:06 StephenBrown2

Thanks @StephenBrown2 - yeah, that’s what was suggested in the linked FastAPI issue and is what I’d consider the simplest approach. Simplicity is a plus but it has two important caveats:

  • code duplication and vulnerability to drift
  • not available at runtime, which is what I’m looking for in this case.

kalzoo avatar Jul 01 '20 02:07 kalzoo

I would agree, though:

  • code duplication and vulnerability to drift
    • If you define your models next to each other, it will be more difficult to drift since you'd see the changes, though they would have to be made in two places, explicit is better than implicit.
  • not available at runtime, which is what I’m looking for in this case.
    • I'm not sure what you mean by "at runtime"? The model wouldn't change regardless if you defined it beforehand with the Base model or if you generated it from the Base model, would it? Or maybe I misunderstand.

Even with that said, I can see the benefit of a generated Partial model, for update requests for example. I also don't know the best way to go about that, though, so I'll defer to @samuelcolvin for his thoughts.

StephenBrown2 avatar Jul 02 '20 20:07 StephenBrown2

Hi @kalzoo, we're using a different method that does not require code duplication:

First define your Partial model without Optionals, but with a default value equal to a missing sentinel (https://github.com/samuelcolvin/pydantic/issues/1761). That should allow you to create objects where the supplied fields must validate, and where the omitted fields are equal to the sentinel value.

Then, create a Full model by subclassing the Partial model without redefining the fields, and adding a Config class that sets validate_all to True so that even omitted fields are validated.

lsorber avatar Jul 25 '20 08:07 lsorber

hey I use this mixin to avoid deduplication:

class Clonable(BaseModel):

    @classmethod
    def partial(cls):
        return cls.clone(to_optional='__all__')

    @classmethod
    def clone(
        cls,
        *,
        fields: Set[str] = None,
        exclude: Set[str] = None,
        to_optional: Union[Literal['__all__'], Set[str], Dict[str, Any]] = None
    ) -> 'Clonable':
        if fields is None:
            fields = set(cls.__fields__.keys())

        if exclude is None:
            exclude = set()

        if to_optional == '__all__':
            opt = {f: None for f in fields}
            opt.update(cls.__field_defaults__)
        elif isinstance(to_optional, set):
            opt = {f: None for f in to_optional}
            opt.update(cls.__field_defaults__)
        else:
            opt = cls.__field_defaults__.copy()
            opt.update(to_optional or {})

        model = create_model(
            cls.__name__,
            __base__=Clonable,
            **{
                field: (cls.__annotations__[field], opt.get(field, ...))
                for field in fields - exclude
            }
        )
        model.__name__ += str(id(model))
        return model

mortezaPRK avatar Aug 12 '20 16:08 mortezaPRK

I needed this too. I have an implementation, but I think this is something that belongs in pydantic itself, and for that some more polish is needed. Does any maintainer have time to have a look and advise? Perhaps @dmontagu as it involves generics? The relevant commit is here: 4cbe5b0c339281f1f39c019fa27350f414ebf328

I was going to create a Pull Request, but apparently I'm supposed to open an issue to discuss first, and this issue already exists. If this is welcome (once polished up), I think opening a Pull Request to discuss the details makes sense. Is that what is expected?

dashavoo avatar Nov 10 '20 18:11 dashavoo

Thanks for using pydantic. 🙏

As part of a migration to using discussions and cleanup old issues, I'm closing all open issues with the "question" label. 💭 🆘 🚁

I hope you've now found an answer to your question. If you haven't, feel free to start a :point_right: question discussion.

samuelcolvin avatar Feb 13 '21 12:02 samuelcolvin

Humm, just seen this while reviewing #2245.

I've had this problem too, my solution is to use validate_model directly, see #932.

That way you get errors returned to you and can decide what to do with them. You could even then use construct() to create a model from your values.

The clone() solutions suggested above by @mortezaPRK should also work (I haven't looked through the code there).

You could easily build a utility function for this, either shipped with pydantic or standalone.

The main problem is that type hints and knowledge about the model are no longer valid. You thing you have an instance of (for example) User but actually email is None or raises an AttributeError.

That's one of the advantages of using my validate_model solution above, because you get back a dict, you're less tempted to pretend it's a full model.


Overall, I see the problem here, but I'm not yet sure I know what the best solution is.

samuelcolvin avatar Feb 13 '21 16:02 samuelcolvin

Would love if a skip_fields option was provided which would do the same thing as validate_model. Would be a lot cleaner if I could pass it to my BaseModel class Alternatively, passing the argument to the instance of my BaseModel class would be great as well MyBaseModel(**the_model).dict(skip_invalid=True)

cyruskarsan avatar Mar 01 '22 00:03 cyruskarsan

I had the need for this feature as well, and this is how I solved it:

from typing import *

class Partial(Generic[T]):
    '''Partial[<Type>] returns a pydantic BaseModel identic to the given one,
    except all arguments are optional and defaults to None.
    This is intended to be used with partial updates.'''
    _types = {}

    def __class_getitem__(cls: Type[T], item: Type[Any]) -> Type[Any]:
        if isinstance(item, TypeVar):
            # Handle the case when Partial[T] is being used in type hints,
            # but T is a TypeVar, and the type hint is a generic. In this case,
            # the actual value doesn't matter.
            return item
        if item in cls._types:
            # If the value was already requested, return the same class. The main
            # reason for doing this is ensuring isinstance(obj, Partial[MyModel])
            # works properly, and all invocation of Partial[MyModel] return the
            # same class.
            new_model = cls._types[item]
        else:
            class new_model(item):
                '''Wrapper class to inherit the given class'''
            for _, field in new_model.__fields__.items():
                field.required = False
                if getattr(field, 'default_factory'):
                    field.default_factory = lambda: None
                else:
                    field.default = None
            cls._types[item] = new_model
        return new_model

Basically, the idea is to have Partial[MyModel] returning a copy of the model, where all the fields have been changed to optional with default to None.

rudexi avatar May 07 '22 10:05 rudexi

I just created https://github.com/team23/pydantic-partial just to achieve this ;-)

This is still an early version, but is fully tested.

ddanier avatar Aug 31 '22 05:08 ddanier

Hi all, thanks so much for your patience.

I've been thinking about this more, prompted partly by someone creating a duplicate issue, #5031.

I would love to support "partial-isation" of a pydantic model, or dataclass.

My proposal would b a function which creates a partial variant of a model

Usage would be something like this

from pydantic import BaseModel, partial, PydanticUndefined

class MyModel(BaseModel):
    x: int
    y: str

PartialModel1 = partial(MyModel)
PartialModel2 = partial(MyModel, missing_value=PydanticUndefined)

I know @dmontagu would prefer Partial[MyModel] to be closer to typescript, I'd be happy with that too although I think it's slightly less canonical.

The real point here is that ideally this would be added to python itself so static typing libraries recognised partial.

Therefore, when we get the time we should submit a proposal to discuss.python.org, and be prepare to write a PEP if/when we get enough interest.

We could also ask mypy/pyright to support this before the pep is accepted.

Lastly, we could even add support for partial to our mypy extension and thereby have full typing support for it without any input from others.

Obviously this will be pydantic V2 only, and probably won't be added until V2.1 or later, I don't think we'll have time to work on it before v2 is released, still, happy to hear input from others...?

samuelcolvin avatar Feb 13 '23 16:02 samuelcolvin

Hi @samuelcolvin, I've done some modification to the function I proposed into #5031 . The new code doesn't modify the original model and deals also with List, Dict and Union. Tested with python 3.10.4 and pydantic 1.10.4

Known issues:

  • when using pipe notation (from types.UnionType) instead of typing.Union (e.g. Engine: Fuel | str) the function returns error: TypeError: 'type' object is not subscriptable
  • cannot deal with pydantic types (e.g. conlist) or more in general all type in pydantic.types

Do you have any suggestion for improving this implementation? How to deal with the two previous issues? We understand you prioritize V2 implementation but it would be nice to have a workaround for V1.

from pydantic import BaseModel, validator, create_model, ValidationError, class_validators
from typing import Type, Union, Optional, Any, get_origin, get_args
from pydantic.fields import ModelField


# Returns a new model class which is a copy of the input one
# having all inner fields and subfields as Optional
def make_optional(cls: Type[BaseModel]):
    field_definitions: dict = {}
    field: ModelField
    validators_definitions: dict = {}

    # exit condition: if 'cls' is not a BaseModel return the Optional version of it
    # otherwise its fields must be optionalized
    if not issubclass(cls, BaseModel):
        return ( Optional[cls] )
    
    for name, field in cls.__fields__.items():
        # keep original validators but allow_reuse to avoid errors
        v: class_validators.Validator
        for k, v in field.class_validators.items() or []:
            validators_definitions[k] = validator(
                                                    name, 
                                                    allow_reuse=True, # avoid errors due the the same function owned by original class
                                                    always=v.always, 
                                                    check_fields=v.check_fields, 
                                                    pre=v.pre, 
                                                    each_item=v.each_item
                                                )(v.func)
        
        # Compute field definitions for 'cls' inheriting from  BaseModel recursively. 
        # If the field has an outer type (e.g. Union, Dict, List) then construct the outer type and trasform its inner arguments as Optional 
        # Otherwise the field can be either a BaseModel type (again) or a standard type (so just need to call the function recursively)
        origin: Any | None = get_origin(field.outer_type_)
        args = get_args(field.outer_type_)
        if field.sub_fields and origin:
            field_definitions[name] = ( (origin[ tuple( make_optional(arg_type) for arg_type in args ) ]), field.default or None ) 
        else:
            field_definitions[name] = ( make_optional(field.outer_type_), field.default or None )
        
    return create_model(cls.__name__+"Optional", __base__=cls.__base__, __validators__=validators_definitions, **field_definitions)
   

class Fuel(BaseModel):
    name: str
    spec: str

class Engine(BaseModel):
    type: str
    fuel: Union[str, Fuel]

class Wheel(BaseModel):
    id: int
    name: str = "Pirelli"

    @validator('id')
    def check_id(cls, v):
        if v <= 0:
            raise ValueError('ID must be greater than 0')
        return v

class Driver(BaseModel):
    name: str
    number: int

class Car(BaseModel):
    name: str
    driver: Driver
    wheels: list[Wheel]
    engine: Union[Engine, str]


# 0. Make Optional Car
CarOptional: Any | Type[None] = make_optional(Car)
print()

# 1. Create a standard Car
car1 = Car(name="Ferrari", driver=Driver(name='Schumacher', number=1), engine=Engine(type="V12", fuel=Fuel(name="Petrol",spec="XXX")), wheels=[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}])
print("\n1) Standard Car \n", car1)

# 2. Create a CarOptional model having Optional fields also in nested objects (e.g. Fuel.name becomes Optional as well)
try:
    car_opt1 = CarOptional(engine=dict(type="V12", fuel=dict(spec="XXX")), wheels=[{}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    assert False
except Exception as e:
    assert False
else:
    print("\n2) Optional Car1 with 'engine.fuel : Fuel \n", car_opt1)
    assert True

try:
    car_opt2 = CarOptional(driver=dict(name='Leclerc'), engine=dict(type="V12", fuel='ciao'), wheels=[{}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    assert False
except Exception as e:
    assert False
else:
    print("\n2) Optional Car2 with 'engine.fuel : str \n", car_opt2)
    assert True


# 3. Validators are still executed for fields with a value (e.g. Wheel.id = 0)
print("\n3) Optional Car but WheelOptional.id not valid \n")
try:
    car_not_valid = CarOptional(engine=dict(type="V12", fuel=dict(spec="XXX")), wheels=[{"id": 0}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    print(e)
    assert True
else:
    assert False

# 4. Must raise a validation error
print("\n4) Standard Car still working: should return error missing 'name', 'driver' and 'wheels' \n")
try:
    car2: Car = Car(engine=Engine(type="V12", fuel=Fuel(name="Petrol",spec="XXX")))
except ValidationError as e:
    print(e)
    assert True
else:
    assert False

l-rossetti avatar Feb 27 '23 15:02 l-rossetti

After some reasoning and hard fighting on that topic, I ended up with un updated version of the previous recursive algorithm which handles for example types.UnionType, conlist and complex types (e.g. Engine | str | int | dict[str, Engine] ). After testing it I'm almost satisfied but I see it is very complex and not 100% reliable. That's why for our project we finally decided to just make a new copy of the class definitions we need as Optional. Code duplication is not nice but at least we know it works.

Anyway I wanted to share my effort with the community cause it may be useful to someone else. Moreover, I've just understood that V2 should be very close (Q1 2023) so probably it doesn't make sense to put this effort for having a Typescript's Partial equivalent in V1. In V2 it may be also simpler to implement if the new modeling logic fits it in a better way (at least I hope so).

One thing I really don't understand in my code, is why the last test fails saying that function wheel.get_key() doesn't exist (AttributeError: 'WheelOptional' object has no attribute 'get_key'), while the validator is able to make use of the same get_key() function -_-

from pydantic import BaseModel, validator, create_model, ValidationError, class_validators, conlist
from typing import Type, Union, Optional, Any, List, Dict, Tuple, get_origin, get_args
from pydantic.fields import ModelField
from pydantic.class_validators import Validator
from types import UnionType


def print_check_model(cls):
    print(f"\n------------------------------------ check '{cls}' model")
    if issubclass(cls, BaseModel):
        for field in cls.__fields__.values():
            
            print("field", " ##### name '",  cls.__name__+"."+field.name, "' ##### outer_type_", field.outer_type_, " ##### type_", field.type_, " ##### required", field.required, " ##### allow_none", field.allow_none)
            if field.sub_fields:
                for sub in field.sub_fields or [] :
                    print("sub", " ##### name '",  cls.__name__+"."+sub.name, "' ##### outer_type_", sub.outer_type_, " ##### type_", sub.type_, " ##### required", sub.required, " ##### allow_none", sub.allow_none)
                    print_check_model(sub.type_)
            else:
                print(field.type_)
                print_check_model(field.type_)


def copy_validator(field_name: str, v: Validator) -> classmethod:
    return  validator(
                        field_name, 
                        allow_reuse=True, # avoid errors due the the same function owned by original class
                        always=v.always, 
                        check_fields=v.check_fields, 
                        pre=v.pre, 
                        each_item=v.each_item
                    )(v.func)

# Returns a new model class which is a copy of the input one
# having all inner fields and subfields as non required
def make_optional(cls: Type[BaseModel], recursive: bool):

    field_definitions: dict = {}
    field: ModelField
    validators_definitions: dict = {}
    
    # if cls has args (e.g. list[str] or dict[int, Union[str, Driver]])
    # then make optional types of its arguments
    if get_origin(cls) and get_args(cls):
        return get_origin(cls)[tuple(make_optional(arg, recursive) for arg in get_args(cls))]

    # exit condition: if 'cls' is not a BaseModel return the Optional version of it
    # otherwise its fields must be optionalized
    if not issubclass(cls, BaseModel):
            return cls
    
    for name, field in cls.__fields__.items():

        # keep original validators but allow_reuse to avoid errors
        v: class_validators.Validator
        for k, v in field.class_validators.items() or []:
            validators_definitions[k] = copy_validator(name, v)   

        # Compute field definitions for 'cls' inheriting from  BaseModel recursively. 
        # If the field has an outer type (e.g. Union, Dict, List) then construct the outer type and trasform its inner arguments as Optional 
        # Otherwise the field can be either a BaseModel type (again) or a standard type (so just need to call the function recursively)
        origin: Any | None = get_origin(field.outer_type_)
        args = get_args(field.outer_type_)
        if not recursive:
            field_definitions[name] = ( field.outer_type_, field.default or None )
        elif origin in (dict, Dict, tuple, Tuple, list, List, Union, UnionType):
            if origin is UnionType: # handles 'field: Engine | str'
                origin = Union
            field_definitions[name] = ( origin[ tuple( make_optional(arg_type, recursive) for arg_type in args ) ], field.default or None ) # type: ignore
        
        # handle special cases not handled by previous if branch (e.g. conlist)
        elif field.outer_type_ != field.type_:
            if issubclass(field.outer_type_, list): # handles conlist
                field_definitions[name] = ( list[ make_optional(field.type_, recursive) ], field.default or None )
            else:
                raise Exception(f"Case with outer_type_ {field.outer_type_} and type_ {field.type_} not handled!!")
        
        else:
            field_definitions[name] = ( make_optional(field.outer_type_, recursive), field.default or None )
        
    return create_model(cls.__name__+"Optional", __config__=cls.__config__, __validators__=validators_definitions, **field_definitions)

# ____________________________________________________________________________________________________________________________________________________________

class Comp(BaseModel):
    name: str

class Fuel(BaseModel):
    name: str
    spec: str

class Engine(BaseModel):
    type: str
    fuel: Union[str, Fuel]
    eng_components: dict[str, list[Comp]]
    eng_tuple_var: tuple[str, int, list[Comp]]

class Wheel(BaseModel):
    id: int
    name: str = "Pirelli"

    def get_key(self) -> str:
        return self.id

    def __hash__(self) -> int:
        return hash(self.get_key())

    def __eq__(self, other) -> bool:
        if not isinstance(other, Wheel):
            return False
        return self.get_key() == other.get_key()

    @validator('id')
    def check_id(cls, v):
        if v <= 0:
            raise ValueError('ID must be greater than 0')
        return v

class Driver(BaseModel):
    name: str
    number: int

class Car(BaseModel):
    class Config:
        extra: str = "forbid"

    name: str
    driver: Driver
    wheels: conlist(Wheel, unique_items=True)
    engine: Engine | str | int | dict[str, Engine]
    components: dict[str, list[Comp]]
    tuple_var: tuple[str, int, list[Comp]]
# ____________________________________________________________________________________________________________________________________________________________________________

# 0. Make Optional Car
CarOptional: Any | Type[None] = make_optional(Car, True)
print()
print_check_model(CarOptional)

# 0. Create a standard Car
car0 = Car(name="Ferrari", 
                driver=Driver(name='Schumacher', number=1), 
                engine=Engine(type="V12", fuel=Fuel(name="Petrol",spec="XXX"), eng_components=dict({"c1": [{"name": "eng-comp1.1"}, {"name": "eng-comp1.2"}], "c2": [{"name": "eng-comp2.1"}, {"name": "eng-comp2.2"}]}), eng_tuple_var=tuple(["ciao", 34, [{"name": "tup-comp1"}, {"name": "tup-comp2"}]]) ), 
                wheels=[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}], 
                components=dict({"c1": [{"name": "comp1.1"}, {"name": "comp1.2"}], "c2": [{"name": "comp2.1"}, {"name": "comp2.2"}]}),
                tuple_var=tuple(["ciao", 34, [{"name": "comp1.1"}, {"name": "comp1.2"}]])
            )
print("\n0) Standard Car \n", car0, "\n")

# 1. Create a CarOptional model having Optional fields also in nested objects (e.g. Fuel.name becomes Optional as well)
try:
    car_opt1 = CarOptional(engine=dict(type="V12", fuel=dict(spec="XXX")), wheels=[{}, {"id": 2}, {"id": 3}, {"id": 4}])
    print_check_model(CarOptional)
except ValidationError as e:
    print("[KO] car_opt1")
    assert False
except Exception as e:
    print("[KO] car_opt1")
    assert False
else:
    print("\n[ok] 1) Optional Car1 with 'engine.fuel : Fuel\n", car_opt1)
    assert True

# 2. Create a CarOptional model having Optional fields also in nested objects (e.g. Fuel.name becomes Optional as well)
try:
    car_opt2 = CarOptional(driver=dict(name='Leclerc'), engine=dict(type="V12", fuel='ciao'), wheels=[{}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    print("[KO] car_opt2")
    print(e)
    assert False
except Exception as e:
    print("[KO] car_opt2")
    assert False
else:
    print("\n[ok] 2) Optional Car2 with 'engine.fuel : str \n", car_opt2)
    assert True

# 3. Validators are still executed for fields with a value (e.g. Wheel.id = 0)
print("\n3) Optional Car but WheelOptional.id = 0  not valid \n")
try:
    car_not_valid = CarOptional(engine=dict(type="V12", fuel=dict(spec="XXX")), wheels=[{"id": 0}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    print("[ok] car_not_valid ")
    print(e)
    assert True
else:
    print("[KO] car_not_valid")
    assert False
    
# 4. Validators are still executed for conlist with duplicated items (e.g. having two items with Wheel.id = 2)
print("\n4) Optional Car but duplicated WheelOptionals with same 'id = 2', is not valid due to 'id' validator\n")
try:
    car_not_valid = CarOptional(engine=dict(type="V12", fuel=dict(spec="XXX")), wheels=[{"id": 1}, {"id": 2}, {"id": 2}, {"id": 4}])
except ValidationError as e:
    print("[ok] car_not_valid ")
    print(e)
    assert True
else:
    print("[KO] car_not_valid")
    assert False

# 5. Standard Car must still raise a validation error
print("\n5) Standard Car still working: should return error missing 'name', 'driver', 'wheels', 'components' and 'tuple_var' \n")
try:
    car2: Car = Car(engine=Engine(type="V12", fuel=Fuel(name="Petrol",spec="XXX")))
except ValidationError as e:
    print("[ok] car2 ")
    print(e)
    assert True
else:
    print("[KO] car2")
    assert False

# 6. Check inner Driver model not modified
print("\n6) Inner Driver model not modified \n")
try:
    print("driver", Driver(number=12))
except ValidationError as e:
    print("[ok] Driver ")
    print(e)
    assert True
else:
    print("[KO] Driver")
    assert False

CarOptionalNonRecursive = make_optional(Car, False)
# 7. Non recursive Optional Car 1
print("\n7) Non recursive Optional Car 1 \n")
try:
    caroptnonrec: CarOptionalNonRecursive = CarOptionalNonRecursive(driver=dict(number=12))
except ValidationError as e:
    print("[ok] caroptnonrec1 ")
    print(e)
    assert True
else:
    print("[KO] caroptnonrec1")
    print(caroptnonrec)
    assert False

# 8. Non recursive Optional Car 2
print("\n8) Non recursive Optional Car 2 \n")
try:
    caroptnonrec: CarOptionalNonRecursive = CarOptionalNonRecursive(name='test')
except ValidationError as e:
    print("[KO] caroptnonrec2")
    print(e)
    assert False
else:
    print("[ok] caroptnonrec2 ")
    print(caroptnonrec)
    assert True

# 9. Complex Tuple
print("\n9) Complex Tuple \n")
try:
    caropttuple: CarOptional = CarOptional(name='test', tuple_var=tuple(["ciao", 34, [{}, {"name": "comp1.2"}]]), engine=dict(type="V12", fuel="fuelXX", eng_components=dict({"c1": [{}, {"name": "eng-comp1.2"}], "c2": [{"name": "eng-comp2.1"}, {"name": "eng-comp2.2"}]}), eng_tuple_var=tuple(["ciao", 34, [{}, {"name": "tup-comp2"}]]) ), )
except ValidationError as e:
    print("[KO] caropttuple")
    print(e)
    assert False
else:
    print("[ok] caropttuple ")
    print(caropttuple)
    assert True

# 10. Additional forbidden param
print("\n10) Additional forbidden param \n")
try:
    caroptadditional: CarOptional = CarOptional(additional="non_existing" )
except ValidationError as e:
    print("[ok] caroptadditional ")
    print(e)
    assert True
else:
    print("[KO] caroptadditional")
    print(caroptadditional)
    assert False

# 11. Engine of type int but why wheel.get_key() not found?!?!?
print("\n11)  Engine of type 'int' but why wheel.get_key() not found?!?!? \n")
try:
    car_opt3 = CarOptional(driver=dict(name='Leclerc'), engine=2, wheels=[{}, {"id": 2}, {"id": 3}, {"id": 4}])
except ValidationError as e:
    print("[KO] car_opt3")
    print(e)
    assert False
except Exception as e:
    print("[KO] car_opt3")
    assert False
else:
    print("\n[ok] Optional Car3 with 'engine : int \n", car_opt3)
    for w in car_opt3.wheels:
        print(w.get_key())
    assert True

l-rossetti avatar Mar 03 '23 10:03 l-rossetti

While I think we would like to better support this, ultimately there just isn't anything super analogous to typescript's Partial in the python typing system. As such, we've decided not to support a Partial-like feature for the time being, as we would rather wait until such a feature is present for dataclass or TypedDict and match the implementation, instead of coming up with our own.

I think we'd be open to revisiting this if a good proposal was brought forward, especially one that was compatible with type-checkers, etc. But for now I'm going to close this as "not planned" — at least until the language better supports these kinds of (more advanced) utility types.

See https://github.com/pydantic/pydantic/issues/830#issuecomment-1522202953 for a related comment.

dmontagu avatar May 22 '23 13:05 dmontagu

I adapted a Partial Model that I found on StackOverflow for Pydantic V1x to work well with Pydantic V2.

from copy import deepcopy
from typing import Any, Callable, Optional, Type, TypeVar

from pydantic import BaseModel, create_model
from pydantic.fields import FieldInfo

Model = TypeVar("Model", bound=Type[BaseModel])

def partial_model(without_fields: Optional[list[str]] = None) -> Callable[[Model], Model]:
    """A decorator that create a partial model.

    Args:
        model (Type[BaseModel]): BaseModel model.

    Returns:
        Type[BaseModel]: ModelBase partial model.
    """
    if without_fields is None:
        without_fields = []

    def wrapper(model: Type[Model]) -> Type[Model]:
        base_model: Type[Model] = model

        def make_field_optional(field: FieldInfo, default: Any = None) -> tuple[Any, FieldInfo]:
            new = deepcopy(field)
            new.default = default
            new.annotation = Optional[field.annotation]
            return new.annotation, new

        if without_fields:
            base_model = BaseModel

        return create_model(
            model.__name__,
            __base__=base_model,
            __module__=model.__module__,
            **{
                field_name: make_field_optional(field_info)
                for field_name, field_info in model.model_fields.items()
                if field_name not in without_fields
            },
        )

    return wrapper

How use

class MyFullModel(BaseModel):
    name: str
    age: int
    relation_id: int


@partial_model(without_fields=["relation_id"])
class MyPartialModel(MyFullModel):
    pass

satheler avatar Dec 05 '23 23:12 satheler

Here Is my working solution. Similar to some of the solutions above except that it works recursively and is able to make all sub schemas partial aswell.

from typing import Any, Optional, Type, Union, get_args, get_origin, Annotated

from pydantic import BaseModel, create_model
from pydantic.fields import FieldInfo

MaybePydantic = Type[Union[Any, BaseModel]]


def create_optional_field(field: Union[FieldInfo, MaybePydantic]) -> object:
    field_type = field.annotation if isinstance(field, FieldInfo) else field

    if origin := get_origin(field_type):
        if origin is Annotated:
            return Optional[field_type]

        args = get_args(field_type)
        optional_args = [Optional[create_optional_field(arg)] for arg in args]
        return Optional[origin[*optional_args]]

    # Handle BaseModel subclasses
    if field_type and issubclass(field_type, BaseModel):
        return Optional[Union[field_type, create_optional_model(field_type)]]

    return Optional[field_type]


def create_optional_model(model: Type[BaseModel]) -> Type[BaseModel]:
    """
    Make all fields in a pydantic model optional. Sub schemas will also become 'partialized' 
    """
    return create_model(  # type: ignore
        model.__name__ + "Optional",
        __base__=model,
        **{
            name: (create_optional_field(field), None)
            for name, field in model.model_fields.items()
        },
    )

Usage example:

class Bar(BaseModel):
    id: str

class Baz(BaseModel):
    items: List[Dict[str, Optional[Bar]]]

class Foo(BaseModel):
    bar: Optional[HttpUrl]
    baz: Baz

optional_cls = create_optional_model(Foo)
optional_cls()

awtkns avatar Dec 11 '23 23:12 awtkns

For anyone interested in this feature, I'd love to support it, but really we need a Partial type in Python itself, so we can have a typesafe Partial implementation.

There's currently a discussion on discuss.python.org about adding a Partial type for typed dicts, feel free to upvote the original proposal and/or my reply.

samuelcolvin avatar Feb 07 '24 12:02 samuelcolvin

For anyone interested in this feature, I'd love to support it, but really we need a Partial type in Python itself, so we can have a typesafe Partial implementation.

There's currently a discussion on discuss.python.org about adding a Partial type for typed dicts, feel free to upvote the original proposal and/or my reply.

Nice!! I'll leave my 2 cents contribution there too.

satheler avatar Feb 07 '24 13:02 satheler

@satheler Thank you for the snippet. Just want to point out that this part of the function will strip the resulting partial model of all validators, serializers, and private attributes that would've otherwise been inherited from the input model:

if without_fields:
    base_model = BaseModel

I made some modifications that seem to be working. The idea is to still pass all fields to create_model whether they are in without_fields or not, but to wrap the annotations of the ones in without_fields with ClassVar, which will cause Pydantic to ignore them. This means we can now always inherit the original input model for __base__. However, pydantic will error if validators exist on non-existing fields, so to fix this I make a deepcopy of the input model, and set all validators or serializers to have the "check_fields" argument to false. This works whether the validator was created via a decorator or not, despite the name.

from copy import deepcopy
from dataclasses import asdict
from typing import Any, Callable, Optional, Type, TypeVar, ClassVar

from pydantic import BaseModel, create_model
from pydantic.fields import FieldInfo

Model = TypeVar("Model", bound=Type[BaseModel])


def partial_model(
    without_fields: Optional[list[str]] = None,
) -> Callable[[Model], Model]:
    """A decorator that create a partial model.

    Args:
        model (Type[BaseModel]): BaseModel model.

    Returns:
        Type[BaseModel]: ModelBase partial model.
    """
    if without_fields is None:
        without_fields = []

    def wrapper(model: Type[Model]) -> Type[Model]:
        def make_field_optional(
            field: FieldInfo, default: Any = None, omit: bool = False
        ) -> tuple[Any, FieldInfo]:
            new = deepcopy(field)
            new.default = default
            new.annotation = Optional[field.annotation]
            # Wrap annotation in ClassVar if field in without_fields
            return ClassVar[new.annotation] if omit else new.annotation, new
        
        model_copy = deepcopy(model)
        
        # Pydantic will error if validators are present without the field
        # so we set check_fields to false on all validators
        for dec_group_label, decs in asdict(model_copy.__pydantic_decorators__).items():
            for validator in decs.keys():
                decorator_info = getattr(
                    getattr(model.__pydantic_decorators__, dec_group_label)[validator],
                    "info",
                )
                if hasattr(decorator_info, "check_fields"):
                    setattr(
                        decorator_info,
                        "check_fields",
                        False,
                    )

        return create_model(
            model.__name__,
            __base__=model,
            __module__=model.__module__,
            **{
                field_name: make_field_optional(
                    field_info, omit=(field_name in without_fields)
                )
                for field_name, field_info in model.model_fields.items()
            },
        )

    return wrapper

mhamid3d avatar Mar 20 '24 12:03 mhamid3d