Skip to content

Commit b1d9526

Browse files
committed
Add support for Pydantic v2 settings
1 parent cab75cb commit b1d9526

File tree

5 files changed

+161
-107
lines changed

5 files changed

+161
-107
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
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

Lines changed: 50 additions & 43 deletions
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

Lines changed: 2 additions & 0 deletions
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

tests/unit/providers/configuration/test_from_pydantic_py36.py

Lines changed: 76 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,60 @@
11
"""Configuration.from_pydantic() tests."""
22

3-
import pydantic
4-
from dependency_injector import providers, errors
3+
from pydantic import BaseModel
4+
5+
try:
6+
from pydantic_settings import (
7+
BaseSettings, # type: ignore[import-not-found,unused-ignore]
8+
)
9+
except ImportError:
10+
try:
11+
from pydantic import BaseSettings # type: ignore[no-redef,unused-ignore]
12+
except ImportError:
13+
14+
class BaseSettings: # type: ignore[no-redef]
15+
"""No-op fallback"""
16+
17+
518
from pytest import fixture, mark, raises
619

20+
from dependency_injector import errors, providers
721

8-
class Section11(pydantic.BaseModel):
9-
value1 = 1
22+
pytestmark = mark.pydantic
1023

1124

12-
class Section12(pydantic.BaseModel):
13-
value2 = 2
25+
class Section11(BaseModel):
26+
value1: int = 1
1427

1528

16-
class Settings1(pydantic.BaseSettings):
17-
section1 = Section11()
18-
section2 = Section12()
29+
class Section12(BaseModel):
30+
value2: int = 2
1931

2032

21-
class Section21(pydantic.BaseModel):
22-
value1 = 11
23-
value11 = 11
33+
class Settings1(BaseSettings):
34+
section1: Section11 = Section11()
35+
section2: Section12 = Section12()
2436

2537

26-
class Section3(pydantic.BaseModel):
27-
value3 = 3
38+
class Section21(BaseModel):
39+
value1: int = 11
40+
value11: int = 11
2841

2942

30-
class Settings2(pydantic.BaseSettings):
31-
section1 = Section21()
32-
section3 = Section3()
43+
class Section3(BaseModel):
44+
value3: int = 3
45+
46+
47+
class Settings2(BaseSettings):
48+
section1: Section21 = Section21()
49+
section3: Section3 = Section3()
50+
3351

3452
@fixture
3553
def no_pydantic_module_installed():
36-
providers.pydantic = None
54+
has_pydantic_settings = providers.has_pydantic_settings
55+
providers.has_pydantic_settings = False
3756
yield
38-
providers.pydantic = pydantic
57+
providers.has_pydantic_settings = has_pydantic_settings
3958

4059

4160
def test(config):
@@ -82,66 +101,70 @@ def test_merge(config):
82101

83102

84103
def test_empty_settings(config):
85-
config.from_pydantic(pydantic.BaseSettings())
104+
config.from_pydantic(BaseSettings())
86105
assert config() == {}
87106

88107

89108
@mark.parametrize("config_type", ["strict"])
90109
def test_empty_settings_strict_mode(config):
91110
with raises(ValueError):
92-
config.from_pydantic(pydantic.BaseSettings())
111+
config.from_pydantic(BaseSettings())
93112

94113

95114
def test_option_empty_settings(config):
96-
config.option.from_pydantic(pydantic.BaseSettings())
115+
config.option.from_pydantic(BaseSettings())
97116
assert config.option() == {}
98117

99118

100119
@mark.parametrize("config_type", ["strict"])
101120
def test_option_empty_settings_strict_mode(config):
102121
with raises(ValueError):
103-
config.option.from_pydantic(pydantic.BaseSettings())
122+
config.option.from_pydantic(BaseSettings())
104123

105124

106125
def test_required_empty_settings(config):
107126
with raises(ValueError):
108-
config.from_pydantic(pydantic.BaseSettings(), required=True)
127+
config.from_pydantic(BaseSettings(), required=True)
109128

110129

111130
def test_required_option_empty_settings(config):
112131
with raises(ValueError):
113-
config.option.from_pydantic(pydantic.BaseSettings(), required=True)
132+
config.option.from_pydantic(BaseSettings(), required=True)
114133

115134

116135
@mark.parametrize("config_type", ["strict"])
117136
def test_not_required_empty_settings_strict_mode(config):
118-
config.from_pydantic(pydantic.BaseSettings(), required=False)
137+
config.from_pydantic(BaseSettings(), required=False)
119138
assert config() == {}
120139

121140

122141
@mark.parametrize("config_type", ["strict"])
123142
def test_not_required_option_empty_settings_strict_mode(config):
124-
config.option.from_pydantic(pydantic.BaseSettings(), required=False)
143+
config.option.from_pydantic(BaseSettings(), required=False)
125144
assert config.option() == {}
126145
assert config() == {"option": {}}
127146

128147

129148
def test_not_instance_of_settings(config):
130-
with raises(errors.Error) as error:
149+
with raises(
150+
errors.Error,
151+
match=(
152+
r"Unable to recognize settings instance, expect \"pydantic(?:_settings)?\.BaseSettings\", "
153+
r"got {0} instead".format({})
154+
),
155+
):
131156
config.from_pydantic({})
132-
assert error.value.args[0] == (
133-
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
134-
"got {0} instead".format({})
135-
)
136157

137158

138159
def test_option_not_instance_of_settings(config):
139-
with raises(errors.Error) as error:
160+
with raises(
161+
errors.Error,
162+
match=(
163+
r"Unable to recognize settings instance, expect \"pydantic(?:_settings)?\.BaseSettings\", "
164+
"got {0} instead".format({})
165+
),
166+
):
140167
config.option.from_pydantic({})
141-
assert error.value.args[0] == (
142-
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
143-
"got {0} instead".format({})
144-
)
145168

146169

147170
def test_subclass_instead_of_instance(config):
@@ -164,21 +187,25 @@ def test_option_subclass_instead_of_instance(config):
164187

165188
@mark.usefixtures("no_pydantic_module_installed")
166189
def test_no_pydantic_installed(config):
167-
with raises(errors.Error) as error:
190+
with raises(
191+
errors.Error,
192+
match=(
193+
r"Unable to load pydantic configuration - pydantic(?:_settings)? is not installed\. "
194+
r"Install pydantic or install Dependency Injector with pydantic extras: "
195+
r"\"pip install dependency-injector\[pydantic2?\]\""
196+
),
197+
):
168198
config.from_pydantic(Settings1())
169-
assert error.value.args[0] == (
170-
"Unable to load pydantic configuration - pydantic is not installed. "
171-
"Install pydantic or install Dependency Injector with pydantic extras: "
172-
"\"pip install dependency-injector[pydantic]\""
173-
)
174199

175200

176201
@mark.usefixtures("no_pydantic_module_installed")
177202
def test_option_no_pydantic_installed(config):
178-
with raises(errors.Error) as error:
203+
with raises(
204+
errors.Error,
205+
match=(
206+
r"Unable to load pydantic configuration - pydantic(?:_settings)? is not installed\. "
207+
r"Install pydantic or install Dependency Injector with pydantic extras: "
208+
r"\"pip install dependency-injector\[pydantic2?\]\""
209+
),
210+
):
179211
config.option.from_pydantic(Settings1())
180-
assert error.value.args[0] == (
181-
"Unable to load pydantic configuration - pydantic is not installed. "
182-
"Install pydantic or install Dependency Injector with pydantic extras: "
183-
"\"pip install dependency-injector[pydantic]\""
184-
)

0 commit comments

Comments
 (0)