Picobox¶
Picobox is opinionated dependency injection framework designed to be clean, pragmatic and with Python in mind. No complex graphs, no implicit injections, no type bindings, no XML configurations.
Why?¶
Dependency Injection (DI) design pattern is intended to decouple various parts of an application from each other. So a class can be independent of how the objects it requires are created, and hence the way we create them may be different for production and tests.
One of the most easiest examples is to say that DI is essentially about writing
def do_something(my_service):
return my_service.get_val() + 42
my_service = MyService(foo, bar)
do_something(my_service)
instead of
def do_something():
my_service = MyService(foo, bar)
return my_service.get_val() + 42
do_something()
because the latter is considered non-configurable and is harder to test.
In Python, however, dependency injection is not a big deal due to its dynamic nature and duck typing: anything could be defined anytime and passed anywhere. Due to that reason (and maybe some others) DI frameworks aren’t popular among Python community, though they may be handy in some cases.
One of such cases is code decoupling when we want to create and use objects in different places, preserving clean interface and avoiding global variables. Having all these considerations in mind, Picobox was born.
Quickstart¶
Picobox provides Box
class that acts as a container for objects you want
to deal with. You can put, you can get, you can pass them around.
import picobox
box = picobox.Box()
box.put('foo', 42)
@box.pass_('foo')
def spam(foo):
return foo
@box.pass_('foo', as_='bar')
def eggs(bar):
return bar
print(box.get('foo')) # 42
print(spam()) # 42
print(eggs()) # 42
One of the key principles is not to break existing code. That’s why Picobox does not change function signature and injects dependencies as if they are defaults.
print(spam()) # 42
print(spam(13)) # 13
print(spam(foo=99)) # 99
Another key principle is that pass_()
resolves dependencies lazily which
means you can inject them everywhere you need and define them much later. The
only rule is to define them before calling the function.
import picobox
box = picobox.Box()
@box.pass_('foo')
def spam(foo):
return foo
print(spam(13)) # 13
print(spam()) # KeyError: 'foo'
box.put('foo', 42)
print(spam()) # 42
The value to inject is not necessarily an object. You can pass a factory function which will be used to produce a dependency. A factory function has no arguments, and is assumed to have all the context it needs to work.
import picobox
import random
box = picobox.Box()
box.put('foo', factory=lambda: random.choice(['spam', 'eggs']))
@box.pass_('foo')
def spam(foo):
return foo
print(spam()) # spam
print(spam()) # eggs
print(spam()) # eggs
print(spam()) # spam
print(spam()) # eggs
Whereas factories are enough to implement whatever creation policy you want, there’s no good in repeating yourself again and again. That’s why Picobox introduces scope concept. Scope is a way to say whether you want to share dependencies in some execution context or not.
For instance, you may want to share it globally (singleton) or create only one instance per thread (threadlocal).
import picobox
import random
import threading
box = picobox.Box()
box.put('foo', factory=random.random, scope=picobox.threadlocal)
box.put('bar', factory=random.random, scope=picobox.singleton)
@box.pass_('foo')
def spam(foo):
print(foo)
@box.pass_('bar')
def eggs(bar):
print(bar)
# prints
# > 0.9464005851114538
# > 0.8585111290081737
for _ in range(2):
threading.Thread(target=spam).start()
# prints
# > 0.5333214411659912
# > 0.5333214411659912
for _ in range(2):
threading.Thread(target=eggs).start()
But the cherry on the cake is a so called Picobox’s stack interface. Box
is great to manage dependencies but it requires to be created before using.
In practice it usually means you need to create it globally to get access
from various places. The stack interface is called to solve this by providing
general methods that will be applied to latest active box instance.
import picobox
@picobox.pass_('foo')
def spam(foo):
return foo
box_a = picobox.Box()
box_a.put('foo', 13)
box_b = picobox.Box()
box_b.put('foo', 42)
with picobox.push(box_a):
print(spam()) # 13
with picobox.push(box_b):
print(spam()) # 42
print(spam()) # 13
spam() # RuntimeError: no boxes on the stack
When only partial overriding is necessary, you can chain pushed box so any missed lookups will be proxied to the box one level down the stack.
import picobox
@picobox.pass_('foo')
@picobox.pass_('bar')
def spam(foo, bar):
return foo + bar
box_a = picobox.Box()
box_a.put('foo', 13)
box_a.put('bar', 42)
box_b = picobox.Box()
box_b.put('bar', 0)
with picobox.push(box_a):
with picobox.push(box_b, chain=True):
print(spam()) # 13
The stack interface is recommended way to use Picobox because it allows to switch between DI containers (boxes) on the fly. This is also the only way to test your application because patching (mocking) globally defined boxes is not a solution.
def test_spam():
with picobox.push(picobox.Box(), chain=True) as box:
box.put('foo', 42)
assert spam() == 42
picobox.push()
can also be used as a regular function, not only as a
context manager.
def test_spam():
box = picobox.push(picobox.Box(), chain=True)
box.put('foo', 42)
assert spam() == 42
picobox.pop()
Every call to picobox.push()
should eventually be followed by a corresponding
call to picobox.pop()
to remove the box from the top of the stack, when you
are done with it.
API reference¶
Box¶
-
class
picobox.
Box
¶ Box is a dependency injection (DI) container.
DI container is an object that contains any amount of factories, one for each dependency apart. Dependency, on the other hand, is an ordinary instance or value the container needs to provide on demand.
Thanks to scopes, the class keeps track of produced dependencies and knows exactly when to reuse them or when to create new ones. That is to say each scope defines a set of rules for when to reuse dependencies.
Here’s a minimal example of how Box instance can be used:
import picobox box = picobox.Box() box.put('magic', 42) @box.pass_('magic') def do(magic): return magic + 1 assert box.get('magic') == 42 assert do(13) == 14 assert do() == 43
-
get
(key, default=<optional>)¶ Retrieve a dependency (aka service) out of the box instance.
The process involves creation of requested dependency by calling an associated factory function, and then returning result back to the caller code. If a dependency is scoped, there’s a chance for an existing instance to be returned instead.
Parameters: - key – A key to retrieve a dependency. Must be the one used when
calling
put()
method. - default – (optional) A fallback value to be returned if there’s no key in the box. If not passed, KeyError is raised.
Raises: KeyError – If no dependencies saved under key in the box.
- key – A key to retrieve a dependency. Must be the one used when
calling
-
pass_
(key, as_=<optional>)¶ Pass a dependency to a function if nothing explicitly passed.
The decorator implements late binding which means it does not require to have a dependency instance in the box before applying. The instance will be looked up when a decorated function is called. Other important property is that it doesn’t change a signature of decorated function preserving a way to explicitly pass arguments ignoring injections.
Parameters: - key – A key to retrieve a dependency. Must be the one used when
calling
put()
method. - as_ – (optional) Bind a dependency associated with key to a function argument named as_. If not passed, the same as key.
Raises: KeyError – If no dependencies saved under key in the box.
- key – A key to retrieve a dependency. Must be the one used when
calling
-
put
(key, value=<optional>, factory=<optional>, scope=<optional>)¶ Define a dependency (aka service) within the box instance.
A dependency can be expressed either directly, by passing a concrete value, or via factory function. A factory may be accompanied by scope that defines a set of rules for when to create a new dependency instance and when to reuse existing one. If scope is not passed, no scope is assumed which means produce a new instance each time it’s requested.
Parameters: - key – A key under which to put a dependency. Can be any hashable object, but string is recommended.
- value – A dependency to be stored within a box under key key.
Can be any object. A syntax sugar for
factory=lambda: value
. - factory – A factory function to produce a dependency when needed. Must be callable with no arguments.
- scope – A scope to keep track of produced dependencies. Must be
a class that implements
Scope
interface.
Raises: ValueError – If both value and factory are passed.
-
ChainBox¶
-
class
picobox.
ChainBox
(*boxes)¶ ChainBox groups multiple boxes together to create a single view.
ChainBox for boxes is essentially the same as
ChainMap
for mappings. It mimicsBox
interface and hence can substitute one but provides a way to look up dependencies in underlying boxes.Here’s a minimal example of how ChainBox instance can be used:
box_a = picobox.Box() box_a.put('magic_a', 42) box_b = picobox.Box() box_b.put('magic_a', factory=lambda: 10) box_b.put('magic_b', factory=lambda: 13) chainbox = picobox.ChainBox(box_a, box_b) @chainbox.pass_('magic_a') @chainbox.pass_('magic_b') def do(magic_a, magic_b): return magic_a + magic_b assert chainbox.get('magic_b') == 13 assert do() == 55
Parameters: boxes – (optional) A list of boxes to lookup into. If no boxes are passed, an empty box is created and used as underlying box instead.
Scopes¶
-
class
picobox.
Scope
¶ Scope is an execution context based storage interface.
Execution context is a mechanism of storing and accessing data bound to a logical thread of execution. Thus, one may consider processes, threads, greenlets, coroutines, Flask requests to be examples of a logical thread.
The interface provides just two methods:
See corresponding methods for details below.
-
get
(key)¶ Get value by key for current execution context.
-
set
(key, value)¶ Bind value to key in current execution context.
-
-
picobox.
singleton
¶ Share instances across application.
-
picobox.
threadlocal
¶ Share instances across the same thread.
-
picobox.
noscope
¶ Do not share instances, create them each time on demand.
Stacked API¶
-
picobox.
push
(box, chain=False)¶ Push a
Box
instance to the top of the stack.Returns a context manager, that will automatically pop the box from the top of the stack on exit. Can also be used as a regular function, in which case it’s up to callers to perform a corresponding call to
pop()
, when they are done with the box.The box on the top is used by
put()
,get()
andpass_()
functions (not methods) and together they define a so called Picobox’s stacked interface. The idea behind stacked interface is to provide a way to easily switch DI containers (boxes) without changing injections.Here’s a minimal example of how push can be used (as a context manager):
import picobox @picobox.pass_('magic') def do(magic): return magic + 1 foobox = picobox.Box() foobox.put('magic', 42) barbox = picobox.Box() barbox.put('magic', 13) with picobox.push(foobox): with picobox.push(barbox): assert do() == 14 assert do() == 43
As a regular function:
picobox.push(foobox) picobox.push(barbox) assert do() == 14 picobox.pop() assert do() == 43 picobox.pop()
Parameters: - box – A
Box
instance to push to the top of the stack. - chain – (optional) Look up missed keys one level down the stack. To
look up through multiple levels, each level must be created with this
option set to
True
.
- box – A
-
picobox.
pop
()¶ Pop the box from the top of the stack.
Should be called once for every corresponding call to
push()
in order to remove the box from the top of the stack, when a caller is done with it.Note, that
push()
should normally be used as a context manager, in which case the top box is removed automatically on exit from the with-block and there is no need to callpop()
explicitly.Raises: IndexError: if the stack is empty and there’s nothing to pop
-
picobox.
put
(key, value=<optional>, factory=<optional>, scope=<optional>)¶ The same as
Box.put()
but for a box at the top of the stack.
-
picobox.
pass_
(key, as_=<optional>)¶ The same as
Box.pass_()
but for a box at the top of the stack.
Release Notes¶
Note
Picobox follows Semantic Versioning which means backward incompatible changes will be released along with bumping major version component.
2.0.0¶
Released on Mar 18, 2018.
picobox.push()
can now be used as a regular function as well, not only as a context manager. This is a breaking change because from now one a box is pushed on stack immediately when callingpicobox.push()
, no need to wait for__enter__()
to be called.- New
picobox.pop()
function, that pops the box from the top of the stack. - Fixed a potential race condition on concurrent calls to
picobox.push()
that may occur in non-CPython implementations.
1.1.0¶
Released on Dec 19, 2017.
- New
ChainBox
class that can be used similar toChainMap
but for boxes. This basically means from now on you can group few boxes into one view, and use that view to look up dependencies. - New
picobox.push()
argument calledchain
that can be used to look up keys down the stack on misses.