diff --git a/docs/wiring.rst b/docs/wiring.rst index e0cbed5f..6188d703 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -55,6 +55,11 @@ the wiring works appropriately. This will also contribute to the performance of Specifying the ``@inject`` as a first decorator is also crucial for FastAPI, other frameworks using decorators similarly, for closures, and for any types of custom decorators with the injections. +.. note:: Note on complex class hierarchies + + If you have complex class hierarchies with ``@inject``, when wiring modules, make sure to include all + modules with decorator. Otherwise partially wired classes might violate Liskov Substitution Principle. + FastAPI example: .. code-block:: python diff --git a/src/dependency_injector/wiring.py b/src/dependency_injector/wiring.py index 62267c47..68066813 100644 --- a/src/dependency_injector/wiring.py +++ b/src/dependency_injector/wiring.py @@ -625,6 +625,13 @@ def _patch_method( method = cls.__dict__[name] fn = method.__func__ else: + # For inherited methods, check if the underlying function is already + # patched on a parent class. If so, skip to preserve the classmethod + # descriptor protocol (cls binding) for subclasses. + # See: https://github.com/ets-labs/python-dependency-injector/issues/947 + underlying = getattr(method, "__func__", None) + if underlying is not None and _is_patched(underlying): + return fn = method if not _is_patched(fn): diff --git a/tests/unit/wiring/test_classmethod_inject_inheritance_py36.py b/tests/unit/wiring/test_classmethod_inject_inheritance_py36.py new file mode 100644 index 00000000..60910e3b --- /dev/null +++ b/tests/unit/wiring/test_classmethod_inject_inheritance_py36.py @@ -0,0 +1,48 @@ +"""Test that @inject on classmethods preserves correct cls in subclasses. + +See issue for details: https://github.com/ets-labs/python-dependency-injector/issues/947 +""" + +import sys + +from pytest import fixture +from typing_extensions import Annotated + +from dependency_injector import providers +from dependency_injector.containers import DeclarativeContainer +from dependency_injector.wiring import Provide, inject + + +class Container(DeclarativeContainer): + singleton = providers.Singleton(lambda: object()) + + +class Base: + @classmethod + @inject + def injected_factory(cls, singleton: Annotated[object, Provide["singleton"]]): + return cls, singleton + + +class Sub1(Base): + pass + + +class Sub2(Sub1): + pass + + +@fixture +def container(): + container = Container() + container.wire(modules=[sys.modules[__name__]]) + yield container + container.unwire() + + +def test_base_injected_classmethod(container): + sentinel = container.singleton() + + for cls in [Sub2, Sub1, Base]: + result = cls.injected_factory() + assert result == (cls, sentinel)