Repository: litestar-org/litestar

Issue:
Pydantic 1.9.1 deepcopy breaks Redis caching, possibly other things
I'm open to the possibility that this should be treated as an issue in Pydantic because it's a breaking change there, and https://github.com/samuelcolvin/pydantic/issues/4184 appears to be a variation of this.

I have a Starlite application which configures its caching as follows (excerpt from my actual `app.py`):

```python
import redis
import starlite

from .config import settings

cache_config = (
    starlite.CacheConfig(backend=redis.from_url(settings.CACHE_URL))
    if settings.CACHE_URL is not None
    else starlite.app.DEFAULT_CACHE_CONFIG
)
```

`config.settings` in this case is a `pydantic.BaseSettings` instance which reads the Redis URL from an environment variable, if set. The fallback logic is for local development where a Redis instance may not be running.

I recently did an upgrade of my dependency tree which moved from Pydantic 1.9.0 to Pydantic 1.9.1, and my web worker processes now fail to boot. Reverting to Pydantic 1.9.0 resolves this.

The failure occurs at instantiating the `Starlite` application object:

```python
File /opt/venv/lib/python3.10/site-packages/pydantic/decorator.py:40, in pydantic.decorator.validate_arguments.validate.wrapper_function()

File /opt/venv/lib/python3.10/site-packages/pydantic/decorator.py:133, in pydantic.decorator.ValidatedFunction.call()

File /opt/venv/lib/python3.10/site-packages/pydantic/decorator.py:130, in pydantic.decorator.ValidatedFunction.init_model_instance()

File /opt/venv/lib/python3.10/site-packages/pydantic/main.py:341, in pydantic.main.BaseModel.__init__()

ValidationError: 1 validation error for Init
cache_config
  cannot pickle '_thread.lock' object (type=type_error)
```

The root of the issue appears to be that in Pydantic 1.9.1, Pydantic defaults to performing a `copy.deepcopy()` of model members during validation, which in turn fails on encountering any non-pickle-able object (as in the above traceback where it attempts to `deepcopy()` a Redis client instance). Pydantic 1.9.0 performed only a shallow copy.

This behavior can be disabled by setting the `copy_on_model_validation` option to a false-y value in the config for a Pydantic model class, or by passing it in the `config` dictionary of the `validate_arguments` decorator, and that may be the simplest workaround to apply for now while Pydantic decides what to do about this. It may also be the correct long-term choice since it's likely that non-pickle-able objects, such as caching clients, will be passed in arguments to the `Starlite` constructor from time to time.

This issue appears to be present in any version of Starlite which decorates `Starlite.__init__()` with `validate_arguments`, when using Pydantic 1.9.1 (I've tested Starlite 1.7.3 and 1.3.3), and disappears on reverting to Pydantic 1.9.0.


Discussion:
Ok, thanks for the analysis. Would you like to submit a PR?
If you'd be OK with passing `"copy_on_model_validation": False` as the workaround, I can put together a PR for it in the next couple days. The tricky part will be the testing -- do you have a preference for a non-pickle-able object to pass to `Starlite` to verify the fix?
Absolutely 
Here's a repro for the pydantic side:

```python
import tempfile
from typing import Any

import pydantic


class Container(pydantic.BaseModel):
    fh: Any


@pydantic.validate_arguments(config={"copy_on_model_validation": False})
def receives_container(container: Container) -> None:
    ...


with tempfile.TemporaryFile() as fh:
    receives_container(Container(fh=fh))
```

Results in:

```python-traceback
Traceback (most recent call last):
  File "/home/peter/PycharmProjects/copy-on-model-validation/main.py", line 17, in <module>
    receives_container(Container(fh=fh))
  File "pydantic/decorator.py", line 40, in pydantic.decorator.validate_arguments.validate.wrapper_function
  File "pydantic/decorator.py", line 133, in pydantic.decorator.ValidatedFunction.call
  File "pydantic/decorator.py", line 130, in pydantic.decorator.ValidatedFunction.init_model_instance
  File "pydantic/main.py", line 341, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for ReceivesContainer
container
  cannot pickle '_io.BufferedRandom' object (type=type_error)
```

I created that as I found this issue: https://github.com/samuelcolvin/pydantic/issues/3284 and wanted a quick test.. and as that issue suggests `@pydantic.validate_arguments(config={"copy_on_model_validation": False})` has no effect.

Setting it on the `Config` object on `Container` does fix it.
Well, if passing it to `validate_arguments` doesn't actually work, then I guess I won't try that.
So whats the solution? we strip validate arguments or we pin to 1.9? Or is there an alternative?
As a user, I'm pinning to Pydantic 1.9.0 until they decide what they're going to do about it. I'm not sure, and I don't presume to tell you, what the best choice is for Starlite -- both pinning and remove `validate_arguments` seem like they have downsides.
We should probably set `copy_on_model_validation = False` on `CacheConfig.Config` to address this specific case, and see if any other issues arise. It's not as if `1.9.1` is a new release, so there's unlikely to be many more similar issues in my opinion.

Resolving patch:
diff --git a/starlite/config.py b/starlite/config.py
--- a/starlite/config.py
+++ b/starlite/config.py
@@ -12,7 +12,7 @@
 )
 from urllib.parse import urlencode
 
-from pydantic import AnyUrl, BaseModel, DirectoryPath, constr, validator
+from pydantic import AnyUrl, BaseConfig, BaseModel, DirectoryPath, constr, validator
 from pydantic_openapi_schema.utils import construct_open_api_with_schema_class
 from pydantic_openapi_schema.v3_1_0.contact import Contact
 from pydantic_openapi_schema.v3_1_0.external_documentation import ExternalDocumentation
@@ -144,6 +144,9 @@ def dict(self, *args, **kwargs) -> Dict[str, Any]:  # type: ignore[no-untyped-de
 class OpenAPIConfig(BaseModel):
     """Class containing Settings and Schema Properties"""
 
+    class Config(BaseConfig):
+        copy_on_model_validation = False
+
     create_examples: bool = False
     openapi_controller: Type[OpenAPIController] = OpenAPIController
 
@@ -214,8 +217,9 @@ class StaticFilesConfig(BaseModel):
 
 
 class TemplateConfig(BaseModel):
-    class Config:
+    class Config(BaseConfig):
         arbitrary_types_allowed = True
+        copy_on_model_validation = False
 
     directory: Union[DirectoryPath, List[DirectoryPath]]
     engine: Type[TemplateEngineProtocol]
@@ -232,8 +236,9 @@ def default_cache_key_builder(request: "Request") -> str:
 
 
 class CacheConfig(BaseModel):
-    class Config:
+    class Config(BaseConfig):
         arbitrary_types_allowed = True
+        copy_on_model_validation = False
 
     backend: CacheBackendProtocol = SimpleCacheBackend()
     expiration: int = 60  # value in seconds
diff --git a/starlite/exceptions/utils.py b/starlite/exceptions/utils.py
--- a/starlite/exceptions/utils.py
+++ b/starlite/exceptions/utils.py
@@ -2,7 +2,7 @@
 
 from pydantic import BaseModel
 from starlette.exceptions import HTTPException as StarletteHTTPException
-from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR  # noqa: TC002
+from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
 
 from starlite.enums import MediaType
 from starlite.response import Response


Test patch:
diff --git a/test.sqlite b/test.sqlite
new file mode 100644
Binary files /dev/null and b/test.sqlite differ
diff --git a/tests/caching/__init__.py b/tests/caching/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/caching/__init__.py
@@ -0,0 +1,20 @@
+import random
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+if TYPE_CHECKING:
+    from starlite import Response
+
+
+async def slow_handler() -> dict:
+    output = {}
+    count = 0
+    while count < 1000:
+        output[str(count)] = random.random()
+        count += 1
+    return output
+
+
+def after_request_handler(response: "Response") -> "Response":
+    response.headers["unique-identifier"] = str(uuid4())
+    return response
diff --git a/tests/caching/test_async_caching.py b/tests/caching/test_async_caching.py
new file mode 100644
--- /dev/null
+++ b/tests/caching/test_async_caching.py
@@ -0,0 +1,30 @@
+from typing import Any
+
+from starlite import CacheConfig, get
+from starlite.cache import SimpleCacheBackend
+from starlite.testing import create_test_client
+
+from . import after_request_handler, slow_handler
+
+
+def test_async_handling() -> None:
+    class AsyncCacheBackend(SimpleCacheBackend):
+        async def set(self, key: str, value: Any, expiration: int) -> Any:  # type: ignore
+            super().set(key=key, value=value, expiration=expiration)
+
+        async def get(self, key: str) -> Any:
+            return super().get(key=key)
+
+    cache_config = CacheConfig(backend=AsyncCacheBackend())
+
+    with create_test_client(
+        route_handlers=[get("/cached-async", cache=True)(slow_handler)],
+        after_request=after_request_handler,
+        cache_config=cache_config,
+    ) as client:
+        first_response = client.get("/cached-async")
+        first_response_identifier = first_response.headers["unique-identifier"]
+        assert first_response_identifier
+        second_response = client.get("/cached-async")
+        assert second_response.headers["unique-identifier"] == first_response_identifier
+        assert first_response.json() == second_response.json()
diff --git a/tests/caching/test_config_validation.py b/tests/caching/test_config_validation.py
new file mode 100644
--- /dev/null
+++ b/tests/caching/test_config_validation.py
@@ -0,0 +1,38 @@
+from typing import Any
+
+import pytest
+import redis
+from pydantic import ValidationError
+
+from starlite import CacheConfig, Starlite
+from starlite.cache import CacheBackendProtocol
+
+
+def test_config_validation_scenario() -> None:
+    class ProtocolBaseBackend(CacheBackendProtocol):
+        def get(self, key: str) -> None:
+            ...
+
+        def set(self, key: str, value: Any, expiration: int) -> None:
+            ...
+
+    CacheConfig(backend=ProtocolBaseBackend)  # type: ignore[arg-type]
+
+    class NoneProtocolBasedBackend:
+        def get(self, key: str) -> None:
+            ...
+
+        def set(self, key: str, value: Any, expiration: int) -> None:
+            ...
+
+    with pytest.raises(ValidationError):
+        CacheConfig(backend=NoneProtocolBasedBackend)  # type: ignore[arg-type]
+
+
+def test_config_validation_deep_copy() -> None:
+    """
+    test fix for issue-333: https://github.com/starlite-api/starlite/issues/333
+    """
+
+    cache_config = CacheConfig(backend=redis.from_url("redis://localhost:6379/1"))
+    Starlite(route_handlers=[], cache_config=cache_config)
diff --git a/tests/test_caching.py b/tests/caching/test_response_caching.py
similarity index 55%
rename from tests/test_caching.py
rename to tests/caching/test_response_caching.py
--- a/tests/test_caching.py
+++ b/tests/caching/test_response_caching.py
@@ -1,30 +1,11 @@
-import random
 from datetime import datetime, timedelta
-from time import sleep
-from typing import Any
-from uuid import uuid4
 
-import pytest
 from freezegun import freeze_time
-from pydantic import ValidationError
 
-from starlite import CacheConfig, Request, Response, get
-from starlite.cache import SimpleCacheBackend
+from starlite import Request, get
 from starlite.testing import create_test_client
 
-
-async def slow_handler() -> dict:
-    output = {}
-    count = 0
-    while count < 1000:
-        output[str(count)] = random.random()
-        count += 1
-    return output
-
-
-def after_request_handler(response: Response) -> Response:
-    response.headers["unique-identifier"] = str(uuid4())
-    return response
+from . import after_request_handler, slow_handler
 
 
 def test_default_cache_response() -> None:
@@ -67,7 +48,7 @@ def test_default_expiration() -> None:
         assert first_response.headers["unique-identifier"] != third_response.headers["unique-identifier"]
 
 
-def test_cache_key() -> None:
+def test_custom_cache_key() -> None:
     def custom_cache_key_builder(request: Request) -> str:
         return request.url.path + ":::cached"
 
@@ -76,46 +57,3 @@ def custom_cache_key_builder(request: Request) -> str:
     ) as client:
         client.get("/cached")
         assert client.app.cache_config.backend.get("/cached:::cached")
-
-
-def test_async_handling() -> None:
-    class AsyncCacheBackend(SimpleCacheBackend):
-        async def set(self, key: str, value: Any, expiration: int) -> Any:  # type: ignore
-            super().set(key=key, value=value, expiration=expiration)
-
-        async def get(self, key: str) -> Any:
-            return super().get(key=key)
-
-    cache_config = CacheConfig(backend=AsyncCacheBackend())
-
-    with create_test_client(
-        route_handlers=[get("/cached-async", cache=True)(slow_handler)],
-        after_request=after_request_handler,
-        cache_config=cache_config,
-    ) as client:
-        first_response = client.get("/cached-async")
-        first_response_identifier = first_response.headers["unique-identifier"]
-        assert first_response_identifier
-        second_response = client.get("/cached-async")
-        assert second_response.headers["unique-identifier"] == first_response_identifier
-        assert first_response.json() == second_response.json()
-
-
-def test_config_validation() -> None:
-    class MyBackend:
-        def get(self) -> None:
-            ...
-
-        def set(self) -> None:
-            ...
-
-    with pytest.raises(ValidationError):
-        CacheConfig(backend=MyBackend)  # type: ignore[arg-type]
-
-
-def test_naive_cache_backend() -> None:
-    backend = SimpleCacheBackend()
-    backend.set("test", "1", 0.1)  # type: ignore
-    assert backend.get("test")
-    sleep(0.2)
-    assert not backend.get("test")
diff --git a/tests/caching/test_simple_cache_backend.py b/tests/caching/test_simple_cache_backend.py
new file mode 100644
--- /dev/null
+++ b/tests/caching/test_simple_cache_backend.py
@@ -0,0 +1,11 @@
+from time import sleep
+
+from starlite.cache import SimpleCacheBackend
+
+
+def test_simple_cache_backend() -> None:
+    backend = SimpleCacheBackend()
+    backend.set("test", "1", 0.1)  # type: ignore
+    assert backend.get("test")
+    sleep(0.2)
+    assert not backend.get("test")
diff --git a/tests/test.sqlite b/tests/test.sqlite
new file mode 100644
Binary files /dev/null and b/tests/test.sqlite differ