Default static file root to the static directory

Commit 7826c02 · patx · 2026-10-15T14:40:23-04:00

Changeset
7826c02800c3b5126d0daa7c9cb0a33521118dd0
Parents
5265e03d02be41bc94d63e158dc7eedeefdd5b34

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 31b09a7..f53ecee 100644
--- a/README.md
+++ b/README.md
@@ -192,23 +192,20 @@ streaming, downloads, or SSE. Use ASGI middleware for behavior across routes.
 ## Static files
 
 ```python
-from pathlib import Path
-
-static_root = Path(__file__).parent / 'static'
-
 @app.route('/')
 async def index(request):
-    return await app.static('index.html', root=static_root)
+    return await app.static('index.html')
 
 @app.route('/static/{filename}')
 async def asset(request, filename):
-    return await app.static(filename, root=static_root)
+    return await app.static(filename)
 ```
 
-`static(filename, root=...)` returns `(bytes, content_type, status)`.
-The root is required and accepts a string or `Path`. MIME types are guessed;
-missing, unreadable, or outside-root files return plain-text 404, including
-symlink escapes. Files are read in a worker thread, entirely into memory.
+`static(filename, root='static')` returns `(bytes, content_type, status)`.
+Omitting `root`, passing `''`, or passing `None` uses the `static` directory in the
+current working directory. Set `root` to a string or `Path` to use another directory.
+MIME types are guessed; missing, unreadable, or outside-root files return
+plain-text 404, including symlink escapes. Files are read in a worker thread, entirely into memory.
 Small files. Don't feed it a movie.
 
 ## Errors
diff --git a/tests/test_wred.py b/tests/test_wred.py
index f91c12f..c72b26b 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -1,5 +1,6 @@
 import tempfile
 import unittest
+from contextlib import chdir
 from pathlib import Path
 from unittest.mock import patch
 
@@ -115,13 +116,16 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
             (root / 'asset.unknownextension').write_bytes(b'\x00\xff')
             (base / 'secret.txt').write_text('private')
             (root / 'escape.txt').symlink_to(base / 'secret.txt')
+            custom_root = base / 'assets'
+            custom_root.mkdir()
+            (custom_root / 'index.html').write_bytes(b'<p>Custom root</p>')
 
             app = App()
 
             @app.route('/file', methods=['POST'])
             async def file(request):
                 data = await request.json()
-                return await app.static(data['filename'], root=root)
+                return await app.static(data['filename'], **root_options)
 
             cases = [
                 ('index.html', 200, b'text/html', b'<p>Hello</p>'),
@@ -133,17 +137,24 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
                 ('escape.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
                 ('invalid\x00.txt', 404, b'text/plain; charset=utf-8', b'Not Found'),
             ]
-            for filename, status, content_type, body in cases:
-                with self.subTest(filename=filename):
-                    async def receive():
-                        return {'type': 'http.request', 'body': orjson.dumps({'filename': filename})}
-
-                    messages = await call_app(app, {'type': 'http', 'path': '/file', 'method': 'POST'}, receive)
-                    self.assertEqual(messages, [
-                        {'type': 'http.response.start', 'status': status,
-                         'headers': [(b'content-type', content_type)]},
-                        {'type': 'http.response.body', 'body': body},
-                    ])
+            cases = [
+                (options, *case)
+                for options in ({}, {'root': ''}, {'root': None}, {'root': root}, {'root': str(root)})
+                for case in cases
+            ]
+            cases.append(({'root': custom_root}, 'index.html', 200, b'text/html', b'<p>Custom root</p>'))
+            with chdir(base):
+                for root_options, filename, status, content_type, body in cases:
+                    with self.subTest(root=root_options, filename=filename):
+                        async def receive():
+                            return {'type': 'http.request', 'body': orjson.dumps({'filename': filename})}
+
+                        messages = await call_app(app, {'type': 'http', 'path': '/file', 'method': 'POST'}, receive)
+                        self.assertEqual(messages, [
+                            {'type': 'http.response.start', 'status': status,
+                             'headers': [(b'content-type', content_type)]},
+                            {'type': 'http.response.body', 'body': body},
+                        ])
 
     async def test_dictionary_and_list_responses_are_json(self):
         app = App()
diff --git a/wred.py b/wred.py
index 2aa8788..a0b00ec 100644
--- a/wred.py
+++ b/wred.py
@@ -161,11 +161,11 @@ class App:
                 return None
         return params
 
-    async def static(self, filename: str | Path, *, root: str | Path) -> tuple[bytes, str, int]:
-        """Serve a small file inside root; read its entire body off the event loop."""
+    async def static(self, filename: str | Path, *, root: str | Path | None = 'static') -> tuple[bytes, str, int]:
+        """Serve a small file inside root (default: ./static), off the event loop."""
         def read() -> tuple[bytes, str, int]:
             try:
-                directory = Path(root).resolve()
+                directory = Path(root or 'static').resolve()
                 path = (directory / filename).resolve()
                 if not path.is_relative_to(directory) or not path.is_file():
                     return b'Not Found', 'text/plain; charset=utf-8', 404