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[source]#
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>)[source]#
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>)[source]#
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:
- Raises:
KeyError – If no dependencies saved under key in the box.
- Return type:
- pass_(key, *, as_=<unset>)[source]#
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:
- Raises:
KeyError – If no dependencies saved under key in the box.
ChainBox#
- class picobox.ChainBox(*boxes)[source]#
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 (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.
New in version 1.1.
Scopes#
- class picobox.Scope[source]#
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.
- picobox.contextvars[source]#
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
.New in version 2.1.
- picobox.ext.flaskscopes.application[source]#
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.New in version 2.2.
Stacked API#
- class picobox.Stack(name=None)[source]#
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 whereStack
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()
andpass_()
functions.- Parameters:
name (str | None) – (optional) A name of the stack.
New in version 2.2.
- push(box, *, chain=False)[source]#
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:
- Return type:
- pop()[source]#
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 callpop()
manually).- Returns:
a removed box
- Raises:
IndexError – If the stack is empty and there’s nothing to pop.
- Return type:
- put(key, value=<unset>, *, factory=<unset>, scope=<unset>)[source]#
The same as
Box.put()
but for a box at the top.
- pass_(key, *, as_=<unset>)[source]#
The same as
Box.pass_()
but for a box at the top.
- picobox.push(box, *, chain=False)[source]#
The same as
Stack.push()
but for a shared stack instance.New in version 1.1:
chain
parameter- Parameters:
- Return type:
- picobox.pop()[source]#
The same as
Stack.pop()
but for a shared stack instance.New in version 2.0.
- Return type:
- picobox.put(key, value=<unset>, *, factory=<unset>, scope=<unset>)[source]#
The same as
Stack.put()
but for a shared stack instance.
- picobox.get(key, default=<unset>)[source]#
The same as
Stack.get()
but for a shared stack instance.
- picobox.pass_(key, *, as_=<unset>)[source]#
The same as
Stack.pass_()
but for a shared stack instance.
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 intopicobox.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()
andpicobox.put()
to require eithervalue
orfactory
argument. Previously, they could have been invoked withkey
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 withPython 2
should stick with2.x
branch.Drop
Python 3.4
,Python 3.5
andPython 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
andscope
inBox.put()
,as_
inBox.pass_()
andchain
inpicobox.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 thanstr
.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 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.
1.0.0#
Released on Nov 25, 2017.
First public release with initial bunch of features.