8000 allow for shallow copies (#4093) · pydantic/pydantic@1d6a6e6 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1d6a6e6

Browse files
allow for shallow copies (#4093)
* allow for shallow copies * Add changes file * tweak change * update for comments * rename attr * use single quotes * bump ci * add warning if not a string, switch to string literals * fix linting, prompt ci * fix ci * extend and fix tests * change default to "shallow" Co-authored-by: Samuel Colvin <s@muelcolvin.com>
1 parent 5774756 commit 1d6a6e6

File tree

6 files changed

+89
-17
lines changed

6 files changed

+89
-17
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
- pydantic-v2-blog
99
tags:
1010
- '**'
11+
pull_request: {}
1112

1213
jobs:
1314
lint:

changes/4093-timkpaine.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Allow for shallow copies of attributes, adjusting the behavior of #3642
2+
`Config.copy_on_model_validation` is now a str enum of `["none", "deep", "shallow"]` corresponding to
3+
not copying, deep copy, shallow copy, default `"shallow"`.

docs/requirements.txt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
ansi2html==1.6.0
2-
flake8==4.0.1
1+
ansi2html==1.8.0
2+
flake8==5.0.4
33
flake8-quotes==3.3.1
4-
hypothesis==6.46.3
5-
markdown-include==0.6.0
6-
mdx-truly-sane-lists==1.2
7-
mkdocs==1.3.0
4+
hypothesis==6.54.1
5+
markdown-include==0.7.0
6+
mdx-truly-sane-lists==1.3
7+
mkdocs==1.3.1
88
mkdocs-exclude==1.0.2
9-
mkdocs-material==8.2.14
9+
mkdocs-material==8.3.9
1010
sqlalchemy
1111
orjson
1212
ujson

pydantic/config.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@
22
from enum import Enum
33
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple, Type, Union
44

5+
from typing_extensions import Literal, Protocol
6+
57
from .typing import AnyCallable
68
from .utils import GetterDict
79

810
if TYPE_CHECKING:
911
from typing import overload
1012

11-
import typing_extensions
12-
1313
from .fields import ModelField
1414
from .main import BaseModel
1515

1616
ConfigType = Type['BaseConfig']
1717

18-
class SchemaExtraCallable(typing_extensions.Protocol):
18+
class SchemaExtraCallable(Protocol):
1919
@overload
2020
def __call__(self, schema: Dict[str, Any]) -> None:
2121
pass
@@ -63,8 +63,10 @@ class BaseConfig:
6363
json_encoders: Dict[Union[Type[Any], str], AnyCallable] = {}
6464
underscore_attrs_are_private: bool = False
6565

66-
# whether inherited models as fields should be reconstructed as base model
67-
copy_on_model_validation: bool = True
66+
# whether inherited models as fields should be reconstructed as base model,
67+
# and whether such a copy should be shallow or deep
68+
copy_on_model_validation: Literal['none', 'deep', 'shallow'] = 'shallow'
69+
6870
# whether `Union` should check all allowed types before even trying to coerce
6971
smart_union: bool = False
7072

pydantic/main.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -675,10 +675,28 @@ def __get_validators__(cls) -> 'CallableGenerator':
675675
@classmethod
676676
def validate(cls: Type['Model'], value: Any) -> 'Model':
677677
if isinstance(value, cls):
678-
if cls.__config__.copy_on_model_validation:
679-
return value._copy_and_set_values(value.__dict__, value.__fields_set__, deep=True)
680-
else:
678+
copy_on_model_validation = cls.__config__.copy_on_model_validation
679+
# whether to deep or shallow copy the model on validation, None means do not copy
680+
deep_copy: Optional[bool] = None
681+
if copy_on_model_validation not in {'deep', 'shallow', 'none'}:
682+
# Warn about deprecated behavior
683+
warnings.warn(
684+
"`copy_on_model_validation` should be a string: 'deep', 'shallow' or 'none'", DeprecationWarning
685+
)
686+
if copy_on_model_validation:
687+
deep_copy = False
688+
689+
if copy_on_model_validation == 'shallow':
690+
# shallow copy
691+
deep_copy = False
692+
elif copy_on_model_validation == 'deep':
693+
# deep copy
694+
deep_copy = True
695+
696+
if deep_copy is None:
681697
return value
698+
else:
699+
return value._copy_and_set_values(value.__dict__, value.__fields_set__, deep=deep_copy)
682700

683701
value = cls._enforce_dict_if_root(value)
684702

tests/test_main.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,18 +1561,66 @@ class Config:
15611561

15621562
assert t.user is not my_user
15631563
assert t.user.hobbies == ['scuba diving']
1564-
assert t.user.hobbies is not my_user.hobbies # `Config.copy_on_model_validation` does a deep copy
1564+
assert t.user.hobbies is my_user.hobbies # `Config.copy_on_model_validation` does a shallow copy
15651565
assert t.user._priv == 13
15661566
assert t.user.password.get_secret_value() == 'hashedpassword'
15671567
assert t.dict() == {'id': '1234567890', 'user': {'id': 42, 'hobbies': ['scuba diving']}}
15681568

15691569

1570+
def test_model_exclude_copy_on_model_validation_shallow():
1571+
"""When `Config.copy_on_model_validation` is set and `Config.copy_on_model_validation_shallow` is set,
1572+
do the same as the previous test but perform a shallow copy"""
1573+
1574+
class User(BaseModel):
1575+
class Config:
1576+
copy_on_model_validation = 'shallow'
1577+
1578+
hobbies: List[str]
1579+
1580+
my_user = User(hobbies=['scuba diving'])
1581+
1582+
class Transaction(BaseModel):
1583+
user: User = Field(...)
1584+
1585+
t = Transaction(user=my_user)
1586+
1587+
assert t.user is not my_user
1588+
assert t.user.hobbies is my_user.hobbies # unlike above, this should be a shallow copy
1589+
1590+
1591+
@pytest.mark.parametrize('comv_value', [True, False])
1592+
def test_copy_on_model_validation_warning(comv_value):
1593+
class User(BaseModel):
1594+
class Config:
1595+
# True interpreted as 'shallow', False interpreted as 'none'
1596+
copy_on_model_validation = comv_value
1597+
1598+
hobbies: List[str]
1599+
1600+
my_user = User(hobbies=['scuba diving'])
1601+
1602+
class Transaction(BaseModel):
1603+
user: User
1604+
1605+
with pytest.warns(DeprecationWarning, match="`copy_on_model_validation` should be a string: 'deep', 'shallow' or"):
1606+
t = Transaction(user=my_user)
1607+
1608+
if comv_value:
1609+
assert t.user is not my_user
1610+
else:
1611+
assert t.user is my_user
1612+
assert t.user.hobbies is my_user.hobbies
1613+
1614+
15701615
def test_validation_deep_copy():
15711616
"""By default, Config.copy_on_model_validation should do a deep copy"""
15721617

15731618
class A(BaseModel):
15741619
name: str
15751620

1621+
class Config:
1622+
copy_on_model_validation = 'deep'
1623+
15761624
class B(BaseModel):
15771625
list_a: List[A]
15781626

@@ -1987,7 +2035,7 @@ def __hash__(self):
19872035
return id(self)
19882036

19892037
class Config:
1990-
copy_on_model_validation = False
2038+
copy_on_model_validation = 'none'
19912039

19922040
class Item(BaseModel):
19932041
images: List[Image]

0 commit comments

Comments
 (0)
0