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