Skip to content

v0.1.0

Choose a tag to compare

@paqstd-dev paqstd-dev released this 09 Aug 14:01
· 9 commits to main since this release

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 instance

The 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").db

provider() 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 either

active() 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_ctx or FromCtx — the two build the same marker. Under mypy either works in either position; pyright statically sees FromCtx as an Annotated alias and refuses to call it, so from_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 None included. 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.
  • injected is an Any-typed default that keeps the signature satisfiable when callers omit the parameter, and it is what __defaults__ and getfullargspec report. 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 is use() 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 FrozenContextError

The 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 nodrill

Documentation is at https://nodrill.readthedocs.io/. The tutorial covers the whole library in about ten minutes.