Fix annotated attribute injection (#889)

* Add example for Annotated attribute injection for module/class attributes

* Fix attribute injection with Annotated types

* Add unit tests for Annotated attribute and argument injection in wiring

* Add .cursor to .gitignore

* Style: add blank lines between class definitions and attributes in annotated attribute example

* Docs: clarify and format module/class attribute injection for classic and Annotated forms

* Changelog: add note and discussion link for Annotated attribute injection support

* Fix nls

* Fix CI checks and Python 3.8 tests

* Fix PR issues

* Fix Python 3.8 tests

* Fix flake8 issues

* Fix: robust Annotated detection for wiring across Python versions

* Refactor: extract annotation retrieval and improve typing for Python 3.9 compatibility

* Update src/dependency_injector/wiring.py

Co-authored-by: ZipFile <zipfile.d@protonmail.com>

---------

Co-authored-by: ZipFile <zipfile.d@protonmail.com>
This commit is contained in:
Roman Mogylatov 2025-05-21 16:13:37 -04:00 committed by GitHub
parent 8bf9ed04c8
commit 8814db3fb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 405 additions and 7 deletions

3
.gitignore vendored
View File

@ -73,3 +73,6 @@ src/**/*.html
.workspace/
.vscode/
# Cursor project files
.cursor

View File

@ -7,6 +7,14 @@ that were made in every particular version.
From version 0.7.6 *Dependency Injector* framework strictly
follows `Semantic versioning`_
Develop
-------
- Add support for ``Annotated`` type for module and class attribute injection in wiring,
with updated documentation and examples.
See discussion:
https://github.com/ets-labs/python-dependency-injector/pull/721#issuecomment-2025263718
4.46.0
------

View File

@ -254,13 +254,43 @@ To inject a container use special identifier ``<container>``:
Making injections into modules and class attributes
---------------------------------------------------
You can use wiring to make injections into modules and class attributes.
You can use wiring to make injections into modules and class attributes. Both the classic marker
syntax and the ``Annotated`` form are supported.
Classic marker syntax:
.. code-block:: python
service: Service = Provide[Container.service]
class Main:
service: Service = Provide[Container.service]
Full example of the classic marker syntax:
.. literalinclude:: ../examples/wiring/example_attribute.py
:language: python
:lines: 3-
:emphasize-lines: 14,19
Annotated form (Python 3.9+):
.. code-block:: python
from typing import Annotated
service: Annotated[Service, Provide[Container.service]]
class Main:
service: Annotated[Service, Provide[Container.service]]
Full example of the annotated form:
.. literalinclude:: ../examples/wiring/example_attribute_annotated.py
:language: python
:lines: 3-
:emphasize-lines: 16,21
You could also use string identifiers to avoid a dependency on a container:
.. code-block:: python

View File

@ -0,0 +1,31 @@
"""Wiring attribute example with Annotated."""
from typing import Annotated
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide
class Service:
...
class Container(containers.DeclarativeContainer):
service = providers.Factory(Service)
service: Annotated[Service, Provide[Container.service]]
class Main:
service: Annotated[Service, Provide[Container.service]]
if __name__ == "__main__":
container = Container()
container.wire(modules=[__name__])
assert isinstance(service, Service)
assert isinstance(Main.service, Service)

View File

@ -8,7 +8,7 @@ per-file-ignores =
examples/containers/traverse.py: E501
examples/providers/async.py: F841
examples/providers/async_overriding.py: F841
examples/wiring/*: F841
examples/wiring/*: F821,F841
[pydocstyle]
ignore = D100,D101,D102,D105,D106,D107,D203,D213

View File

@ -415,7 +415,7 @@ def wire( # noqa: C901
providers_map = ProvidersMap(container)
for module in modules:
for member_name, member in inspect.getmembers(module):
for member_name, member in _get_members_and_annotated(module):
if _inspect_filter.is_excluded(member):
continue
@ -426,7 +426,7 @@ def wire( # noqa: C901
elif inspect.isclass(member):
cls = member
try:
cls_members = inspect.getmembers(cls)
cls_members = _get_members_and_annotated(cls)
except Exception: # noqa
# Hotfix, see: https://github.com/ets-labs/python-dependency-injector/issues/441
continue
@ -579,7 +579,11 @@ def _unpatch_attribute(patched: PatchedAttribute) -> None:
def _extract_marker(parameter: inspect.Parameter) -> Optional["_Marker"]:
if get_origin(parameter.annotation) is Annotated:
marker = get_args(parameter.annotation)[1]
args = get_args(parameter.annotation)
if len(args) > 1:
marker = args[1]
else:
marker = None
else:
marker = parameter.default
@ -1025,3 +1029,23 @@ def _get_sync_patched(fn: F, patched: PatchedCallable) -> F:
patched.closing,
)
return cast(F, _patched)
if sys.version_info >= (3, 10):
def _get_annotations(obj: Any) -> Dict[str, Any]:
return inspect.get_annotations(obj)
else:
def _get_annotations(obj: Any) -> Dict[str, Any]:
return getattr(obj, "__annotations__", {})
def _get_members_and_annotated(obj: Any) -> Iterable[Tuple[str, Any]]:
members = inspect.getmembers(obj)
annotations = _get_annotations(obj)
for annotation_name, annotation in annotations.items():
if get_origin(annotation) is Annotated:
args = get_args(annotation)
if len(args) > 1:
member = args[1]
members.append((annotation_name, member))
return members

View File

@ -0,0 +1,126 @@
"""Test module for wiring with Annotated."""
import sys
import pytest
if sys.version_info < (3, 9):
pytest.skip("Annotated is only available in Python 3.9+", allow_module_level=True)
from decimal import Decimal
from typing import Callable, Annotated
from dependency_injector import providers
from dependency_injector.wiring import inject, Provide, Provider
from .container import Container, SubContainer
from .service import Service
service: Annotated[Service, Provide[Container.service]]
service_provider: Annotated[Callable[..., Service], Provider[Container.service]]
undefined: Annotated[Callable, Provide[providers.Provider()]]
class TestClass:
service: Annotated[Service, Provide[Container.service]]
service_provider: Annotated[Callable[..., Service], Provider[Container.service]]
undefined: Annotated[Callable, Provide[providers.Provider()]]
@inject
def __init__(self, service: Annotated[Service, Provide[Container.service]]):
self.service = service
@inject
def method(self, service: Annotated[Service, Provide[Container.service]]):
return service
@classmethod
@inject
def class_method(cls, service: Annotated[Service, Provide[Container.service]]):
return service
@staticmethod
@inject
def static_method(service: Annotated[Service, Provide[Container.service]]):
return service
@inject
def test_function(service: Annotated[Service, Provide[Container.service]]):
return service
@inject
def test_function_provider(service_provider: Annotated[Callable[..., Service], Provider[Container.service]]):
service = service_provider()
return service
@inject
def test_config_value(
value_int: Annotated[int, Provide[Container.config.a.b.c.as_int()]],
value_float: Annotated[float, Provide[Container.config.a.b.c.as_float()]],
value_str: Annotated[str, Provide[Container.config.a.b.c.as_(str)]],
value_decimal: Annotated[Decimal, Provide[Container.config.a.b.c.as_(Decimal)]],
value_required: Annotated[str, Provide[Container.config.a.b.c.required()]],
value_required_int: Annotated[int, Provide[Container.config.a.b.c.required().as_int()]],
value_required_float: Annotated[float, Provide[Container.config.a.b.c.required().as_float()]],
value_required_str: Annotated[str, Provide[Container.config.a.b.c.required().as_(str)]],
value_required_decimal: Annotated[str, Provide[Container.config.a.b.c.required().as_(Decimal)]],
):
return (
value_int,
value_float,
value_str,
value_decimal,
value_required,
value_required_int,
value_required_float,
value_required_str,
value_required_decimal,
)
@inject
def test_config_value_required_undefined(
value_required: Annotated[int, Provide[Container.config.a.b.c.required()]],
):
return value_required
@inject
def test_provide_provider(service_provider: Annotated[Callable[..., Service], Provide[Container.service.provider]]):
service = service_provider()
return service
@inject
def test_provider_provider(service_provider: Annotated[Callable[..., Service], Provider[Container.service.provider]]):
service = service_provider()
return service
@inject
def test_provided_instance(some_value: Annotated[int, Provide[Container.service.provided.foo["bar"].call()]]):
return some_value
@inject
def test_subcontainer_provider(some_value: Annotated[int, Provide[Container.sub.int_object]]):
return some_value
@inject
def test_config_invariant(some_value: Annotated[int, Provide[Container.config.option[Container.config.switch]]]):
return some_value
@inject
def test_provide_from_different_containers(
service: Annotated[Service, Provide[Container.service]],
some_value: Annotated[int, Provide[SubContainer.int_object]],
):
return service, some_value
class ClassDecorator:
def __init__(self, fn):
self._fn = fn
def __call__(self, *args, **kwargs):
return self._fn(*args, **kwargs)
@ClassDecorator
@inject
def test_class_decorator(service: Annotated[Service, Provide[Container.service]]):
return service
def test_container(container: Annotated[Container, Provide[Container]]):
return container.service()

View File

@ -0,0 +1,176 @@
"""Main wiring tests for Annotated attribute and argument injection."""
from decimal import Decimal
import typing
from dependency_injector import errors
from dependency_injector.wiring import Closing, Provide, Provider, wire
from pytest import fixture, mark, raises
from samples.wiring import module_annotated as module, package, resourceclosing
from samples.wiring.service import Service
from samples.wiring.container import Container, SubContainer
@fixture(autouse=True)
def container():
container = Container(config={"a": {"b": {"c": 10}}})
container.wire(
modules=[module],
packages=[package],
)
yield container
container.unwire()
@fixture
def subcontainer():
container = SubContainer()
container.wire(
modules=[module],
packages=[package],
)
yield container
container.unwire()
@fixture
def resourceclosing_container():
container = resourceclosing.Container()
container.wire(modules=[resourceclosing])
yield container
container.unwire()
def test_module_attributes_wiring():
assert isinstance(module.service, Service)
assert isinstance(module.service_provider(), Service)
assert isinstance(module.__annotations__['undefined'], typing._AnnotatedAlias)
def test_class_wiring():
test_class_object = module.TestClass()
assert isinstance(test_class_object.service, Service)
def test_class_wiring_context_arg(container: Container):
test_service = container.service()
test_class_object = module.TestClass(service=test_service)
assert test_class_object.service is test_service
def test_class_method_wiring():
test_class_object = module.TestClass()
service = test_class_object.method()
assert isinstance(service, Service)
def test_class_classmethod_wiring():
service = module.TestClass.class_method()
assert isinstance(service, Service)
def test_instance_classmethod_wiring():
instance = module.TestClass()
service = instance.class_method()
assert isinstance(service, Service)
def test_class_staticmethod_wiring():
service = module.TestClass.static_method()
assert isinstance(service, Service)
def test_instance_staticmethod_wiring():
instance = module.TestClass()
service = instance.static_method()
assert isinstance(service, Service)
def test_class_attribute_wiring():
assert isinstance(module.TestClass.service, Service)
assert isinstance(module.TestClass.service_provider(), Service)
assert isinstance(module.TestClass.__annotations__['undefined'], typing._AnnotatedAlias)
def test_function_wiring():
service = module.test_function()
assert isinstance(service, Service)
def test_function_wiring_context_arg(container: Container):
test_service = container.service()
service = module.test_function(service=test_service)
assert service is test_service
def test_function_wiring_provider():
service = module.test_function_provider()
assert isinstance(service, Service)
def test_function_wiring_provider_context_arg(container: Container):
test_service = container.service()
service = module.test_function_provider(service_provider=lambda: test_service)
assert service is test_service
def test_configuration_option():
(
value_int,
value_float,
value_str,
value_decimal,
value_required,
value_required_int,
value_required_float,
value_required_str,
value_required_decimal,
) = module.test_config_value()
assert value_int == 10
assert value_float == 10.0
assert value_str == "10"
assert value_decimal == Decimal(10)
assert value_required == 10
assert value_required_int == 10
assert value_required_float == 10.0
assert value_required_str == "10"
assert value_required_decimal == Decimal(10)
def test_configuration_option_required_undefined(container: Container):
container.config.reset_override()
with raises(errors.Error, match="Undefined configuration option \"config.a.b.c\""):
module.test_config_value_required_undefined()
def test_provide_provider():
service = module.test_provide_provider()
assert isinstance(service, Service)
def test_provider_provider():
service = module.test_provider_provider()
assert isinstance(service, Service)
def test_provided_instance(container: Container):
class TestService:
foo = {"bar": lambda: 10}
with container.service.override(TestService()):
some_value = module.test_provided_instance()
assert some_value == 10
def test_subcontainer():
some_value = module.test_subcontainer_provider()
assert some_value == 1
def test_config_invariant(container: Container):
config = {
"option": {
"a": 1,
"b": 2,
},
"switch": "a",
}
container.config.from_dict(config)
value_default = module.test_config_invariant()
assert value_default == 1
with container.config.switch.override("a"):
value_a = module.test_config_invariant()
assert value_a == 1
with container.config.switch.override("b"):
value_b = module.test_config_invariant()
assert value_b == 2
def test_class_decorator():
service = module.test_class_decorator()
assert isinstance(service, Service)
def test_container():
service = module.test_container()
assert isinstance(service, Service)

View File

@ -89,8 +89,8 @@ commands=
deps=
flake8
commands=
flake8 --max-complexity=10 src/dependency_injector/
flake8 --max-complexity=10 examples/
flake8 src/dependency_injector/
flake8 examples/
[testenv:pydocstyle]
deps=