Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

'Partial' Equivalent #1673

Closed
kalzoo opened this issue Jun 30, 2020 · 21 comments
Closed

'Partial' Equivalent #1673

kalzoo opened this issue Jun 30, 2020 · 21 comments
Assignees
Labels
deferred deferred until future release or until something else gets done feature request

Comments

@kalzoo
Copy link

kalzoo commented Jun 30, 2020

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!

@StephenBrown2
Copy link
Contributor

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)

@kalzoo
Copy link
Author

kalzoo commented Jul 1, 2020

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.

@StephenBrown2
Copy link
Contributor

StephenBrown2 commented Jul 2, 2020

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.

@lsorber
Copy link

lsorber commented Jul 25, 2020

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 (#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.

@mortezaPRK
Copy link

mortezaPRK commented Aug 12, 2020

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

@dashavoo
Copy link

dashavoo commented Nov 10, 2020

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: 4cbe5b0

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?

@samuelcolvin

This comment has been minimized.

@samuelcolvin
Copy link
Member

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.

@cyruskarsan
Copy link

cyruskarsan commented Mar 1, 2022

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)

@rudexi
Copy link

rudexi commented May 7, 2022

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.

@ddanier
Copy link
Sponsor

ddanier commented Aug 31, 2022

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

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

@samuelcolvin
Copy link
Member

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...?

@l-rossetti
Copy link

l-rossetti commented Feb 27, 2023

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
Copy link

l-rossetti commented Mar 3, 2023

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

@dmontagu
Copy link
Contributor

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 #830 (comment) for a related comment.

@satheler
Copy link
Contributor

satheler commented Dec 5, 2023

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

@awtkns
Copy link

awtkns commented Dec 11, 2023

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()

@samuelcolvin
Copy link
Member

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 samuelcolvin added feature request deferred deferred until future release or until something else gets done and removed question labels Feb 7, 2024
@satheler

This comment was marked as duplicate.

@mhamid3d
Copy link

mhamid3d commented Mar 20, 2024

@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

@mohamedtaee
Copy link

@samuelcolvin what would you suggest is the best current work around with Pydantic V2.8?

I know you suggested validate_model awhile back, but I can't tell if this is now deprecated?

Right now the method I am going with is this:

from pydantic import BaseModel, Field


class Field3(BaseModel):
    subfield1: str = Field(...)
    subfield2: str = Field(...)


class MainClass(BaseModel):
    field1: str = Field(...)
    field2: str = Field(...)
    field3: Field3 = Field(...)


class MainClassSubset(MainClass):
    pass


if __name__ == "__main__":
    main_class = MainClass(field1="field1", field2="field2", field3=Field3(subfield1="subfield1", subfield2="subfield2"))
    print(main_class.dict())

    exclude_dict = {
        'field1': True,
        'field2': True,
        'field3': {
            'subfield1': True
        }
    }

    main_class_subset = MainClassSubset(**main_class.dict())
    print(main_class_subset.dict(exclude=exclude_dict))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
deferred deferred until future release or until something else gets done feature request
Projects
None yet
Development

No branches or pull requests