Releases: paqstd-dev/nodrill
Release list
v0.2.0
Release Notes — nodrill v0.2.0
v0.2.0 is the providers release. Three additions change what a provider may hold and how two scopes of the same name compose: lazy() defers building a value until something below actually reads it, extend=True lets a named scope lay its values over the one enclosing it instead of shadowing it, and ref() names a key by import path so a module can provide or read a key it cannot import (#3, #4, #5).
The release adds four public names — lazy, ref, resolve_refs, KeyResolutionError — one keyword on provider(), two how-to guides, a rewritten landing page, and a design chapter for the late-bound key. Nothing changes for code written against 0.1.0: use() is still one dict read, and the compiled @inject wrappers never learned a branch.
Minimum Python is still 3.10+ (3.10–3.14), and nodrill still has zero runtime dependencies.
Deferred values — lazy()
provider(lazy(Cls, factory)) registers a value that is built on the first read inside the scope, and not at all if nothing reads it (#3). A middleware can put a database session, a tenant record or an audit origin on every request without paying for the requests that never touch it.
from nodrill import lazy, provider, use
with provider(lazy(Origin, lambda: Origin(actor_id=request.user.pk))):
response = get_response(request) # nothing built yet
# deep inside, on the few requests that care
use(Origin).actor_id # the factory runs here, onceThe key is passed explicitly, since there is no value yet to derive one from. What the scope holds is a cell that resolves on the first operation needing the value and then delegates to it — reads, writes, comparisons and the operators all behave as the value does, and isinstance holds before and after resolution, so ordinary code cannot tell the difference.
- Once per scope — the result is cached until the scope exits, and a second scope starts unresolved again, including a second entry of the same provider object. The cache belongs to the scope, not to the object, which is what keeps one request's value out of the next.
- Resolved under the declaring context — the factory runs under the context the scope was entered with, so
use()inside it reads the scope that declared the value rather than whatever is active at the moment of the first read. - Thread-safe, with a plain lock — two threads inside one scope resolve once, the second waiting for the first. Keep a slow factory off an event loop, as you would any blocking call.
- Failures are cached — a factory that raises has its exception re-raised on every later touch, so the failure does not depend on which frame happened to read first. A factory that reads or returns the key it is building raises
RuntimeErrorinstead of recursing. - Safe to inspect —
reprreports the cell's state instead of resolving it, which is what keepsactive()printable:{<class 'Config'>: <lazy Config, unresolved>}.str()does resolve, because it asks the value for its own text. - Composes with
frozen=True— the block and the registry get two cells over one build rather than a proxy stacked on a proxy, so a frozen lazy read still costs one hop and the block keeps writing.
with provider(lazy(Config, load), frozen=True) as cfg:
cfg.dsn = "the owner can still write"
use(Config).dsn = "..." # FrozenContextErrorLayered scopes — provider(name, extend=True)
A string-named provider shadows the namespace an outer scope registered under the same name. With extend=True it lays its values over a copy of that namespace instead, so each layer adds what it knows and the code below reads one name and gets all of it (#4).
def audit_middleware(request):
with provider("audit", extend=True, request_id=new_id(), path=request.path):
return authenticate(request)
def authenticate(request):
with provider("audit", extend=True, actor_id=request.user_id):
return update_view(request)
def post_save(document):
# request_id, path, actor_id — contributed by three layers that never met
vars(use("audit"))The audit row is the shape it was built for: middleware knows the request, authentication knows the actor, the view names the action, and the receiver that writes the row runs below all of them.
- Merged at enter, not at call — a reused provider object layers over whatever encloses it at that moment, not over what enclosed it when
provider()ran. - Copy, never mutation — the enclosing namespace is copied, so a sibling task holding a reference to it sees nothing, in either direction. That is the one surprising rule and the price of sibling isolation.
- One level deep — a nested value is carried over as-is; a deeper merge rule would be about types rather than about scopes.
- Shadowing stays the default — overriding a whole namespace is what a test override wants, so
extendis opt-in, and a parameter rather than a second function so there is one entry point to read. - Refusals are explicit — instance and
lazytargets rejectextend=Truewith a message pointing atdataclasses.replace, and a non-Namespacevalue already under the name raises on entry rather than silently falling back to shadowing.frozenis not inherited from the layer below.
Late-bound keys — ref()
A class key has to be imported to be named, and when the module owning the key already imports the module that wants to read it, importing back is a cycle Python refuses. ref() names the key by import path and imports it on the first lookup (#5).
# myapp/models.py — myapp.context already imports this module
from nodrill import ref, use
RequestScope = ref("myapp.context:RequestScope")
def on_save(sender, instance, **kwargs):
return use(RequestScope).user_idThe import happens the first time on_save runs, after both modules are loaded, so the cycle never forms. The colon says where the module ends and the attribute begins; ref("myapp.context.RequestScope") is accepted too, resolved from the longest importable prefix.
- A ref is not a new kind of key — it borrows its target's hash and equality once resolved, so the entry stored under the class is the entry a lookup through the ref finds. The provider side is unchanged, and nothing in
use()branches on a ref. - Everywhere a class key goes —
use(),provider(key=),lazy(),set_default(),from_ctx()and@inject(from_=)all take one. - Keep the static type — put the import a checker can follow behind
TYPE_CHECKINGand the ref in theelse. mypy and pyright both typeuse(RequestScope)asRequestScope, while the runtime only ever runs theelsebranch. It is the same tradeFromCtxmakes.
if TYPE_CHECKING:
from myapp.context import RequestScope
else:
RequestScope = ref("myapp.context:RequestScope")- Fail at startup, not on the first request —
resolve_refs()imports every ref created so far in creation order and raises on the first bad one. In Django it belongs inAppConfig.ready(). Refs that already resolved are left alone, so a second call costs one read each. KeyResolutionError— a newLookupErrorsubclass carrying the failingpathas an attribute. Reading a ref at module scope during the very import that defines its target says exactly that, and names the fix: move the lookup into a function body.
KeyResolutionError: ref('myapp.context:RequestScope'): 'myapp.context' is still
executing its own import, so 'RequestScope' does not exist yet. The lookup ran
during that import, so move it inside a function and it will run once the module
is loaded
- Unlocked resolution, on purpose — resolving is deterministic and idempotent, while a lock held across
import_module()would order this library's lock against the interpreter's per-module import locks, which is the deadlock every lazy importer eventually reports.
Documentation
The landing page was rewritten around the problem the library is named after (#2): a before/after pair of one checkout path — db and tenant drilled through three signatures on the left, one provider at the boundary on the right — followed by a band saying what nodrill is not, so the reader who arrives asking whether this is another container gets an answer before the walkthrough.
- Two new how-to guides: Accumulate an audit trail and Refer to a key you cannot import.
- New reference and topic coverage for
lazy,extend=True,ref,resolve_refsandKeyResolutionError, plus design chapters on the copy-on-write layer and the late-bound key. - Fonts are self-hosted instead of fetched from a CDN, every page gets a canonical link and an
og:image, and three stale stylesheet rules and an unusedsphinx-designdependency are gone.
Compatibility
Purely additive — there are no breaking changes and no migration steps. provider, use, @inject, wrap, Executor, set_default and the ambient context behave exactly as in 0.1.0, and every new capability is opt-in at the call site. Coverage stays at 100% on branches, with mypy strict and pyright clean over src and the tests.
Links
- Documentation: https://nodrill.readthedocs.io/
- GitHub: https://github.com/paqstd-dev/nodrill
- Issues: https://github.com/paqstd-dev/nodrill/issues
Full Changelog: v0.1.0...v0.2.0
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](https://nodri...