The little ASGI library that shines.
Starlette provides efficient static file serving with automatic content type detection, HTTP caching, range request support, and security features for serving CSS, JavaScript, images, and other static assets.
from starlette.staticfiles import StaticFiles
from starlette.responses import Response
from starlette.types import Scope
from typing import List, Optional
from os import PathLike
import os
class StaticFiles:
"""
ASGI application for serving static files.
Features:
- Automatic content type detection
- HTTP caching headers (ETag, Last-Modified)
- Range request support for partial content
- Directory traversal protection
- Index file serving
- Package-based file serving
"""
def __init__(
self,
directory: str | PathLike[str] | None = None,
packages: List[str | tuple[str, str]] | None = None,
html: bool = False,
check_dir: bool = True,
follow_symlink: bool = False,
) -> None:
"""
Initialize static file server.
Args:
directory: Directory path to serve files from
packages: Python packages to serve files from
html: Whether to serve .html files for extensionless URLs
check_dir: Whether to check directory exists at startup
follow_symlink: Whether to follow symbolic links
"""
self.directory = directory
self.packages = packages or []
self.html = html
self.check_dir = check_dir
self.follow_symlink = follow_symlink
if check_dir:
self.check_config()
def check_config(self) -> None:
"""
Validate static files configuration.
Raises:
RuntimeError: If configuration is invalid
"""
def get_directories(
self,
directory: str | None = None,
packages: List[str | tuple[str, str]] | None = None,
) -> List[PathLike[str]]:
"""
Get list of directories to serve files from.
Args:
directory: Override directory
packages: Override packages
Returns:
List of directory paths to search for files
"""
def get_path(self, scope: Scope) -> str:
"""
Get file path from request scope.
Args:
scope: ASGI request scope
Returns:
File path relative to served directories
"""
def lookup_path(self, path: str) -> tuple[str | None, os.stat_result | None]:
"""
Find file in configured directories.
Args:
path: Relative file path
Returns:
Tuple of (full_path, stat_result) or (None, None) if not found
"""
def get_response(self, path: str, scope: Scope) -> Response:
"""
Create response for file path.
Args:
path: File path to serve
scope: ASGI request scope
Returns:
HTTP response for the file
"""
@staticmethod
def file_response(
full_path: str | PathLike[str],
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
"""
Create optimized file response.
Args:
full_path: Full path to file
stat_result: File statistics
scope: ASGI request scope
status_code: HTTP status code
Returns:
FileResponse or NotModifiedResponse
"""
@staticmethod
def is_not_modified(
response_headers: Headers,
request_headers: Headers,
) -> bool:
"""
Check if file has been modified based on HTTP headers.
Args:
response_headers: Response headers (ETag, Last-Modified)
request_headers: Request headers (If-None-Match, If-Modified-Since)
Returns:
True if file has not been modified
"""
class NotModifiedResponse(Response):
"""
HTTP 304 Not Modified response.
Returned when client's cached version is still current
based on ETag or Last-Modified headers.
"""
def __init__(self, headers: Mapping[str, str] | None = None) -> None:
super().__init__(status_code=304, headers=headers)from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.staticfiles import StaticFiles
# Serve files from directory
app = Starlette(routes=[
Mount("/static", StaticFiles(directory="static"), name="static"),
])
# Directory structure:
# static/
# ├── css/
# │ └── style.css
# ├── js/
# │ └── app.js
# └── images/
# └── logo.png
# URLs will be:
# /static/css/style.css -> static/css/style.css
# /static/js/app.js -> static/js/app.js
# /static/images/logo.png -> static/images/logo.pngfrom starlette.routing import Mount
app = Starlette(routes=[
# Different mount points for different asset types
Mount("/css", StaticFiles(directory="assets/css"), name="css"),
Mount("/js", StaticFiles(directory="assets/js"), name="js"),
Mount("/images", StaticFiles(directory="assets/images"), name="images"),
# General static files
Mount("/static", StaticFiles(directory="static"), name="static"),
])# Serve files from Python package
app = Starlette(routes=[
Mount("/assets", StaticFiles(packages=["mypackage"]), name="assets"),
])
# Serve from multiple packages
app = Starlette(routes=[
Mount("/vendor", StaticFiles(packages=[
"bootstrap",
"jquery",
("mylib", "static"), # Serve mylib/static/ directory
]), name="vendor"),
])
# Package structure:
# mypackage/
# ├── __init__.py
# ├── static/
# │ ├── style.css
# │ └── script.js
# URLs will be:
# /assets/style.css -> mypackage/static/style.css
# /assets/script.js -> mypackage/static/script.js# Enable HTML mode for SPA (Single Page Application) support
app = Starlette(routes=[
Mount("/", StaticFiles(directory="dist", html=True), name="spa"),
])
# With html=True:
# /about -> looks for dist/about.html or dist/about/index.html
# / -> looks for dist/index.html
# /dashboard/users -> looks for dist/dashboard/users.html
# Without html=True (default):
# /about.html -> dist/about.html (exact match only)# Follow symbolic links (security consideration)
app = Starlette(routes=[
Mount("/static", StaticFiles(
directory="static",
follow_symlink=True # Default: False for security
), name="static"),
])
# Security note: Only enable follow_symlink if you trust
# all symbolic links in the directory tree# Disable directory check at startup (for dynamic directories)
app = Starlette(routes=[
Mount("/uploads", StaticFiles(
directory="user_uploads",
check_dir=False # Don't validate directory exists at startup
), name="uploads"),
])
# Useful when directory might be created after application startsfrom starlette.routing import Route, Mount
from starlette.responses import HTMLResponse
async def homepage(request):
# Generate URLs for static files
css_url = request.url_for("static", path="css/style.css")
js_url = request.url_for("static", path="js/app.js")
image_url = request.url_for("static", path="images/logo.png")
html = f"""
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="{css_url}">
</head>
<body>
<img src="{image_url}" alt="Logo">
<script src="{js_url}"></script>
</body>
</html>
"""
return HTMLResponse(html)
app = Starlette(routes=[
Route("/", homepage),
Mount("/static", StaticFiles(directory="static"), name="static"),
])from starlette.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
async def template_page(request):
return templates.TemplateResponse("page.html", {
"request": request,
"title": "My Page"
})
# In templates/page.html:
# <link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
# <script src="{{ url_for('static', path='js/app.js') }}"></script>Static files automatically include caching headers:
# Automatic headers for static files:
# - ETag: Based on file modification time and size
# - Last-Modified: File modification timestamp
# - Content-Length: File size
# - Content-Type: Detected from file extension
# Client requests with caching headers:
# If-None-Match: "etag-value"
# If-Modified-Since: "timestamp"
# Server responds with:
# 304 Not Modified (if unchanged)
# 200 OK with file content (if changed)from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
class CacheControlMiddleware(BaseHTTPMiddleware):
"""Add Cache-Control headers to static files."""
def __init__(self, app, max_age: int = 31536000): # 1 year
super().__init__(app)
self.max_age = max_age
async def dispatch(self, request, call_next):
response = await call_next(request)
# Add cache headers for static files
if request.url.path.startswith("/static/"):
response.headers["Cache-Control"] = f"public, max-age={self.max_age}"
return response
# Apply to application
app.add_middleware(CacheControlMiddleware, max_age=86400) # 1 dayfrom starlette.middleware.gzip import GZipMiddleware
# Add compression for static files
app.add_middleware(GZipMiddleware, minimum_size=500)
# Compresses CSS, JS, HTML, and other text files
# Binary files (images, videos) are not compressed# StaticFiles automatically prevents directory traversal
# These requests are automatically rejected:
# /static/../../../etc/passwd
# /static/..%2F..%2Fetc%2Fpasswd
# /static/....//....//etc/passwd
# Safe paths only:
# /static/css/style.css ✓
# /static/subdir/file.js ✓import mimetypes
from starlette.staticfiles import StaticFiles
from starlette.responses import Response
class SecureStaticFiles(StaticFiles):
"""StaticFiles with content type restrictions."""
ALLOWED_TYPES = {
'text/css',
'text/javascript',
'application/javascript',
'image/png',
'image/jpeg',
'image/gif',
'image/svg+xml',
'font/woff',
'font/woff2',
'application/font-woff',
'application/font-woff2',
}
def get_response(self, path: str, scope) -> Response:
# Get the normal response
response = super().get_response(path, scope)
# Check content type
content_type = response.media_type
if content_type not in self.ALLOWED_TYPES:
# Return 403 for disallowed file types
return Response("Forbidden file type", status_code=403)
return response
# Use secure static files
app = Starlette(routes=[
Mount("/static", SecureStaticFiles(directory="static"), name="static"),
])class LimitedStaticFiles(StaticFiles):
"""StaticFiles with size limits."""
def __init__(self, *args, max_size: int = 10 * 1024 * 1024, **kwargs):
super().__init__(*args, **kwargs)
self.max_size = max_size # 10MB default
def get_response(self, path: str, scope) -> Response:
full_path, stat_result = self.lookup_path(path)
if stat_result and stat_result.st_size > self.max_size:
return Response("File too large", status_code=413)
return super().get_response(path, scope)import os
from starlette.staticfiles import StaticFiles
from starlette.responses import Response
class ProcessedStaticFiles(StaticFiles):
"""StaticFiles with custom processing."""
def get_response(self, path: str, scope) -> Response:
# Custom processing for CSS files
if path.endswith('.css'):
return self.process_css_file(path, scope)
# Custom processing for JS files
if path.endswith('.js'):
return self.process_js_file(path, scope)
# Default handling for other files
return super().get_response(path, scope)
def process_css_file(self, path: str, scope) -> Response:
"""Process CSS files (minification, variable substitution, etc.)"""
full_path, stat_result = self.lookup_path(path)
if not full_path:
return Response("Not found", status_code=404)
# Read and process CSS
with open(full_path, 'r') as f:
content = f.read()
# Custom processing (minification, variable replacement, etc.)
processed_content = self.minify_css(content)
return Response(
processed_content,
media_type="text/css",
headers={
"Last-Modified": format_date_time(stat_result.st_mtime),
"ETag": f'"{stat_result.st_mtime}-{stat_result.st_size}"',
}
)
def minify_css(self, content: str) -> str:
"""Basic CSS minification."""
# Remove comments
import re
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
# Remove extra whitespace
content = re.sub(r'\s+', ' ', content)
return content.strip()import json
from datetime import datetime
from starlette.responses import JSONResponse
class DynamicStaticFiles(StaticFiles):
"""Serve some files dynamically."""
def get_response(self, path: str, scope) -> Response:
# Generate manifest.json dynamically
if path == 'manifest.json':
return self.generate_manifest(scope)
# Generate sitemap.xml dynamically
if path == 'sitemap.xml':
return self.generate_sitemap(scope)
return super().get_response(path, scope)
def generate_manifest(self, scope) -> Response:
"""Generate web app manifest."""
manifest = {
"name": "My Web App",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"generated_at": datetime.now().isoformat(),
}
return JSONResponse(manifest, headers={
"Cache-Control": "no-cache" # Don't cache dynamic content
})
def generate_sitemap(self, scope) -> Response:
"""Generate XML sitemap."""
base_url = f"{scope['scheme']}://{scope['server'][0]}"
sitemap = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{base_url}/</loc>
<lastmod>{datetime.now().date()}</lastmod>
</url>
</urlset>"""
return Response(
sitemap,
media_type="application/xml",
headers={"Cache-Control": "no-cache"}
)from starlette.testclient import TestClient
import os
def test_static_files():
# Create test file
os.makedirs("test_static", exist_ok=True)
with open("test_static/test.css", "w") as f:
f.write("body { color: red; }")
app = Starlette(routes=[
Mount("/static", StaticFiles(directory="test_static"), name="static"),
])
client = TestClient(app)
# Test file serving
response = client.get("/static/test.css")
assert response.status_code == 200
assert response.text == "body { color: red; }"
assert response.headers["content-type"] == "text/css"
# Test 404 for missing file
response = client.get("/static/missing.css")
assert response.status_code == 404
# Test caching headers
response = client.get("/static/test.css")
assert "etag" in response.headers
assert "last-modified" in response.headers
# Test conditional request
etag = response.headers["etag"]
response = client.get("/static/test.css", headers={
"If-None-Match": etag
})
assert response.status_code == 304
def test_url_generation():
app = Starlette(routes=[
Route("/", lambda r: JSONResponse({"css_url": str(r.url_for("static", path="style.css"))})),
Mount("/static", StaticFiles(directory="static"), name="static"),
])
client = TestClient(app)
response = client.get("/")
assert response.json()["css_url"] == "http://testserver/static/style.css"import time
import statistics
def test_static_file_performance():
app = Starlette(routes=[
Mount("/static", StaticFiles(directory="static"), name="static"),
])
client = TestClient(app)
# Measure response times
times = []
for _ in range(100):
start = time.time()
response = client.get("/static/large_file.js")
end = time.time()
assert response.status_code == 200
times.append(end - start)
# Check performance metrics
avg_time = statistics.mean(times)
max_time = max(times)
assert avg_time < 0.1 # Average under 100ms
assert max_time < 0.5 # Max under 500msStatic file serving in Starlette provides efficient, secure, and flexible asset delivery with automatic optimization, caching, and security features suitable for production applications.
Install with Tessl CLI
npx tessl i tessl/pypi-starlette