v0.1.0
Release Notes — nodrill v0.1.0
v0.1.0 is the first release. nodrill gives a call tree a scoped context: a provider block puts values on the current scope, use() reads them anywhere below it, and none of the frames in between carry them through a signature. The whole thing is one module-level ContextVar holding a copy-on-write dict — entering copies, exiting resets the token — so a lookup is a single dict read, sibling asyncio tasks never see each other's writes, and threads are isolated unless you hand the context over deliberately.
The public surface is fifteen names: provider, use, set_default, active, isolate, Namespace, context, inject, FromCtx, from_ctx, injected, wrap, Executor, NoProviderError and FrozenContextError. Everything else is private and free to move.
Minimum Python is 3.10+ (3.10–3.14), there are no dependencies, and the package ships py.typed. The library is checked under mypy strict and pyright, and several API shapes exist only because the two checkers disagree.
Providing and reading
provider(instance) registers under type(instance), and use(Cls) reads it back with the type preserved:
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}"Keys are exact. A Sub instance does not answer use(Base) — MRO search would turn "which provider wins" into an ordering question the moment two subclasses are live. key= names the key instead of deriving it, which is the only reason use(SomeProtocol) is expressible:
with provider(PostgresRepo(), key=Repo):
use(Repo) # the PostgresRepo instanceThe key is deliberately not checked against the instance: isinstance against a plain Protocol raises, which would rule out the case key= exists for.
String namespaces
For values that do not deserve a class, a named provider builds a Namespace and use("name") reads it:
with provider("app", db=engine) as ctx:
ctx.user_id = 42 # the block keeps writing after entry
handle() # any callee reads use("app").dbprovider() takes its target positionally on purpose: provider("doc", name="report.pdf") has to treat name as data, so frozen and key are the only two names that cannot be prefilled. Namespace compares by attributes and is unhashable, following types.SimpleNamespace.
When there is no provider
A miss raises NoProviderError with the key described in the message. Two things soften that, in a fixed order:
set_default(cls, factory)— the canonical fallback, registered once, declared by the owner of the type. The factory runs on every miss; caching it would be a global mutable singleton.set_default(cls, None)clears it.use(key, default=…)— the call-site fallback, which speaks only for one caller and therefore loses to a registered default.
from nodrill import set_default, use
set_default(Config, lambda: Config(dsn="sqlite://"))
use(Config) # the factory result, outside any provider
use(Config, default=None) # None, if nothing is registered eitheractive() returns a read-only mapping of everything currently in scope, for debugging and for the tests that want to assert on it.
@inject
@inject moves the lookup out of the body and into the signature. The decorator reads inspect.signature and get_type_hints(include_extras=True) once at decoration, then compiles a wrapper whose parameter list mirrors the function's own, so the interpreter binds every call shape natively and no call touches inspect again:
from nodrill import FromCtx, inject, injected, provider
@inject
def report(cfg: FromCtx[Config] = injected) -> str:
return cfg.url
with provider(Config(url="postgres://prod")):
report()FromCtx[SomeClass] is use(SomeClass) written in the signature. To pull a single attribute out of a named provider, annotate with from_ctx("name") — the attribute taken is the one named after the parameter:
from typing import Annotated
from nodrill import from_ctx
@inject
def query(sql: str, db: Annotated[Engine, from_ctx("app")] = injected) -> Rows:
return db.execute(sql)
with provider("app", db=engine):
query("SELECT 1")from_ctxorFromCtx— the two build the same marker. Under mypy either works in either position; pyright statically seesFromCtxas anAnnotatedalias and refuses to call it, sofrom_ctx(...)is the spelling for pyright-checked code.@inject(from_="app")— fills every eligible parameter by name from one namespace, without a marker per parameter. Looser than marker style by design, and the only mode that overrides a parameter's own default.- Explicit arguments always win, an explicit
Noneincluded. That is what makes injected code testable with nothing set up: a test calls the function with fakes, outside any provider, and the context is never consulted. injectedis anAny-typed default that keeps the signature satisfiable when callers omit the parameter, and it is what__defaults__andgetfullargspecreport. It fails loudly if it ever reaches a body.- A bad call fails before any resolution runs. Unknown keywords and over-long positional lists are rejected by the interpreter. An under-supplied call is caught by a guard that raises in CPython's own wording, serial comma and all, pinned by a parity test against an undecorated twin.
- Forward references that do not resolve at decoration defer the build to the first call rather than failing, and only raise if something in the signature actually asked for injection.
- Generators and classes are rejected at decoration time. A generator body runs at
next(), possibly under different providers, so anything resolved at call time is silently stale. The answer there isuse()in the body. For a class,__init__is the thing to decorate.
Read-only providers
frozen=True hands consumers a read-only view while the block keeps the writable object:
with provider(Config(url="…"), frozen=True) as cfg:
cfg.url = "…" # the block's own object, still writable
consumer() # use(Config).url = "…" raises FrozenContextErrorThe proxy lives on the registry side rather than patching __setattr__ on your object, which would mutate user objects and break on __slots__, frozen dataclasses and concurrency. Special methods are looked up on the type, so every forwarded protocol is generated from a table on the class. In-place operators are absent on purpose: with no __iadd__, += falls back to __add__ and rebinds the caller's name, leaving the target alone. Freezing is shallow, __class__ is spoofed for isinstance, and pickle and copy are refused — it is a guard rail, not a security boundary.
Threads and asyncio
Asyncio tasks inherit the context for free. Threads do not, so two names carry it across:
from nodrill import Executor, wrap
with provider(Config(url="…")):
threading.Thread(target=wrap(worker)).start()
with Executor(max_workers=4) as pool:
pool.submit(worker) # each task takes its own copy_context()wrap() snapshots at wrap time and replays the snapshot into a fresh Context per call, because a single Context raises if entered concurrently. It rejects async def: Context.run() on a coroutine function only builds the coroutine, whose body resumes in the caller's context, so the snapshot would be lost silently. Creating the task inside the provider block is the answer there.
Testing
isolate() gives a test fresh context state and rolls everything back afterwards. Providers and ambient attributes start empty, and any set_default registration made inside the block is rolled back with them:
from nodrill import isolate
with isolate():
set_default(Config, lambda: Config(dsn="sqlite://"))
...The ambient context
context is an unscoped, attribute-only namespace for the cases that want process-wide state without a block. It exposes nothing but dunders, so no attribute you set can collide with an API name — the g.get / g.pop mistake is the one being avoided. That is also what makes __iter__, __len__ and __contains__ safe to have and keys() not.
Cost
A lookup is one dict read on a single ContextVar, with nothing constructed, resolved or cached along the way. The first rows are one function doing one read, reached four ways, measured on CPython 3.14.5 / arm64:
| operation | ns | × |
|---|---|---|
| one read in a function, value passed in as a parameter | 25 | 1.0 |
the same read through use() |
64 | 2.6 |
the same read through @inject |
78 | 3.2 |
the same read through a frozen=True provider |
124 | 5.0 |
with provider(...), enter and exit |
576 | 23 |
| the same with 8 providers already open | 762 | 31 |
wrap(fn)(), per call into a thread |
654 | 26 |
Entering a provider is the expensive end, because it copies the registry so sibling tasks stay isolated — proportional to how many providers are open, and paid once per scope rather than once per lookup. 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. Regenerate on your own machine with make bench ARGS=--write.
Install
pip install nodrillDocumentation is at https://nodrill.readthedocs.io/. The tutorial covers the whole library in about ten minutes.