ASGI static file server — like Whitenoise, but native ASGI with a Rust hot path
Project description
whitesnout
WhiteSnout is an ASGI static file server for Python — like Whitenoise, but built for ASGI frameworks (FastAPI, Starlette, Django, etc.). It serves static files with minimal memory overhead, streaming content in chunks and leveraging pre-compressed assets. A Rust extension (PyO3) accelerates the hot path transparently.
Quick start
from whitesnout import WhiteSnout
# Standalone static file server
app = WhiteSnout(directory="./static")
Serve with any ASGI server:
$ uvicorn myapp:app
With FastAPI
from fastapi import FastAPI
from whitesnout import WhiteSnout
api = FastAPI()
@api.get("/api")
def read_root():
return {"hello": "world"}
app = WhiteSnout(api, directory="static")
With Django
# asgi.py
import os
from django.core.asgi import get_asgi_application
from whitesnout import WhiteSnout
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django_app = get_asgi_application()
application = WhiteSnout(django_app, directory="static")
Installation
$ uv add whitesnout
Or with pip:
$ pip install whitesnout
Requires Python ≥ 3.10.
No required runtime dependencies. Optional extras:
$ uv add 'whitesnout[compress]' # Brotli for the compress CLI
$ uv add 'whitesnout[streaming]' # aiofiles for non-blocking large-file streaming
What streaming does
Files ≤ sync_threshold (default 64 KB) are read in a single asyncio.to_thread call — no extra dependency needed. Covers ~95% of typical static assets (HTML, CSS, JS bundles, icons, fonts).
Files larger than sync_threshold are streamed in chunk_size (default 64 KB) pieces. Two backends are available:
- Without
streamingextra (default): chunks are read via blockingopen()inside the running event-loop task. Fine for low-concurrency workloads, but a slow disk read can stall other requests on the same worker. - With
streamingextra: chunks are read viaaiofiles, which dispatches each read to a thread pool. Other requests keep progressing while the slow read happens.
Install [streaming] when:
- Serving files routinely larger than 64 KB (large JS bundles, videos, downloads, datasets)
- Running on slow / network-mounted disks (NFS, SMB, EBS gp2)
- High concurrency: many simultaneous large-file downloads
Skip it when:
- Only serving small assets (typical SPA build output, icons, fonts) — fast path already covers everything
- Raising
sync_thresholdto fit your largest expected file (e.g.sync_threshold=2_000_000for ≤ 2 MB) - Memory-constrained environments —
aiofilesadds ~500 KB resident
Pre-compressing assets
Generate .gz and .br variants for all files in a directory:
$ python -m whitesnout compress static/
Compressed: 42 gzip, 42 brotli
CLI flags:
| Flag | What it does |
|---|---|
--force |
Recompress even when the .gz / .br is newer than the source |
--include GLOB |
Only compress matching files. Repeatable (e.g. --include "*.css" --include "*.js") |
--exclude GLOB |
Skip matching files. Repeatable |
--jobs N / -j N |
Worker processes (default: CPU count). Set --jobs 1 for deterministic ordering |
--quiet / -q |
Suppress summary output |
This is a build-time step — at runtime WhiteSnout serves the pre-compressed files directly with zero CPU overhead. Common image, font, and archive extensions are skipped automatically (.jpg, .png, .woff2, .zip, …).
Standalone server (whitesnout serve)
For quick local previews — no framework, no uvicorn invocation:
$ python -m whitesnout serve ./static --port 8000
$ python -m whitesnout serve ./static --health-check-path /healthz \
--request-id-header X-Request-ID
This wraps uvicorn.run(WhiteSnout(directory=...)) with sensible defaults. Use it for demos, smoke tests, and the --require-rust path of CI. For production, run uvicorn directly with your own app so worker count, proxy headers, and graceful shutdown are explicit.
Pre-flight (whitesnout validate)
Catch misconfigurations before deploy:
$ python -m whitesnout validate ./static --manifest ./static/staticfiles.json
OK directory: /app/static
OK files: 142 regular files under directory
OK brotli: importable
OK rust extension: loaded
OK manifest: ./static/staticfiles.json (142 entries)
Exit code is 1 if any FAIL line is printed. Flags --require-brotli and --require-rust upgrade the matching WARN to a FAIL.
Configuration
All options can be passed as keyword arguments to WhiteSnout:
| Option | Default | Description |
|---|---|---|
app |
None |
Inner ASGI app to fall through to when a file is not found |
directory |
"static" |
Root directory to serve files from |
index_file |
"index.html" |
File to serve for directory requests |
cache_max_age |
3600 |
max-age in Cache-Control for regular files |
immutable_max_age |
31536000 |
max-age for hashed files (1 year) |
immutable_pattern |
r"\.[a-f0-9]{8,}\." |
Regex to detect hashed filenames (e.g. styles.a1b2c3d4.css) |
chunk_size |
65536 |
Stream chunk size in bytes (64 KB) |
sync_threshold |
65536 |
Read files ≤ this size in a single thread call (skips async generator) |
charset |
"utf-8" |
Charset for text-based content types |
brotli |
True |
Look for .br pre-compressed variants |
gzip |
True |
Look for .gz pre-compressed variants |
max_cache_size |
64 |
Max entries in the native StatCache (stores size, mtime_ns tuples) |
cors |
False |
Shortcut: add Access-Control-Allow-Origin: * (legacy; prefer cors_allow_origins) |
cors_allow_origins |
None |
List of allowed origins (e.g. ["https://app.com"] or ["*"]); CORS preflight is handled accordingly; non-wildcard responses include Vary: Origin |
security_headers |
True |
Add X-Content-Type-Options: nosniff and X-Frame-Options: DENY |
hsts |
None |
Strict-Transport-Security header value (e.g. "max-age=31536000; includeSubDomains") |
csp |
None |
Content-Security-Policy header value |
referrer_policy |
None |
Referrer-Policy header value (e.g. "no-referrer") |
permissions_policy |
None |
Permissions-Policy header value |
mime_types |
None |
Dict of extension → MIME type overrides (e.g. {".epub": "application/epub+zip"}) |
skip_compress_extensions |
{".jpg", ".png", ".gif", ...} |
Extensions excluded from on-the-fly compression |
manifest_path |
None |
Path to a staticfiles.json / Webpack / Vite manifest; listed files are served as immutable |
autocompress |
False |
On-the-fly gzip/brotli compression with in-memory LRU cache (per-process) |
autocompress_max_size |
1_048_576 |
Skip on-the-fly compression for files larger than this (bytes) |
on_request |
None |
Callable (sync or async) invoked after every served request with a dict of metadata |
autorefresh |
False |
Clear path and stat caches on every request (dev mode); auto-enabled by the Django wrapper when settings.DEBUG is True |
path_resolver |
None |
Optional callable (path: str) -> Path | None called when the standard resolution misses; used by the Django integration to plug staticfiles.finders |
health_check_path |
None |
Path (e.g. "/healthz") for a fixed 200 OK reply with Cache-Control: no-store; bypasses the file pipeline |
request_id_header |
None |
Header name (e.g. "X-Request-ID") for per-request correlation; echoes incoming value or generates a UUID4 hex; exposed in on_request info as request_id |
error_responses |
{404: b"Not Found", 405: b"Method Not Allowed", 416: b"Range Not Satisfiable"} |
Customize response bodies for error status codes; {} for empty bodies |
log_level |
"INFO" |
Logging level ("DEBUG", "INFO", "WARNING", etc.); None disables logging entirely |
app = WhiteSnout(
app=my_asgi_app,
directory="public",
cache_max_age=86400,
immutable_max_age=31536000,
chunk_size=131072,
)
Features
- ASGI-native — middleware or standalone, works with any ASGI framework
- Streaming — files are served in configurable chunks (64 KB by default), never loaded entirely into memory
- Zero-copy pre-compression — serves pre-existing
.gzand.brfiles with automaticAccept-Encodingnegotiation; brotli preferred over gzip - On-the-fly compression — opt-in
autocompress=Truecompresses uncompressed assets on first request and caches the result in memory - Powerful caching —
ETag,Last-Modified,Cache-Control,Vary: Accept-Encodingheaders; 304 Not Modified responses for conditional requests - Immutable cache — detects hashed filenames via regex or a Django/Webpack/Vite manifest, then applies
Cache-Control: public, immutable, max-age=31536000 - Index files —
index.htmlserved automatically for directory paths - Clean URLs —
/dirredirects to/dir/(301 Moved Permanently) - Path traversal protection — resolved paths are verified to stay within the root directory
- Low overhead — LRU cache for file stats reduces
stat()syscalls; no required runtime dependencies - MIME types — content-type detection for 100+ file extensions with
mime_types={...}overrides - Hardened security headers —
HSTS,CSP,Referrer-Policy,Permissions-Policycontrollable via config - CORS allowlist —
cors_allow_origins=[...]matches per-requestOriginand emitsVary: Origin - Observability hook —
on_request=callableis invoked after every served request with method/path/status/length/elapsed metadata - Compress CLI —
python -m whitesnout compress <directory>generates pre-compressed.gzand.brfiles as a build step - Rust extension —
whitesnout._rsspeeds up the LRU cache, stat cache, response building, header parsing, and date comparison transparently; pure Python fallback when unavailable - Native StatCache — stores
(size, mtime_ns)as a Rust struct instead of Pythonos.stat_result, reducing GC pressure and memory overhead - Django integration —
whitesnout.django.get_static_application()wires Django ASGI + STATIC_ROOT + manifest in one call - Configurable error bodies — customize 404/405/416 responses, or set
{}for empty bodies - Silencable logging — set
log_level=Noneto disable all logging output - Multiple directories — serve from additional directories and individual files via
add_directory()/add_files()with runtime registration and removal - Multi-platform wheels — pre-built for Linux (x86_64, arm64), macOS (x86_64, arm64), and Windows (amd64)
Error responses
By default, whitesnout returns 404 Not Found, 405 Method Not Allowed, and 416 Range Not Satisfiable with matching text bodies. Customize them via error_responses:
app = WhiteSnout(
directory="static",
error_responses={404: b"File not found"},
)
# Empty bodies for all errors
app = WhiteSnout(directory="static", error_responses={})
When an inner ASGI app is configured, 404 and 405 errors are delegated to it instead.
Logging
Request logging is enabled by default at INFO level. Control it via log_level:
# Default INFO logging (method, path, status, bytes, duration)
app = WhiteSnout(directory="static")
# Custom level
app = WhiteSnout(directory="static", log_level="WARNING")
# Completely silent
app = WhiteSnout(directory="static", log_level=None)
Multiple directories & extra files
Use add_directory() and add_files() to serve content from multiple locations:
from fastapi import FastAPI
from whitesnout import WhiteSnout
app = FastAPI()
@app.get("/api/health")
def health():
return {"status": "ok"}
ws = WhiteSnout(app, directory="frontend/dist")
# Extra directories
ws.add_directory("/media/uploads", "/mnt/storage/uploads")
ws.add_directory("/avatars", "/var/avatars")
# Individual files (take precedence over directories)
ws.add_files({
"/.well-known/security.txt": "security/security.txt",
"/favicon.ico": "branding/favicon.ico",
})
# Dynamic registrations at runtime
ws.remove_files("/favicon.ico")
ws.remove_directory("/avatars")
Resolution order: add_files() → add_directory() → main directory → inner ASGI app.
Hardened security headers
Enable strict transport security, content security policy, referrer policy, and permissions policy via constructor kwargs. They are appended to every static response:
app = WhiteSnout(
directory="static",
hsts="max-age=31536000; includeSubDomains; preload",
csp="default-src 'self'; img-src 'self' data:",
referrer_policy="strict-origin-when-cross-origin",
permissions_policy="geolocation=(), camera=()",
)
X-Content-Type-Options: nosniff and X-Frame-Options: DENY are still controlled by security_headers=True/False (on by default).
CORS allowlist
# Whitelist specific origins (preferred). Each match returns Vary: Origin.
app = WhiteSnout(
directory="static",
cors_allow_origins=["https://app.example.com", "https://admin.example.com"],
)
# Wildcard (legacy behavior; same as cors=True)
app = WhiteSnout(directory="static", cors_allow_origins=["*"])
OPTIONS preflights are answered with 204 when the request origin matches. Unmatched origins return 405 (or fall through to your inner app if configured).
Custom MIME types
Override or extend the built-in MIME table per instance:
app = WhiteSnout(
directory="static",
mime_types={
".epub": "application/epub+zip",
".webmanifest": "application/manifest+json",
},
)
The lookup runs after Rust derives the default content-type, so overrides win without sacrificing the hot path for everything else.
Manifest-based immutable caching
Whitesnout reads three manifest formats out of the box. Listed files get Cache-Control: public, immutable, max-age=31536000 regardless of the immutable_pattern regex.
app = WhiteSnout(
directory="static",
manifest_path="static/staticfiles.json", # Django, Webpack, or Vite
)
Supported shapes:
- Django
ManifestStaticFilesStorage—{"paths": {"app.css": "app.abc123.css"}} - Webpack —
{"app.js": "app.abc123.js"} - Vite —
{"src/main.ts": {"file": "assets/main.abc123.js"}}
This handles modern bundlers' hashed filenames that don't match the default regex (e.g. app-Abc123.js).
On-the-fly compression
Useful when running without a build-time compression step, or for dynamic directories where assets land at runtime:
app = WhiteSnout(
directory="static",
autocompress=True, # opt-in
autocompress_max_size=1_048_576, # skip files > 1 MB
skip_compress_extensions={
".jpg", ".png", ".webp", ".gz", ".br",
},
)
The first request triggers compression in a background thread and caches the result in a bounded in-memory LRU (per process, keyed by (path, mtime_ns, encoding)). Subsequent requests for the same file serve the cached bytes. Pre-compressed .gz / .br files still win when they exist on disk — autocompress is the fallback.
Brotli requires whitesnout[compress] installed; without it, only gzip is used.
Observability hook
def on_request(info: dict) -> None:
# info: method, path, status, length, elapsed_s, scope
metrics.timing("whitesnout.request_ms", info["elapsed_s"] * 1000)
metrics.incr(f"whitesnout.status.{info['status']}")
app = WhiteSnout(directory="static", on_request=on_request)
Async callables are awaited; exceptions raised by the hook are logged and swallowed so they never break a response. Use for OpenTelemetry spans, Prometheus counters, structured access logs, etc.
OpenTelemetry helper
If you use OpenTelemetry, drop in the bundled adapter:
from whitesnout import WhiteSnout
from whitesnout.otel import OpenTelemetryHook
hook = OpenTelemetryHook() # uses the global tracer provider
app = WhiteSnout(directory="static", on_request=hook)
Each request becomes a span named "{METHOD} {path}" with HTTP semconv attributes (http.request.method, http.response.status_code, http.response.body.size, url.path). 5xx responses set the span status to ERROR. Pair with opentelemetry-instrumentation-asgi on the inner app to get a full trace tree. Optional dep: opentelemetry-api.
Prometheus helper
If you use prometheus-client, drop in the bundled adapter:
from prometheus_client import make_asgi_app
from whitesnout import WhiteSnout
from whitesnout.prometheus import PrometheusHook
hook = PrometheusHook()
app = WhiteSnout(directory="static", on_request=hook)
# Expose /metrics via prometheus_client.make_asgi_app() mounted on a sibling
# route in your framework (FastAPI, Starlette, Django).
Metrics emitted:
whitesnout_requests_total{method,status}— counterwhitesnout_response_bytes_total{method}— counterwhitesnout_request_duration_seconds{method,status}— histogram
Request-ID propagation
Pass request_id_header="X-Request-ID" and WhiteSnout will:
- Echo the incoming header value back on the response if present, or
- Generate a fresh UUID4 hex (32 chars, no dashes) when missing, and add it to the response.
The same value is exposed to on_request hooks via info["request_id"], so structured loggers and tracing exporters can stitch every request together with the upstream proxy and downstream app:
app = WhiteSnout(
inner_app,
directory="static",
request_id_header="X-Request-ID",
on_request=lambda info: logger.info(
"static", extra={"request_id": info["request_id"], **info}
),
)
The same header name is used for both directions — if your reverse proxy already inserts X-Request-Id, WhiteSnout will pick it up, not double it.
Health check endpoint
For load balancers and Kubernetes probes, set health_check_path. The reply is 200 OK with text/plain body OK and Cache-Control: no-store — it bypasses the static file pipeline (no disk I/O, no cache lookups):
app = WhiteSnout(directory="static", health_check_path="/healthz")
The check fires the on_request hook with status 200 like any other request, so it shows up in your metrics.
Django integration
Whitesnout is ASGI-native, so the Django integration only supports Django ASGI deployments (the
asgi.pypattern). For Django WSGI / classicrunserver, keep usingwhitenoise.
asgi.py wrapper
# asgi.py
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()
from whitesnout.django import get_static_application
application = get_static_application()
get_static_application() reads:
STATIC_ROOT→directorySTATIC_URL→ mount prefix when not/static/STATICFILES_STORAGEorSTORAGES["staticfiles"]["BACKEND"]→ if a Manifest storage is configured, hooksstatic/staticfiles.jsonautomaticallyDEBUG→ if True andautorefreshis unset, enablesautorefresh=True
Pass overrides through to WhiteSnout:
application = get_static_application(
hsts="max-age=31536000; includeSubDomains",
cors_allow_origins=["https://app.example.com"],
)
Development without collectstatic
application = get_static_application(use_finders=True, autorefresh=True)
use_finders=True wires a path_resolver that calls django.contrib.staticfiles.finders.find() for every request that misses STATIC_ROOT. Combined with autorefresh=True, every request re-resolves and re-stats the file so edits show up immediately. Both options are typically gated by settings.DEBUG.
collectstatic with compression in one step
Replace Django's default storage with whitesnout's compressed manifest storage to emit .gz + .br siblings during manage.py collectstatic. No separate build step needed.
# settings.py (Django 4.2+)
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitesnout.storage.CompressedManifestStaticFilesStorage",
},
}
# Django < 4.2
STATICFILES_STORAGE = "whitesnout.storage.CompressedManifestStaticFilesStorage"
Two variants are shipped:
whitesnout.storage.CompressedStaticFilesStorage— no hashing, just gzip/brotli siblingswhitesnout.storage.CompressedManifestStaticFilesStorage— Django'sManifestStaticFilesStorage+ gzip/brotli (recommended for production)
Brotli output requires whitesnout[compress] installed in the build environment.
Examples
End-to-end deployments live in examples/:
fastapi-spa/— FastAPI + Vite SPA with manifest, SPA fallback, hardened headers, autocompressdjango-asgi/— Django ASGI withget_static_application()+CompressedManifestStaticFilesStorage+ dev mode (use_finders/autorefresh)starlette/— Starlette + multi-directory mount +add_filesoverrides +on_requestobservability hooklitestar/— Litestar + Prometheus metrics + health endpoint + per-request correlation IDquart/— Quart (async Flask) + on-the-fly compression + request-id propagation
Each example is self-contained — cd in, install requirements, uvicorn to run.
Migration from Whitenoise
| Whitenoise option | Whitesnout equivalent |
|---|---|
WHITENOISE_ROOT / root= |
directory= |
WHITENOISE_USE_FINDERS |
Not applicable — point directory= at STATIC_ROOT after collectstatic |
WHITENOISE_MAX_AGE |
cache_max_age= |
WHITENOISE_IMMUTABLE_FILE_TEST |
immutable_pattern= + manifest_path= |
WHITENOISE_ALLOW_ALL_ORIGINS |
cors_allow_origins=["*"] (or cors=True) |
WHITENOISE_AUTOREFRESH |
autorefresh=True (auto-enabled by whitesnout.django.get_static_application() when settings.DEBUG is True) |
WHITENOISE_USE_FINDERS |
get_static_application(use_finders=True) |
CompressedManifestStaticFilesStorage |
whitesnout.storage.CompressedManifestStaticFilesStorage |
WHITENOISE_MIMETYPES |
mime_types={...} |
WHITENOISE_ADD_HEADERS_FUNCTION |
on_request=callable for observability; use mime_types + the security-header configs for static additions |
WHITENOISE_KEEP_ONLY_HASHED_FILES |
Handled by Django's STATICFILES_STORAGE; whitesnout reads the resulting manifest |
add_files() (whitenoise) |
add_files({...}) |
WhiteNoise(application, root=...) |
WhiteSnout(application, directory=...) |
ASGI-specific: whitenoise is WSGI-only and must be wrapped in WsgiToAsgi when used with ASGI servers, paying the adapter cost on every request. Whitesnout is ASGI-native.
Production deployment
WhiteSnout is a regular ASGI app. It does not open ports, terminate TLS, or spawn workers — that is the ASGI server's job. The recipe below is what we run in production.
Behind a reverse proxy (recommended)
[client] -> [nginx / Caddy / Traefik] -> [uvicorn workers] -> [WhiteSnout]
The proxy terminates TLS, applies rate limits, and serves large static binaries (>10 MB) via sendfile if you point it at the same static/ directory. WhiteSnout handles everything else — manifest-hashed assets, range requests, conditional GETs, on-the-fly compression, security headers.
Minimal uvicorn invocation:
$ uvicorn myapp:app \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--proxy-headers \
--forwarded-allow-ips='*' \
--log-level warning
--workers 4 matches roughly 2× CPU cores. WhiteSnout is async-safe with multiple workers — each worker has its own in-process cache, which is the trade-off you want (no shared-memory invalidation).
Graceful shutdown
WhiteSnout handles ASGI lifespan events natively. When the host server starts draining (SIGTERM, kubectl rollout, etc.), WhiteSnout responds lifespan.shutdown.complete immediately — there is no background work, queue, or open connection to flush.
Pair with uvicorn --timeout-graceful-shutdown 30 (default 30s) so in-flight requests finish before the worker exits.
WebSockets
WhiteSnout does not serve WebSockets itself, but it transparently passes WebSocket scopes through to the inner ASGI app:
from fastapi import FastAPI, WebSocket
from whitesnout import WhiteSnout
api = FastAPI()
@api.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
await websocket.send_text("hi")
app = WhiteSnout(api, directory="static")
# /ws -> handled by FastAPI
# /any.css -> handled by WhiteSnout
The HTTP path is the only one WhiteSnout intercepts. WebSocket, lifespan, and any other scope type goes straight to the inner app with no overhead.
Kubernetes
Suggested probe config (assuming health_check_path="/healthz"):
readinessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 10
failureThreshold: 3
The endpoint is Cache-Control: no-store and bypasses the file pipeline, so probes never poison the cache.
Docker
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir whitesnout uvicorn[standard]
COPY . .
CMD ["uvicorn", "myapp:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
WhiteSnout ships pre-compressed wheels for manylinux_2_28_x86_64, aarch64, macOS x86/arm, and Windows — no Rust toolchain needed inside the image. Wheels install in milliseconds.
Performance tuning
Defaults are tuned for typical SPAs (small JS/CSS bundles, a few MB total). For larger asset sets, two knobs help:
| Knob | Default | When to change |
|---|---|---|
max_cache_size |
64 | Set to roughly the count of unique URLs you serve. Hits stay in the path/stat cache; misses re-stat the FS. For 10k-file sites, 1024–4096. |
sync_threshold |
65536 bytes (64 KiB) | Files at or below this size use a fast-path single send() (skips async generator overhead). Raise to 131072 if 95% of your assets are under 128 KiB and you have memory headroom; lower if you serve large media. |
chunk_size |
65536 bytes | Stream chunk size for files above sync_threshold. Match this to your network MSS or the proxy buffer. Larger = fewer ASGI sends, higher RAM per request. |
autocompress |
False |
Enable only when you cannot run whitesnout compress dir/ ahead of time. Compression is cached in-memory by (path, mtime, encoding) — first request pays the CPU cost; the rest are free. |
autocompress_max_size |
1 MiB | Cap on the source file size considered for on-the-fly compression. Large files should be pre-compressed. |
Pre-compress over autocompress
whitesnout compress static/ (or CompressedManifestStaticFilesStorage for Django) emits .gz and .br siblings at build time. Serving them is zero CPU at request time — WhiteSnout stats the variant and sends it directly. autocompress=True is a fallback for dev or for files that change at runtime.
Disable what you do not need
app = WhiteSnout(
directory="static",
log_level=None, # skip access-log formatting
security_headers=False, # if your reverse proxy emits them
brotli=False, # if you do not ship .br variants
)
log_level=None saves a time.perf_counter() call and the headers walk on every request. Worth it under heavy load if you log at the proxy.
What the Rust hot path does
The Rust extension (whitesnout._rs) fuses find_compressed + stat + header construction into a single FFI call (build_full_response_v2). On a cache hit it returns roughly 1µs of headers; on a miss it returns ready-to-send bytes. If the extension is unavailable (no wheel for your arch), the Python fallback kicks in transparently — slower but functionally identical, exercised by tests/test_fallbacks.py.
Architecture
whitesnout/
├── main.py # ASGI middleware (always Python)
├── file_handler.py # Path resolution, compression negotiation
├── response.py # Header building, chunked streaming, 304
├── cache.py # LRU cache (Python → falls back to Rust)
├── config.py # Configuration dataclass
├── utils.py # MIME type table, helpers
├── cli.py # CLI entry point
├── compress.py # Compression logic
├── manifest.py # Django/Webpack/Vite manifest loader
├── autocompress.py # In-memory LRU + gzip/brotli on-the-fly
├── django.py # Django ASGI integration (get_static_application)
├── storage.py # Django staticfiles storage backend (collectstatic + compress)
├── _rs.pyi # Type stubs for the Rust extension
└── py.typed
examples/
├── fastapi-spa/ # FastAPI + Vite SPA
├── django-asgi/ # Django ASGI + collectstatic compress storage
└── starlette/ # Multi-directory + observability hook
whitesnout._rs # Compiled Rust extension (PyO3)
├── LRUCache # Generic LRU cache (O(1) via `lru` crate)
├── StatCache # Native stat cache (stores size, mtime_ns without PyObject)
├── response # compute_etag, format_last_modified, build_headers,
│ # check_304, parse_range, build_content_range, etc.
├── utils # guess_content_type (100+ MIME types)
└── file_handler # find_compressed, parse_accept_encoding, is_hashed_file
The core logic consists of pure functions designed for gradual migration to Rust. The ASGI integration layer (main.py) stays in Python forever — it is the thin touchpoint with the ASGI protocol.
Development
With Docker (recommended)
$ make build # Build Docker image + compile Rust + install deps
$ make test # Run test suite inside container
$ make shell # Open interactive shell in container
$ make release # Build release wheel
Requires Docker. The image is based on rust:slim-trixie with Python, uv, and maturin pre-installed.
Without Docker
$ uv sync --dev # Install Python deps + build Rust extension
$ uv run pytest -v # Run tests
$ maturin develop --uv # Rebuild Rust extension only
Requires Rust (via rustup) and maturin (cargo install maturin).
CHANGELOG
See CHANGELOG.md for the full release history.
Benchmark
Results measured with benchmarks/benchmark.py (median of 15 runs) — 500 requests (10 concurrent) against uvicorn with a mix of static files (265 KB across 34 items) and a JSON API endpoint.
- RPS — Requests per second (higher is better)
- P50 — Median latency in milliseconds (lower is better)
- P99 — 99th percentile latency in milliseconds (lower is better)
- RAM — Resident set size in megabytes (lower is better)
| Server | RPS | P50 (ms) | P99 (ms) | RAM (MB) |
|---|---|---|---|---|
| whitesnout | 845 | 6.0 | 82.2 | 33.3 |
| whitenoise | 778 | 6.0 | 86.1 | 31.7 |
Platform: Linux x86_64 (bare metal) · Python: 3.14.5 · uvicorn: 0.47.0 · WSGI bridge:
a2wsgiBoth servers run behind uvicorn for an apples-to-apples comparison. Whitenoise is WSGI-only, so it goes through
a2wsgi.WSGIMiddleware; whitesnout speaks ASGI natively. Variance is ±10% on busy machines.
nginx baseline (ceiling reference)
Both whitesnout and whitenoise are Python ASGI/WSGI servers — they cap out at roughly Python's request-handling rate. For honest context, run the nginx baseline (single-worker, sendfile-enabled) on the same machine:
$ uv run python benchmarks/benchmark_nginx.py
nginx will typically land in the 10–20k RPS range on the same workload. Use it as a "what's the hardware ceiling?" reference, not a peer comparison — whitesnout's value is being part of your Python application process, not replacing a CDN edge.
Running yourself
$ uv run python benchmarks/benchmark.py # whitesnout vs whitenoise
$ uv run python benchmarks/benchmark_nginx.py # nginx ceiling (requires nginx on PATH)
Stability & security
- STABILITY.md — versioning, deprecation policy, public API surface.
- SECURITY.md — vulnerability disclosure and supply-chain notes.
Releases land on PyPI via GitHub Actions Trusted Publishing (OIDC); wheels are
attested with sigstore and verifiable from the release artifacts.
License
MIT — see LICENSE for the full text.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file whitesnout-2.1.0.tar.gz.
File metadata
- Download URL: whitesnout-2.1.0.tar.gz
- Upload date:
- Size: 156.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
309f177a1645e4beab9214c51df4451407b162a18bce39406d0b7f14060525f5
|
|
| MD5 |
f012c1c399459c597408637308990fa6
|
|
| BLAKE2b-256 |
f1d4d211bb58f588d00fc9d2c52e06c076fbf93a62ce5a8cc906511af08ad52c
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0.tar.gz:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0.tar.gz -
Subject digest:
309f177a1645e4beab9214c51df4451407b162a18bce39406d0b7f14060525f5 - Sigstore transparency entry: 1601769342
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-win_amd64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-win_amd64.whl
- Upload date:
- Size: 870.7 kB
- Tags: CPython 3.10+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
871968e0be3074db3c280af25b15650f03c49d23f31970b3076ea3ed920c8237
|
|
| MD5 |
5798d3e41942757ab38be822a396240b
|
|
| BLAKE2b-256 |
1ddd9f9f4f0e991a89e419aab4b5a08bf3184059164e6ccd0737d8fdfcd9b5fe
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-win_amd64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-win_amd64.whl -
Subject digest:
871968e0be3074db3c280af25b15650f03c49d23f31970b3076ea3ed920c8237 - Sigstore transparency entry: 1601770456
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 1.2 MB
- Tags: CPython 3.10+, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
016ee9f8057f4802e44de61302a1d7a8186b289e04fbf4a405e89020bca25b3a
|
|
| MD5 |
00f5c68273d4474bc7895fd63340670d
|
|
| BLAKE2b-256 |
3605090b65c1efa99dbb69f676a95e501c62edf3a9d399a7fb12af56e9402481
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-musllinux_1_2_x86_64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-musllinux_1_2_x86_64.whl -
Subject digest:
016ee9f8057f4802e44de61302a1d7a8186b289e04fbf4a405e89020bca25b3a - Sigstore transparency entry: 1601769741
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 1.1 MB
- Tags: CPython 3.10+, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f47c413e2ed4cfeaf0334030878853add16fbfdb6e111b1e7539897cbd5e713f
|
|
| MD5 |
1ab308e5c64d0ea2b633a50a64c7a255
|
|
| BLAKE2b-256 |
b3156eca1c4c6a305793f8d30b4b5a53b4c604362c3970ec99c328d8ca139512
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-musllinux_1_2_aarch64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-musllinux_1_2_aarch64.whl -
Subject digest:
f47c413e2ed4cfeaf0334030878853add16fbfdb6e111b1e7539897cbd5e713f - Sigstore transparency entry: 1601769987
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 1.1 MB
- Tags: CPython 3.10+, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
664549b7cf46e2c35be3753bffe305ce5ef0d3720f8cb67d3a104d83699b9da5
|
|
| MD5 |
144d2cfb9bc30971f5822368e0b2b0d8
|
|
| BLAKE2b-256 |
3eade0b2d90662ed54277c34e47d915b88d934f7ba63ca9fbd5d726bb3a56f26
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-manylinux_2_28_x86_64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-manylinux_2_28_x86_64.whl -
Subject digest:
664549b7cf46e2c35be3753bffe305ce5ef0d3720f8cb67d3a104d83699b9da5 - Sigstore transparency entry: 1601770194
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-manylinux_2_28_aarch64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-manylinux_2_28_aarch64.whl
- Upload date:
- Size: 1.1 MB
- Tags: CPython 3.10+, manylinux: glibc 2.28+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
81fa17022dcf909ea5f684247ac01361b7ba7a5fc9aa587e409e6f2a02c16458
|
|
| MD5 |
c47296ea3c0b3bd39199cd3c1ba235a6
|
|
| BLAKE2b-256 |
446dbd7528d82e35d646f38e6a4e270c3cad1b61ba30773256ca1e89897158a6
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-manylinux_2_28_aarch64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-manylinux_2_28_aarch64.whl -
Subject digest:
81fa17022dcf909ea5f684247ac01361b7ba7a5fc9aa587e409e6f2a02c16458 - Sigstore transparency entry: 1601770331
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 933.6 kB
- Tags: CPython 3.10+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
359c0a6d88e2c377bedaf2f0304fad5c6a576b398ee94acc4e09e7ad741b231f
|
|
| MD5 |
e6f0d93b33063d6c85d67a1eef4c5ad4
|
|
| BLAKE2b-256 |
cfd6745470da8b5352351820052ce100b24d0c89744cc18f16d374c14b46f79d
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-macosx_11_0_arm64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-macosx_11_0_arm64.whl -
Subject digest:
359c0a6d88e2c377bedaf2f0304fad5c6a576b398ee94acc4e09e7ad741b231f - Sigstore transparency entry: 1601769634
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type:
File details
Details for the file whitesnout-2.1.0-cp310-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: whitesnout-2.1.0-cp310-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 981.9 kB
- Tags: CPython 3.10+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7668b031952326f6b0bd6793881cbc938e1163f8cebfb2757be89a99c6deb36e
|
|
| MD5 |
6ca54adcbaac9c8c49fa58571e54eb46
|
|
| BLAKE2b-256 |
6b46854ba4fce53bd11e6e4bff8b66bef142e954422503c68a33c3cc61af09e7
|
Provenance
The following attestation bundles were made for whitesnout-2.1.0-cp310-abi3-macosx_10_12_x86_64.whl:
Publisher:
publish.yml on rroblf01/whitesnout
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
whitesnout-2.1.0-cp310-abi3-macosx_10_12_x86_64.whl -
Subject digest:
7668b031952326f6b0bd6793881cbc938e1163f8cebfb2757be89a99c6deb36e - Sigstore transparency entry: 1601769519
- Sigstore integration time:
-
Permalink:
rroblf01/whitesnout@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Branch / Tag:
refs/tags/v2.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f9f93b4776e68aec81036404888f2f2e858e1f03 -
Trigger Event:
push
-
Statement type: