diff --git a/.github/workflows/tests-and-linters.yml b/.github/workflows/tests-and-linters.yml index 184889a7..a924c5e7 100644 --- a/.github/workflows/tests-and-linters.yml +++ b/.github/workflows/tests-and-linters.yml @@ -36,6 +36,17 @@ jobs: env: TOXENV: ${{ matrix.python-version }} + test-different-pydantic-versions: + name: Run tests with different pydantic versions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.12" + - run: pip install tox + - run: tox -e pydantic-v1,pydantic-v2 + test-coverage: name: Run tests with coverage runs-on: ubuntu-latest diff --git a/docs/providers/configuration.rst b/docs/providers/configuration.rst index 3e4696f1..582c0cc1 100644 --- a/docs/providers/configuration.rst +++ b/docs/providers/configuration.rst @@ -183,22 +183,22 @@ See also: :ref:`configuration-envs-interpolation`. Loading from a Pydantic settings -------------------------------- -``Configuration`` provider can load configuration from a ``pydantic`` settings object using the +``Configuration`` provider can load configuration from a ``pydantic_settings.BaseSettings`` object using the :py:meth:`Configuration.from_pydantic` method: .. literalinclude:: ../../examples/providers/configuration/configuration_pydantic.py :language: python :lines: 3- - :emphasize-lines: 31 + :emphasize-lines: 32 -To get the data from pydantic settings ``Configuration`` provider calls ``Settings.dict()`` method. +To get the data from pydantic settings ``Configuration`` provider calls its ``model_dump()`` method. If you need to pass an argument to this call, use ``.from_pydantic()`` keyword arguments. .. code-block:: python container.config.from_pydantic(Settings(), exclude={"optional"}) -Alternatively, you can provide a ``pydantic`` settings object over the configuration provider argument. In that case, +Alternatively, you can provide a ``pydantic_settings.BaseSettings`` object over the configuration provider argument. In that case, the container will call ``config.from_pydantic()`` automatically: .. code-block:: python @@ -215,18 +215,23 @@ the container will call ``config.from_pydantic()`` automatically: .. note:: - ``Dependency Injector`` doesn't install ``pydantic`` by default. + ``Dependency Injector`` doesn't install ``pydantic-settings`` by default. You can install the ``Dependency Injector`` with an extra dependency:: - pip install dependency-injector[pydantic] + pip install dependency-injector[pydantic2] - or install ``pydantic`` directly:: + or install ``pydantic-settings`` directly:: - pip install pydantic + pip install pydantic-settings *Don't forget to mirror the changes in the requirements file.* +.. note:: + + For backward-compatibility, Pydantic v1 is still supported. + Passing ``pydantic.BaseSettings`` instances will work just as fine as ``pydantic_settings.BaseSettings``. + Loading from a dictionary ------------------------- diff --git a/examples/miniapps/fastapi-redis/fastapiredis/tests.py b/examples/miniapps/fastapi-redis/fastapiredis/tests.py index bde075ab..7d31a99d 100644 --- a/examples/miniapps/fastapi-redis/fastapiredis/tests.py +++ b/examples/miniapps/fastapi-redis/fastapiredis/tests.py @@ -3,7 +3,7 @@ from unittest import mock import pytest -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from .application import app, container from .services import Service @@ -11,7 +11,10 @@ @pytest.fixture def client(event_loop): - client = AsyncClient(app=app, base_url="http://test") + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) yield client event_loop.run_until_complete(client.aclose()) diff --git a/examples/miniapps/fastapi-simple/tests.py b/examples/miniapps/fastapi-simple/tests.py index 4d80e072..cf033592 100644 --- a/examples/miniapps/fastapi-simple/tests.py +++ b/examples/miniapps/fastapi-simple/tests.py @@ -1,14 +1,17 @@ from unittest import mock import pytest -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from fastapi_di_example import app, container, Service @pytest.fixture async def client(event_loop): - async with AsyncClient(app=app, base_url="http://test") as client: + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: yield client diff --git a/examples/miniapps/fastapi/giphynavigator/tests.py b/examples/miniapps/fastapi/giphynavigator/tests.py index 2b57d50d..c1505e78 100644 --- a/examples/miniapps/fastapi/giphynavigator/tests.py +++ b/examples/miniapps/fastapi/giphynavigator/tests.py @@ -3,7 +3,7 @@ from unittest import mock import pytest -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from giphynavigator.application import app from giphynavigator.giphy import GiphyClient @@ -11,7 +11,10 @@ @pytest.fixture async def client(): - async with AsyncClient(app=app, base_url="http://test") as client: + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as client: yield client diff --git a/examples/providers/configuration/configuration_pydantic.py b/examples/providers/configuration/configuration_pydantic.py index aaed5d26..aca34e92 100644 --- a/examples/providers/configuration/configuration_pydantic.py +++ b/examples/providers/configuration/configuration_pydantic.py @@ -3,7 +3,7 @@ import os from dependency_injector import containers, providers -from pydantic import BaseSettings, Field +from pydantic_settings import BaseSettings, SettingsConfigDict # Emulate environment variables os.environ["AWS_ACCESS_KEY_ID"] = "KEY" @@ -11,15 +11,16 @@ class AwsSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="aws_") - access_key_id: str = Field(env="aws_access_key_id") - secret_access_key: str = Field(env="aws_secret_access_key") + access_key_id: str + secret_access_key: str class Settings(BaseSettings): aws: AwsSettings = AwsSettings() - optional: str = Field(default="default_value") + optional: str = "default_value" class Container(containers.DeclarativeContainer): diff --git a/examples/providers/configuration/configuration_pydantic_init.py b/examples/providers/configuration/configuration_pydantic_init.py index f904d9df..ee0a02e6 100644 --- a/examples/providers/configuration/configuration_pydantic_init.py +++ b/examples/providers/configuration/configuration_pydantic_init.py @@ -3,7 +3,7 @@ import os from dependency_injector import containers, providers -from pydantic import BaseSettings, Field +from pydantic_settings import BaseSettings, SettingsConfigDict # Emulate environment variables os.environ["AWS_ACCESS_KEY_ID"] = "KEY" @@ -11,15 +11,16 @@ class AwsSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="aws_") - access_key_id: str = Field(env="aws_access_key_id") - secret_access_key: str = Field(env="aws_secret_access_key") + access_key_id: str + secret_access_key: str class Settings(BaseSettings): aws: AwsSettings = AwsSettings() - optional: str = Field(default="default_value") + optional: str = "default_value" class Container(containers.DeclarativeContainer): diff --git a/pyproject.toml b/pyproject.toml index cf317aba..d0b0f7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = ["six"] [project.optional-dependencies] yaml = ["pyyaml"] pydantic = ["pydantic"] +pydantic2 = ["pydantic-settings"] flask = ["flask"] aiohttp = ["aiohttp"] diff --git a/src/dependency_injector/providers.pyx b/src/dependency_injector/providers.pyx index 402b513a..2db9fa2f 100644 --- a/src/dependency_injector/providers.pyx +++ b/src/dependency_injector/providers.pyx @@ -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. diff --git a/tests/.configs/pytest.ini b/tests/.configs/pytest.ini index 5ef04e2c..ea92be96 100644 --- a/tests/.configs/pytest.ini +++ b/tests/.configs/pytest.ini @@ -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 diff --git a/tests/unit/providers/configuration/test_from_pydantic_py36.py b/tests/unit/providers/configuration/test_from_pydantic_py36.py index f5a2c97e..ee2a1176 100644 --- a/tests/unit/providers/configuration/test_from_pydantic_py36.py +++ b/tests/unit/providers/configuration/test_from_pydantic_py36.py @@ -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]\"" - ) diff --git a/tests/unit/providers/configuration/test_pydantic_settings_in_init_py36.py b/tests/unit/providers/configuration/test_pydantic_settings_in_init_py36.py index bb7b1865..9768ca52 100644 --- a/tests/unit/providers/configuration/test_pydantic_settings_in_init_py36.py +++ b/tests/unit/providers/configuration/test_pydantic_settings_in_init_py36.py @@ -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 + +pytestmark = mark.pydantic + -class Section11(pydantic.BaseModel): +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 @@ class Settings4(pydantic.BaseSettings): 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() == {} diff --git a/tests/unit/wiring/test_fastapi_py36.py b/tests/unit/wiring/test_fastapi_py36.py index 4615b221..1e9ff584 100644 --- a/tests/unit/wiring/test_fastapi_py36.py +++ b/tests/unit/wiring/test_fastapi_py36.py @@ -1,4 +1,4 @@ -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from pytest import fixture, mark from pytest_asyncio import fixture as aio_fixture @@ -19,7 +19,7 @@ @aio_fixture async def async_client(): - client = AsyncClient(app=web.app, base_url="http://test") + client = AsyncClient(transport=ASGITransport(app=web.app), base_url="http://test") yield client await client.aclose() diff --git a/tox.ini b/tox.ini index 4e5d08aa..c06b0f95 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] parallel_show_output = true envlist= - coveralls, pylint, flake8, pydocstyle, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, pypy3.9, pypy3.10 + coveralls, pylint, flake8, pydocstyle, pydantic-v1, pydantic-v2, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, pypy3.9, pypy3.10 [testenv] deps= @@ -29,6 +29,27 @@ setenv = [testenv:.pkg] passenv = DEPENDENCY_INJECTOR_* +[testenv:pydantic-{v1,v2}] +description = run tests with different pydantic versions +base_python = python3.12 +deps = + v1: pydantic<2 + v2: pydantic-settings + pytest + pytest-asyncio + -rrequirements.txt + typing_extensions + httpx + fastapi + flask<2.2 + aiohttp<=3.9.0b1 + numpy + scipy + boto3 + mypy_boto3_s3 + werkzeug<=2.2.2 +commands = pytest -c tests/.configs/pytest.ini -m pydantic + [testenv:coveralls] passenv = GITHUB_*, COVERALLS_*, DEPENDENCY_INJECTOR_* basepython=python3.12 # TODO: Upgrade to version 3.13 is blocked by coveralls 4.0.1 not supporting Python 3.13