serum
is a fresh take on Dependency Injection in Python 3.
serum
is pure python, has no dependencies, and is less than 300 lines of code.
> pip install serum
from serum import *
class Log(Component):
@abstractmethod
def info(self, message: str):
pass
class SimpleLog(Log):
def info(self, message: str):
print(message)
class StubLog(Log):
def info(self, message: str):
pass
class NeedsLog:
log = inject(Log)
NeedsLog().log.info('Hello serum!') # raises: NoEnvironment
with Environment():
NeedsLog().log.info('Hello serum!') # raises: UnregisteredDependency
with Environment(SimpleLog):
NeedsLog().log.info('Hello serum!') # outputs: Hello serum!
with Environment(StubLog):
NeedsLog().log.info('Hello serum!') # doesn't output anything
class NeedsSimpleLog:
log = inject(SimpleLog)
with Environment():
NeedsSimpleLog().log.info('Hello serum!') # outputs: Hello serum!
serum
uses 3 main abstractions: Component
, Environment
and inject
.
Component
s are dependencies that can be injected.
from serum import Component, Environment, inject
class Log(Component):
def info(self, message):
print(message)
class NeedsLog:
log = inject(Log)
instance = NeedsLog()
with Environment():
assert isinstance(instance.log, Log)
Environment
s provide implementations of Components
. An Environment
will always provide the most
specific subtype of the requested type (in Method Resolution Order).
class MockLog(Log):
def info(self, message):
pass
with Environment(MockLog):
assert isinstance(instance.log, MockLog)
It is an error to inject a type in an Environment
that provides two or more equally specific subtypes of that type:
class FileLog(Log):
_file = 'log.txt'
def info(self, message):
with open(self._file, 'w') as f:
f.write(message)
with Environment(MockLog, FileLog):
instance.log # raises: AmbiguousDependencies: Attempt to inject type <class 'Log'> with equally specific provided subtypes: <class 'MockLog'>, <class 'FileLog'>
Environment
s can also be used as decorators:
test_environment = Environment(MockLog)
@test_environment
def f():
assert isinstance(instance.log, MockLog)
You can only provide subtypes of Component
with Environment
.
class C:
pass
Environment(C) # raises: InvalidDependency: Attempt to register type that is not a Component: <class 'C'>
Similarly, you can't inject types that are not Component
subtypes.
class InvalidDependent:
dependency = inject(C) # raises: InvalidDependency: Attempt to inject type that is not a Component: <class 'C'>
Injected Component
s can't be accessed outside an Environment
context:
instance.log # raises NoEnvironment: Can't inject components outside an environment
Injected Component
s are immutable
with Environment():
instance.log = 'mutate this' # raises AttributeError: Can't set property
You can define mutable static fields in a Component
. If you want to define
immutable static fields (constants), serum
provides the immutable
utility
that also supports type inference with PEP 484 tools.
from serum import immutable
class Immutable(Component):
value = immutable(1)
i = Immutable()
i.value = 2 # raises AttributeError: Can't set property
This is just convenience for:
class Immutable(Component):
value = property(fget=lambda _: 1)
Component
s can only define an __init__
method that takes 1 parameter.
class ValidComponent(Component): # OK!
some_dependency = inject(SomeDependency)
def __init__(self):
self.value = self.some_dependency.method()
class InvalidComponent(Component): # raises: InvalidComponent: __init__ method in Components can only take 1 parameter
def __init__(self, a):
self.a = a
To construct Component
s with dependencies, you should instead use inject
class ComponentWithDependencies(Component):
log = inject(Log)
Note that if you access injected members in the constructor of any type, that type can only be instantiated inside an environment.
Component
s can be abstract. Abstract Component
s can't be injected in an
Environment
that doesn't provide a concrete implementation. For convenience you can import
abstractmethod
, abstractclassmethod
or abstractclassmethod
from serum
,
but they are simply references to the equivalent decorators from the abc
module
in the standard library.
from serum import abstractmethod
class AbstractLog(Component):
@abstractmethod
def info(self, message):
pass
class NeedsLog:
log = inject(AbstractLog)
instance = NeedsLog()
with Environment():
instance.log # raises UnregisteredDependency: No concrete implementation of <class 'AbstractLog'> found
class ConcreteLog(AbstractLog):
def info(self, message):
print(message)
with Environment(ConcreteLog):
instance.log # Ok!
Environment
s are local to each thread. This means that when using multi-threading
each thread must define its own environment.
import threading
def worker_without_environment():
NeedsLog().log # raises NoEnvironment: Can't inject components outside an environment
def worker_with_environment():
with Environment(ConcreteLog):
NeedsLog().log # OK!
with Environment(ConcreteLog):
threading.Thread(target=worker_without_environment()).start()
threading.Thread(target=worker_with_environment()).start()
serum
has support for injecting MagicMock
s from the builtin
unittest.mock
library in unittests using the mock
utility
function. mock
is only usable inside an an environment context. Mocks are reset
when the environment context is closed.
from serum import mock
environment = Environment(ConcreteLog)
with environment:
log_mock = mock(AbstractLog)
log_mock.method.return_value = 'some value'
instance = NeedsLog()
assert instance.log is log_mock
assert instance.log.method() == 'some value'
with environment:
instance = NeedsLog()
assert instance.log is not log_mock
assert isinstance(instance.log, ConcreteLog)
mock(AbstractLog) # raises: NoEnvironment: Can't register mock outside environment
Note that mock
will only mock requests of the
exact type supplied as its argument, but not requests of
more or less specific types
from unittest.mock import MagicMock
class Super(Component):
pass
class Sub(Super):
pass
class SubSub(Sub):
pass
class NeedsSuper:
injected = inject(Super)
class NeedsSub:
injected = inject(Sub)
class NeedsSubSub:
injected = inject(SubSub)
with Environment():
mock(Sub)
needs_super = NeedsSuper()
needs_sub = NeedsSub()
needs_subsub = NeedsSubSub()
assert isinstance(needs_super.injected, Super)
assert isinstance(needs_sub.injected, MagicMock)
assert isinstance(needs_subsub.injected, SubSub)
serum
is designed for type inference with PEP 484 tools (work in progress).
This feature is currently only supported for the PyCharm type checker.
If you've been researching Dependency Injection frameworks for python, you've no doubt come across this opinion:
You dont need Dependency Injection in python. You can just use duck typing and monkey patching!
The position behind this statement is often that you only need Dependency Injection in statically typed languages.
In truth, you don't really need Dependency Injection in any language,
statically typed or otherwise.
When building large applications that need to run in multiple environments however,
Dependency Injection can make your life a lot easier. In my experience,
excessive use of monkey patching for managing environments leads to a jumbled
mess of implicit initialisation steps and if value is None
type code.
In addition to being a framework, I've attempted to design serum
to encourage
designing classes that follow the Dependency Inversion Principle:
one should “depend upon abstractions, not concretions."
This is achieved by letting inheritance being the principle way of providing
dependencies and allowing dependencies to be abstract. See the example.py
for a
detailed tutorial (work in progress).