nodrill gives a call tree a shared, scoped context. Values set in a provider block are visible to any function below it through use(), without being passed through the signatures in between.
It is built on contextvars, so lookups are thread-safe and asyncio-task-safe, and it has no dependencies.
from dataclasses import dataclass
from nodrill import provider, use
@dataclass
class RequestScope:
user_id: int
db: str
def handle_request():
with provider(RequestScope(user_id=42, db="postgres://...")):
render_page()
def render_page():
return render_sidebar() # knows nothing about RequestScope
def render_sidebar():
scope = use(RequestScope) # inferred as RequestScope
return f"{scope.user_id} @ {scope.db}"String names work too, when a typed key is more than the case needs.
with provider("app", db=engine) as ctx:
ctx.user_id = 42
handle() # any callee reads use("app").db- Typed keys:
use(Config)is inferred asConfig, under both mypy and pyright. - String namespaces for the values that do not deserve a class, as above.
@injectto declare the dependency in the signature and still pass it explicitly in a test.set_default(Config, factory)for code that has to run outside any provider.- Threads and asyncio: tasks inherit the context,
wrapandExecutorcarry it into threads. frozen=Truehands consumers a read-only view while the block keeps a writable object.extend=Truefor a scope that accumulates as the call descends, one layer perwithblock, each unwound on exit.lazy(Cls, factory)for a value that costs something to build and only some requests read.isolate()to give a test fresh context state and roll everything back after it.- No dependencies, Python 3.10 and up, and a public API of sixteen names.
A lookup is one dict read on a single ContextVar, and nothing is constructed, resolved or cached along the way.
The first five rows are one function doing one read, reached five ways, so they can be read against each other and against the parameter they replace.
| operation | ns | × |
|---|---|---|
| one read in a function, value passed in as a parameter | 23 | 1.0 |
the same read through use() |
60 | 2.7 |
the same read through @inject |
71 | 3.1 |
the same read through a frozen=True provider |
117 | 5.2 |
the same read through a resolved lazy provider |
138 | 6.1 |
use(Config) on its own, without the call frame |
44 | 2.0 |
bare ContextVar.get(), for reference |
16 | 0.7 |
with provider(...), enter and exit |
544 | 24 |
| the same with 8 providers already open | 626 | 28 |
with provider(lazy(...)), entered and exited unread |
1492 | 66 |
with provider(..., extend=True), over an 8-attribute namespace |
1697 | 75 |
wrap(fn)(), per call into a thread |
537 | 24 |
CPython 3.14.5, arm64.
The × column is against handing the value in as a parameter, which is the alternative nodrill removes from the signatures in between.
Reading through use() costs a little over the parameter it replaces; @inject costs more, because it fills the argument before the body runs; frozen=True and lazy add a proxy hop to every attribute the consumer touches.
A request that reads a provided value a hundred times spends microseconds in nodrill, against hundreds of microseconds for one round trip to a database.
Entering a provider is the expensive end, because it copies the registry so that sibling tasks stay isolated.
That copy is proportional to how many providers are open, which the with provider(...) rows price at one and at eight, and it happens once per scope rather than once per lookup.
A lazy provider pays for the cell it allocates on top, which is the trade the feature is for: a microsecond on entry, against a value that is never built at all on the requests that never read it.
An extending layer copies the enclosing namespace on top of the registry, so its row grows with how many attributes have accumulated rather than with how many layers are open, and that second copy is what keeps a sibling task from seeing a layer opened after it started.
The absolute numbers move with the machine, and the ratios are the part worth reading.
Regenerate with make bench ARGS=--write, which measures on your machine and rewrites the block above.
A rerun lands within a few percent, so read the digits as approximate; nothing here runs in CI, because timing on a shared runner measures the runner.
pip install nodrillPython 3.10 or newer.
Full documentation is at https://nodrill.readthedocs.io/.
Start with the tutorial, which covers the whole library in about ten minutes, over typed class keys, fallbacks for a miss, @inject, threads and asyncio, frozen providers, and testing.
Bug reports and small focused pull requests are welcome. See CONTRIBUTING.md.
make install sets up the environment, and make runs the same gate CI does.
Security issues go through a private advisory rather than the issue tracker.
MIT