Skip to content

Commit

Permalink
null handling & readme enhancement
Browse files Browse the repository at this point in the history
  • Loading branch information
alexismanuel committed Jul 14, 2024
1 parent 5dd9206 commit 0eeb5ef
Show file tree
Hide file tree
Showing 6 changed files with 380 additions and 101 deletions.
204 changes: 188 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# ditto: dependency injection tool

ditto is a simple and lightweight dependency injection tool for Python.
ditto is a simple and lightweight dependency injection tool for Python, with support for nullable arguments and parent class registration.

## Features

- Easy-to-use decorators for dependency injection.
- Supports class and instance-based services.
- Easy-to-use decorators for dependency injection
- Supports class and instance-based services
- Sync and Async support
- Nullable argument handling
- Parent class registration

## Installation

Expand All @@ -15,7 +17,10 @@ pip install pyditto
```

## Usage
Declare your dependencies to ditto on application start.

### Basic Usage

Declare your dependencies to ditto on application start:

```python
import ditto as di
Expand All @@ -28,11 +33,12 @@ class FirePokemon(Pokemon):
def attack(self) -> str:
return 'Ember ! 🔥'

di.register(FirePokemon) # register as a class
di.register(FirePokemon()) # or as an object
di.register(FirePokemon) # register as a class
di.register(FirePokemon()) # or as an object
```

Then you can inject your dependency into your class:

```python
@di.inject
class Team:
Expand All @@ -41,31 +47,197 @@ class Team:
def battle(self):
self.charmander.attack()



team = Team()
team.battle() # 'Ember ! 🔥'
team.battle() # 'Ember ! 🔥'
```

Or into an isolated function within your class:

```python
class Team:

@di.inject
def battle(self, charmander: FirePokemon):
self.charmander.attack()
charmander.attack()

team = Team()
team.battle() # 'Ember ! 🔥'

team.battle() # 'Ember ! 🔥'
```

Or just within a bare function:

```python
@di.inject
def battle(charmander: FirePokemon):
self.charmander.attack()
charmander.attack()

battle() # 'Ember ! 🔥'
battle() # 'Ember ! 🔥'
```

## Advanced Usage

### Type Lookup in Registry

ditto uses the lowercase name of the class as the key in its registry:

```python
di.register(Charmander)

# These will all retrieve the Charmander instance:
fire_pokemon = di.ServiceRegistry.get_instance().get('firepokemon')
charmander = di.ServiceRegistry.get_instance().get('charmander')
```

This is particularly useful to understand when working with optional dependencies or when overriding parent class implementations.

### Asynchronous Support

ditto supports dependency injection for asynchronous functions:

```python
import asyncio

class AsyncPokemon:
async def attack(self) -> str:
await asyncio.sleep(1)
return "Async attack!"

di.register(AsyncPokemon)

@di.inject
async def async_battle(pokemon: AsyncPokemon):
result = await pokemon.attack()
print(result)

asyncio.run(async_battle()) # Output: Async attack!
```

### Nullable Argument Handling

ditto supports nullable arguments, allowing you to specify optional dependencies:

```python
from typing import Optional

@di.inject
def train_pokemon(charmander: FirePokemon, optional_pokemon: Optional[Pokemon] = None):
charmander.attack()
if optional_pokemon:
optional_pokemon.attack()
else:
print("No optional Pokemon available")

# This will work even if Pokemon is not registered
train_pokemon()

# You can also explicitly pass None for optional arguments
train_pokemon(optional_pokemon=None)
```

In this example:
- `charmander` is a required dependency and must be registered.
- `optional_pokemon` is an optional dependency. If it's not registered, `None` will be injected.

**Note on Type Registration for Optional Parameters:**
For both `Pokemon` and `Optional[Pokemon]`, ditto uses the same key (`'pokemon'`) in its registry. The `Optional` wrapper doesn't affect the registration key; it only changes how ditto handles the case when the service isn't found.

### Parent Class Registration

When you register a service class, ditto automatically registers it under its immediate parent class as well:

```python
class Pokemon:
pass

class FirePokemon(Pokemon):
pass

class Charmander(FirePokemon):
pass

di.register(Charmander)

@di.inject
def train(fire_pokemon: FirePokemon, charmander: Charmander):
print(f"Training: {fire_pokemon.__class__.__name__}, {charmander.__class__.__name__}")

train() # Output: Training: Charmander, Charmander

@di.inject
def catch(pokemon: Pokemon):
print(f"Trying to catch: {pokemon.__class__.__name__}")

catch() # This will raise a ValueError: Service 'pokemon' not found.
```

In this example:
- Registering `Charmander` also makes it available when `FirePokemon` (its immediate parent class) is requested.
- However, `Pokemon` (the grandparent class) is not automatically registered.
- This allows for one level of inheritance in dependency injection.


### Error Handling and Common Pitfalls

1. Missing dependencies:
If a required dependency is not registered, ditto will raise a `ValueError`:

```python
@di.inject
def use_unregistered(service: NotInPokedexPokemon):
pass

use_unregistered() # Raises ValueError: Service 'notinpokedexpokemon' not found.
```

2. Circular dependencies:
ditto doesn't automatically resolve circular dependencies. Be cautious when designing your dependency graph.

### Multiple Registrations

When multiple classes are registered for the same type, ditto will use the most recently registered one:

```python
class Pikachu(Pokemon):
pass

class Charmander(Pokemon):
pass

di.register(Pikachu)
di.register(Charmander)

@di.inject
def catch(pokemon: Pokemon):
print(f"Caught a {pokemon.__class__.__name__}")

catch() # Output: Caught a Charmander
```

### Best Practices

1. Register dependencies at the application's entry point.
2. Use interfaces (abstract base classes) for better decoupling.
3. Avoid registering too many concrete implementations to keep your dependency graph simple.

### Managing Registered Services

To unregister a service:

```python
# Unregister a service
ServiceRegistry.get_instance()._services.pop('pokemon', None)

```

## Limitations

- ditto doesn't support automatic constructor injection. Parameters must be explicitly annotated.
- Circular dependencies are not automatically resolved and may cause issues if not carefully managed.

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.


## License
This project is licensed under the MIT License - see the LICENSE file for details.

This project is licensed under the MIT License - see the LICENSE file for details.
51 changes: 39 additions & 12 deletions ditto/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations
import inspect
from functools import wraps
from typing import Type, Any, Callable, Union
from typing import Type, Any, Callable, Union, Optional, get_origin, get_args


class ServiceRegistry:
Expand Down Expand Up @@ -45,30 +45,55 @@ def get_instance(cls) -> ServiceRegistry:
return cls.instance


def is_optional(annotation: Any) -> bool:
"""Check if a type annotation is Optional."""
origin = get_origin(annotation)
if origin is Union:
args = get_args(annotation)
return type(None) in args
return False

def get_base_type(annotation: Any) -> Type:
"""Get the base type from an Optional annotation."""
if is_optional(annotation):
args = get_args(annotation)
return next(arg for arg in args if arg is not type(None))
return annotation

def inject(func: Callable) -> Callable:
"""
Decorator to inject dependencies into a function.
:param func: The function to inject dependencies into.
:return: The wrapped function with dependencies injected.
Decorator to inject dependencies into a function,
with support for nullable arguments.
"""
@wraps(func)
def wrapper(*args, **kwargs):
sig = inspect.signature(func)
bound_args = sig.bind_partial(*args, **kwargs)
for name, param in sig.parameters.items():
if name in bound_args.arguments or param.default != inspect.Parameter.empty:
if name in bound_args.arguments:
continue
if param.annotation == inspect.Parameter.empty:
raise ValueError(f"Missing type annotation for parameter '{name}'")
service_name = param.annotation.__name__.lower()
service = ServiceRegistry.get_instance().get(service_name)
kwargs[name] = service
if param.default == inspect.Parameter.empty:
raise ValueError(f"Missing type annotation for parameter '{name}'")
continue

is_nullable = is_optional(param.annotation) or param.default is None
base_type = get_base_type(param.annotation)
service_name = base_type.__name__.lower()

try:
service = ServiceRegistry.get_instance().get(service_name)
kwargs[name] = service
except ValueError:
if is_nullable:
kwargs[name] = None
else:
raise ValueError(f"Required service '{service_name}' not found.")

return func(*args, **kwargs)

return wrapper


def register(service: Any) -> None:
"""
Register a service with the registry.
Expand All @@ -79,6 +104,8 @@ def register(service: Any) -> None:
registry = ServiceRegistry.get_instance()
service_class = service if inspect.isclass(service) else service.__class__
parent_class = service_class.__bases__[0]

if parent_class.__name__ != 'object':
registry.register(parent_class, service)
registry.register(service_class, service)

registry.register(service_class, service)
Loading

0 comments on commit 0eeb5ef

Please sign in to comment.