Skip to content

Commit 58e86c6

Browse files
Add support for the decorator pattern #15 (#70)
1 parent 3d4efaa commit 58e86c6

File tree

7 files changed

+536
-3
lines changed

7 files changed

+536
-3
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[flake8]
22
exclude = __pycache__,built,build,venv
3-
ignore = E203, E266, W503, E701, E704
3+
ignore = E203, E266, W503, E701, E704, C901
44
max-line-length = 88
55
max-complexity = 18
66
select = B,C,E,F,W,T4,B9

CHANGELOG.md

Lines changed: 16 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] - 2026-03-??
8+
## [2.1.0] - 2026-03-08 :woman:
99

1010
- Improve `resolve()` typing, by @sobolevn.
1111
- Use `Self` type for Container, by @sobolevn.
@@ -35,6 +35,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
Resolves [issue #43](https://github.com/Neoteroi/rodi/issues/43), reported by
3737
[@lucas-labs](https://github.com/lucas-labs).
38+
- Add support for the [decorator pattern](https://en.wikipedia.org/wiki/Decorator_pattern)
39+
via `Container.decorate(base_type, decorator_type)`. The decorator class must have an
40+
`__init__` parameter whose type annotation matches the registered type; that parameter
41+
receives the inner service instance, while all other parameters are resolved from the
42+
container as usual. Decorators can be chained by calling `decorate()` multiple times —
43+
each call wraps the previous registration:
44+
45+
```python
46+
container.add_singleton(IGreeter, SimpleGreeter)
47+
container.decorate(IGreeter, LoggingGreeter) # wraps SimpleGreeter
48+
container.decorate(IGreeter, CachingGreeter) # wraps LoggingGreeter
49+
# resolves as: CachingGreeter(LoggingGreeter(SimpleGreeter()))
50+
```
51+
52+
Resolves [issue #15](https://github.com/Neoteroi/rodi/issues/15), requested by @Eldar1205.
3853

3954
## [2.0.8] - 2025-04-12
4055

examples/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,8 @@ from exact implementations of data access logic).
2323
## example-03.py
2424

2525
This example illustrates how to configure a singleton object.
26+
27+
28+
## example-04.py
29+
30+
This example illustrates how to use the decorator pattern (available since `2.1.0`).

examples/example-04.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
This example illustrates the decorator pattern using Container.decorate().
3+
4+
The decorator pattern lets you wrap a registered service with another implementation
5+
of the same interface, transparently adding behaviour (logging, caching, retries, etc.)
6+
without modifying the original class.
7+
8+
Rules:
9+
- The decorator class must implement (or be compatible with) the same interface.
10+
- Its __init__ must have exactly one parameter whose type annotation matches the
11+
registered base type; that parameter receives the inner service instance.
12+
- All other __init__ parameters (and class-level annotations) are resolved from the
13+
container as usual.
14+
- Calling decorate() multiple times chains decorators — each call wraps the previous
15+
registration, so the last registered decorator is the outermost one.
16+
"""
17+
from abc import ABC, abstractmethod
18+
19+
from rodi import Container
20+
21+
22+
# --- Domain interface ---
23+
24+
25+
class MessageSender(ABC):
26+
@abstractmethod
27+
def send(self, message: str) -> None:
28+
"""Sends a message."""
29+
30+
31+
# --- Concrete implementation ---
32+
33+
34+
class ConsoleSender(MessageSender):
35+
"""Sends messages by printing them to the console."""
36+
37+
def send(self, message: str) -> None:
38+
print(f"[console] {message}")
39+
40+
41+
# --- Decorator 1: logging ---
42+
43+
44+
class LoggingMessageSender(MessageSender):
45+
"""Decorator that records every sent message before delegating."""
46+
47+
def __init__(self, inner: MessageSender) -> None:
48+
self.inner = inner
49+
self.log: list[str] = []
50+
51+
def send(self, message: str) -> None:
52+
self.log.append(message)
53+
self.inner.send(message)
54+
55+
56+
# --- Decorator 2: prefixing (chained on top of the logging decorator) ---
57+
58+
59+
class PrefixedMessageSender(MessageSender):
60+
"""Decorator that prepends a fixed prefix to every message."""
61+
62+
def __init__(self, inner: MessageSender) -> None:
63+
self.inner = inner
64+
65+
def send(self, message: str) -> None:
66+
self.inner.send(f"[app] {message}")
67+
68+
69+
# --- Wiring ---
70+
71+
container = Container()
72+
73+
container.add_singleton(MessageSender, ConsoleSender)
74+
container.decorate(MessageSender, LoggingMessageSender) # wraps ConsoleSender
75+
container.decorate(MessageSender, PrefixedMessageSender) # wraps LoggingMessageSender
76+
77+
sender = container.resolve(MessageSender)
78+
79+
# Resolution order: PrefixedMessageSender → LoggingMessageSender → ConsoleSender
80+
assert isinstance(sender, PrefixedMessageSender)
81+
assert isinstance(sender.inner, LoggingMessageSender)
82+
assert isinstance(sender.inner.inner, ConsoleSender)
83+
84+
sender.send("Hello, world")
85+
# prints: [console] [app] Hello, world
86+
87+
assert sender.inner.log == ["[app] Hello, world"]
88+
89+
# Singleton: same instance every time
90+
assert sender is container.resolve(MessageSender)

rodi/__init__.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,27 @@ def __init__(self, _type):
209209
)
210210

211211

212+
class DecoratorRegistrationException(DIException):
213+
"""
214+
Exception raised when registering a decorator fails, either because the base type
215+
is not registered or because the decorator class has no parameter matching the
216+
base type.
217+
"""
218+
219+
def __init__(self, base_type, decorator_type):
220+
if decorator_type is None:
221+
super().__init__(
222+
f"Cannot register a decorator for type '{class_name(base_type)}': "
223+
f"the type is not registered in the container."
224+
)
225+
else:
226+
super().__init__(
227+
f"Cannot register '{class_name(decorator_type)}' as a decorator for "
228+
f"'{class_name(base_type)}': no __init__ parameter with a type "
229+
f"annotation matching '{class_name(base_type)}' was found."
230+
)
231+
232+
212233
class ServiceLifeStyle(Enum):
213234
TRANSIENT = 1
214235
SCOPED = 2
@@ -787,6 +808,143 @@ def __call__(self, context: ResolutionContext):
787808
return FactoryTypeProvider(self.concrete_type, self.factory)
788809

789810

811+
def _get_resolver_lifestyle(resolver) -> "ServiceLifeStyle":
812+
"""Returns the ServiceLifeStyle of a resolver, defaulting to SINGLETON."""
813+
if isinstance(resolver, (DynamicResolver, FactoryResolver)):
814+
return resolver.life_style
815+
return ServiceLifeStyle.SINGLETON
816+
817+
818+
class DecoratorResolver:
819+
"""
820+
Resolver that wraps an existing resolver with a decorator class. The decorator
821+
must have an __init__ parameter whose type annotation matches (or is a supertype
822+
of) the registered base type; that parameter receives the inner service instance.
823+
All other __init__ parameters are resolved normally from the container.
824+
"""
825+
826+
__slots__ = (
827+
"_base_type",
828+
"_decorator_type",
829+
"_inner_resolver",
830+
"services",
831+
"life_style",
832+
)
833+
834+
def __init__(self, base_type, decorator_type, inner_resolver, services, life_style):
835+
self._base_type = base_type
836+
self._decorator_type = decorator_type
837+
self._inner_resolver = inner_resolver
838+
self.services = services
839+
self.life_style = life_style
840+
841+
def _get_resolver(self, desired_type, context: ResolutionContext):
842+
if desired_type in context.resolved:
843+
return context.resolved[desired_type]
844+
reg = self.services._map.get(desired_type)
845+
assert (
846+
reg is not None
847+
), f"A resolver for type {class_name(desired_type)} is not configured"
848+
resolver = reg(context)
849+
context.resolved[desired_type] = resolver
850+
return resolver
851+
852+
def __call__(self, context: ResolutionContext):
853+
inner_provider = self._inner_resolver(context)
854+
855+
sig = Signature.from_callable(self._decorator_type.__init__)
856+
params = {
857+
key: Dependency(key, value.annotation)
858+
for key, value in sig.parameters.items()
859+
}
860+
861+
globalns = dict(vars(sys.modules[self._decorator_type.__module__]))
862+
globalns.update(_get_obj_globals(self._decorator_type))
863+
try:
864+
annotations = get_type_hints(
865+
self._decorator_type.__init__,
866+
globalns,
867+
_get_obj_locals(self._decorator_type),
868+
)
869+
for key, value in params.items():
870+
if key in annotations:
871+
value.annotation = annotations[key]
872+
except Exception:
873+
pass
874+
875+
fns = []
876+
decoratee_found = False
877+
878+
for param_name, dep in params.items():
879+
if param_name in ("self", "args", "kwargs"):
880+
continue
881+
882+
annotation = dep.annotation
883+
if (
884+
annotation is not _empty
885+
and isclass(annotation)
886+
and annotation is not object
887+
and issubclass(self._base_type, annotation)
888+
):
889+
fns.append(inner_provider)
890+
decoratee_found = True
891+
else:
892+
if annotation is _empty or annotation not in self.services._map:
893+
raise CannotResolveParameterException(
894+
param_name, self._decorator_type
895+
)
896+
fns.append(self._get_resolver(annotation, context))
897+
898+
if not decoratee_found:
899+
raise DecoratorRegistrationException(self._base_type, self._decorator_type)
900+
901+
# Also resolve class-level annotations (property injection), excluding any
902+
# names already covered by __init__ params or ClassVar / pre-initialised attrs.
903+
init_param_names = set(params.keys())
904+
annotation_resolvers: dict[str, Callable] = {}
905+
906+
if self._decorator_type.__annotations__:
907+
class_hints = get_type_hints(
908+
self._decorator_type,
909+
{
910+
**dict(vars(sys.modules[self._decorator_type.__module__])),
911+
**_get_obj_globals(self._decorator_type),
912+
},
913+
_get_obj_locals(self._decorator_type),
914+
)
915+
for attr_name, attr_type in class_hints.items():
916+
if attr_name in init_param_names:
917+
continue
918+
is_classvar = getattr(attr_type, "__origin__", None) is ClassVar
919+
is_initialized = (
920+
getattr(self._decorator_type, attr_name, None) is not None
921+
)
922+
if is_classvar or is_initialized:
923+
continue
924+
if attr_type not in self.services._map:
925+
raise CannotResolveParameterException(
926+
attr_name, self._decorator_type
927+
)
928+
annotation_resolvers[attr_name] = self._get_resolver(attr_type, context)
929+
930+
decorator_type = self._decorator_type
931+
932+
if annotation_resolvers:
933+
934+
def factory(context, parent_type):
935+
instance = decorator_type(*[fn(context, parent_type) for fn in fns])
936+
for name, resolver in annotation_resolvers.items():
937+
setattr(instance, name, resolver(context, parent_type))
938+
return instance
939+
940+
else:
941+
942+
def factory(context, parent_type):
943+
return decorator_type(*[fn(context, parent_type) for fn in fns])
944+
945+
return FactoryResolver(decorator_type, factory, self.life_style)(context)
946+
947+
790948
first_cap_re = re.compile("(.)([A-Z][a-z]+)")
791949
all_cap_re = re.compile("([a-z0-9])([A-Z])")
792950

@@ -1227,6 +1385,38 @@ def add_transient(
12271385

12281386
return self.bind_types(base_type, concrete_type, ServiceLifeStyle.TRANSIENT)
12291387

1388+
def decorate(
1389+
self: _ContainerSelf,
1390+
base_type: Type,
1391+
decorator_type: Type,
1392+
) -> _ContainerSelf:
1393+
"""
1394+
Registers a decorator for an already-registered type. The decorator wraps the
1395+
existing service: when base_type is resolved, the decorator instance is returned
1396+
with the inner service injected as the decorated dependency.
1397+
1398+
The decorator class must have an __init__ parameter whose type annotation is
1399+
base_type (or a supertype of it); that parameter receives the inner service.
1400+
All other __init__ parameters are resolved from the container as usual.
1401+
1402+
Calling decorate() multiple times for the same type chains the decorators —
1403+
each wrapping the previous one (last registered = outermost decorator).
1404+
1405+
:param base_type: the type being decorated (must already be registered)
1406+
:param decorator_type: the decorator class
1407+
:return: the service collection itself
1408+
"""
1409+
existing = self._map.get(base_type)
1410+
if existing is None:
1411+
raise DecoratorRegistrationException(base_type, None)
1412+
life_style = _get_resolver_lifestyle(existing)
1413+
self._map[base_type] = DecoratorResolver(
1414+
base_type, decorator_type, existing, self, life_style
1415+
)
1416+
if self._provider is not None:
1417+
self._provider = None
1418+
return self
1419+
12301420
def _add_exact_singleton(
12311421
self: _ContainerSelf, concrete_type: Type
12321422
) -> _ContainerSelf:

0 commit comments

Comments
 (0)