Skip to content

Commit 751bbd3

Browse files
committed
Name the main class HashedStatic to coexist with Starlette
The public API is now `from staticware import HashedStatic, StaticRewriteMiddleware`. The name follows the standard library's adjective-noun pattern (OrderedDict, NamedTuple) and tells users what makes this class different from any other static file server: content hashing. Key design decisions: - StaticRewriteMiddleware keeps its name. It has no collision with anything in the Starlette ecosystem, and "Rewrite" communicates what the middleware does at the ASGI level.
1 parent 68b3dc4 commit 751bbd3

6 files changed

Lines changed: 54 additions & 54 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ ASGI middleware for static file serving with content-based cache busting. Zero r
2323
## Quick Start
2424

2525
```python
26-
from staticware import StaticFiles, StaticRewriteMiddleware
26+
from staticware import HashedStatic, StaticRewriteMiddleware
2727

2828
# Point at your static files directory
29-
static = StaticFiles("static")
29+
static = HashedStatic("static")
3030

3131
# Mount it however your framework mounts sub-apps:
3232
app.mount("/static", static)

docs/usage.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
## Basic Setup
44

5-
Create a `StaticFiles` instance pointing at your static files directory, then wrap your ASGI app with `StaticRewriteMiddleware`:
5+
Create a `HashedStatic` instance pointing at your static files directory, then wrap your ASGI app with `StaticRewriteMiddleware`:
66

77
```python
8-
from staticware import StaticFiles, StaticRewriteMiddleware
8+
from staticware import HashedStatic, StaticRewriteMiddleware
99

10-
static = StaticFiles("static")
10+
static = HashedStatic("static")
1111

1212
# Mount it however your framework mounts sub-apps:
1313
app.mount("/static", static)
@@ -16,9 +16,9 @@ app.mount("/static", static)
1616
app = StaticRewriteMiddleware(app, static=static)
1717
```
1818

19-
`StaticFiles` hashes every file in the directory at startup. When a browser requests the hashed filename, it gets an immutable cache header. When it requests the original filename, the file is served without aggressive caching.
19+
`HashedStatic` hashes every file in the directory at startup. When a browser requests the hashed filename, it gets an immutable cache header. When it requests the original filename, the file is served without aggressive caching.
2020

21-
File hashes are computed once when `StaticFiles` is created. If you deploy updated static files, restart the ASGI process to pick up the new hashes. This is the same model used by Starlette and most ASGI static file handlers.
21+
File hashes are computed once when `HashedStatic` is created. If you deploy updated static files, restart the ASGI process to pick up the new hashes. This is the same model used by Starlette and most ASGI static file handlers.
2222

2323
## Resolving URLs in Templates
2424

@@ -46,7 +46,7 @@ This means cache busting works even without explicit `static.url()` calls in tem
4646
### Custom Prefix
4747

4848
```python
49-
static = StaticFiles("static", prefix="/assets")
49+
static = HashedStatic("static", prefix="/assets")
5050
static.url("styles.css") # /assets/styles.a1b2c3d4.css
5151
```
5252

@@ -55,5 +55,5 @@ static.url("styles.css") # /assets/styles.a1b2c3d4.css
5555
The default hash is 8 characters from the SHA-256 digest. To change it:
5656

5757
```python
58-
static = StaticFiles("static", hash_length=12)
58+
static = HashedStatic("static", hash_length=12)
5959
```

src/staticware/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Top-level package for Staticware."""
22

3-
from staticware.middleware import StaticFiles, StaticRewriteMiddleware
3+
from staticware.middleware import HashedStatic, StaticRewriteMiddleware
44

5-
__all__ = ["StaticFiles", "StaticRewriteMiddleware"]
5+
__all__ = ["HashedStatic", "StaticRewriteMiddleware"]

src/staticware/middleware.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
Zero dependencies beyond the Python standard library. Works with any ASGI
44
framework: Starlette, FastAPI, Air, Litestar, Django, or raw ASGI.
55
6-
from staticware import StaticFiles, StaticRewriteMiddleware
6+
from staticware import HashedStatic, StaticRewriteMiddleware
77
8-
static = StaticFiles("static")
8+
static = HashedStatic("static")
99
1010
# Wrap any ASGI app to rewrite /static/styles.css -> /static/styles.a1b2c3d4.css
1111
app = StaticRewriteMiddleware(your_app, static=static)
@@ -30,7 +30,7 @@
3030
type ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
3131

3232

33-
class StaticFiles:
33+
class HashedStatic:
3434
"""Serve static files with content-hashed filenames.
3535
3636
Computes SHA-256 hashes of every file in ``directory`` at startup.
@@ -40,7 +40,7 @@ class StaticFiles:
4040
4141
This is a mountable ASGI app *and* a URL resolver::
4242
43-
static = StaticFiles("static")
43+
static = HashedStatic("static")
4444
4545
# Mount it however your framework mounts sub-apps:
4646
app.mount("/static", static)
@@ -164,7 +164,7 @@ class StaticRewriteMiddleware:
164164
app = StaticRewriteMiddleware(app, static=static)
165165
"""
166166

167-
def __init__(self, app: ASGIApp, *, static: StaticFiles) -> None:
167+
def __init__(self, app: ASGIApp, *, static: HashedStatic) -> None:
168168
self.app = app
169169
self.static = static
170170
escaped = re.escape(static.prefix)

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from staticware import StaticFiles
5+
from staticware import HashedStatic
66

77

88
@pytest.fixture()
@@ -17,5 +17,5 @@ def static_dir(tmp_path: Path) -> Path:
1717

1818

1919
@pytest.fixture()
20-
def static(static_dir: Path) -> StaticFiles:
21-
return StaticFiles(static_dir)
20+
def static(static_dir: Path) -> HashedStatic:
21+
return HashedStatic(static_dir)

tests/test_staticware.py

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
loop. Regular ``def`` tests run normally without one.
77
88
Use ``async def`` for tests that call ASGI apps (they are async callables).
9-
Use plain ``def`` for tests that only exercise sync APIs like StaticFiles()
9+
Use plain ``def`` for tests that only exercise sync APIs like HashedStatic()
1010
construction, url(), and file_map lookups.
1111
1212
Do NOT write ``async def`` for a test that has no await in its body. It will
@@ -20,7 +20,7 @@
2020

2121
import pytest
2222

23-
from staticware import StaticFiles, StaticRewriteMiddleware
23+
from staticware import HashedStatic, StaticRewriteMiddleware
2424

2525

2626
# ── Helpers ──────────────────────────────────────────────────────────────
@@ -58,49 +58,49 @@ def expected_hash(content: bytes, length: int = 8) -> str:
5858
return hashlib.sha256(content).hexdigest()[:length]
5959

6060

61-
# ── StaticFiles: hashing and url() ──────────────────────────────────────
61+
# ── HashedStatic: hashing and url() ──────────────────────────────────────
6262

6363

6464

65-
def test_file_map_contains_all_files(static: StaticFiles, static_dir: Path) -> None:
65+
def test_file_map_contains_all_files(static: HashedStatic, static_dir: Path) -> None:
6666
assert "styles.css" in static.file_map
6767
assert "images/logo.png" in static.file_map
6868

6969

70-
def test_hash_is_correct(static: StaticFiles, static_dir: Path) -> None:
70+
def test_hash_is_correct(static: HashedStatic, static_dir: Path) -> None:
7171
css_content = (static_dir / "styles.css").read_bytes()
7272
h = expected_hash(css_content)
7373
assert static.file_map["styles.css"] == f"styles.{h}.css"
7474

7575

76-
def test_hash_in_subdirectory(static: StaticFiles, static_dir: Path) -> None:
76+
def test_hash_in_subdirectory(static: HashedStatic, static_dir: Path) -> None:
7777
png_content = (static_dir / "images" / "logo.png").read_bytes()
7878
h = expected_hash(png_content)
7979
assert static.file_map["images/logo.png"] == f"images/logo.{h}.png"
8080

8181

82-
def test_url_returns_hashed_path(static: StaticFiles) -> None:
82+
def test_url_returns_hashed_path(static: HashedStatic) -> None:
8383
url = static.url("styles.css")
8484
assert url.startswith("/static/styles.")
8585
assert url.endswith(".css")
8686
assert url != "/static/styles.css"
8787

8888

89-
def test_url_unknown_file_returns_unchanged(static: StaticFiles) -> None:
89+
def test_url_unknown_file_returns_unchanged(static: HashedStatic) -> None:
9090
assert static.url("nonexistent.js") == "/static/nonexistent.js"
9191

9292

93-
def test_url_strips_leading_slash(static: StaticFiles) -> None:
93+
def test_url_strips_leading_slash(static: HashedStatic) -> None:
9494
assert static.url("/styles.css") == static.url("styles.css")
9595

9696

9797
def test_custom_prefix(static_dir: Path) -> None:
98-
s = StaticFiles(static_dir, prefix="/assets")
98+
s = HashedStatic(static_dir, prefix="/assets")
9999
assert s.url("styles.css").startswith("/assets/")
100100

101101

102102
def test_custom_hash_length(static_dir: Path) -> None:
103-
s = StaticFiles(static_dir, hash_length=4)
103+
s = HashedStatic(static_dir, hash_length=4)
104104
url = s.url("styles.css")
105105
# /static/styles.XXXX.css — 4-char hash
106106
stem = url.split("/")[-1] # styles.XXXX.css
@@ -109,7 +109,7 @@ def test_custom_hash_length(static_dir: Path) -> None:
109109

110110

111111
def test_nonexistent_directory(tmp_path: Path) -> None:
112-
s = StaticFiles(tmp_path / "nope")
112+
s = HashedStatic(tmp_path / "nope")
113113
assert s.file_map == {}
114114

115115

@@ -119,15 +119,15 @@ def test_symlinks_outside_directory_excluded(tmp_path: Path) -> None:
119119
outside = tmp_path / "secret.txt"
120120
outside.write_text("secret data")
121121
(static_dir / "link.txt").symlink_to(outside)
122-
s = StaticFiles(static_dir)
122+
s = HashedStatic(static_dir)
123123
assert "link.txt" not in s.file_map
124124

125125

126126
def test_extensionless_file(tmp_path: Path) -> None:
127127
d = tmp_path / "static"
128128
d.mkdir()
129129
(d / "Makefile").write_text("all: build")
130-
s = StaticFiles(d)
130+
s = HashedStatic(d)
131131
h = expected_hash(b"all: build")
132132
assert s.file_map["Makefile"] == f"Makefile.{h}"
133133

@@ -136,7 +136,7 @@ def test_dotfile(tmp_path: Path) -> None:
136136
d = tmp_path / "static"
137137
d.mkdir()
138138
(d / ".gitignore").write_text("*.pyc")
139-
s = StaticFiles(d)
139+
s = HashedStatic(d)
140140
h = expected_hash(b"*.pyc")
141141
assert s.file_map[".gitignore"] == f".gitignore.{h}"
142142

@@ -145,23 +145,23 @@ def test_multi_dot_filename(tmp_path: Path) -> None:
145145
d = tmp_path / "static"
146146
d.mkdir()
147147
(d / "jquery.min.js").write_text("js code")
148-
s = StaticFiles(d)
148+
s = HashedStatic(d)
149149
h = expected_hash(b"js code")
150150
assert s.file_map["jquery.min.js"] == f"jquery.min.{h}.js"
151151

152152

153-
# ── StaticFiles: ASGI serving ───────────────────────────────────────────
153+
# ── HashedStatic: ASGI serving ───────────────────────────────────────────
154154

155155

156-
async def test_serve_original_filename(static: StaticFiles, static_dir: Path) -> None:
156+
async def test_serve_original_filename(static: HashedStatic, static_dir: Path) -> None:
157157
resp = ResponseCollector()
158158
await static(make_scope("/static/styles.css"), receive, resp)
159159
assert resp.status == 200
160160
assert resp.text == "body { color: red; }"
161161
assert b"cache-control" not in resp.headers
162162

163163

164-
async def test_serve_hashed_filename_with_immutable_cache(static: StaticFiles) -> None:
164+
async def test_serve_hashed_filename_with_immutable_cache(static: HashedStatic) -> None:
165165
hashed_name = static.file_map["styles.css"]
166166
resp = ResponseCollector()
167167
await static(make_scope(f"/static/{hashed_name}"), receive, resp)
@@ -170,39 +170,39 @@ async def test_serve_hashed_filename_with_immutable_cache(static: StaticFiles) -
170170
assert resp.headers[b"cache-control"] == b"public, max-age=31536000, immutable"
171171

172172

173-
async def test_serve_404_for_missing_file(static: StaticFiles) -> None:
173+
async def test_serve_404_for_missing_file(static: HashedStatic) -> None:
174174
resp = ResponseCollector()
175175
await static(make_scope("/static/nope.css"), receive, resp)
176176
assert resp.status == 404
177177

178178

179-
async def test_serve_404_outside_prefix(static: StaticFiles) -> None:
179+
async def test_serve_404_outside_prefix(static: HashedStatic) -> None:
180180
resp = ResponseCollector()
181181
await static(make_scope("/other/styles.css"), receive, resp)
182182
assert resp.status == 404
183183

184184

185-
async def test_path_traversal_rejected(static: StaticFiles) -> None:
185+
async def test_path_traversal_rejected(static: HashedStatic) -> None:
186186
resp = ResponseCollector()
187187
await static(make_scope("/static/../../etc/passwd"), receive, resp)
188188
assert resp.status == 404
189189

190190

191-
async def test_non_http_scope_ignored(static: StaticFiles) -> None:
191+
async def test_non_http_scope_ignored(static: HashedStatic) -> None:
192192
"""WebSocket and lifespan scopes should be silently ignored."""
193193
resp = ResponseCollector()
194194
await static({"type": "websocket", "path": "/static/styles.css"}, receive, resp)
195195
assert resp.status == 0 # send was never called
196196

197197

198-
async def test_serve_subdirectory_file(static: StaticFiles) -> None:
198+
async def test_serve_subdirectory_file(static: HashedStatic) -> None:
199199
resp = ResponseCollector()
200200
await static(make_scope("/static/images/logo.png"), receive, resp)
201201
assert resp.status == 200
202202
assert resp.body == b"\x89PNG fake image data"
203203

204204

205-
async def test_content_type_header(static: StaticFiles) -> None:
205+
async def test_content_type_header(static: HashedStatic) -> None:
206206
resp = ResponseCollector()
207207
await static(make_scope("/static/styles.css"), receive, resp)
208208
assert b"text/css" in resp.headers[b"content-type"]
@@ -246,7 +246,7 @@ async def app(scope: dict, receive: Any, send: Any) -> None:
246246
return app
247247

248248

249-
async def test_rewrite_html_response(static: StaticFiles) -> None:
249+
async def test_rewrite_html_response(static: HashedStatic) -> None:
250250
html = '<link href="/static/styles.css">'
251251
app = StaticRewriteMiddleware(make_html_app(html), static=static)
252252
resp = ResponseCollector()
@@ -257,7 +257,7 @@ async def test_rewrite_html_response(static: StaticFiles) -> None:
257257
assert "/static/styles.css" not in resp.text
258258

259259

260-
async def test_rewrite_updates_content_length(static: StaticFiles) -> None:
260+
async def test_rewrite_updates_content_length(static: HashedStatic) -> None:
261261
html = '<link href="/static/styles.css">'
262262
app = StaticRewriteMiddleware(make_html_app(html), static=static)
263263
resp = ResponseCollector()
@@ -267,23 +267,23 @@ async def test_rewrite_updates_content_length(static: StaticFiles) -> None:
267267
assert declared_length == len(resp.body)
268268

269269

270-
async def test_rewrite_leaves_unknown_paths_alone(static: StaticFiles) -> None:
270+
async def test_rewrite_leaves_unknown_paths_alone(static: HashedStatic) -> None:
271271
html = '<script src="/static/app.js"></script>'
272272
app = StaticRewriteMiddleware(make_html_app(html), static=static)
273273
resp = ResponseCollector()
274274
await app(make_scope("/"), receive, resp)
275275
assert "/static/app.js" in resp.text
276276

277277

278-
async def test_non_html_passes_through(static: StaticFiles) -> None:
278+
async def test_non_html_passes_through(static: HashedStatic) -> None:
279279
data = b'{"path": "/static/styles.css"}'
280280
app = StaticRewriteMiddleware(make_json_app(data), static=static)
281281
resp = ResponseCollector()
282282
await app(make_scope("/api/data"), receive, resp)
283283
assert resp.body == data
284284

285285

286-
async def test_rewrite_multiple_paths(static: StaticFiles) -> None:
286+
async def test_rewrite_multiple_paths(static: HashedStatic) -> None:
287287
html = '<link href="/static/styles.css"><img src="/static/images/logo.png">'
288288
app = StaticRewriteMiddleware(make_html_app(html), static=static)
289289
resp = ResponseCollector()
@@ -293,7 +293,7 @@ async def test_rewrite_multiple_paths(static: StaticFiles) -> None:
293293
assert f"/static/{static.file_map['images/logo.png']}" in resp.text
294294

295295

296-
async def test_rewrite_non_http_passes_through(static: StaticFiles) -> None:
296+
async def test_rewrite_non_http_passes_through(static: HashedStatic) -> None:
297297
"""Non-HTTP scopes are forwarded to the wrapped app without rewriting."""
298298
calls: list[str] = []
299299

@@ -306,7 +306,7 @@ async def ws_app(scope: dict, receive: Any, send: Any) -> None:
306306

307307

308308
async def test_rewrite_raises_runtime_error_on_body_before_start(
309-
static: StaticFiles,
309+
static: HashedStatic,
310310
) -> None:
311311
"""Middleware should raise RuntimeError if app sends body before start.
312312
@@ -328,7 +328,7 @@ async def broken_app(scope: dict, receive: Any, send: Any) -> None:
328328
await app(make_scope("/"), receive, ResponseCollector())
329329

330330

331-
async def test_rewrite_streaming_html_response(static: StaticFiles) -> None:
331+
async def test_rewrite_streaming_html_response(static: HashedStatic) -> None:
332332
"""Middleware rewrites static paths even when the body arrives in multiple chunks."""
333333
chunk1 = b'<link href="/static/'
334334
chunk2 = b'styles.css">'
@@ -355,7 +355,7 @@ async def streaming_app(scope: dict, receive: Any, send: Any) -> None:
355355
assert "/static/styles.css" not in resp.text
356356

357357

358-
async def test_serve_prefix_only_returns_404(static: StaticFiles) -> None:
358+
async def test_serve_prefix_only_returns_404(static: HashedStatic) -> None:
359359
"""Requesting /static or /static/ with no filename returns 404."""
360360
# /static with no trailing slash
361361
resp_no_slash = ResponseCollector()
@@ -368,7 +368,7 @@ async def test_serve_prefix_only_returns_404(static: StaticFiles) -> None:
368368
assert resp_slash.status == 404
369369

370370

371-
async def test_rewrite_non_utf8_html_passes_through(static: StaticFiles) -> None:
371+
async def test_rewrite_non_utf8_html_passes_through(static: HashedStatic) -> None:
372372
"""HTML response with non-UTF-8 bytes passes through unchanged."""
373373
raw_body = b"<html>\x80\x81\x82 not valid utf-8</html>"
374374

0 commit comments

Comments
 (0)