Skip to content

Commit bb55adf

Browse files
Possible fix for #43
1 parent 875352e commit bb55adf

File tree

4 files changed

+269
-1
lines changed

4 files changed

+269
-1
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,30 @@ 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
- Drop support for Python <= 3.10.
1111
- Add Python 3.14 to the build matrix and to classifiers.
1212
- Remove Codecov from GitHub Workflow and from README.
1313
- Upgrade type annotations to Python >= 3.10.
1414
- Remove code checks for Python <= 3.10.
15+
- Support mixing `__init__` parameters and class-level annotated properties for
16+
dependency injection. Previously, when a class defined a custom `__init__`,
17+
rodi would only inspect constructor parameters and ignore class-level type
18+
annotations. Now both are resolved: constructor parameters are injected as
19+
arguments, and any remaining class-level annotated properties are injected via
20+
`setattr` after instantiation. This enables patterns like:
21+
22+
```python
23+
class MyService:
24+
extra_dep: ExtraDependency # injected via setattr
25+
26+
def __init__(self, main_dep: MainDependency) -> None:
27+
self.main_dep = main_dep
28+
```
29+
30+
Resolves [issue #43](https://github.com/Neoteroi/rodi/issues/43), reported by
31+
[@lucas-labs](https://github.com/lucas-labs).
1532

1633
## [2.0.8] - 2025-04-12
1734

rodi/__init__.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,28 @@ def factory(context, parent_type):
448448
return FactoryResolver(concrete_type, factory, life_style)(resolver_context)
449449

450450

451+
def get_mixed_type_provider(
452+
concrete_type: Type,
453+
args_callbacks: list,
454+
annotation_resolvers: Mapping[str, Callable],
455+
life_style: ServiceLifeStyle,
456+
resolver_context: ResolutionContext,
457+
):
458+
"""
459+
Provider that combines __init__ argument injection with class-level annotation
460+
property injection. Used when a class defines both a custom __init__ (with or
461+
without parameters) and class-level annotated attributes.
462+
"""
463+
464+
def factory(context, parent_type):
465+
instance = concrete_type(*[fn(context, parent_type) for fn in args_callbacks])
466+
for name, resolver in annotation_resolvers.items():
467+
setattr(instance, name, resolver(context, parent_type))
468+
return instance
469+
470+
return FactoryResolver(concrete_type, factory, life_style)(resolver_context)
471+
472+
451473
def _get_plain_class_factory(concrete_type: Type):
452474
def factory(*args):
453475
return concrete_type()
@@ -628,6 +650,47 @@ def _resolve_by_annotations(
628650
self.concrete_type, resolvers, self.life_style, context
629651
)
630652

653+
def _resolve_by_init_and_annotations(
654+
self, context: ResolutionContext, extra_annotations: Dict[str, Type]
655+
):
656+
"""
657+
Resolves by both __init__ parameters and class-level annotated properties.
658+
Used when a class defines a custom __init__ AND class-level type annotations.
659+
The __init__ parameters are injected as constructor arguments; the class
660+
annotations are injected via setattr after instantiation.
661+
"""
662+
sig = Signature.from_callable(self.concrete_type.__init__)
663+
params = {
664+
key: Dependency(key, value.annotation)
665+
for key, value in sig.parameters.items()
666+
}
667+
668+
if sys.version_info >= (3, 10): # pragma: no cover
669+
annotations = get_type_hints(
670+
self.concrete_type.__init__,
671+
vars(sys.modules[self.concrete_type.__module__]),
672+
_get_obj_locals(self.concrete_type),
673+
)
674+
for key, value in params.items():
675+
if key in annotations:
676+
value.annotation = annotations[key]
677+
678+
concrete_type = self.concrete_type
679+
init_fns = self._get_resolvers_for_parameters(concrete_type, context, params)
680+
681+
ann_params = {
682+
key: Dependency(key, value)
683+
for key, value in extra_annotations.items()
684+
}
685+
ann_fns = self._get_resolvers_for_parameters(concrete_type, context, ann_params)
686+
annotation_resolvers = {
687+
name: ann_fns[i] for i, name in enumerate(ann_params.keys())
688+
}
689+
690+
return get_mixed_type_provider(
691+
concrete_type, init_fns, annotation_resolvers, self.life_style, context
692+
)
693+
631694
def __call__(self, context: ResolutionContext):
632695
concrete_type = self.concrete_type
633696

@@ -651,6 +714,29 @@ def __call__(self, context: ResolutionContext):
651714
concrete_type, _get_plain_class_factory(concrete_type), self.life_style
652715
)(context)
653716

717+
# Custom __init__: also check for class-level annotations to inject as properties
718+
class_annotations = get_type_hints(
719+
concrete_type,
720+
vars(sys.modules[concrete_type.__module__]),
721+
_get_obj_locals(concrete_type),
722+
)
723+
if class_annotations:
724+
sig = Signature.from_callable(concrete_type.__init__)
725+
init_param_names = set(sig.parameters.keys()) - {"self"}
726+
extra_annotations = {
727+
k: v
728+
for k, v in class_annotations.items()
729+
if k not in init_param_names
730+
and not self._ignore_class_attribute(k, v)
731+
}
732+
if extra_annotations:
733+
try:
734+
return self._resolve_by_init_and_annotations(
735+
context, extra_annotations
736+
)
737+
except RecursionError:
738+
raise CircularDependencyException(chain[0], concrete_type)
739+
654740
try:
655741
return self._resolve_by_init_method(context)
656742
except RecursionError:

tests/examples.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,63 @@ 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+
"""Has a custom __init__ with injectable args, plus additional class-level annotations."""
291+
292+
extra: MixedDep2
293+
294+
def __init__(self, dep1: MixedDep1) -> None:
295+
self.dep1 = dep1
296+
self.value = "hello"
297+
298+
299+
class MixedSingleton:
300+
"""Singleton variant for mixed injection."""
301+
302+
dep2: MixedDep2
303+
304+
def __init__(self, dep1: MixedDep1) -> None:
305+
self.dep1 = dep1
306+
307+
308+
class MixedScoped:
309+
"""Scoped variant for mixed injection."""
310+
311+
dep2: MixedDep2
312+
313+
def __init__(self, dep1: MixedDep1) -> None:
314+
self.dep1 = dep1
315+
316+
317+
class MixedAnnotationOverlapsInit:
318+
"""
319+
Class where a class annotation name matches an __init__ parameter.
320+
The annotation should NOT be double-injected; init param takes precedence.
321+
"""
322+
323+
dep1: MixedDep1 # same name as __init__ param - should be handled by init only
324+
325+
def __init__(self, dep1: MixedDep1) -> None:
326+
self.dep1 = dep1

tests/test_services.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@
6565
Jang,
6666
Jing,
6767
Ko,
68+
MixedAnnotationOverlapsInit,
69+
MixedDep1,
70+
MixedDep2,
71+
MixedNoInitArgs,
72+
MixedScoped,
73+
MixedSingleton,
74+
MixedWithInitArgs,
6875
Ok,
6976
P,
7077
PrecedenceOfTypeHintsOverNames,
@@ -2760,3 +2767,101 @@ async def test_nested_scope_async_1():
27602767
nested_scope_async(),
27612768
nested_scope_async(),
27622769
)
2770+
2771+
2772+
# Tests for mixed __init__ + class annotation injection (issue #43)
2773+
2774+
2775+
def test_mixed_no_init_args_transient():
2776+
"""
2777+
Class with a custom no-arg __init__ AND class-level annotations:
2778+
annotations should be injected via setattr after instantiation.
2779+
"""
2780+
container = Container()
2781+
container.add_transient(MixedDep1)
2782+
container.add_transient(MixedNoInitArgs)
2783+
provider = container.build_provider()
2784+
2785+
instance = provider.get(MixedNoInitArgs)
2786+
assert instance is not None
2787+
assert isinstance(instance.injected, MixedDep1)
2788+
assert instance.value == "hello"
2789+
2790+
2791+
def test_mixed_no_init_args_new_instance_each_time():
2792+
"""Transient mixed services produce a new instance on each resolve."""
2793+
container = Container()
2794+
container.add_transient(MixedDep1)
2795+
container.add_transient(MixedNoInitArgs)
2796+
provider = container.build_provider()
2797+
2798+
a = provider.get(MixedNoInitArgs)
2799+
b = provider.get(MixedNoInitArgs)
2800+
assert a is not b
2801+
2802+
2803+
def test_mixed_with_init_args_transient():
2804+
"""
2805+
Class with a custom __init__ that has injectable params AND class-level annotations:
2806+
both should be injected.
2807+
"""
2808+
container = Container()
2809+
container.add_transient(MixedDep1)
2810+
container.add_transient(MixedDep2)
2811+
container.add_transient(MixedWithInitArgs)
2812+
provider = container.build_provider()
2813+
2814+
instance = provider.get(MixedWithInitArgs)
2815+
assert instance is not None
2816+
assert isinstance(instance.dep1, MixedDep1)
2817+
assert isinstance(instance.extra, MixedDep2)
2818+
assert instance.value == "hello"
2819+
2820+
2821+
def test_mixed_with_init_args_singleton():
2822+
"""Singleton mixed service: same instance returned each time."""
2823+
container = Container()
2824+
container.add_singleton(MixedDep1)
2825+
container.add_singleton(MixedDep2)
2826+
container.add_singleton(MixedSingleton)
2827+
provider = container.build_provider()
2828+
2829+
a = provider.get(MixedSingleton)
2830+
b = provider.get(MixedSingleton)
2831+
assert a is b
2832+
assert isinstance(a.dep1, MixedDep1)
2833+
assert isinstance(a.dep2, MixedDep2)
2834+
2835+
2836+
def test_mixed_with_init_args_scoped():
2837+
"""Scoped mixed service: same instance within a scope, new across scopes."""
2838+
container = Container()
2839+
container.add_scoped(MixedDep1)
2840+
container.add_scoped(MixedDep2)
2841+
container.add_scoped(MixedScoped)
2842+
provider = container.build_provider()
2843+
2844+
with provider.create_scope() as scope1:
2845+
a = provider.get(MixedScoped, scope1)
2846+
b = provider.get(MixedScoped, scope1)
2847+
assert a is b
2848+
assert isinstance(a.dep1, MixedDep1)
2849+
assert isinstance(a.dep2, MixedDep2)
2850+
2851+
with provider.create_scope() as scope2:
2852+
c = provider.get(MixedScoped, scope2)
2853+
assert c is not a
2854+
2855+
2856+
def test_mixed_annotation_overlaps_init_param():
2857+
"""
2858+
When a class annotation has the same name as an __init__ parameter,
2859+
the annotation is NOT double-injected — init handles it, setattr is skipped.
2860+
"""
2861+
container = Container()
2862+
container.add_transient(MixedDep1)
2863+
container.add_transient(MixedAnnotationOverlapsInit)
2864+
provider = container.build_provider()
2865+
2866+
instance = provider.get(MixedAnnotationOverlapsInit)
2867+
assert isinstance(instance.dep1, MixedDep1)

0 commit comments

Comments
 (0)