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.

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.

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 mimics Box 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.
get(key, default=<optional>)

Same as Box.get() but looks up for key in underlying boxes.

put(key, value=<optional>, factory=<optional>, scope=<optional>)

Same as Box.put() but applies to first underlying box.

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:

  • set() - set execution context item
  • get() - get execution context item

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() and pass_() 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.
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 call pop() 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.get(key, default=<optional>)

The same as Box.get() 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 calling picobox.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 to ChainMap 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 called chain that can be used to look up keys down the stack on misses.

1.0.0

Released on Nov 25, 2017.

  • First public release with initial bunch of features.