Repository: pydantic/pydantic

Issue:
v1.9.1 release missing from `HISTORY.md` on `master`
The latest version of Pydantic is v1.9.1, however the most recent version mentioned in the HISTORY.md file on `master` is v1.9.0:
https://github.com/samuelcolvin/pydantic/blob/9a0ae097bfdefa9bfb41e19d71a5001fe8240e36/HISTORY.md

It seems the new changelog entry for 1.9.1 was only added to the 1.9.1 tag/branch:
https://github.com/samuelcolvin/pydantic/blob/v1.9.1/HISTORY.md#v191-2022-05-19

...however that tag is not what people see when first navigating to the repo to find out recent changes :-)


Discussion:

Resolving patch:
diff --git a/pydantic/config.py b/pydantic/config.py
--- a/pydantic/config.py
+++ b/pydantic/config.py
@@ -2,6 +2,8 @@
 from enum import Enum
 from typing import TYPE_CHECKING, Any, Callable, Dict, ForwardRef, Optional, Tuple, Type, Union
 
+from typing_extensions import Literal, Protocol
+
 from .typing import AnyCallable
 from .utils import GetterDict
 from .version import compiled
@@ -9,14 +11,12 @@
 if TYPE_CHECKING:
     from typing import overload
 
-    import typing_extensions
-
     from .fields import ModelField
     from .main import BaseModel
 
     ConfigType = Type['BaseConfig']
 
-    class SchemaExtraCallable(typing_extensions.Protocol):
+    class SchemaExtraCallable(Protocol):
         @overload
         def __call__(self, schema: Dict[str, Any]) -> None:
             pass
@@ -40,7 +40,7 @@ class Extra(str, Enum):
 # https://github.com/cython/cython/issues/4003
 # Will be fixed with Cython 3 but still in alpha right now
 if not compiled:
-    from typing_extensions import Literal, TypedDict
+    from typing_extensions import TypedDict
 
     class ConfigDict(TypedDict, total=False):
         title: Optional[str]
@@ -103,8 +103,10 @@ class BaseConfig:
     json_encoders: Dict[Union[Type[Any], str, ForwardRef], AnyCallable] = {}
     underscore_attrs_are_private: bool = False
 
-    # whether inherited models as fields should be reconstructed as base model
-    copy_on_model_validation: bool = True
+    # whether inherited models as fields should be reconstructed as base model,
+    # and whether such a copy should be shallow or deep
+    copy_on_model_validation: Literal['none', 'deep', 'shallow'] = 'shallow'
+
     # whether `Union` should check all allowed types before even trying to coerce
     smart_union: bool = False
     # whether dataclass `__post_init__` should be run before or after validation
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -679,10 +679,28 @@ def __get_validators__(cls) -> 'CallableGenerator':
     @classmethod
     def validate(cls: Type['Model'], value: Any) -> 'Model':
         if isinstance(value, cls):
-            if cls.__config__.copy_on_model_validation:
-                return value._copy_and_set_values(value.__dict__, value.__fields_set__, deep=True)
-            else:
+            copy_on_model_validation = cls.__config__.copy_on_model_validation
+            # whether to deep or shallow copy the model on validation, None means do not copy
+            deep_copy: Optional[bool] = None
+            if copy_on_model_validation not in {'deep', 'shallow', 'none'}:
+                # Warn about deprecated behavior
+                warnings.warn(
+                    "`copy_on_model_validation` should be a string: 'deep', 'shallow' or 'none'", DeprecationWarning
+                )
+                if copy_on_model_validation:
+                    deep_copy = False
+
+            if copy_on_model_validation == 'shallow':
+                # shallow copy
+                deep_copy = False
+            elif copy_on_model_validation == 'deep':
+                # deep copy
+                deep_copy = True
+
+            if deep_copy is None:
                 return value
+            else:
+                return value._copy_and_set_values(value.__dict__, value.__fields_set__, deep=deep_copy)
 
         value = cls._enforce_dict_if_root(value)
 
diff --git a/pydantic/version.py b/pydantic/version.py
--- a/pydantic/version.py
+++ b/pydantic/version.py
@@ -1,6 +1,6 @@
 __all__ = 'compiled', 'VERSION', 'version_info'
 
-VERSION = '1.9.0'
+VERSION = '1.9.2'
 
 try:
     import cython  # type: ignore


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1561,18 +1561,66 @@ class Config:
 
     assert t.user is not my_user
     assert t.user.hobbies == ['scuba diving']
-    assert t.user.hobbies is not my_user.hobbies  # `Config.copy_on_model_validation` does a deep copy
+    assert t.user.hobbies is my_user.hobbies  # `Config.copy_on_model_validation` 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']}}
 
 
+def test_model_exclude_copy_on_model_validation_shallow():
+    """When `Config.copy_on_model_validation` is set and `Config.copy_on_model_validation_shallow` is set,
+    do the same as the previous test but perform a shallow copy"""
+
+    class User(BaseModel):
+        class Config:
+            copy_on_model_validation = 'shallow'
+
+        hobbies: List[str]
+
+    my_user = User(hobbies=['scuba diving'])
+
+    class Transaction(BaseModel):
+        user: User = Field(...)
+
+    t = Transaction(user=my_user)
+
+    assert t.user is not my_user
+    assert t.user.hobbies is my_user.hobbies  # unlike above, this should be a shallow copy
+
+
+@pytest.mark.parametrize('comv_value', [True, False])
+def test_copy_on_model_validation_warning(comv_value):
+    class User(BaseModel):
+        class Config:
+            # True interpreted as 'shallow', False interpreted as 'none'
+            copy_on_model_validation = comv_value
+
+        hobbies: List[str]
+
+    my_user = User(hobbies=['scuba diving'])
+
+    class Transaction(BaseModel):
+        user: User
+
+    with pytest.warns(DeprecationWarning, match="`copy_on_model_validation` should be a string: 'deep', 'shallow' or"):
+        t = Transaction(user=my_user)
+
+    if comv_value:
+        assert t.user is not my_user
+    else:
+        assert t.user is my_user
+    assert t.user.hobbies is my_user.hobbies
+
+
 def test_validation_deep_copy():
     """By default, Config.copy_on_model_validation should do a deep copy"""
 
     class A(BaseModel):
         name: str
 
+        class Config:
+            copy_on_model_validation = 'deep'
+
     class B(BaseModel):
         list_a: List[A]
 
@@ -1986,7 +2034,7 @@ def __hash__(self):
             return id(self)
 
         class Config:
-            copy_on_model_validation = False
+            copy_on_model_validation = 'none'
 
     class Item(BaseModel):
         images: List[Image]