Static Assets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Pyramid 2.1 (with pyramid_jinja2 2.10.1, WebTest 3.0.7 and setuptools below 82) on
Python 3.13, checked on 2026-10-07. Every Python example here was executed with webtest.TestApp or, where response
encodings matter, with WebOb’s own Request.get_response, and every name was checked against the official narrative
chapter on static assets, the API pages and the installed package source. The running example is the Bookshelf
Shelves scenario: public shelves of books whose pages need a stylesheet, a script, a favicon and a handful of
images, first on a laptop and then behind a CDN.
A web framework spends most of its requests on files that never change per user: stylesheets, scripts, images and
fonts. Pyramid treats them as assets, files that live inside a Python package, and gives you three things: a way to
address them independently of the filesystem (the asset specification), a way to serve them (add_static_view, an
ordinary route plus view underneath) and a way to link to them (request.static_url), which is also the hook for
cache busting and for moving the files to a CDN without touching a template. The serving part is deliberately modest.
It is correct and convenient in development, and in production it is usually replaced by a reverse proxy or a CDN
while the URL-generation part stays exactly as it is.
Asset Specifications
An asset is any file inside a Python package that is not a Python source file: a stylesheet, an image, a template.
Pyramid addresses assets with an asset specification, written package:path/inside/the/package, for example
bookshelf_shelves:static/css/shelf.css. The part before the colon is a dotted package name, the part after it is a
/-separated path relative to the package directory. Pyramid resolves it with the pkg_resources API (Pyramid 2.1
still imports it, which is why 2.1 needs a setuptools older than 82), which is also what makes assets overridable
(see Overriding Assets) and what lets a package installed as a wheel or zip serve its own files.
There is a second, relative form without a package name (static/css/shelf.css). Pyramid APIs that accept it
resolve it against a "current package" that each API documents: usually the package that contains the code making the
call. Relative specifications are convenient in a single package and fragile in shared helper code, so this page uses
full specifications everywhere. Where a Pyramid API takes a filesystem path, it generally also accepts an absolute
path, with the caveats noted below.
The examples need a package to point at. In a real project the cookiecutter has already created static/ inside the
application package (see Cookiecutter Projects & pserve).
This first block creates a small equivalent in a temporary directory so that the rest of the page can be run
unchanged: a stylesheet and its gzipped twin, a script, an index.html, a favicon and a second package that will play
the role of a theme later.
import gzip
import sys
import tempfile
from pathlib import Path
root = Path(tempfile.mkdtemp())
pkg = root / "shelves_demo"
theme = root / "theme_demo"
for sub in ("static/css", "static/js", "templates"):
(pkg / sub).mkdir(parents=True)
(theme / "static/css").mkdir(parents=True)
(pkg / "__init__.py").write_text("")
(theme / "__init__.py").write_text("")
css = "body { font-family: Georgia, serif; }\n" * 40
(pkg / "static/css/shelf.css").write_text(css)
(pkg / "static/css/shelf.css.gz").write_bytes(gzip.compress(css.encode()))
(pkg / "static/js/app.js").write_text("console.log('shelves');\n")
(pkg / "static/index.html").write_text("<h1>Shelves assets</h1>\n")
(pkg / "static/favicon.ico").write_bytes(b"\x00\x00\x01\x00")
(theme / "static/css/shelf.css").write_text("body { font-family: monospace; }\n")
sys.path.insert(0, str(root))
Source: Pyramid: Static Assets
Serving Assets with add_static_view
Configurator.add_static_view(name, path, **kw) registers a directory of files so that they are served below a URL
prefix. name is the prefix (static serves /static/...) and path is the directory, as an asset specification or
an absolute path. Everything under that directory is served recursively, the Content-Type comes from the file
extension, and by default the files are reachable by anonymous users. Underneath, Pyramid does nothing exotic: it
registers a route named __static/ (the name with a double underscore in front and a trailing slash) whose pattern is
static/*subpath, and a view on that route whose callable is an instance of pyramid.static.static_view created with
use_subpath=True. Because it is an ordinary route, it takes part in route ordering, shows up in proutes, accepts
route_prefix from config.include and can be given any add_route keyword argument (such as factory) through the
keyword arguments of add_static_view.
| Argument | Meaning |
|---|---|
|
URL prefix relative to the application root ( |
|
Directory to serve: asset specification ( |
|
Seconds for the |
|
List of encodings for which precompressed siblings exist ( |
|
Permission required to read the files. Default: no permission needed, even when the application has a default permission. |
any other keyword |
Passed to |
The block below serves the demo package and shows the pieces working together: a view that links to two assets, the
files served with the right content type and no cache headers, the directory URL answered with index.html, and a
missing file and a path-escape attempt both answered with 404.
from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp
def home(request):
css = request.static_url("shelves_demo:static/css/shelf.css")
js = request.static_path("shelves_demo:static/js/app.js")
return Response(f"{css} {js}")
with Configurator() as config:
config.add_static_view(name="static", path="shelves_demo:static")
config.add_route("home", "/")
config.add_view(home, route_name="home")
app = config.make_wsgi_app()
client = TestApp(app)
assert client.get("/").text == "http://localhost/static/css/shelf.css /static/js/app.js"
res = client.get("/static/css/shelf.css")
assert res.content_type == "text/css"
assert "Cache-Control" not in res.headers # cache_max_age defaults to None
assert client.get("/static/").text.startswith("<h1>") # index.html of the directory
client.get("/static/missing.css", status=404)
client.get("/static/%2e%2e/__init__.py", status=404) # cannot leave the directory
A few behaviours follow from that implementation and are worth knowing before they surprise you.
-
The view resolves
request.subpath, rejects any path that would leave the directory and answers 404. A request for a directory without a trailing slash is redirected (301) to the slash form and a directory URL serves the file named by the view’sindex(index.html) if it exists; there is no directory listing. -
Static views are matched by their route, which is added at the point of the
add_static_viewcall. A/{name}route registered before it can shadow/static/..., so keep the static view near the top of the configuration. -
A package that does not exist is a deprecation in 2.1: passing a path such as
nosuchpkg:staticstill works but warns that it will break in the future; point the view at a directory inside a real package instead (see Overriding Assets for the supported way to serve files from elsewhere). -
Files added to the directory while the process runs are not noticed by default because the view caches the list of files it has resolved. The
pyramid.reload_assetssetting (environment variablePYRAMID_RELOAD_ASSETS) turns that cache off, which suits development together withpserve --reload(the generateddevelopment.iniswitches onpyramid.reload_templatesonly); leave it off in production. The settings are covered in Settings, Environment & .ini Files. -
Pyramid’s own static view is a Python WSGI application served by your worker processes. It reads the file and streams it with WSGI’s file wrapper where the server supports it, which is perfectly fine for a small site, a development machine and a test suite, and it is the wrong place to spend worker time for a busy site; see Serving Assets in Production.
Generating Static URLs
You should never write /static/css/shelf.css by hand. request.static_url(spec, **kw) returns an absolute URL and
request.static_path(spec, **kw) the same URL without scheme and host; both take the asset specification of the
file and look up which static view or external base it belongs to. They accept the keyword arguments of route_url:
_query, _anchor, _app_url, _scheme, _host and _port. The benefit is the one every URL-generation API
offers: if you rename the prefix, move the view behind a route_prefix or switch to an external base URL, every
generated link follows.
If no static view or external base matches the specification, static_url raises ValueError
(No static URL definition matching ...), which is the usual symptom of a typo in the package name or of calling it
for a file outside any registered directory. The functions pyramid.url.static_url(path, request) and
static_path(path, request) are older spellings of the same API.
def urls(request):
return Response("\n".join([
request.static_url("shelves_demo:static/js/app.js", _query={"v": "2"}, _anchor="top"),
request.static_path("shelves_demo:static/js/app.js"),
]))
with Configurator() as config:
config.add_static_view("static", "shelves_demo:static")
config.add_route("urls", "/urls")
config.add_view(urls, route_name="urls")
app = config.make_wsgi_app()
lines = TestApp(app).get("/urls").text.splitlines()
assert lines == ["http://localhost/static/js/app.js?v=2#top", "/static/js/app.js"]
try:
with Configurator() as config:
config.add_route("only", "/only")
config.add_view(lambda request: Response(request.static_url("shelves_demo:static/x.css")), route_name="only")
app = config.make_wsgi_app()
TestApp(app).get("/only")
except ValueError as exc:
assert "No static URL definition" in str(exc)
Source: Pyramid: Request.static_url
In a template the same call is available through the template engine’s helpers. With pyramid_jinja2 the package provides filters
static_url_filter and static_path_filter in pyramid_jinja2.filters, but they are not active until you register
them under names with the jinja2.filters setting. (The cookiecutter project skips the filters and calls
{{ request.static_url('bookshelf_shelves:static/theme.css') }} in its layout template, which works in any engine.) The template below
is written to the demo package and rendered through a view, so the HTML contains the URL, busted or not, that the
registered static view produces. In Jinja the expression is written
{{ 'shelves_demo:static/css/shelf.css'|static_url }}; with Chameleon or Mako you call request.static_url(...)
directly, as the Getting Started page does for routes. The template engines
themselves are the subject of the Templates page.
(pkg / "templates/layout.jinja2").write_text(
'<link rel="stylesheet" href="{{ \'shelves_demo:static/css/shelf.css\'|static_url }}">\n'
)
jinja_settings = {
"jinja2.filters": "\n".join([
"static_url = pyramid_jinja2.filters:static_url_filter",
"static_path = pyramid_jinja2.filters:static_path_filter",
])
}
with Configurator(settings=jinja_settings) as config:
config.include("pyramid_jinja2")
config.add_static_view("static", "shelves_demo:static")
config.add_route("layout", "/layout")
config.add_view(lambda request: {}, route_name="layout", renderer="shelves_demo:templates/layout.jinja2")
app = config.make_wsgi_app()
html = TestApp(app).get("/layout").text
assert 'href="http://localhost/static/css/shelf.css"' in html
Source: pyramid_jinja2 documentation
Cache Headers
Browsers cache what you tell them to cache, and Pyramid tells them nothing by default. add_static_view passes
cache_max_age=None to the view it creates, so a response carries Last-Modified and Content-Length but no
Cache-Control and no Expires. (The class pyramid.static.static_view, used directly, defaults to 3600 seconds. The
two defaults differ, which is easy to forget when you move a view from one form to the other.) The browser then
revalidates with If-Modified-Since on each use, which costs a round trip per file but never serves stale content.
Setting cache_max_age switches that to "trust the cache for this long". That is only safe when the URL changes
whenever the content does, which is the whole point of the next section; a long cache_max_age on stable URLs means
visitors see old stylesheets until their cache expires.
with Configurator() as config:
config.add_static_view("static", "shelves_demo:static", cache_max_age=86400)
app = config.make_wsgi_app()
client = TestApp(app)
res = client.get("/static/js/app.js")
assert res.headers["Cache-Control"] == "max-age=86400"
assert "Expires" in res.headers
# a conditional request for an unchanged file is answered without a body
again = client.get("/static/js/app.js", headers={"If-Modified-Since": res.headers["Last-Modified"]}, status=304)
assert again.body == b""
Cache Busting
The combination that gives the best performance is a very long cache lifetime plus URLs that change when the file
changes. Pyramid’s hook for that is config.add_cache_buster(path, cachebust, explicit=False): it registers an object
implementing pyramid.interfaces.ICacheBuster, a callable (request, subpath, kw) that returns a possibly modified
(subpath, kw), for every asset below path. From then on static_url and static_path run the buster before
building the URL. Only URL generation is affected. The buster does not serve anything, so the files must still be
reachable under the URL it produces. path is a directory (the trailing slash is added for you) and the longest
matching registration wins.
Pyramid ships three implementations.
| Class | Behaviour | Typical use |
|---|---|---|
|
Adds the same token to every asset URL: |
Quick start, deployment version number. A coarse tool: any change invalidates everything. |
|
Base class: you implement |
Per-file tokens (modification time, content hash) computed by the application. |
|
Looks the subpath up in a JSON manifest written by your build tool; unknown paths pass through unchanged. |
Hashed file names produced by an asset pipeline; works when the application cannot see the files. |
The constant buster and a per-file buster look like this. The second class reads the file through an
AssetResolver and returns the first eight hex digits of its SHA-256; the token is cached because the file does not
change while the process runs. Note the signature of tokenize: the installed source calls it with
(request, subpath, kw), and the pathspec of the asset is in kw. (The git-hash example in the narrative chapter
still shows an older tokenize(self, pathspec) form, which would fail with that call.)
import hashlib
from pyramid.path import AssetResolver
from pyramid.static import QueryStringCacheBuster, QueryStringConstantCacheBuster
class ContentHashCacheBuster(QueryStringCacheBuster):
"""Token = first 8 hex digits of the SHA-256 of the file's content."""
def __init__(self, param="h"):
super().__init__(param=param)
self._tokens = {}
def tokenize(self, request, subpath, kw):
pathspec = kw["pathspec"]
if pathspec not in self._tokens:
data = Path(AssetResolver().resolve(pathspec).abspath()).read_bytes()
self._tokens[pathspec] = hashlib.sha256(data).hexdigest()[:8]
return self._tokens[pathspec]
def links(request):
return Response("\n".join([
request.static_url("shelves_demo:static/js/app.js"),
request.static_path("shelves_demo:static/css/shelf.css"),
]))
def build(buster, settings=None):
with Configurator(settings=settings) as config:
config.add_static_view("static", "shelves_demo:static")
config.add_cache_buster("shelves_demo:static", buster)
config.add_route("links", "/links")
config.add_view(links, route_name="links")
return TestApp(config.make_wsgi_app())
constant = build(QueryStringConstantCacheBuster("v42")).get("/links").text.splitlines()
assert constant == ["http://localhost/static/js/app.js?x=v42", "/static/css/shelf.css?x=v42"]
hashed = build(ContentHashCacheBuster()).get("/links").text.splitlines()
digest = hashlib.sha256(b"console.log('shelves');\n").hexdigest()[:8]
assert hashed[0] == f"http://localhost/static/js/app.js?h={digest}"
Source: Pyramid: Cache Busting
Query-string busters are simple but have a well-known weakness: many caching proxies decline to cache a URL that has a
query string. The narrative chapter therefore recommends changing a path segment when possible, which means renaming
the file, which in turn means the file must exist under the new name. That is the job of a build tool (hash the
content, write shelf.7f3a1c.css, record the mapping in a manifest), and of ManifestCacheBuster on the Pyramid
side. The manifest is a flat JSON object from the source path to the hashed path. The example writes one, registers the
buster and shows both the generated URL and that the hashed file is what must be served:
import json
from pyramid.static import ManifestCacheBuster
(pkg / "static/manifest.json").write_text(json.dumps({"css/shelf.css": "css/shelf.7f3a1c.css"}))
(pkg / "static/css/shelf.7f3a1c.css").write_text(css) # produced by the build tool
client = build(ManifestCacheBuster("shelves_demo:static/manifest.json"))
paths = client.get("/links").text.splitlines()
assert paths == ["http://localhost/static/js/app.js", "/static/css/shelf.7f3a1c.css"] # unlisted paths are unchanged
client.get("/static/css/shelf.7f3a1c.css") # the application (or the CDN) must serve the hashed name
client.get("/static/css/shelf.css") # the original name still works for old HTML
Source: Pyramid: Cache Busting
Three details of ManifestCacheBuster matter in practice. With the default reload=False the manifest is read
immediately, so a missing file stops the application at startup (a crash at boot is better than links that silently
lack their hashes). With reload=True the file is re-read when it changes and a missing file counts as an empty
mapping; that is a development convenience and the class documentation advises against it in production. And because
the format is whatever parse_manifest(content) returns, a subclass adapts other tools. Build tools such as Vite write a
manifest whose values are objects rather than strings; a two-line override turns that into the flat mapping:
class ViteManifestCacheBuster(ManifestCacheBuster):
def parse_manifest(self, content):
entries = json.loads(content.decode("utf-8"))
return {src: entry["file"] for src, entry in entries.items()}
(pkg / "static/vite-manifest.json").write_text(json.dumps({"js/app.js": {"file": "js/app-4ed993c7.js"}}))
buster = ViteManifestCacheBuster("shelves_demo:static/vite-manifest.json")
assert buster.manifest == {"js/app.js": "js/app-4ed993c7.js"}
Source: Vite: Backend Integration
Cache busting only rewrites the URLs that Pyramid generates. References inside stylesheets and scripts
(url(img/cover.png), dynamic import()) are invisible to it, because Pyramid knows nothing about their content.
The narrative chapter’s advice is to let the asset pipeline rewrite those references into their final hashed form, so
that the files stay truly static and can be hosted by Pyramid or by a CDN without changes; templating the CSS and
calling static_url inside it works but makes your "static" files depend on the application.
To switch every buster off in development without editing the calls, set the pyramid.prevent_cachebust setting or
the PYRAMID_PREVENT_CACHEBUST environment variable to a true value. The decision is made when the registrations are
committed, so it applies to the whole process.
quiet = build(QueryStringConstantCacheBuster("v42"), settings={"pyramid.prevent_cachebust": "true"})
assert quiet.get("/links").text.splitlines() == ["http://localhost/static/js/app.js", "/static/css/shelf.css"]
Serving Assets from a CDN or Another Host
When name of add_static_view is a URL, Pyramid registers no route and no view. It only remembers the base URL, and
static_url produces links beneath it. The files still have to be there (copied to a bucket, or served by another
web server), and that server must be configured to answer the generated URLs; Pyramid does not check. The
cache_max_age argument is ignored because Pyramid is not serving the files, so cache headers are the job of the CDN or
server. Together with a cache buster the setup is typically "hashed names, long lifetime, CDN in front".
The narrative chapter suggests reading the base from a custom setting so that the same code serves from the
application in development and from a CDN in production. Since the setting value is just a URL prefix, one line of
the .ini file moves the assets:
# development.ini: served by the application at /static/...
media_location = static
# production.ini: served by the CDN
media_location = https://cdn.example.com/shelves/
def external(settings):
with Configurator(settings=settings) as config:
config.add_static_view(name=settings["media_location"], path="shelves_demo:static")
config.add_route("links", "/links")
config.add_view(links, route_name="links")
return TestApp(config.make_wsgi_app())
cdn = external({"media_location": "https://cdn.example.com/shelves/"})
assert cdn.get("/links").text.splitlines() == [
"https://cdn.example.com/shelves/js/app.js",
"https://cdn.example.com/shelves/css/shelf.css",
]
cdn.get("/shelves/js/app.js", status=404) # nothing is served locally
relative = external({"media_location": "//cdn.example.com/shelves"}) # takes the request scheme
assert relative.get("/links").text.splitlines()[0] == "http://cdn.example.com/shelves/js/app.js"
Precompressed Assets
Compressing text assets on every request wastes CPU, and compressing them once at build time is free. Since Pyramid
version 2.0, content_encodings=["gzip"] tells the static view that shelf.css.gz is the gzip form of shelf.css. When the
client’s Accept-Encoding includes an encoding for which a sibling file exists, the view serves the smallest
acceptable file and sets Content-Encoding accordingly; if the sibling is missing or the client does not accept the
encoding, the plain file is sent. When any alternative exists the response also gets Vary: Accept-Encoding, which
caches need to keep the two forms apart. The file extensions are looked up in mimetypes.encodings_map: .gz, .Z,
.bz2, .xz and .br are present on Python 3.13, so content_encodings=["br"] works with .br files; if an
encoding you need is missing, add it to the dictionary before configuring the view.
WebTest’s TestApp decodes a gzipped body and drops the Content-Encoding header before your assertion sees it, so
this particular test uses WebOb’s Request.get_response against the WSGI app instead.
from webob import Request
with Configurator() as config:
config.add_static_view("static", "shelves_demo:static", content_encodings=["gzip"])
app = config.make_wsgi_app()
gz = Request.blank("/static/css/shelf.css", headers={"Accept-Encoding": "gzip"}).get_response(app)
assert gz.headers["Content-Encoding"] == "gzip"
assert gzip.decompress(gz.body).decode() == css
assert gz.headers["Vary"] == "Accept-Encoding"
plain = Request.blank("/static/css/shelf.css").get_response(app)
assert "Content-Encoding" not in plain.headers and plain.text == css
other = Request.blank("/static/js/app.js", headers={"Accept-Encoding": "gzip"}).get_response(app)
assert "Content-Encoding" not in other.headers # no app.js.gz exists: the plain file is served
Protecting Assets with a Permission
By default a static view is open to everyone, even in an application that sets a default permission. Passing
permission to add_static_view requires that permission for every file in the directory; the check is made by the
application’s security policy against the default root factory’s ACL (or against the factory you pass through the
keyword arguments, since they go to add_route). The security machinery is the subject of the security pages of this
section; the example uses the smallest possible policy, with a request header standing in for authentication, which is
only acceptable in a test:
from pyramid.security import Allowed, Denied
class ReaderHeaderPolicy:
"""Toy policy for the test: whoever sends X-Reader: ada may read."""
def identity(self, request):
return request.headers.get("X-Reader")
def authenticated_userid(self, request):
return self.identity(request)
def permits(self, request, context, permission):
if self.identity(request) == "ada":
return Allowed("reader")
return Denied("anonymous")
def remember(self, request, userid, **kw):
return []
def forget(self, request, **kw):
return []
with Configurator() as config:
config.set_security_policy(ReaderHeaderPolicy())
config.add_static_view("private", "shelves_demo:static", permission="read")
app = config.make_wsgi_app()
client = TestApp(app)
client.get("/private/js/app.js", status=403)
assert client.get("/private/js/app.js", headers={"X-Reader": "ada"}).status_int == 200
Source: Pyramid: Serving Static Assets
A permission on a directory is the simple case. When the rule depends on the individual file (a book’s cover that only
its owner may see, an uploaded manuscript), do not put the files in a static directory at all: write a view callable
that checks the rule and returns a pyramid.response.FileResponse. User uploads in particular should live outside the
package and be served by such a view, never by a public static view, and never under a URL that lets the user choose
the file name or content type.
Static Files from a View Callable
add_static_view cannot serve one file, and it cannot serve a directory at the root of the site. Both are done with
plain views, and pyramid.static.static_view is the same helper that add_static_view uses, available for direct use.
The favicon is the classic single file: a view returns a FileResponse, which sets the length and the content type
from the file extension and serves the file as fast as the server allows. A catch-all static view at the root is a
static_view instance with use_subpath=True registered on a route /*subpath that comes last in the routing
table, because it matches everything.
from pyramid.response import FileResponse
from pyramid.static import static_view
def favicon(request):
return FileResponse(str(pkg / "static/favicon.ico"), request=request)
def api(request):
return Response("api")
with Configurator() as config:
config.add_route("favicon", "/favicon.ico")
config.add_view(favicon, route_name="favicon")
config.add_route("api", "/api")
config.add_view(api, route_name="api")
# every other add_route comes before this one: it matches all paths
config.add_route("catchall_static", "/*subpath")
config.add_view(static_view("shelves_demo:static", use_subpath=True), route_name="catchall_static")
app = config.make_wsgi_app()
client = TestApp(app)
assert client.get("/favicon.ico").content_type.endswith("icon")
assert client.get("/api").text == "api"
assert client.get("/js/app.js").text.startswith("console.log")
Two caveats from the chapter. static_url cannot generate URLs for files served this way, because it only knows the
directories registered with add_static_view. And the root-relative form does not work in an application that uses
traversal: the route matches before traversal starts and would swallow every view without a route_name; there the
static_view has to be registered as the Not Found view instead.
Overriding Assets
config.override_asset(to_override, override_with) lets one package replace assets of another without forking it. It
works for single files, for directories (both specifications must end in a slash) and for the static views built on
those packages. This is how a deployment replaces a logo or a stylesheet in a reusable application, and how themes
stack: several calls naming the same to_override form a search path in which the first existing file wins and the
original is the fallback. The specification may omit the colon and path to redirect a whole package, may start with a
dot to be relative to the configuring package, and may be an absolute filesystem path, which is the supported way to
serve files that live outside any package. A static view or static_url then keeps working on the original
specification.
with Configurator() as config:
config.add_static_view("static", "shelves_demo:static")
config.add_route("links", "/links")
config.add_view(links, route_name="links")
config.override_asset(
to_override="shelves_demo:static/css/shelf.css",
override_with="theme_demo:static/css/shelf.css",
)
app = config.make_wsgi_app()
client = TestApp(app)
assert client.get("/static/css/shelf.css").text == "body { font-family: monospace; }\n"
assert client.get("/static/js/app.js").text.startswith("console.log") # not overridden
# an absolute path as the override target: serve files that live outside any package
uploads = root / "covers"
uploads.mkdir()
(uploads / "dune.txt").write_text("cover bytes")
with Configurator() as config:
config.add_static_view("covers", "shelves_demo:covers") # a dummy directory inside a real package
config.override_asset(to_override="shelves_demo:covers/", override_with=str(uploads) + "/")
app = config.make_wsgi_app()
assert TestApp(app).get("/covers/dune.txt").text == "cover bytes"
Source: Pyramid: Overriding Assets
Two interactions with cache busting are worth knowing. An asset-aware buster such as ManifestCacheBuster has a
manifest for the original assets only, so an overridden file would receive the token of the file it replaced. The
remedy is add_cache_buster(path, buster, explicit=True) with a second buster for the override package, matched
against the raw specification (rawspec, the location of the file actually served) instead of the virtual one. And
Pyramid 2.1 changed the lookup for stacked overrides: the cache buster now finds the first file that exists in the
override stack, instead of the first override regardless of whether it has the file.
Serving Assets in Production
Pyramid’s static view is a good development server and a bad CDN. In production the standard arrangement is that a
reverse proxy (nginx, Apache) or a CDN serves the files straight from disk or object storage and forwards only
dynamic requests to the Waitress, Gunicorn or mod_wsgi workers. Because the URLs come from static_url, this is a
configuration change and not a code change: either keep name='static' and let the proxy answer /static/ before the
request reaches the application, or switch name to the CDN URL as in the previous section. Pair it with a cache
buster and a long lifetime. The proxy needs the files on its own disk, so the build or the container image must put them
there (the same hashed names as the manifest says).
# nginx in front of the application server on 127.0.0.1:6543
location /static/ {
alias /srv/bookshelf_shelves/static/; # the directory behind add_static_view
expires 1y; # only with hashed or versioned URLs
gzip_static on; # serve shelf.css.gz when the client accepts gzip
}
location / {
proxy_pass http://127.0.0.1:6543;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
Source: nginx: alias and nginx: ngx_http_gzip_static_module
The gzip_static directive belongs to an optional nginx module that must be compiled in; it mirrors what
content_encodings does in Pyramid, using the same .gz siblings. Deployment as a whole (Waitress, Gunicorn,
mod_wsgi, proxies and containers) belongs to the deployment page of this section, and the equivalent decisions in
other frameworks are described in Flask: Static Files & Assets,
Tornado: Static Files and FastAPI: Static Files, Templates & Frontend.
Settings That Affect Static Assets
| Setting | Environment variable | Effect |
|---|---|---|
|
|
The static view re-resolves files on each request instead of caching the file list, so new and changed files appear without a restart. Development only. |
|
|
No cache buster is registered, so |
|
|
Affects views that use the |
Pitfalls and Security Notes
| Pitfall | What to do |
|---|---|
Hand-written |
They break when the prefix, the |
|
The specification names a directory that no |
A long |
Browsers keep serving the old file. Pair long lifetimes only with hashed paths or versioned query strings, and keep
|
A buster’s |
Use |
Manifest missing at startup |
|
Hashed URL, file not served |
A buster only rewrites URLs. The renamed file must exist in the served directory or on the CDN. |
|
Not supported. Use a |
Catch-all route registered too early |
It captures every later route. Add it last. |
Testing precompressed assets with TestApp |
WebTest decodes the body and removes |
Files added while running |
Not seen unless |
User uploads in a static directory |
A public static view lets anyone fetch anything placed there, with a content type derived from the extension. Keep uploads outside the package, validate extension and size at upload, and serve them from a view that checks authorisation and sets the content type itself (see the files and uploads discussion in the deployment page of this section). |
Secrets and source in the static directory |
Everything under the served directory is public. Never put |
|
Pyramid 2.1 still imports it; the warning is harmless, and the supported setuptools is below 82. Do not silence a broken asset lookup together with it. |
The directory traversal protection of static_view (the %2e%2e request in the first example returns 404) is part of
Pyramid, but treat it as a last line of defence: do not feed user input into path or into a FileResponse filename in
your own views, and keep static directories free of anything you would not publish.
What Changed and Where Older Material Is Outdated
-
1.6: cache busters (
add_cache_buster,QueryStringConstantCacheBuster,ManifestCacheBuster) and absolute paths for static views andoverride_asset. Tutorials older than 1.6 versioned URLs by hand or not at all. -
2.0:
content_encodingsforadd_static_viewandstatic_view, and thereloadoption ofstatic_view(whatpyramid.reload_assetsdrives). -
2.1: passing a package that does not exist to
add_static_vieworstatic_viewis deprecated; cache busting with overrides picks the first existing file in the override stack. Both are in the official "What’s New in Pyramid 2.1". -
Cookiecutter default: the 2.1-branch starter registers
config.add_static_view('static', 'static', cache_max_age=3600)with a relative path (resolved against the application package) and one hour of caching on plain URLs, a sensible starting point that you replace with hashed URLs and a longer lifetime once a build step exists. -
Outdated or inaccurate material: the narrative example of a
QueryStringCacheBustersubclass showstokenize(self, pathspec)while the code callstokenize(request, subpath, kw); blog posts that callconfig.add_static_view('static', 'static', cache_max_age=3600)with a relative path rely on the "current package" rule and break when moved into another module; and anything claiming Pyramid generates ETags or compresses on the fly for static files is wrong in 2.1: it sendsLast-Modifiedand serves precompressed siblings you provide.
Comparison with Other Frameworks
| Framework | How static files are served | URL generation and busting |
|---|---|---|
Pyramid |
|
|
Flask |
Built-in |
|
Tornado |
|
|
FastAPI |
|
No built-in helper beyond |
Django |
|
|
Pyramid’s distinguishing choices are the asset specification (files addressed by package, so they can be overridden and installed as part of a wheel) and the explicit cache-buster interface. Tornado’s built-in hash and Django’s manifest storage give you busting without a decision; Pyramid asks you to choose and in exchange lets you plug in your own pipeline.
Summary
-
An asset is a file inside a package, addressed by a specification
package:path. Use full specifications; Pyramid resolves them withpkg_resources. -
add_static_view(name, path)is a route plus astatic_view; it serves a directory below a prefix, cannot serve one file or the site root, and sends no cache headers unlesscache_max_ageis set. -
Generate every link with
request.static_urlorrequest.static_path; they follow renames,route_prefix, cache busters and external base URLs. -
Combine long lifetimes with
add_cache_buster:QueryStringConstantCacheBusterfor a global token, aQueryStringCacheBustersubclass (tokenize(request, subpath, kw)) for per-file tokens,ManifestCacheBusterfor hashed file names.pyramid.prevent_cachebustdisables them in development. -
A URL as
namemoves the files to a CDN without code changes;content_encodingsserves precompressed siblings withVary: Accept-Encoding. -
Protect a directory with
permission, serve per-file rules and uploads with a view returningFileResponse, and replace assets of other packages withoverride_asset(stacking,explicit=Truefor cache busters). -
In production let nginx or a CDN serve the files and keep the URL generation unchanged.