Skip to content

Commit ae3c8b3

Browse files
Merge pull request #5 from whiteducksoftware:feature/decorator
feat: Enhance DI Container with Service Decorators and Robustness Improvements This PR introduces a powerful service decorator pattern to the `wd-di` dependency injection container, modernizes core components, and significantly improves robustness, error handling, and backward compatibility. **Key Enhancements:** 1. **Service Decorator Support:** * **`ServiceDescriptor` Rework:** The `ServiceDescriptor` is now an immutable `dataclass` and includes a `decorators` attribute to hold a list of decorator factories. This enables services to be wrapped or augmented after creation but before use. * **`ServiceCollection.decorate()`:** A new `decorate(service_type, decorator_factory)` method allows users to register decorator factories against specific service types. Decorators are applied in reverse order of registration (last registered is outermost). * **Decorator Application:** The `ServiceProvider` now applies these decorators sequentially when a service instance is created. * **Use Cases:** This feature is ideal for cross-cutting concerns like caching, logging, transaction management, retries, etc., without modifying the core service logic. 2. **Core DI Component Modernization & Robustness:** * **`ServiceCollection` Improvements:** * Retained full backward compatibility for all existing registration methods (`add_transient`, `add_singleton`, `add_instance`, `add_*_factory`, `configure`, and Python-level class decorators like `@services.singleton`). * Introduced a build-time lock to prevent modifications after `build_service_provider()` is called, enhancing stability (`InvalidOperationError`). * Internal logic refined for consistent handling of `ServiceDescriptor` creation. * **`ServiceProvider` & `Scope` Overhaul:** * Unified `ServiceProvider` and `Scope` into a single class, simplifying the internal model while maintaining backward compatibility for `from wd.di.container import Scope` via an alias. * The constructor now flexibly accepts both the legacy dictionary-based and new list-based `ServiceDescriptor` collections from `ServiceCollection`. * **Circular Dependency Detection:** Implemented robust cycle detection for both constructor injection and the new service decorator application using a `ContextVar`-based resolution stack. Raises `RuntimeError` for DI cycles and `CircularDecoratorError` for decorator cycles. * **Thread-Safe Singletons:** Singleton creation is now thread-safe using `threading.RLock()`. * **Improved Constructor Injection:** * Uses `inspect.signature` and `typing.get_type_hints` for accurate parameter and type resolution, including handling of string forward references. * Gracefully manages classes without explicit `__init__` methods by ignoring unannotated `*args`/`**kwargs`. * **Scoped Service Resolution:** Restored the behavior of raising an `InvalidOperationError` if a scoped service is requested directly from the root provider. * **Typed `get_service`:** Ensured `get_service(ServiceType)` returns `ServiceType` (not `Any`) for better static type checking, using `@overload`. 3. **Exception Handling:** * Leveraged existing custom exceptions (`InvalidOperationError`, `CircularDecoratorError`) for more specific and actionable error reporting. * Improved error messages to include resolution stacks where relevant. 4. **Testing & Examples:** * **New Tests:** Added `tests/test_service_decorators.py` with comprehensive tests for the new decorator functionality, including basic application, multiple decorators, error cases, and circular decorator detection. * **Existing Test Fixes:** Addressed and fixed failures in `test_decorators.py`, `test_scoped.py`, and `test_circular_dependency.py` to align with the updated container behavior and ensure backward compatibility. * **New Example (`examples/caching_with_decorators`):** Added a practical example demonstrating how to implement a caching layer using service decorators. * **READMEs for Examples:** Generated detailed `README.md` files for `examples/complex_ingest` and `examples/order_processor` based on their existing structure and functionality. 5. **Documentation:** * Created a new documentation page (`docs/advanced/decorators.md`) explaining the service decorator feature, its use cases, how to register decorators, ordering, handling parameters, and circular detection. This set of changes significantly enhances the capabilities and reliability of the `wd-di` library, providing users with a more powerful and flexible tool for managing dependencies and implementing cross-cutting concerns.
2 parents 067377b + 56683b5 commit ae3c8b3

15 files changed

Lines changed: 1312 additions & 243 deletions

File tree

docs/advanced/decorators.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Service Decorators
2+
3+
The dependency injection container supports a powerful decorator pattern that allows you to wrap, intercept, or modify service instances *after* they are created but *before* they are returned to the caller or cached by their lifetime manager. This is an implementation of the [Decorator design pattern](https://en.wikipedia.org/wiki/Decorator_pattern) applied to services managed by the DI container.
4+
5+
Decorators are registered against a specific service type and are applied in the reverse order of their registration (the last decorator registered is the outermost).
6+
7+
## Use Cases
8+
9+
Service decorators are useful for a variety of cross-cutting concerns, such as:
10+
11+
* **Caching:** Wrap a service with a caching layer.
12+
* **Logging/Auditing:** Log method calls to a service or audit actions.
13+
* **Transaction Management:** Start and commit/rollback transactions around service method calls.
14+
* **Retries/Circuit Breakers:** Add resilience to service operations.
15+
* **Data Validation/Transformation:** Validate input or transform output of service methods.
16+
* **Feature Toggling:** Conditionally alter behavior by applying different decorators.
17+
18+
## Registering Decorators
19+
20+
You register decorators using the `decorate` method on the `ServiceCollection`:
21+
22+
```python
23+
from wd.di import ServiceCollection, ServiceProvider
24+
25+
# 1. Define your service interface and implementation
26+
class IDataService:
27+
def get_data(self, item_id: str) -> str:
28+
raise NotImplementedError
29+
30+
class RealDataService(IDataService):
31+
def get_data(self, item_id: str) -> str:
32+
print(f"RealDataService: Fetching data for {item_id}")
33+
return f"Data for {item_id}"
34+
35+
# 2. Define your decorator(s)
36+
class LoggingDecorator(IDataService):
37+
def __init__(self, inner: IDataService, provider: ServiceProvider):
38+
self._inner = inner
39+
self._provider = provider # Example: if decorator needs other services
40+
print("LoggingDecorator: Initialized")
41+
42+
def get_data(self, item_id: str) -> str:
43+
print(f"LoggingDecorator: Before calling get_data for {item_id}")
44+
result = self._inner.get_data(item_id)
45+
print(f"LoggingDecorator: After calling get_data for {item_id}, result: {result}")
46+
return result
47+
48+
# 3. Define a decorator factory
49+
# A decorator factory is a callable that takes two arguments:
50+
# - The current ServiceProvider instance.
51+
# - The "inner" service instance that is being decorated.
52+
# It must return the decorated instance.
53+
def logging_decorator_factory(provider: ServiceProvider, inner_service: IDataService) -> IDataService:
54+
return LoggingDecorator(inner_service, provider)
55+
56+
# 4. Configure services
57+
services = ServiceCollection()
58+
services.add_transient(IDataService, RealDataService)
59+
60+
# 5. Apply the decorator
61+
services.decorate(IDataService, logging_decorator_factory)
62+
63+
# 6. Build and use
64+
provider = services.build_service_provider()
65+
data_service = provider.get_service(IDataService)
66+
data_service.get_data("item123")
67+
68+
# Output would be:
69+
# LoggingDecorator: Initialized
70+
# LoggingDecorator: Before calling get_data for item123
71+
# RealDataService: Fetching data for item123
72+
# LoggingDecorator: After calling get_data for item123, result: Data for item123
73+
```
74+
75+
### Decorator Factories
76+
77+
A **decorator factory** is a callable with the signature:
78+
79+
```python
80+
Callable[[ServiceProvider, InnerServiceType], OuterServiceType]
81+
```
82+
83+
* `ServiceProvider`: The current service provider, which can be used by the decorator to resolve other dependencies if needed.
84+
* `InnerServiceType`: The instance of the service being wrapped.
85+
* `OuterServiceType`: The (potentially) wrapped instance that will be returned. Often, this is the same type as `InnerServiceType`, but it can be different if the decorator changes the interface (though this should be done with care).
86+
87+
### Order of Application
88+
89+
If multiple decorators are registered for the same service type, they are applied sequentially. The `ServiceDescriptor` stores them in a list. When an instance is created:
90+
1. The base instance (from `implementation_type` or `factory`) is created.
91+
2. The decorators are applied in **reverse order of registration**. This means the decorator registered *last* becomes the *outermost* wrapper, and the decorator registered *first* becomes the *innermost* wrapper (closest to the original service instance).
92+
93+
```python
94+
services.decorate(IMyService, inner_decorator_factory) # Applied first (becomes inner)
95+
services.decorate(IMyService, outer_decorator_factory) # Applied second (becomes outer)
96+
97+
# Execution flow: outer_decorator -> inner_decorator -> actual_service
98+
```
99+
100+
## Advanced: Decorators with Parameters
101+
102+
Sometimes, your decorator itself might need configuration. You can achieve this by creating a decorator factory *function* that takes parameters and *returns* the actual decorator factory callable:
103+
104+
```python
105+
class RetryingDataServiceDecorator(IDataService):
106+
def __init__(self, inner: IDataService, max_retries: int):
107+
self._inner = inner
108+
self._max_retries = max_retries
109+
110+
def get_data(self, item_id: str) -> str:
111+
attempts = 0
112+
while True:
113+
try:
114+
return self._inner.get_data(item_id)
115+
except Exception as e:
116+
attempts += 1
117+
if attempts >= self._max_retries:
118+
print(f"RetryingDecorator: Max retries ({self._max_retries}) reached. Failing.")
119+
raise
120+
print(f"RetryingDecorator: Attempt {attempts} failed. Retrying...")
121+
# In a real scenario, you might add a delay here
122+
123+
# Factory that creates the decorator factory
124+
def create_retrying_decorator_factory(max_retries: int):
125+
def actual_decorator_factory(provider: ServiceProvider, inner: IDataService) -> IDataService:
126+
return RetryingDataServiceDecorator(inner, max_retries)
127+
return actual_decorator_factory
128+
129+
# Usage:
130+
services.add_transient(IDataService, RealDataService)
131+
retry_factory_with_config = create_retrying_decorator_factory(max_retries=3)
132+
services.decorate(IDataService, retry_factory_with_config)
133+
134+
provider = services.build_service_provider()
135+
service = provider.get_service(IDataService)
136+
# service.get_data("test") will now have retry logic
137+
```
138+
139+
## Circular Decorator Detection
140+
141+
The container includes a mechanism to detect circular decorator applications at runtime. If a decorator, during its application to a service, causes itself to be re-applied to the *same service instance resolution*, a `CircularDecoratorError` will be raised. This helps prevent infinite loops during service construction.
142+
143+
For example, if `DecoratorA` for `IServiceX` internally tries to resolve `IServiceX` again in a way that re-triggers the application of `DecoratorA` to the same `IServiceX` instance being formed, a cycle is detected.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Caching with Service Decorators Example
2+
3+
This example demonstrates how to use the service decorator functionality of the `wd-di` library to add a caching layer to a service.
4+
5+
## Scenario
6+
7+
We have an `IExternalWeatherService` that simulates fetching weather data from an external source. The concrete implementation, `SlowExternalWeatherService`, is intentionally slow to mimic network latency or computationally expensive operations.
8+
9+
To improve performance and reduce calls to the slow service, we introduce a `CachingWeatherServiceDecorator`. This decorator wraps the `IExternalWeatherService` and caches its results for a configured duration.
10+
11+
## Files
12+
13+
* `services.py`: Defines the `IExternalWeatherService` interface and the `SlowExternalWeatherService` implementation.
14+
* `decorators.py`: Defines the `CachingWeatherServiceDecorator` and `create_caching_weather_decorator_factory` which creates the actual decorator factory callable used for registration.
15+
* `main.py`: Sets up the `ServiceCollection`, registers the services, applies the caching decorator, and then demonstrates the caching behavior by calling the weather service multiple times for different cities and observing cache hits, misses, and expirations.
16+
17+
## How it Works
18+
19+
1. **Service Registration**: In `main.py`, `SlowExternalWeatherService` is registered as a singleton for the `IExternalWeatherService` interface.
20+
2. **Decorator Factory Creation**: `create_caching_weather_decorator_factory(cache_duration_seconds=5)` is called. This function returns another function (the actual decorator factory) that is configured with a 5-second cache duration. This demonstrates how decorators can be parameterized.
21+
3. **Decorator Application**: `services.decorate(IExternalWeatherService, caching_factory)` tells the service collection that whenever `IExternalWeatherService` is resolved, it should be passed through the `caching_factory`.
22+
4. **Instance Creation & Decoration**: When `provider.get_service(IExternalWeatherService)` is first called:
23+
* The `ServiceProvider` creates an instance of `SlowExternalWeatherService`.
24+
* It then calls the `caching_factory`, passing the `ServiceProvider` and the `SlowExternalWeatherService` instance.
25+
* The `caching_factory` returns an instance of `CachingWeatherServiceDecorator` which wraps the `SlowExternalWeatherService` instance.
26+
* This decorated instance is returned to the caller.
27+
5. **Caching Logic**:
28+
* The `CachingWeatherServiceDecorator` maintains an in-memory cache (`self._cache`).
29+
* When `get_current_temperature` is called, it first checks the cache for the given city.
30+
* If a valid (non-expired) entry is found (Cache HIT), it returns the cached temperature.
31+
* If the entry is missing or expired (Cache MISS/STALE), it calls the `get_current_temperature` method of the *inner* (`SlowExternalWeatherService`) instance, stores the result in the cache with a new timestamp, and then returns it.
32+
33+
## Running the Example
34+
35+
Navigate to the `examples/caching_with_decorators` directory and run:
36+
37+
```bash
38+
python main.py
39+
```
40+
41+
You will see output indicating cache hits, misses, and the calls made to the `SlowExternalWeatherService`. The simulated delays will make the effect of caching apparent.
42+
43+
This example showcases how decorators can add significant cross-cutting concerns like caching in a clean, modular, and maintainable way without modifying the original service implementation.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# examples/caching_with_decorators/decorators.py
2+
import time
3+
from typing import Dict, Tuple
4+
from wd.di import ServiceProvider # Assuming ServiceProvider is available for type hinting
5+
from .services import IExternalWeatherService
6+
7+
class CachingWeatherServiceDecorator(IExternalWeatherService):
8+
def __init__(self, inner: IExternalWeatherService, cache_duration_seconds: int = 60):
9+
self._inner = inner
10+
self._cache: Dict[str, Tuple[float, float]] = {} # Key: city, Value: (timestamp, temperature)
11+
self._cache_duration = cache_duration_seconds
12+
print(f"[CachingWeatherServiceDecorator] Initialized with cache duration: {self._cache_duration}s")
13+
14+
def get_current_temperature(self, city: str) -> float:
15+
city_key = city.lower()
16+
current_time = time.time()
17+
18+
if city_key in self._cache:
19+
timestamp, temperature = self._cache[city_key]
20+
if current_time - timestamp < self._cache_duration:
21+
print(f"[CachingWeatherServiceDecorator] Cache HIT for {city}. Temp: {temperature}°C")
22+
return temperature
23+
else:
24+
print(f"[CachingWeatherServiceDecorator] Cache STALE for {city}. Re-fetching...")
25+
else:
26+
print(f"[CachingWeatherServiceDecorator] Cache MISS for {city}. Fetching from real service...")
27+
28+
# Cache miss or stale, fetch from inner service
29+
temperature = self._inner.get_current_temperature(city)
30+
self._cache[city_key] = (current_time, temperature)
31+
print(f"[CachingWeatherServiceDecorator] Cached new temperature for {city}: {temperature}°C")
32+
return temperature
33+
34+
def clear_cache(self):
35+
self._cache.clear()
36+
print("[CachingWeatherServiceDecorator] Cache cleared.")
37+
38+
# Decorator Factory that creates the CachingWeatherServiceDecorator
39+
def create_caching_weather_decorator_factory(cache_duration_seconds: int = 60):
40+
print(f"[Factory] Creating caching decorator factory with duration: {cache_duration_seconds}s")
41+
def actual_factory(provider: ServiceProvider, inner: IExternalWeatherService) -> IExternalWeatherService:
42+
# `provider` is available if the decorator itself needed other services, not used in this simple cache.
43+
return CachingWeatherServiceDecorator(inner, cache_duration_seconds)
44+
return actual_factory
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# examples/caching_with_decorators/main.py
2+
import time
3+
from wd.di import ServiceCollection
4+
from .services import IExternalWeatherService, SlowExternalWeatherService
5+
from .decorators import create_caching_weather_decorator_factory
6+
7+
def main():
8+
print("--- Caching Decorator Example ---")
9+
10+
services = ServiceCollection()
11+
12+
# Register the slow weather service
13+
services.add_singleton(IExternalWeatherService, SlowExternalWeatherService)
14+
15+
# Create a caching decorator factory with a short cache duration for demo purposes
16+
caching_factory = create_caching_weather_decorator_factory(cache_duration_seconds=5)
17+
18+
# Decorate the weather service with the caching layer
19+
services.decorate(IExternalWeatherService, caching_factory)
20+
21+
provider = services.build_service_provider()
22+
23+
weather_service = provider.get_service(IExternalWeatherService)
24+
slow_weather_service_instance = provider.get_service(SlowExternalWeatherService) # Get direct instance for call count
25+
26+
print("\n--- First call for London (should be slow and cache miss) ---")
27+
temp_london1 = weather_service.get_current_temperature("London")
28+
print(f"Reported temperature for London: {temp_london1}°C")
29+
print(f"API call count: {slow_weather_service_instance.get_api_call_count()}") # type: ignore
30+
31+
print("\n--- Second call for London (should be fast from cache) ---")
32+
temp_london2 = weather_service.get_current_temperature("London")
33+
print(f"Reported temperature for London: {temp_london2}°C")
34+
print(f"API call count: {slow_weather_service_instance.get_api_call_count()}") # type: ignore
35+
36+
print("\n--- First call for New York (should be slow and cache miss) ---")
37+
temp_ny1 = weather_service.get_current_temperature("New York")
38+
print(f"Reported temperature for New York: {temp_ny1}°C")
39+
print(f"API call count: {slow_weather_service_instance.get_api_call_count()}") # type: ignore
40+
41+
print(f"\n--- Waiting for {7} seconds to ensure London cache expires... ---")
42+
time.sleep(7)
43+
44+
print("\n--- Third call for London (should be slow again due to cache expiry) ---")
45+
temp_london3 = weather_service.get_current_temperature("London")
46+
print(f"Reported temperature for London: {temp_london3}°C")
47+
print(f"API call count: {slow_weather_service_instance.get_api_call_count()}") # type: ignore
48+
49+
print("\n--- Second call for New York (should still be fast from cache) ---")
50+
temp_ny2 = weather_service.get_current_temperature("New York") # NY cache (5s) might be stale or not depending on timing
51+
print(f"Reported temperature for New York: {temp_ny2}°C")
52+
print(f"API call count: {slow_weather_service_instance.get_api_call_count()}") # type: ignore
53+
54+
print("\n--- Example Complete ---")
55+
56+
if __name__ == "__main__":
57+
main()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# examples/caching_with_decorators/services.py
2+
import time
3+
from abc import ABC, abstractmethod
4+
5+
class IExternalWeatherService(ABC):
6+
@abstractmethod
7+
def get_current_temperature(self, city: str) -> float:
8+
"""Simulates fetching current temperature for a city."""
9+
pass
10+
11+
class SlowExternalWeatherService(IExternalWeatherService):
12+
"""A simulated weather service that is intentionally slow."""
13+
def __init__(self):
14+
self._call_count = 0
15+
16+
def get_current_temperature(self, city: str) -> float:
17+
self._call_count += 1
18+
print(f"[SlowExternalWeatherService] Fetching temperature for {city}... (Call #{self._call_count})")
19+
time.sleep(2) # Simulate network latency or expensive computation
20+
if city.lower() == "london":
21+
temp = 15.0 + self._call_count # Make it change slightly to show cache effect
22+
print(f"[SlowExternalWeatherService] Temperature for London: {temp}°C")
23+
return temp
24+
elif city.lower() == "new york":
25+
temp = 22.0 + self._call_count
26+
print(f"[SlowExternalWeatherService] Temperature for New York: {temp}°C")
27+
return temp
28+
else:
29+
temp = 10.0 + self._call_count
30+
print(f"[SlowExternalWeatherService] Temperature for {city}: {temp}°C")
31+
return temp
32+
33+
def get_api_call_count(self) -> int:
34+
return self._call_count

0 commit comments

Comments
 (0)