Skip to content
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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.0] - 2025-11-24 :notes:
## [2.1.0] - 2026-03-??

- Improve `resolve()` typing, by @sobolevn.
- Use `Self` type for Container, by @sobolevn.
Expand All @@ -18,6 +18,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Remove Codecov from GitHub Workflow and from README.
- Upgrade type annotations to Python >= 3.10.
- Remove code checks for Python <= 3.10.
- Support mixing `__init__` parameters and class-level annotated properties for
dependency injection. Previously, when a class defined a custom `__init__`,
rodi would only inspect constructor parameters and ignore class-level type
annotations. Now both are resolved: constructor parameters are injected as
arguments, and any remaining class-level annotated properties are injected via
`setattr` after instantiation. This enables patterns like:

```python
class MyService:
extra_dep: ExtraDependency # injected via setattr

def __init__(self, main_dep: MainDependency) -> None:
self.main_dep = main_dep
```

Resolves [issue #43](https://github.com/Neoteroi/rodi/issues/43), reported by
[@lucas-labs](https://github.com/lucas-labs).

## [2.0.8] - 2025-04-12

Expand Down
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,24 @@ format:

lint-types:
mypy rodi --explicit-package-bases


check-flake8:
@echo "$(BOLD)Checking flake8$(RESET)"
@flake8 rodi 2>&1
@flake8 tests 2>&1


check-isort:
@echo "$(BOLD)Checking isort$(RESET)"
@isort --check-only rodi 2>&1
@isort --check-only tests 2>&1


check-black: ## Run the black tool in check mode only (won't modify files)
@echo "$(BOLD)Checking black$(RESET)"
@black --check rodi 2>&1
@black --check tests 2>&1


lint: check-flake8 check-isort check-black
93 changes: 93 additions & 0 deletions rodi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,28 @@ def factory(context, parent_type):
return FactoryResolver(concrete_type, factory, life_style)(resolver_context)


def get_mixed_type_provider(
concrete_type: Type,
args_callbacks: list,
annotation_resolvers: Mapping[str, Callable],
life_style: ServiceLifeStyle,
resolver_context: ResolutionContext,
):
"""
Provider that combines __init__ argument injection with class-level annotation
property injection. Used when a class defines both a custom __init__ (with or
without parameters) and class-level annotated attributes.
"""

def factory(context, parent_type):
instance = concrete_type(*[fn(context, parent_type) for fn in args_callbacks])
for name, resolver in annotation_resolvers.items():
setattr(instance, name, resolver(context, parent_type))
return instance

return FactoryResolver(concrete_type, factory, life_style)(resolver_context)


def _get_plain_class_factory(concrete_type: Type):
def factory(*args):
return concrete_type()
Expand Down Expand Up @@ -645,6 +667,48 @@ def _resolve_by_annotations(
self.concrete_type, resolvers, self.life_style, context
)

def _resolve_by_init_and_annotations(
self, context: ResolutionContext, extra_annotations: dict[str, Type]
):
"""
Resolves by both __init__ parameters and class-level annotated properties.
Used when a class defines a custom __init__ AND class-level type annotations.
The __init__ parameters are injected as constructor arguments; the class
annotations are injected via setattr after instantiation.
"""
sig = Signature.from_callable(self.concrete_type.__init__)
params = {
key: Dependency(key, value.annotation)
for key, value in sig.parameters.items()
}

if sys.version_info >= (3, 10): # pragma: no cover
globalns = dict(vars(sys.modules[self.concrete_type.__module__]))
globalns.update(_get_obj_globals(self.concrete_type))
annotations = get_type_hints(
self.concrete_type.__init__,
globalns,
_get_obj_locals(self.concrete_type),
)
for key, value in params.items():
if key in annotations:
value.annotation = annotations[key]

concrete_type = self.concrete_type
init_fns = self._get_resolvers_for_parameters(concrete_type, context, params)

ann_params = {
key: Dependency(key, value) for key, value in extra_annotations.items()
}
ann_fns = self._get_resolvers_for_parameters(concrete_type, context, ann_params)
annotation_resolvers = {
name: ann_fns[i] for i, name in enumerate(ann_params.keys())
}

return get_mixed_type_provider(
concrete_type, init_fns, annotation_resolvers, self.life_style, context
)

def __call__(self, context: ResolutionContext):
concrete_type = self.concrete_type

Expand All @@ -670,6 +734,35 @@ def __call__(self, context: ResolutionContext):
concrete_type, _get_plain_class_factory(concrete_type), self.life_style
)(context)

# Custom __init__: also check for class-level annotations to inject as
# properties. The cheap __annotations__ check avoids the expensive
# get_type_hints call for the common case of no class-level annotations.
if concrete_type.__annotations__:
class_annotations = get_type_hints(
concrete_type,
{
**dict(vars(sys.modules[concrete_type.__module__])),
**_get_obj_globals(concrete_type),
},
_get_obj_locals(concrete_type),
)
if class_annotations:
sig = Signature.from_callable(concrete_type.__init__)
init_param_names = set(sig.parameters.keys()) - {"self"}
extra_annotations = {
k: v
for k, v in class_annotations.items()
if k not in init_param_names
and not self._ignore_class_attribute(k, v)
}
if extra_annotations:
try:
return self._resolve_by_init_and_annotations(
context, extra_annotations
)
except RecursionError:
raise CircularDependencyException(chain[0], concrete_type)

try:
return self._resolve_by_init_method(context)
except RecursionError:
Expand Down
63 changes: 63 additions & 0 deletions tests/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,66 @@ class PrecedenceOfTypeHintsOverNames:
def __init__(self, foo: Q, ko: P):
self.q = foo
self.p = ko


# Classes for testing mixed __init__ + class annotation injection


class MixedDep1:
pass


class MixedDep2:
pass


class MixedNoInitArgs:
"""Has a custom __init__ with no injectable args, plus class-level annotations."""

injected: MixedDep1

def __init__(self) -> None:
self.value = "hello"


class MixedWithInitArgs:
"""
Has a custom __init__ with injectable args, plus additional class-level
annotations.
"""

extra: MixedDep2

def __init__(self, dep1: MixedDep1) -> None:
self.dep1 = dep1
self.value = "hello"


class MixedSingleton:
"""Singleton variant for mixed injection."""

dep2: MixedDep2

def __init__(self, dep1: MixedDep1) -> None:
self.dep1 = dep1


class MixedScoped:
"""Scoped variant for mixed injection."""

dep2: MixedDep2

def __init__(self, dep1: MixedDep1) -> None:
self.dep1 = dep1


class MixedAnnotationOverlapsInit:
"""
Class where a class annotation name matches an __init__ parameter.
The annotation should NOT be double-injected; init param takes precedence.
"""

dep1: MixedDep1 # same name as __init__ param - should be handled by init only

def __init__(self, dep1: MixedDep1) -> None:
self.dep1 = dep1
Loading