Skip to content

Bug: Wrong cls type in combination of @classmethod and @inject #947

@dayenchrysler12

Description

@dayenchrysler12

Injected classmethod returns instance of wrong subclass when using @Inject

Description

When decorating a @classmethod with @Inject in dependency‑injector and calling it through multiple levels of inheritance, the injected method is bound to the wrong class.

In the provided reproducer, a base class defines a simple @classmethod factory method and an injected factory method called through @classmethod *_creator method. The injected version returns an instance of a different subclass when invoked on a deeper subclass. This breaks polymorphic factories and violates the semantics of class methods.

Affected versions

The issue was observed on:
• Python: 3.14.0
• pytest: 9.0.2

>> uv pip show dependency-injector 
Name: dependency-injector
Version: 4.48.3
Location: /Users/danielchiliaev/PyCharmMiscProject/.venv/lib/python3.14/site-packages
Requires:
Required-by:

Older issues (#318) related to classmethod injection were fixed in version 4.3.3, but those addressed extra parameters being injected. The current problem concerns the binding of cls when subclasses are involved.

Steps to reproduce

  1. test_inject.py
# Reproducer for bug in dependency-injector where classmethod factories do not
# receive the correct cls when called from subclasses if the factory is injected.

from typing import Self

import pytest
from dependency_injector import providers
from dependency_injector.containers import DeclarativeContainer
from dependency_injector.wiring import inject, Provide

# 1. Define a container with a trivial provider:
class Container(DeclarativeContainer):
    singleton = providers.Singleton(lambda: object())

# 2. Define a base class with a plain factory, an injected factory, and creator methods:
class Base:
    @classmethod
    def factory(cls, repo_cls: type | None = None) -> Self:
        instance = cls()

        return instance

    @classmethod
    def creator(cls, repo_cls: type) -> Self:
        # sub factory is not injected -> cls is always the current class
        instance = cls.factory(repo_cls=repo_cls)
        assert repo_cls is cls, cls
        assert isinstance(
            instance,
            repo_cls
        ), f"Expected instance of {repo_cls.__name__}, got {type(instance).__name__}"
        return instance

    @classmethod
    @inject
    def injected_factory(cls, container: Container = Provide[Container], repo_cls: type | None = None) -> Self:
        instance = cls()

        return instance

    @classmethod
    def inject_creator(cls, repo_cls: type) -> Self:
        # sub factory is injected -> cls is  Sub1 when called from Sub2
        instance = cls.injected_factory(repo_cls=repo_cls)
        assert repo_cls is cls, cls
        assert isinstance(
            instance,
            repo_cls
        ), f"Expected instance of {repo_cls.__name__}, got {type(instance).__name__}"
        return instance


class Sub1(Base):
    """
    Subclass of Base to test factory methods.
    """
    pass


class Sub2(Sub1):
    """
    Subclass of Sub1 to test factory methods.
    """
    pass


@pytest.fixture
def container():
    _container = Container()
    _container.wire(modules=[__name__])
    yield _container
    _container.unwire()


# Tests for direct factories
def test_sub1_factory(container: Container):
    # Passes correctly (1 layer of inheritance)

    sub1_instance = Sub1.factory(repo_cls=Sub1)
    assert isinstance(sub1_instance, Sub1)


def test_sub2_factory(container: Container):
    # Passes correctly (2 layers of inheritance)

    sub2_instance = Sub2.creator(repo_cls=Sub2)
    assert isinstance(sub2_instance, Sub2)


# Tests for injected factories
def test_sub1_factory_inject(container: Container):
    # Passes correctly (1 layer of inheritance)
    sub1_instance = Sub1.injected_factory(repo_cls=Sub1)
    assert isinstance(sub1_instance, Sub1)


def test_sub2_factory_inject(container: Container):
    # Fails (2 layers of inheritance)
    sub2_instance = Sub2.inject_creator(repo_cls=Sub2)
    assert isinstance(sub2_instance, Sub2)
  1. Run
>> pytest test_inject.py

================================================================= test session starts =================================================================
platform darwin -- Python 3.14.0, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/danielchiliaev/PyCharmMiscProject
configfile: pyproject.toml
plugins: anyio-4.12.0
collected 4 items                                                                                                                                     

reproduces/dependency-injector/test_inject.py ...F                                                                                              [100%]

====================================================================== FAILURES =======================================================================
______________________________________________________________ test_sub2_factory_inject _______________________________________________________________

container = <dependency_injector.containers.DynamicContainer object at 0x108a05350>

    def test_sub2_factory_inject(container: Container):
        # Fails (2 layers of inheritance)
>       sub2_instance = Sub2.inject_creator(repo_cls=Sub2)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

reproduces/dependency-injector/test_inject.py:99: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <class 'test_inject.Sub2'>, repo_cls = <class 'test_inject.Sub2'>

    @classmethod
    def inject_creator(cls, repo_cls: type) -> Self:
        # sub factory is injected -> cls is  Sub1 when called from Sub2
        instance = cls.injected_factory(repo_cls=repo_cls)
        assert repo_cls is cls, cls
>       assert isinstance(
            instance,
            repo_cls
        ), f"Expected instance of {repo_cls.__name__}, got {type(instance).__name__}"
E       AssertionError: Expected instance of Sub2, got Sub1
E       assert False
E        +  where False = isinstance(<test_inject.Sub1 object at 0x107c2a0d0>, <class 'test_inject.Sub2'>)

reproduces/dependency-injector/test_inject.py:46: AssertionError
=============================================================== short test summary info ===============================================================
FAILED reproduces/dependency-injector/test_inject.py::test_sub2_factory_inject - AssertionError: Expected instance of Sub2, got Sub1
============================================================= 1 failed, 3 passed in 0.15s =============================================================

Expected behavior

Inheritance:
Base -> Sub1 -> Sub2
Calls:
Sub2.inject_creator(cls, ...) -> Sub2.inject_factory(cls, ...)

Each class method should receive its own class as cls regardless of whether it is decorated with @Inject or how deep the inheritance chain is. Calling the injected factory on Sub2 should produce an instance of Sub2, just as the plain factory does.

Actual behavior

When a @inject‑decorated classmethod is inherited and called in another classmethod, it receives wrong cls param

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions