Skip to content

Commit 3d4efaa

Browse files
Fix #43
1 parent 7382dd7 commit 3d4efaa

File tree

5 files changed

+352
-54
lines changed

5 files changed

+352
-54
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

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

1010
- Improve `resolve()` typing, by @sobolevn.
1111
- Use `Self` type for Container, by @sobolevn.
@@ -18,6 +18,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Remove Codecov from GitHub Workflow and from README.
1919
- Upgrade type annotations to Python >= 3.10.
2020
- Remove code checks for Python <= 3.10.
21+
- Support mixing `__init__` parameters and class-level annotated properties for
22+
dependency injection. Previously, when a class defined a custom `__init__`,
23+
rodi would only inspect constructor parameters and ignore class-level type
24+
annotations. Now both are resolved: constructor parameters are injected as
25+
arguments, and any remaining class-level annotated properties are injected via
26+
`setattr` after instantiation. This enables patterns like:
27+
28+
```python
29+
class MyService:
30+
extra_dep: ExtraDependency # injected via setattr
31+
32+
def __init__(self, main_dep: MainDependency) -> None:
33+
self.main_dep = main_dep
34+
```
35+
36+
Resolves [issue #43](https://github.com/Neoteroi/rodi/issues/43), reported by
37+
[@lucas-labs](https://github.com/lucas-labs).
2138

2239
## [2.0.8] - 2025-04-12
2340

Makefile

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,24 @@ format:
4242

4343
lint-types:
4444
mypy rodi --explicit-package-bases
45+
46+
47+
check-flake8:
48+
@echo "$(BOLD)Checking flake8$(RESET)"
49+
@flake8 rodi 2>&1
50+
@flake8 tests 2>&1
51+
52+
53+
check-isort:
54+
@echo "$(BOLD)Checking isort$(RESET)"
55+
@isort --check-only rodi 2>&1
56+
@isort --check-only tests 2>&1
57+
58+
59+
check-black: ## Run the black tool in check mode only (won't modify files)
60+
@echo "$(BOLD)Checking black$(RESET)"
61+
@black --check rodi 2>&1
62+
@black --check tests 2>&1
63+
64+
65+
lint: check-flake8 check-isort check-black

rodi/__init__.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,28 @@ def factory(context, parent_type):
463463
return FactoryResolver(concrete_type, factory, life_style)(resolver_context)
464464

465465

466+
def get_mixed_type_provider(
467+
concrete_type: Type,
468+
args_callbacks: list,
469+
annotation_resolvers: Mapping[str, Callable],
470+
life_style: ServiceLifeStyle,
471+
resolver_context: ResolutionContext,
472+
):
473+
"""
474+
Provider that combines __init__ argument injection with class-level annotation
475+
property injection. Used when a class defines both a custom __init__ (with or
476+
without parameters) and class-level annotated attributes.
477+
"""
478+
479+
def factory(context, parent_type):
480+
instance = concrete_type(*[fn(context, parent_type) for fn in args_callbacks])
481+
for name, resolver in annotation_resolvers.items():
482+
setattr(instance, name, resolver(context, parent_type))
483+
return instance
484+
485+
return FactoryResolver(concrete_type, factory, life_style)(resolver_context)
486+
487+
466488
def _get_plain_class_factory(concrete_type: Type):
467489
def factory(*args):
468490
return concrete_type()
@@ -645,6 +667,48 @@ def _resolve_by_annotations(
645667
self.concrete_type, resolvers, self.life_style, context
646668
)
647669

670+
def _resolve_by_init_and_annotations(
671+
self, context: ResolutionContext, extra_annotations: dict[str, Type]
672+
):
673+
"""
674+
Resolves by both __init__ parameters and class-level annotated properties.
675+
Used when a class defines a custom __init__ AND class-level type annotations.
676+
The __init__ parameters are injected as constructor arguments; the class
677+
annotations are injected via setattr after instantiation.
678+
"""
679+
sig = Signature.from_callable(self.concrete_type.__init__)
680+
params = {
681+
key: Dependency(key, value.annotation)
682+
for key, value in sig.parameters.items()
683+
}
684+
685+
if sys.version_info >= (3, 10): # pragma: no cover
686+
globalns = dict(vars(sys.modules[self.concrete_type.__module__]))
687+
globalns.update(_get_obj_globals(self.concrete_type))
688+
annotations = get_type_hints(
689+
self.concrete_type.__init__,
690+
globalns,
691+
_get_obj_locals(self.concrete_type),
692+
)
693+
for key, value in params.items():
694+
if key in annotations:
695+
value.annotation = annotations[key]
696+
697+
concrete_type = self.concrete_type
698+
init_fns = self._get_resolvers_for_parameters(concrete_type, context, params)
699+
700+
ann_params = {
701+
key: Dependency(key, value) for key, value in extra_annotations.items()
702+
}
703+
ann_fns = self._get_resolvers_for_parameters(concrete_type, context, ann_params)
704+
annotation_resolvers = {
705+
name: ann_fns[i] for i, name in enumerate(ann_params.keys())
706+
}
707+
708+
return get_mixed_type_provider(
709+
concrete_type, init_fns, annotation_resolvers, self.life_style, context
710+
)
711+
648712
def __call__(self, context: ResolutionContext):
649713
concrete_type = self.concrete_type
650714

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

737+
# Custom __init__: also check for class-level annotations to inject as
738+
# properties. The cheap __annotations__ check avoids the expensive
739+
# get_type_hints call for the common case of no class-level annotations.
740+
if concrete_type.__annotations__:
741+
class_annotations = get_type_hints(
742+
concrete_type,
743+
{
744+
**dict(vars(sys.modules[concrete_type.__module__])),
745+
**_get_obj_globals(concrete_type),
746+
},
747+
_get_obj_locals(concrete_type),
748+
)
749+
if class_annotations:
750+
sig = Signature.from_callable(concrete_type.__init__)
751+
init_param_names = set(sig.parameters.keys()) - {"self"}
752+
extra_annotations = {
753+
k: v
754+
for k, v in class_annotations.items()
755+
if k not in init_param_names
756+
and not self._ignore_class_attribute(k, v)
757+
}
758+
if extra_annotations:
759+
try:
760+
return self._resolve_by_init_and_annotations(
761+
context, extra_annotations
762+
)
763+
except RecursionError:
764+
raise CircularDependencyException(chain[0], concrete_type)
765+
673766
try:
674767
return self._resolve_by_init_method(context)
675768
except RecursionError:

tests/examples.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,66 @@ class PrecedenceOfTypeHintsOverNames:
264264
def __init__(self, foo: Q, ko: P):
265265
self.q = foo
266266
self.p = ko
267+
268+
269+
# Classes for testing mixed __init__ + class annotation injection
270+
271+
272+
class MixedDep1:
273+
pass
274+
275+
276+
class MixedDep2:
277+
pass
278+
279+
280+
class MixedNoInitArgs:
281+
"""Has a custom __init__ with no injectable args, plus class-level annotations."""
282+
283+
injected: MixedDep1
284+
285+
def __init__(self) -> None:
286+
self.value = "hello"
287+
288+
289+
class MixedWithInitArgs:
290+
"""
291+
Has a custom __init__ with injectable args, plus additional class-level
292+
annotations.
293+
"""
294+
295+
extra: MixedDep2
296+
297+
def __init__(self, dep1: MixedDep1) -> None:
298+
self.dep1 = dep1
299+
self.value = "hello"
300+
301+
302+
class MixedSingleton:
303+
"""Singleton variant for mixed injection."""
304+
305+
dep2: MixedDep2
306+
307+
def __init__(self, dep1: MixedDep1) -> None:
308+
self.dep1 = dep1
309+
310+
311+
class MixedScoped:
312+
"""Scoped variant for mixed injection."""
313+
314+
dep2: MixedDep2
315+
316+
def __init__(self, dep1: MixedDep1) -> None:
317+
self.dep1 = dep1
318+
319+
320+
class MixedAnnotationOverlapsInit:
321+
"""
322+
Class where a class annotation name matches an __init__ parameter.
323+
The annotation should NOT be double-injected; init param takes precedence.
324+
"""
325+
326+
dep1: MixedDep1 # same name as __init__ param - should be handled by init only
327+
328+
def __init__(self, dep1: MixedDep1) -> None:
329+
self.dep1 = dep1

0 commit comments

Comments
 (0)