F6 — scopes
Row F6 of the execution matrix: scoped
contexts. The other rows are about invocation shape — this row is about
the ambient context every call runs inside. dls.scope sets a key
prefix; dls.run resolves prefix-relative string keys to local paths. A UDF
body never sees a bucket, a credential, or an absolute path — string keys
in, string keys out, file bytes never on the msgpack wire.
| Level | Shape | Spelling |
|---|---|---|
| L0 | one call | with dls.scope(p): plus read, write, return keys |
| L1 | fan-out | each call gets its own key ledger |
| L2 | static pipeline | with dls.scope(f"scenes/{i:04d}"): per item |
| L3 | dynamic graph | a scope per data-discovered branch |
Despite the name, dls.run is not an entry point. It is the author-facing
proxy onto the ambient run context, and its whole surface is prefix,
root, read(key), write(key, dir=False), read_all(keys), and
write_all(keys, dir=False). read and write return
pathlib.Path objects. The things that actually run work are
f(...), f.submit(...), f.remote(...), f.local(...), the queue
verbs, and dls.run_worker(...).
Everything on this page is the local tier — bare @dls.udf, no queue, no
worker. Scopes work with zero infrastructure and touch no network.
L0 — the contract
A data UDF does I/O through dls.run.read(key) and dls.run.write(key) and
returns the string keys it wrote. Keys are prefix-relative POSIX
strings; the ambient context maps them to real locations. In local mode a
key resolves to root / prefix / key — write hands out that path with
parents created, read resolves it and raises if it does not exist.
The root defaults to the current working directory; the outermost
dls.scope(..., root=...) of a run re-anchors it.
normalize names nothing outside its scope — the scene number lives in the
ambient prefix, the machine location lives in the root.
The return-your-keys contract is enforced after every call: each key handed
out by dls.run.write must appear in the returned manifest. The accepted
manifest shapes are str, list[str], tuple[str, ...], and
dict[str, str] (named outputs, where the values are the keys). Return the
wrong thing and the error states the fix:
Return nothing at all and the message spells out the accepted shapes:
KeyError_ is a subclass of ValueError, not of the builtin KeyError.
The check is one-directional: a returned string that was never written is allowed, because it may be plain data or an upstream key passed through by a selector, and the two are indistinguishable by inspection. A silent write is not allowed — lineage and downstream wiring key off the returned manifest, so an unreturned key is an invisible artifact. (A typo'd return still gets caught, from the other side: the correctly written key goes unreturned and fails the check.)
L1 — a ledger per call
Every @dls.udf call runs in a fresh child context: same root, prefix,
storage, and ids as the ambient scope, but its own write ledger. The unit of
the return-your-keys check is one call, so sequential calls under one scope
never cross-contaminate.
If the ledger were shared, the second call would fail its check for not
returning the first call's key. It is not — stamp("b") answers only for
out/b.txt. The scope's own context stays clean; it accumulates nothing
from the calls inside it.
Generator bodies follow the same contract stretched over time: each yield
is a partial manifest, and together the yields are THE manifest, checked
once when the body finishes.
Yields that are not key-shaped concatenate to nothing, so a generator that streams plain data has no manifest. That is fine as long as it also wrote no files — the check only fires when the ledger is non-empty. A body that both writes files and yields plain data fails, with the same "did not return their keys" message.
L2 — scope per scene
Nesting joins prefixes. A pipeline scopes the run once at the top and each item once inside the loop; the stage chain in the middle is written entirely in relative keys.
Inside the loop, dls.run.prefix is dataset-v2/scenes/0002 — the inner
scope joined onto the outer. Neither UDF nor the stage chain mentions a
scene number or a directory. Move the run by changing root=, re-home it in
a dataset by changing the outer scope string, and every key still reads the
same.
For a stage that takes or produces several keys at once, dls.run.read_all
and dls.run.write_all mirror the shape you hand them: a str maps to a
Path, a list to a list, a dict to a dict.
L3 — scope per branch
When the graph's width is discovered at run time, open a scope per branch. Each branch gets its own namespace, and keys inside stay short and identical across branches.
A branch often needs an input from its parent scope. Keys may climb with
../ — resolution joins prefix and key, so ../ climbs the scope, and the
result is checked against the run root: a key may climb out of its prefix,
never out of the run. Absolute keys are rejected outright.
Three labels in the frame, three branch scopes — the structure of the key
space mirrors the structure the data dictated. refine is the same function
in every branch; only the ambient prefix differs. The manifest is
scope-qualified by joining dls.run.prefix onto each returned key — plain
strings, ready for a journal line.
Climbing past the root is refused:
Why an ambient context
The alternative is threading a path or a context argument through every
call, at which point UDF signatures grow a parameter that is not data and
every caller becomes responsible for plumbing. With scopes, the pattern
rows (F1 through F5) stay pure: a sync call is a call, a generator is a
generator, and where the bytes land is decided entirely by the with blocks
around them. The same body runs under a temp directory in a test, under
scenes/0042 in a pipeline, and under a bound storage on a worker —
unchanged.