Skip to content

Commit

Permalink
Yet another Pydantic 2 support (#832)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ZipFile authored Dec 7, 2024
1 parent cab75cb commit c61fc16
Show file tree
Hide file tree
Showing 14 changed files with 234 additions and 132 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/tests-and-linters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions docs/providers/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
-------------------------

Expand Down
7 changes: 5 additions & 2 deletions examples/miniapps/fastapi-redis/fastapiredis/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
from unittest import mock

import pytest
from httpx import AsyncClient
from httpx import ASGITransport, AsyncClient

from .application import app, container
from .services import Service


@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())

Expand Down
7 changes: 5 additions & 2 deletions examples/miniapps/fastapi-simple/tests.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down
7 changes: 5 additions & 2 deletions examples/miniapps/fastapi/giphynavigator/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
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


@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


Expand Down
9 changes: 5 additions & 4 deletions examples/providers/configuration/configuration_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@
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"
os.environ["AWS_SECRET_ACCESS_KEY"] = "SECRET"


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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@
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"
os.environ["AWS_SECRET_ACCESS_KEY"] = "SECRET"


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):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies = ["six"]
[project.optional-dependencies]
yaml = ["pyyaml"]
pydantic = ["pydantic"]
pydantic2 = ["pydantic-settings"]
flask = ["flask"]
aiohttp = ["aiohttp"]

Expand Down
93 changes: 50 additions & 43 deletions src/dependency_injector/providers.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tests/.configs/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit c61fc16

Please sign in to comment.