Add support for Pydantic v2 settings

This commit is contained in:
ZipFile 2024-11-30 12:06:43 +00:00
parent cab75cb9c7
commit b1d95267df
5 changed files with 161 additions and 107 deletions

View File

@ -58,6 +58,7 @@ dependencies = ["six"]
[project.optional-dependencies]
yaml = ["pyyaml"]
pydantic = ["pydantic"]
pydantic2 = ["pydantic-settings"]
flask = ["flask"]
aiohttp = ["aiohttp"]

View File

@ -48,10 +48,25 @@ try:
except ImportError:
yaml = None
has_pydantic_settings = True
cdef bint pydantic_v1 = False
cdef str pydantic_module = "pydantic_settings"
cdef str pydantic_extra = "pydantic2"
try:
import pydantic
from pydantic_settings import BaseSettings as PydanticSettings
except ImportError:
pydantic = None
try:
# pydantic-settings requires pydantic v2,
# so it is safe to assume that we're dealing with v1:
from pydantic import BaseSettings as PydanticSettings
pydantic_v1 = True
pydantic_module = "pydantic"
pydantic_extra = "pydantic"
except ImportError:
# if it is present, ofc
has_pydantic_settings = False
from .errors import (
Error,
@ -149,6 +164,31 @@ cdef int ASYNC_MODE_DISABLED = 2
cdef set __iscoroutine_typecache = set()
cdef tuple __COROUTINE_TYPES = asyncio.coroutines._COROUTINE_TYPES if asyncio else tuple()
cdef dict pydantic_settings_to_dict(settings, dict kwargs):
if not has_pydantic_settings:
raise Error(
f"Unable to load pydantic configuration - {pydantic_module} is not installed. "
"Install pydantic or install Dependency Injector with pydantic extras: "
f"\"pip install dependency-injector[{pydantic_extra}]\""
)
if isinstance(settings, CLASS_TYPES) and issubclass(settings, PydanticSettings):
raise Error(
"Got settings class, but expect instance: "
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
)
if not isinstance(settings, PydanticSettings):
raise Error(
f"Unable to recognize settings instance, expect \"{pydantic_module}.BaseSettings\", "
f"got {settings} instead"
)
if pydantic_v1:
return settings.dict(**kwargs)
return settings.model_dump(mode="python", **kwargs)
cdef class Provider(object):
"""Base provider class.
@ -1786,36 +1826,20 @@ cdef class ConfigurationOption(Provider):
Loaded configuration is merged recursively over existing configuration.
:param settings: Pydantic settings instances.
:type settings: :py:class:`pydantic.BaseSettings`
:type settings: :py:class:`pydantic.BaseSettings` (pydantic v1) or
:py:class:`pydantic_settings.BaseSettings` (pydantic v2 and onwards)
:param required: When required is True, raise an exception if settings dict is empty.
:type required: bool
:param kwargs: Keyword arguments forwarded to ``pydantic.BaseSettings.dict()`` call.
:param kwargs: Keyword arguments forwarded to ``pydantic.BaseSettings.dict()`` or
``pydantic_settings.BaseSettings.model_dump()`` call (based on pydantic version).
:type kwargs: Dict[Any, Any]
:rtype: None
"""
if pydantic is None:
raise Error(
"Unable to load pydantic configuration - pydantic is not installed. "
"Install pydantic or install Dependency Injector with pydantic extras: "
"\"pip install dependency-injector[pydantic]\""
)
if isinstance(settings, CLASS_TYPES) and issubclass(settings, pydantic.BaseSettings):
raise Error(
"Got settings class, but expect instance: "
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
)
if not isinstance(settings, pydantic.BaseSettings):
raise Error(
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
"got {0} instead".format(settings)
)
self.from_dict(settings.dict(**kwargs), required=required)
self.from_dict(pydantic_settings_to_dict(settings, kwargs), required=required)
def from_dict(self, options, required=UNDEFINED):
"""Load configuration from the dictionary.
@ -2355,7 +2379,8 @@ cdef class Configuration(Object):
Loaded configuration is merged recursively over existing configuration.
:param settings: Pydantic settings instances.
:type settings: :py:class:`pydantic.BaseSettings`
:type settings: :py:class:`pydantic.BaseSettings` (pydantic v1) or
:py:class:`pydantic_settings.BaseSettings` (pydantic v2 and onwards)
:param required: When required is True, raise an exception if settings dict is empty.
:type required: bool
@ -2365,26 +2390,8 @@ cdef class Configuration(Object):
:rtype: None
"""
if pydantic is None:
raise Error(
"Unable to load pydantic configuration - pydantic is not installed. "
"Install pydantic or install Dependency Injector with pydantic extras: "
"\"pip install dependency-injector[pydantic]\""
)
if isinstance(settings, CLASS_TYPES) and issubclass(settings, pydantic.BaseSettings):
raise Error(
"Got settings class, but expect instance: "
"instead \"{0}\" use \"{0}()\"".format(settings.__name__)
)
if not isinstance(settings, pydantic.BaseSettings):
raise Error(
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
"got {0} instead".format(settings)
)
self.from_dict(settings.dict(**kwargs), required=required)
self.from_dict(pydantic_settings_to_dict(settings, kwargs), required=required)
def from_dict(self, options, required=UNDEFINED):
"""Load configuration from the dictionary.

View File

@ -2,6 +2,8 @@
testpaths = tests/unit/
python_files = test_*_py3*.py
asyncio_mode = auto
markers =
pydantic: Tests with Pydantic as a dependency
filterwarnings =
ignore:Module \"dependency_injector.ext.aiohttp\" is deprecated since version 4\.0\.0:DeprecationWarning
ignore:Module \"dependency_injector.ext.flask\" is deprecated since version 4\.0\.0:DeprecationWarning

View File

@ -1,41 +1,60 @@
"""Configuration.from_pydantic() tests."""
import pydantic
from dependency_injector import providers, errors
from pydantic import BaseModel
try:
from pydantic_settings import (
BaseSettings, # type: ignore[import-not-found,unused-ignore]
)
except ImportError:
try:
from pydantic import BaseSettings # type: ignore[no-redef,unused-ignore]
except ImportError:
class BaseSettings: # type: ignore[no-redef]
"""No-op fallback"""
from pytest import fixture, mark, raises
from dependency_injector import errors, providers
class Section11(pydantic.BaseModel):
value1 = 1
pytestmark = mark.pydantic
class Section12(pydantic.BaseModel):
value2 = 2
class Section11(BaseModel):
value1: int = 1
class Settings1(pydantic.BaseSettings):
section1 = Section11()
section2 = Section12()
class Section12(BaseModel):
value2: int = 2
class Section21(pydantic.BaseModel):
value1 = 11
value11 = 11
class Settings1(BaseSettings):
section1: Section11 = Section11()
section2: Section12 = Section12()
class Section3(pydantic.BaseModel):
value3 = 3
class Section21(BaseModel):
value1: int = 11
value11: int = 11
class Settings2(pydantic.BaseSettings):
section1 = Section21()
section3 = Section3()
class Section3(BaseModel):
value3: int = 3
class Settings2(BaseSettings):
section1: Section21 = Section21()
section3: Section3 = Section3()
@fixture
def no_pydantic_module_installed():
providers.pydantic = None
has_pydantic_settings = providers.has_pydantic_settings
providers.has_pydantic_settings = False
yield
providers.pydantic = pydantic
providers.has_pydantic_settings = has_pydantic_settings
def test(config):
@ -82,66 +101,70 @@ def test_merge(config):
def test_empty_settings(config):
config.from_pydantic(pydantic.BaseSettings())
config.from_pydantic(BaseSettings())
assert config() == {}
@mark.parametrize("config_type", ["strict"])
def test_empty_settings_strict_mode(config):
with raises(ValueError):
config.from_pydantic(pydantic.BaseSettings())
config.from_pydantic(BaseSettings())
def test_option_empty_settings(config):
config.option.from_pydantic(pydantic.BaseSettings())
config.option.from_pydantic(BaseSettings())
assert config.option() == {}
@mark.parametrize("config_type", ["strict"])
def test_option_empty_settings_strict_mode(config):
with raises(ValueError):
config.option.from_pydantic(pydantic.BaseSettings())
config.option.from_pydantic(BaseSettings())
def test_required_empty_settings(config):
with raises(ValueError):
config.from_pydantic(pydantic.BaseSettings(), required=True)
config.from_pydantic(BaseSettings(), required=True)
def test_required_option_empty_settings(config):
with raises(ValueError):
config.option.from_pydantic(pydantic.BaseSettings(), required=True)
config.option.from_pydantic(BaseSettings(), required=True)
@mark.parametrize("config_type", ["strict"])
def test_not_required_empty_settings_strict_mode(config):
config.from_pydantic(pydantic.BaseSettings(), required=False)
config.from_pydantic(BaseSettings(), required=False)
assert config() == {}
@mark.parametrize("config_type", ["strict"])
def test_not_required_option_empty_settings_strict_mode(config):
config.option.from_pydantic(pydantic.BaseSettings(), required=False)
config.option.from_pydantic(BaseSettings(), required=False)
assert config.option() == {}
assert config() == {"option": {}}
def test_not_instance_of_settings(config):
with raises(errors.Error) as error:
with raises(
errors.Error,
match=(
r"Unable to recognize settings instance, expect \"pydantic(?:_settings)?\.BaseSettings\", "
r"got {0} instead".format({})
),
):
config.from_pydantic({})
assert error.value.args[0] == (
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
"got {0} instead".format({})
)
def test_option_not_instance_of_settings(config):
with raises(errors.Error) as error:
with raises(
errors.Error,
match=(
r"Unable to recognize settings instance, expect \"pydantic(?:_settings)?\.BaseSettings\", "
"got {0} instead".format({})
),
):
config.option.from_pydantic({})
assert error.value.args[0] == (
"Unable to recognize settings instance, expect \"pydantic.BaseSettings\", "
"got {0} instead".format({})
)
def test_subclass_instead_of_instance(config):
@ -164,21 +187,25 @@ def test_option_subclass_instead_of_instance(config):
@mark.usefixtures("no_pydantic_module_installed")
def test_no_pydantic_installed(config):
with raises(errors.Error) as error:
with raises(
errors.Error,
match=(
r"Unable to load pydantic configuration - pydantic(?:_settings)? is not installed\. "
r"Install pydantic or install Dependency Injector with pydantic extras: "
r"\"pip install dependency-injector\[pydantic2?\]\""
),
):
config.from_pydantic(Settings1())
assert error.value.args[0] == (
"Unable to load pydantic configuration - pydantic is not installed. "
"Install pydantic or install Dependency Injector with pydantic extras: "
"\"pip install dependency-injector[pydantic]\""
)
@mark.usefixtures("no_pydantic_module_installed")
def test_option_no_pydantic_installed(config):
with raises(errors.Error) as error:
with raises(
errors.Error,
match=(
r"Unable to load pydantic configuration - pydantic(?:_settings)? is not installed\. "
r"Install pydantic or install Dependency Injector with pydantic extras: "
r"\"pip install dependency-injector\[pydantic2?\]\""
),
):
config.option.from_pydantic(Settings1())
assert error.value.args[0] == (
"Unable to load pydantic configuration - pydantic is not installed. "
"Install pydantic or install Dependency Injector with pydantic extras: "
"\"pip install dependency-injector[pydantic]\""
)

View File

@ -1,35 +1,52 @@
"""Configuration.from_pydantic() tests."""
import pydantic
from dependency_injector import providers
from pydantic import BaseModel
try:
from pydantic_settings import (
BaseSettings, # type: ignore[import-not-found,unused-ignore]
)
except ImportError:
try:
from pydantic import BaseSettings # type: ignore[no-redef,unused-ignore]
except ImportError:
class BaseSettings: # type: ignore[no-redef]
"""No-op fallback"""
from pytest import fixture, mark, raises
from dependency_injector import providers
class Section11(pydantic.BaseModel):
pytestmark = mark.pydantic
class Section11(BaseModel):
value1: int = 1
class Section12(pydantic.BaseModel):
class Section12(BaseModel):
value2: int = 2
class Settings1(pydantic.BaseSettings):
class Settings1(BaseSettings):
section1: Section11 = Section11()
section2: Section12 = Section12()
class Section21(pydantic.BaseModel):
class Section21(BaseModel):
value1: int = 11
value11: int = 11
class Section3(pydantic.BaseModel):
class Section3(BaseModel):
value3: int = 3
class Settings2(pydantic.BaseSettings):
class Settings2(BaseSettings):
section1: Section21 = Section21()
section3: Section3= Section3()
section3: Section3 = Section3()
@fixture
@ -86,10 +103,10 @@ def test_copy(config, pydantic_settings_1, pydantic_settings_2):
def test_set_pydantic_settings(config):
class Settings3(pydantic.BaseSettings):
class Settings3(BaseSettings):
...
class Settings4(pydantic.BaseSettings):
class Settings4(BaseSettings):
...
settings_3 = Settings3()
@ -100,27 +117,27 @@ def test_set_pydantic_settings(config):
def test_file_does_not_exist(config):
config.set_pydantic_settings([pydantic.BaseSettings()])
config.set_pydantic_settings([BaseSettings()])
config.load()
assert config() == {}
@mark.parametrize("config_type", ["strict"])
def test_file_does_not_exist_strict_mode(config):
config.set_pydantic_settings([pydantic.BaseSettings()])
config.set_pydantic_settings([BaseSettings()])
with raises(ValueError):
config.load()
assert config() == {}
def test_required_file_does_not_exist(config):
config.set_pydantic_settings([pydantic.BaseSettings()])
config.set_pydantic_settings([BaseSettings()])
with raises(ValueError):
config.load(required=True)
@mark.parametrize("config_type", ["strict"])
def test_not_required_file_does_not_exist_strict_mode(config):
config.set_pydantic_settings([pydantic.BaseSettings()])
config.set_pydantic_settings([BaseSettings()])
config.load(required=False)
assert config() == {}