diff --git a/pydantic/main.py b/pydantic/main.py index 62dc0bb..02bf91d 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -586,6 +586,24 @@ class BaseModel(Representation, metaclass=ModelMetaclass): m._init_private_attributes() return m + def _copy_and_set_values(self: 'Model', values: 'DictStrAny', fields_set: 'SetStr', *, deep: bool) -> 'Model': + if deep: + # chances of having empty dict here are quite low for using smart_deepcopy + values = deepcopy(values) + + cls = self.__class__ + m = cls.__new__(cls) + object_setattr(m, '__dict__', values) + object_setattr(m, '__fields_set__', fields_set) + for name in self.__private_attributes__: + value = getattr(self, name, Undefined) + if value is not Undefined: + if deep: + value = deepcopy(value) + object_setattr(m, name, value) + + return m + def copy( self: 'Model', *, @@ -605,32 +623,18 @@ class BaseModel(Representation, metaclass=ModelMetaclass): :return: new model instance """ - v = dict( + values = dict( self._iter(to_dict=False, by_alias=False, include=include, exclude=exclude, exclude_unset=False), **(update or {}), ) - if deep: - # chances of having empty dict here are quite low for using smart_deepcopy - v = deepcopy(v) - - cls = self.__class__ - m = cls.__new__(cls) - object_setattr(m, '__dict__', v) # new `__fields_set__` can have unset optional fields with a set value in `update` kwarg if update: fields_set = self.__fields_set__ | update.keys() else: fields_set = set(self.__fields_set__) - object_setattr(m, '__fields_set__', fields_set) - for name in self.__private_attributes__: - value = getattr(self, name, Undefined) - if value is not Undefined: - if deep: - value = deepcopy(value) - object_setattr(m, name, value) - return m + return self._copy_and_set_values(values, fields_set, deep=deep) @classmethod def schema(cls, by_alias: bool = True, ref_template: str = default_ref_template) -> 'DictStrAny': @@ -658,7 +662,10 @@ class BaseModel(Representation, metaclass=ModelMetaclass): @classmethod def validate(cls: Type['Model'], value: Any) -> 'Model': if isinstance(value, cls): - return value.copy() if cls.__config__.copy_on_model_validation else value + if cls.__config__.copy_on_model_validation: + return value._copy_and_set_values(value.__dict__, value.__fields_set__, deep=False) + else: + return value value = cls._enforce_dict_if_root(value) diff --git a/tests/test_main.py b/tests/test_main.py index f44776c..688cca1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,7 +28,9 @@ from pydantic import ( Field, NoneBytes, NoneStr, + PrivateAttr, Required, + SecretStr, ValidationError, constr, root_validator, @@ -1516,6 +1518,45 @@ def test_model_exclude_config_field_merging(): assert Model.__fields__['b'].field_info.exclude == {'foo': ..., 'bar': ...} +def test_model_exclude_copy_on_model_validation(): + """When `Config.copy_on_model_validation` is set, it should keep private attributes and excluded fields""" + + class User(BaseModel): + _priv: int = PrivateAttr() + id: int + username: str + password: SecretStr = Field(exclude=True) + hobbies: List[str] + + my_user = User(id=42, username='JohnDoe', password='hashedpassword', hobbies=['scuba diving']) + + my_user._priv = 13 + assert my_user.id == 42 + assert my_user.password.get_secret_value() == 'hashedpassword' + assert my_user.dict() == {'id': 42, 'username': 'JohnDoe', 'hobbies': ['scuba diving']} + + class Transaction(BaseModel): + id: str + user: User = Field(..., exclude={'username'}) + value: int + + class Config: + fields = {'value': {'exclude': True}} + + t = Transaction( + id='1234567890', + user=my_user, + value=9876543210, + ) + + assert t.user is not my_user + assert t.user.hobbies == ['scuba diving'] + assert t.user.hobbies is my_user.hobbies # `Config.copy_on_model_validation` only does a shallow copy + assert t.user._priv == 13 + assert t.user.password.get_secret_value() == 'hashedpassword' + assert t.dict() == {'id': '1234567890', 'user': {'id': 42, 'hobbies': ['scuba diving']}} + + @pytest.mark.parametrize( 'kinds', [