Layers¶
A layer is a backend that wraps another backend. Because it implements the same
StorageBackend port, it composes with backends and with other layers, the way
middleware wraps a web app. Layers are how storix adds cross-cutting behavior
(sandboxing, caching, capability backfill) without touching the core or the
backends.
from storix import Storix, SandboxLayer
from storix.backends import LocalBackend
backend = LocalBackend("~/storix-data")
host = Storix(backend)
host.mkdir("/tenant-42", parents=True)
fs = Storix(SandboxLayer(backend, root="/tenant-42"))
You can also add layers fluently, which returns a new session and leaves the original untouched:
The sandbox root must exist before the layer is composed. This is deliberate: constructing a layer does not mutate its backend, and provisioning remains an explicit operation on the unsandboxed session.
Sandbox: chroot as middleware¶
SandboxLayer confines a session beneath a virtual root. Inside it, / means
the root, and traversal cannot escape, even if a caller tries to bypass the core
with ... Errors are re-scoped to the virtual namespace, so the real path prefix
never leaks. A sandboxed session simply cannot see or reach anything above its
root, which makes it a real security boundary, not a convenience.
host.echo("outside the tenant", "/secrets.txt")
fs.touch("hi.txt")
fs.cat("../secrets.txt") # raises PathNotFoundError, not a jailbreak
The real /secrets.txt exists outside /tenant-42. The .. is clamped at the
sandbox root, so the lookup stays inside the tenant and cannot reach it.
Cache: read-through, opt-in per operation¶
CacheLayer caches the operations that repeat. Metadata (stat/ls/exists)
is cached by default; the expensive tree walk (du), file content (read), and
presigned URLs are opt-in. Every write through the layer evicts what it touched,
so you always see your own changes.
from storix import CacheLayer, cache, get_storage
fs = get_storage("azure").with_layer(
CacheLayer,
du=cache(ttl=60), # opt in; the tree walk is the big win
read=cache(max_bytes=8 * 1024 * 1024), # content, capped at 8 MiB per file
)
The store protocol deliberately matches
Cashews, so a cashews.Cache plugs into the
async flavor with no adapter and can use Redis or disk underneath. The raw
redis-py client has a
different API; use it through Cashews or provide a small adapter. Correctness
assumes you are the only writer; pass a ttl to bound staleness. The
cache recipe covers both Cashews and raw redis-py.
Progress: transfer events, not callbacks¶
ObservabilityLayer turns the byte streams into progress. Every chunk that
moves through read_stream/write_stream emits a TransferEvent (op,
path, cumulative transferred) to the sink you attach; the sink may be
sync or async:
from storix import ObservabilityLayer, get_storage
fs = get_storage("local").with_layer(ObservabilityLayer, sink=print)
fs.echo(chunks, "/upload.bin")
# TransferEvent(op='write', path=StorixPath('/upload.bin'), transferred=65536)
# TransferEvent(op='write', path=StorixPath('/upload.bin'), transferred=131072)
# ...
The events deliberately carry no total and no percentage. A total is never
storix's to know: an arbitrary write stream has no knowable end. It is the
caller's knowledge of their own source (a local file's stat().st_size, an
HTTP upload's Content-Length), so you supply the total and do the division.
With no sink the layer is a pure passthrough. Compose it outermost so it
counts the full logical transfer. The
progress bars recipe drives a real bar from it.
Portable capabilities¶
Some layers backfill a capability a backend lacks, so one code path spans
providers. with_layer_missing applies a layer only when the backend is not
already native, inferring the capability from the layer:
from storix import DataUrlLayer, MetadataLayer, get_storage
fs = (
get_storage("local")
.with_layer_missing(DataUrlLayer) # url() everywhere: SAS on azure, data: URL on local
.with_layer_missing(MetadataLayer) # custom metadata everywhere
)
Switch local to azure and the native capability wins, so the layer becomes a
no-op. No code changes.
Bypassing a layer¶
without_layer returns a session with a layer type stripped, and uncached is
sugar for dropping the cache, which is handy for a guaranteed-fresh read:
A SandboxLayer is deliberately non-removable. You can always ask for less
caching; you can never ask your way out of a security boundary.
Reading the stack¶
fs.layers reports what is wrapping a session, outermost first, and
fs.base_backend walks past all of it to the real provider:
[type(layer).__name__ for layer in fs.layers] # ['CacheLayer', 'SandboxLayer']
type(fs.base_backend).__name__ # 'AzureBackend'
Use it to show a user what is active, or to record it in an audit trail. It
is read-only: composition stays with with_layer / without_layer.
To build middleware of your own, see the runnable custom layer recipe.