Skip to content
Back to top

# 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 get_foo(foo):
        return foo

    print(get_foo())        # ==> spam
    print(get_foo())        # ==> eggs
    print(get_foo())        # ==> eggs
    print(get_foo())        # ==> spam
    print(get_foo())        # ==> 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.

Note

Dependency Injection is usually used in applications, not libraries, to wire things together. Occasionally such need may come in libraries too, so picobox provides a `picobox.Stack` class to create an independent non overlapping stack with boxes suitable to be used in such cases.

Just create a global instance of stack (globals themeselves aren’t bad), and use it as you’d use picobox stacked interface:

    import picobox

    stack = picobox.Stack()

    @stack.pass_("a", as_="b")
    def mysum(a, b):
        return a + b

    with stack.push(picobox.Box()) as box:
        box.put("a", 42)
        assert mysum(13) == 55

## 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 a 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

  put(key, value=<unset>, *, factory=<unset>, scope=<unset>)

    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 (Hashable) – A key under which to put a dependency. Can be any hashable object, but string is recommended.

      - value (Any) – A dependency to be stored within a box under key key. Can be any object. A syntax sugar for `factory=lambda: value`.

      - factory (Callable[[], Any]) – A factory function to produce a dependency when needed. Must be callable with no arguments.

      - scope (Type[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.

    Return type:

      None

  get(key, default=<unset>)

    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 (Hashable) – A key to retrieve a dependency. Must be the one used when calling `put()` method.

      - default (Any) – (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.

    Return type:

      Any

  pass_(key, *, as_=<unset>)

    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 (Hashable) – A key to retrieve a dependency. Must be the one used when calling `put()` method.

      - as_ (str) – (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.

### 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 (Box) – (optional) A list of boxes to lookup into. If no boxes are passed, an empty box is created and used as underlying box instead.

  Added in version 1.1.

  put(key, value=<unset>, *, factory=<unset>, scope=<unset>)

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

    Parameters:

      - key (Hashable)

      - value (Any)

      - factory (Callable[[], Any])

      - scope (Type[Scope])

    Return type:

      None

  get(key, default=<unset>)

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

    Parameters:

      - key (Hashable)

      - default (Any)

    Return type:

      Any

### 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.

  abstractmethod set(key, value)

    Bind value to key in current execution context.

    Parameters:

      - key (Hashable)

      - value (Any)

    Return type:

      None

  abstractmethod get(key)

    Get value by key for current execution context.

    Parameters:

      key (Hashable)

    Return type:

      Any

picobox.singleton

  Share instances across application.

picobox.threadlocal

  Share instances across the same thread.

picobox.contextvars

  Share instances across the same execution context (PEP 567).

  Since asyncio does support context variables, the scope could be used in asynchronous applications to share dependencies between coroutines of the same `asyncio.Task`.

  Added in version 2.1.

picobox.noscope

  Do not share instances, create them each time on demand.

picobox.ext.flaskscopes.application

  Share instances across the same Flask (HTTP) application.

  In most cases can be used interchangeably with `picobox.singleton` scope. Comes around when you have multiple Flask applications and you want to have independent instances for each Flask application, despite the fact they are running in the same WSGI context.

  Added in version 2.2.

picobox.ext.flaskscopes.request

  Share instances across the same Flask (HTTP) request.

  Added in version 2.2.

### Stacked API

class picobox.Stack(name=None)

  Stack is a dependency injection (DI) container for containers (boxes).

  While `Box` is a great way to manage dependencies, it has no means to override them. This might be handy most of all in tests, where you usually need to provide a special set of dependencies configured for test purposes. This is where `Stack` comes in. It provides the very same interface Box does, but proxies all calls to a box on the top.

  This basically means you can define injection points once, but change dependencies on the fly by changing DI containers (boxes) on the stack. Here’s a minimal example of how a stack can be used:

      import picobox

      stack = picobox.Stack()

      @stack.pass_('magic')
      def do(magic):
          return magic + 1

      foobox = picobox.Box()
      foobox.put('magic', 42)

      barbox = picobox.Box()
      barbox.put('magic', 13)

      with stack.push(foobox):
          with stack.push(barbox):
              assert do() == 14
          assert do() == 43

  Note

  Usually you want to have only one stack instance to wire things up. That’s why picobox comes with pre-created stack instance. You can work with that instance using `push()`, `pop()`, `put()`, `get()` and `pass_()` functions.

  Parameters:

    name (str | None) – (optional) A name of the stack.

  Added in version 2.2.

  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.

    Parameters:

      - box (Box) – A `Box` instance to push to the top of the stack.

      - chain (bool) – (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`.

    Return type:

      ContextManager[Box, bool | None]

  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

    Normally `push()` should be used a context manager, in which case the box on the top is removed automatically on exit from the block (i.e. no need to call `pop()` manually).

    Returns:

      a removed box

    Raises:

      IndexError – If the stack is empty and there’s nothing to pop.

    Return type:

      Box

  put(key, value=<unset>, *, factory=<unset>, scope=<unset>)

    The same as `Box.put()` but for a box at the top.

    Parameters:

      - key (Hashable)

      - value (Any)

      - factory (Callable[[], Any])

      - scope (Type[Scope])

    Return type:

      None

  get(key, default=<unset>)

    The same as `Box.get()` but for a box at the top.

    Parameters:

      - key (Hashable)

      - default (Any)

    Return type:

      Any

  pass_(key, *, as_=<unset>)

    The same as `Box.pass_()` but for a box at the top.

    Parameters:

      - key (Hashable)

      - as_ (str)

picobox.push(box, *, chain=False)

  The same as `Stack.push()` but for a shared stack instance.

  Added in version 1.1: `chain` parameter

  Parameters:

    - box (Box)

    - chain (bool)

  Return type:

    ContextManager[Box, bool | None]

picobox.pop()

  The same as `Stack.pop()` but for a shared stack instance.

  Added in version 2.0.

  Return type:

    Box

picobox.put(key, value=<unset>, *, factory=<unset>, scope=<unset>)

  The same as `Stack.put()` but for a shared stack instance.

  Parameters:

    - key (Hashable)

    - value (Any)

    - factory (Callable[[], Any])

    - scope (Type[Scope])

  Return type:

    None

picobox.get(key, default=<unset>)

  The same as `Stack.get()` but for a shared stack instance.

  Parameters:

    - key (Hashable)

    - default (Any)

  Return type:

    Any

picobox.pass_(key, *, as_=<unset>)

  The same as `Stack.pass_()` but for a shared stack instance.

  Parameters:

    - key (Hashable)

    - as_ (str)

## Release Notes

Note

Picobox follows Semantic Versioning which means backward incompatible changes will be released along with bumping major version component.

### 4.0.0

Released on Nov 20, 2023.

- BREAKING: The `picobox.contrib` package is renamed into `picobox.ext`.

- Add `Python 3.12` support.

- Drop `Python 3.7` support. It reached its end-of-life recently.

- Fix `@picobox.pass_()` decorator issue when it was shadowing a return type of the wrapped function breaking code completion in some LSP servers.

- Fix `picobox.push()` context manager issue when it wasn’t announcing properly its return type breaking code completion in some LSP servers for the returned object.

- Fix `Box.put()` and `picobox.put()` to require either `value` or `factory` argument. Previously, they could have been invoked with `key` argument only, which makes no sense and causes runtime issues later on.

### 3.0.0

Released on Apr 02, 2023.

- Add `Python 3.10` & `Python 3.11` support.

- Drop `Python 2.7` support. It’s dead for more than a year anyway. Those who want to use picobox with `Python 2` should stick with `2.x` branch.

- Drop `Python 3.4`, `Python 3.5` and `Python 3.6` support. They reached their end-of-life and are not maintained anymore.

- Add type annotations to public interface. Now users can use `mypy` to leverage type checking in their code base.

- Make some parameters keyword-only: `factory` and `scope` in `Box.put()`, `as_` in `Box.pass_()` and `chain` in `picobox.push()`.

- Use PEP 621 `pyproject.toml` in a so-called source distribution.

### 2.2.0

Released on Dec 24, 2018.

- Fix `picobox.singleton`, `picobox.threadlocal` & `picobox.contextvars` scopes so they do not fail with unexpected exception when non-string formattable missing key is passed.

- Add `picobox.contrib.flaskscopes` module with application and request scopes for Flask web framework.

- Add `picobox.Stack` class to create stacks with boxes on demand. Might be useful for third-party developers who want to use picobox yet avoid collisions with main application developers.

### 2.1.0

Released on Sep 25, 2018.

- Add `picobox.contextvars` scope (python 3.7 and above) that can be used in asyncio applications to have a separate set of dependencies in all coroutines of the same task.

- Fix `picobox.threadlocal` issue when it was impossible to use any hashable key other than `str`.

- Nested `picobox.pass_` calls are now squashed into one in order to improve runtime performance.

- Add `Python 2.7` support.

### 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.