Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Yet another Pydantic 2 support #832

Merged
merged 4 commits into from
Dec 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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