Sprunki: An Introduction to Streamlined Dependency Injection in Python
Dependency Injection (DI) is a powerful design pattern that promotes loose coupling and testability in software. It involves providing the dependencies (objects or values that a class needs) to a class from the outside, rather than having the class create them internally. While Python has several established DI libraries like injector
and python-inject
, a newer and more streamlined option has emerged: Sprunki.
This article provides a detailed introduction to Sprunki, exploring its core concepts, features, and practical usage. We’ll cover everything from basic injection to more advanced features like type-hinted injection and custom providers.
What is Sprunki?
Sprunki is a lightweight, yet powerful, dependency injection library for Python. It emphasizes simplicity, readability, and minimal boilerplate. Its core philosophy is to make DI as unobtrusive as possible, allowing you to focus on your application logic rather than the intricacies of dependency management. Sprunki leverages Python’s type hints extensively, making its usage intuitive and type-safe.
Key Features of Sprunki:
- Type-Hinted Injection: Sprunki heavily relies on Python’s type hints. This not only improves readability but also enables static analysis tools like MyPy to catch potential injection errors at compile time.
- Automatic Dependency Resolution: Sprunki can automatically resolve dependencies based on type hints. You don’t need to explicitly define every single dependency; Sprunki will figure it out for you in many cases.
- Simple API: Sprunki’s API is designed to be minimal and easy to understand. It avoids complex configurations and focuses on straightforward injection.
- Context-Based Injection: Sprunki supports the concept of contexts, which allow you to provide different implementations of a dependency based on the context (e.g., testing vs. production).
- Custom Providers: You can define custom providers to control how dependencies are created and managed. This provides flexibility for handling complex scenarios.
- Scope Management: Sprunki offers control over the scope of dependencies (e.g., singleton, request-scoped).
- Asynchronous Support: Sprunki supports asynchronous dependencies and providers, making it suitable for modern asynchronous Python applications (e.g., using
asyncio
). - Minimal Boilerplate: Sprunki minimizes boilerplate with it’s reliance on type-hints.
Getting Started: Installation
Install Sprunki using pip:
bash
pip install sprunki
Basic Dependency Injection with Sprunki
Let’s start with a simple example. Imagine we have a Greeter
class that depends on a MessageService
to get a greeting message:
“`python
from sprunki import inject
class MessageService:
def get_message(self) -> str:
return “Hello, world!”
class Greeter:
@inject
def init(self, message_service: MessageService):
self.message_service = message_service
def greet(self) -> str:
return self.message_service.get_message()
Creating the Greeter instance. Sprunki handles the injection.
greeter = Greeter()
print(greeter.greet()) # Output: Hello, world!
“`
Explanation:
@inject
decorator: We use the@inject
decorator on the__init__
method of theGreeter
class. This tells Sprunki that this method requires dependency injection.- Type Hint: The
message_service: MessageService
parameter in the__init__
method uses a type hint. This tells Sprunki that theGreeter
class depends on an instance ofMessageService
. - Automatic Resolution: When we create an instance of
Greeter
usingGreeter()
, Sprunki automatically detects the dependency onMessageService
, creates an instance ofMessageService
, and injects it into theGreeter
‘s__init__
method.
Using Contexts for Different Environments
In real-world applications, you might want to use different implementations of a dependency depending on the environment (e.g., a mock service for testing). Sprunki’s contexts allow you to do this:
“`python
from sprunki import Context, inject
class MessageService:
def get_message(self) -> str:
raise NotImplementedError()
class ProductionMessageService(MessageService):
def get_message(self) -> str:
return “Hello from production!”
class TestMessageService(MessageService):
def get_message(self) -> str:
return “Hello from testing!”
class Greeter:
@inject
def init(self, message_service: MessageService):
self.message_service = message_service
def greet(self) -> str:
return self.message_service.get_message()
Create contexts
production_context = Context()
production_context.register(MessageService, ProductionMessageService)
test_context = Context()
test_context.register(MessageService, TestMessageService)
Use the contexts
with production_context:
greeter = Greeter()
print(greeter.greet()) # Output: Hello from production!
with test_context:
greeter = Greeter()
print(greeter.greet()) # Output: Hello from testing!
Outside a context, Sprunki will try and default to a registered class,
or throw an error if a matching, default, class cannot be resolved
try:
greeter = Greeter()
except Exception as e:
print(f”Error outside context: {e}”)
“`
Explanation:
Context
: We create twoContext
instances:production_context
andtest_context
.register
: We use theregister
method to associate a specific implementation ofMessageService
with each context.with
statement: We use thewith
statement to activate a context. Within thewith
block, Sprunki will use the registered dependencies for that context.- Contextual Injection: The
Greeter
instances created within each context receive the appropriateMessageService
implementation. - Outside of Context: If an instance of
Greeter
is created without an active context, sprunki will raise an Exception if no default implementation can be resolved.
Custom Providers
If you need more control over how a dependency is created, you can use custom providers:
“`python
from sprunki import Context, inject, Provider
class MyCustomProvider(Provider):
def provide(self) -> str:
# Some complex logic to create the dependency
return “Custom message”
class Greeter:
@inject
def init(self, message: str):
self.message = message
def greet(self) -> str:
return self.message
context = Context()
context.register_provider(str, MyCustomProvider)
with context:
greeter = Greeter()
print(greeter.greet()) # Output: Custom message
“`
Explanation:
Provider
: We create a custom provider classMyCustomProvider
that inherits fromProvider
.provide
method: Theprovide
method contains the logic to create the dependency (in this case, a string).register_provider
: We register the custom provider with the context, associating it with thestr
type.- Type Hint: The
Greeter
class expects thestr
type. - Injection with Provider: When
Greeter
is instantiated within the context, Sprunki uses the registeredMyCustomProvider
to create the string dependency.
Asynchronous Dependency Injection
Sprunki supports asynchronous dependencies and providers. This is particularly useful for applications that use asynchronous libraries (like asyncio
).
“`python
import asyncio
from sprunki import Context, inject, AsyncProvider
class AsyncMessageService:
async def get_message(self) -> str:
await asyncio.sleep(0.1) # Simulate an asynchronous operation
return “Hello asynchronously!”
class Greeter:
@inject
async def init(self, message_service: AsyncMessageService):
self.message_service = message_service
async def greet(self) -> str:
return await self.message_service.get_message()
class CustomAsyncProvider(AsyncProvider):
async def provide(self):
await asyncio.sleep(0.1)
return AsyncMessageService()
async def main():
context = Context()
context.register_provider(AsyncMessageService, CustomAsyncProvider)
with context:
greeter = await Greeter()
message = await greeter.greet()
print(message) # Output: Hello asynchronously!
if name == “main“:
asyncio.run(main())
“`
Explanation:
AsyncProvider
: We createdCustomAsyncProvider
and inherited from theAsyncProvider
class.async def
: Theget_message
,provide
,__init__
, andgreet
methods are defined as asynchronous usingasync def
.await
: We useawait
to call asynchronous methods.asyncio.run
: We useasyncio.run
to run the asynchronousmain
function.await Greeter()
: Because the__init__
method ofGreeter
is now asynchronous, we must useawait
when creating an instance.
Conclusion
Sprunki provides a clean, efficient, and type-safe approach to dependency injection in Python. Its reliance on type hints, simple API, and support for contexts and custom providers make it a valuable tool for building maintainable and testable applications. Whether you’re working on a small script or a large asynchronous project, Sprunki can help you streamline your dependency management and improve the overall quality of your code. The examples above cover the most common scenarios and showcase the core features of Sprunki, demonstrating its power and flexibility.