Skip to content

Commit c61fc16

Browse files
authored
Yet another Pydantic 2 support (#832)
* Add support for Pydantic v2 settings * Configure pipeline to run tests against different pydantic versions * Update Pydantic docs and examples for v2 * Fix compatibility with httpx v0.27.0
1 parent cab75cb commit c61fc16

File tree

14 files changed

+234
-132
lines changed

14 files changed

+234
-132
lines changed

.github/workflows/tests-and-linters.yml

+11
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ jobs:
3636
env:
3737
TOXENV: ${{ matrix.python-version }}
3838

39+
test-different-pydantic-versions:
40+
name: Run tests with different pydantic versions
41+
runs-on: ubuntu-latest
42+
steps:
43+
- uses: actions/checkout@v3
44+
- uses: actions/setup-python@v4
45+
with:
46+
python-version: "3.12"
47+
- run: pip install tox
48+
- run: tox -e pydantic-v1,pydantic-v2
49+
3950
test-coverage:
4051
name: Run tests with coverage
4152
runs-on: ubuntu-latest

docs/providers/configuration.rst

+13-8
Original file line numberDiff line numberDiff line change
@@ -183,22 +183,22 @@ See also: :ref:`configuration-envs-interpolation`.
183183
Loading from a Pydantic settings
184184
--------------------------------
185185

186-
``Configuration`` provider can load configuration from a ``pydantic`` settings object using the
186+
``Configuration`` provider can load configuration from a ``pydantic_settings.BaseSettings`` object using the
187187
:py:meth:`Configuration.from_pydantic` method:
188188

189189
.. literalinclude:: ../../examples/providers/configuration/configuration_pydantic.py
190190
:language: python
191191
:lines: 3-
192-
:emphasize-lines: 31
192+
:emphasize-lines: 32
193193

194-
To get the data from pydantic settings ``Configuration`` provider calls ``Settings.dict()`` method.
194+
To get the data from pydantic settings ``Configuration`` provider calls its ``model_dump()`` method.
195195
If you need to pass an argument to this call, use ``.from_pydantic()`` keyword arguments.
196196

197197
.. code-block:: python
198198
199199
container.config.from_pydantic(Settings(), exclude={"optional"})
200200
201-
Alternatively, you can provide a ``pydantic`` settings object over the configuration provider argument. In that case,
201+
Alternatively, you can provide a ``pydantic_settings.BaseSettings`` object over the configuration provider argument. In that case,
202202
the container will call ``config.from_pydantic()`` automatically:
203203

204204
.. code-block:: python
@@ -215,18 +215,23 @@ the container will call ``config.from_pydantic()`` automatically:
215215
216216
.. note::
217217

218-
``Dependency Injector`` doesn't install ``pydantic`` by default.
218+
``Dependency Injector`` doesn't install ``pydantic-settings`` by default.
219219

220220
You can install the ``Dependency Injector`` with an extra dependency::
221221

222-
pip install dependency-injector[pydantic]
222+
pip install dependency-injector[pydantic2]
223223

224-
or install ``pydantic`` directly::
224+
or install ``pydantic-settings`` directly::
225225

226-
pip install pydantic
226+
pip install pydantic-settings
227227

228228
*Don't forget to mirror the changes in the requirements file.*
229229

230+
.. note::
231+
232+
For backward-compatibility, Pydantic v1 is still supported.
233+
Passing ``pydantic.BaseSettings`` instances will work just as fine as ``pydantic_settings.BaseSettings``.
234+
230235
Loading from a dictionary
231236
-------------------------
232237

examples/miniapps/fastapi-redis/fastapiredis/tests.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
from unittest import mock
44

55
import pytest
6-
from httpx import AsyncClient
6+
from httpx import ASGITransport, AsyncClient
77

88
from .application import app, container
99
from .services import Service
1010

1111

1212
@pytest.fixture
1313
def client(event_loop):
14-
client = AsyncClient(app=app, base_url="http://test")
14+
client = AsyncClient(
15+
transport=ASGITransport(app=app),
16+
base_url="http://test",
17+
)
1518
yield client
1619
event_loop.run_until_complete(client.aclose())
1720

examples/miniapps/fastapi-simple/tests.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from unittest import mock
22

33
import pytest
4-
from httpx import AsyncClient
4+
from httpx import ASGITransport, AsyncClient
55

66
from fastapi_di_example import app, container, Service
77

88

99
@pytest.fixture
1010
async def client(event_loop):
11-
async with AsyncClient(app=app, base_url="http://test") as client:
11+
async with AsyncClient(
12+
transport=ASGITransport(app=app),
13+
base_url="http://test",
14+
) as client:
1215
yield client
1316

1417

examples/miniapps/fastapi/giphynavigator/tests.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
from unittest import mock
44

55
import pytest
6-
from httpx import AsyncClient
6+
from httpx import ASGITransport, AsyncClient
77

88
from giphynavigator.application import app
99
from giphynavigator.giphy import GiphyClient
1010

1111

1212
@pytest.fixture
1313
async def client():
14-
async with AsyncClient(app=app, base_url="http://test") as client:
14+
async with AsyncClient(
15+
transport=ASGITransport(app=app),
16+
base_url="http://test",
17+
) as client:
1518
yield client
1619

1720

examples/providers/configuration/configuration_pydantic.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@
33
import os
44

55
from dependency_injector import containers, providers
6-
from pydantic import BaseSettings, Field
6+
from pydantic_settings import BaseSettings, SettingsConfigDict
77

88
# Emulate environment variables
99
os.environ["AWS_ACCESS_KEY_ID"] = "KEY"
1010
os.environ["AWS_SECRET_ACCESS_KEY"] = "SECRET"
1111

1212

1313
class AwsSettings(BaseSettings):
14+
model_config = SettingsConfigDict(env_prefix="aws_")
1415

15-
access_key_id: str = Field(env="aws_access_key_id")
16-
secret_access_key: str = Field(env="aws_secret_access_key")
16+
access_key_id: str
17+
secret_access_key: str
1718

1819

1920
class Settings(BaseSettings):
2021

2122
aws: AwsSettings = AwsSettings()
22-
optional: str = Field(default="default_value")
23+
optional: str = "default_value"
2324

2425

2526
class Container(containers.DeclarativeContainer):

examples/providers/configuration/configuration_pydantic_init.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,24 @@
33
import os
44

55
from dependency_injector import containers, providers
6-
from pydantic import BaseSettings, Field
6+
from pydantic_settings import BaseSettings, SettingsConfigDict
77

88
# Emulate environment variables
99
os.environ["AWS_ACCESS_KEY_ID"] = "KEY"
1010
os.environ["AWS_SECRET_ACCESS_KEY"] = "SECRET"
1111

1212

1313
class AwsSettings(BaseSettings):
14+
model_config = SettingsConfigDict(env_prefix="aws_")
1415

15-
access_key_id: str = Field(env="aws_access_key_id")
16-
secret_access_key: str = Field(env="aws_secret_access_key")
16+
access_key_id: str
17+
secret_access_key: str
1718

1819

1920
class Settings(BaseSettings):
2021

2122
aws: AwsSettings = AwsSettings()
22-
optional: str = Field(default="default_value")
23+
optional: str = "default_value"
2324

2425

2526
class Container(containers.DeclarativeContainer):

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ dependencies = ["six"]
5858
[project.optional-dependencies]
5959
yaml = ["pyyaml"]
6060
pydantic = ["pydantic"]
61+
pydantic2 = ["pydantic-settings"]
6162
flask = ["flask"]
6263
aiohttp = ["aiohttp"]
6364

src/dependency_injector/providers.pyx

+50-43
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,25 @@ try:
4848
except ImportError:
4949
yaml = None
5050

51+
has_pydantic_settings = True
52+
cdef bint pydantic_v1 = False
53+
cdef str pydantic_module = "pydantic_settings"
54+
cdef str pydantic_extra = "pydantic2"
55+
5156
try:
52-
import pydantic
57+
from pydantic_settings import BaseSettings as PydanticSettings
5358
except ImportError:
54-
pydantic = None
59+
try:
60+
# pydantic-settings requires pydantic v2,
61+
# so it is safe to assume that we're dealing with v1:
62+
from pydantic import BaseSettings as PydanticSettings
63+
pydantic_v1 = True
64+
pydantic_module = "pydantic"
65+
pydantic_extra = "pydantic"
66+
except ImportError:
67+
# if it is present, ofc
68+
has_pydantic_settings = False
69+
5570

5671
from .errors import (
5772
Error,
@@ -149,6 +164,31 @@ cdef int ASYNC_MODE_DISABLED = 2
149164
cdef set __iscoroutine_typecache = set()
150165
cdef tuple __COROUTINE_TYPES = asyncio.coroutines._COROUTINE_TYPES if asyncio else tuple()
151166

167+
cdef dict pydantic_settings_to_dict(settings, dict kwargs):
168+
if not has_pydantic_settings:
169+
raise Error(
170+
f"Unable to load pydantic configuration - {pydantic_module} is not installed. "
171+
"Install pydantic or install Dependency Injector with pydantic extras: "
172+
f"\"pip install dependency-injector[{pydantic_extra}]\""
173+
)
174+
175+
if isinstance(settings, CLASS_TYPES) and issubclass(settings, PydanticSettings):
176+
raise Error(
177+
"Got settings class, but expect instance: "
178+
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
179+
)
180+
181+
if not isinstance(settings, PydanticSettings):
182+
raise Error(
183+
f"Unable to recognize settings instance, expect \"{pydantic_module}.BaseSettings\", "
184+
f"got {settings} instead"
185+
)
186+
187+
if pydantic_v1:
188+
return settings.dict(**kwargs)
189+
190+
return settings.model_dump(mode="python", **kwargs)
191+
152192

153193
cdef class Provider(object):
154194
"""Base provider class.
@@ -1786,36 +1826,20 @@ cdef class ConfigurationOption(Provider):
17861826
Loaded configuration is merged recursively over existing configuration.
17871827
17881828
:param settings: Pydantic settings instances.
1789-
:type settings: :py:class:`pydantic.BaseSettings`
1829+
:type settings: :py:class:`pydantic.BaseSettings` (pydantic v1) or
1830+
:py:class:`pydantic_settings.BaseSettings` (pydantic v2 and onwards)
17901831
17911832
:param required: When required is True, raise an exception if settings dict is empty.
17921833
:type required: bool
17931834
1794-
:param kwargs: Keyword arguments forwarded to ``pydantic.BaseSettings.dict()`` call.
1835+
:param kwargs: Keyword arguments forwarded to ``pydantic.BaseSettings.dict()`` or
1836+
``pydantic_settings.BaseSettings.model_dump()`` call (based on pydantic version).
17951837
:type kwargs: Dict[Any, Any]
17961838
17971839
:rtype: None
17981840
"""
1799-
if pydantic is None:
1800-
raise Error(
1801-
"Unable to load pydantic configuration - pydantic is not installed. "
1802-
"Install pydantic or install Dependency Injector with pydantic extras: "
1803-
"\"pip install dependency-injector[pydantic]\""
1804-
)
18051841

1806-
if isinstance(settings, CLASS_TYPES) and issubclass(settings, pydantic.BaseSettings):
1807-
raise Error(
1808-
"Got settings class, but expect instance: "
1809-
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
1810-
)
1811-
1812-
if not isinstance(settings, pydantic.BaseSettings):
1813-
raise Error(
1814-
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
1815-
"got {0} instead".format(settings)
1816-
)
1817-
1818-
self.from_dict(settings.dict(**kwargs), required=required)
1842+
self.from_dict(pydantic_settings_to_dict(settings, kwargs), required=required)
18191843

18201844
def from_dict(self, options, required=UNDEFINED):
18211845
"""Load configuration from the dictionary.
@@ -2355,7 +2379,8 @@ cdef class Configuration(Object):
23552379
Loaded configuration is merged recursively over existing configuration.
23562380
23572381
:param settings: Pydantic settings instances.
2358-
:type settings: :py:class:`pydantic.BaseSettings`
2382+
:type settings: :py:class:`pydantic.BaseSettings` (pydantic v1) or
2383+
:py:class:`pydantic_settings.BaseSettings` (pydantic v2 and onwards)
23592384
23602385
:param required: When required is True, raise an exception if settings dict is empty.
23612386
:type required: bool
@@ -2365,26 +2390,8 @@ cdef class Configuration(Object):
23652390
23662391
:rtype: None
23672392
"""
2368-
if pydantic is None:
2369-
raise Error(
2370-
"Unable to load pydantic configuration - pydantic is not installed. "
2371-
"Install pydantic or install Dependency Injector with pydantic extras: "
2372-
"\"pip install dependency-injector[pydantic]\""
2373-
)
2374-
2375-
if isinstance(settings, CLASS_TYPES) and issubclass(settings, pydantic.BaseSettings):
2376-
raise Error(
2377-
"Got settings class, but expect instance: "
2378-
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
2379-
)
2380-
2381-
if not isinstance(settings, pydantic.BaseSettings):
2382-
raise Error(
2383-
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
2384-
"got {0} instead".format(settings)
2385-
)
23862393

2387-
self.from_dict(settings.dict(**kwargs), required=required)
2394+
self.from_dict(pydantic_settings_to_dict(settings, kwargs), required=required)
23882395

23892396
def from_dict(self, options, required=UNDEFINED):
23902397
"""Load configuration from the dictionary.

tests/.configs/pytest.ini

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
testpaths = tests/unit/
33
python_files = test_*_py3*.py
44
asyncio_mode = auto
5+
markers =
6+
pydantic: Tests with Pydantic as a dependency
57
filterwarnings =
68
ignore:Module \"dependency_injector.ext.aiohttp\" is deprecated since version 4\.0\.0:DeprecationWarning
79
ignore:Module \"dependency_injector.ext.flask\" is deprecated since version 4\.0\.0:DeprecationWarning

0 commit comments

Comments
 (0)