diff --git a/.gitignore b/.gitignore index ea9d325..26ad7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +uv.lock + # Django # logs *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e70f0..0c9ce05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet) +## [4.3.1] - 2026-06-06 + +- Fix manifest file mangling during `collectstatic` dry run. ## [4.3.0] - 2026-04-09 @@ -27,7 +29,7 @@ Don't forget to remove deprecated code on each major release! - Added new Django setting `SERVESTATIC_USE_STATIC_ROOT` to allow users to opt in to having `ServeStatic` scan all files within `STATIC_ROOT` at start-up. - Add JavaScript and CSS minification support to the `servestatic` CLI command. -- Add JavaScript and CSS minification support to the `servestatic` the Django storage backend. +- Add JavaScript and CSS minification support to Django. ## [4.1.0] - 2026-03-07 @@ -175,7 +177,8 @@ Don't forget to remove deprecated code on each major release! - Forked from [`whitenoise`](https://github.com/evansd/whitenoise) to add ASGI support. -[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.3.0...HEAD +[Unreleased]: https://github.com/Archmonger/ServeStatic/compare/4.3.1...HEAD +[4.3.1]: https://github.com/Archmonger/ServeStatic/compare/4.3.0...4.3.1 [4.3.0]: https://github.com/Archmonger/ServeStatic/compare/4.2.0...4.3.0 [4.2.0]: https://github.com/Archmonger/ServeStatic/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/Archmonger/ServeStatic/compare/4.0.0...4.1.0 diff --git a/README.md b/README.md index 71b45bb..87d7646 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,6 @@

-_Production-grade static file server for Python WSGI & ASGI._ - -_This project is a [fork](https://github.com/evansd/whitenoise/pull/359#issuecomment-2226889088) of [WhiteNoise](https://github.com/evansd/whitenoise) for [ASGI support, bug fixes, security updates, new features, and performance upgrades](https://archmonger.github.io/ServeStatic/latest/changelog/)._ - --- `ServeStatic` simplifies static file serving with minimal lines of configuration. It enables you to create a self-contained unit without requiring external services like Nginx or Amazon S3. This can simplify any production deployment, but is especially useful for platforms like Heroku, OpenShift, and other PaaS providers. @@ -45,14 +41,16 @@ That said, `ServeStatic` is pretty efficient. Because it only has to serve a fix ### Shouldn't I be pushing my static files to S3 (using Django-Storages)? -No, you shouldn't. The main problem with this approach is that Amazon S3 cannot currently selectively serve compressed content to your users. Compression using gzip, zstd (Python 3.14+), or brotli can make dramatic reductions in load time and bandwidth usage. But, in order to do this correctly the server needs to examine the `Accept-Encoding` header of the request to determine which compression formats are supported, and return an appropriate `Vary` header so that intermediate caches know to do the same. This is exactly what `ServeStatic` does, but Amazon S3 currently provides no means of doing this. - -The second problem with a push-based approach to handling static files is that it adds complexity and fragility to your deployment process: extra libraries specific to your storage backend, extra configuration and authentication keys, and extra tasks that must be run at specific points in the deployment in order for everything to work. With the CDN-as-caching-proxy approach that `ServeStatic` takes there are just two bits of configuration: your application needs the URL of the CDN, and the CDN needs the URL of your application. Everything else is just standard HTTP semantics. This makes your deployments simpler, your life easier, and you happier. +A push-based approach adds complexity and fragility to your deployment process: extra libraries specific to your storage backend, extra configuration and authentication keys, and extra tasks that must be run at specific points in the deployment in order for everything to work. With the CDN-as-caching-proxy approach that `ServeStatic` takes there are just two bits of configuration: the URL of the CDN, and the URL of your application. Everything else is just standard HTTP semantics. This makes your deployments simpler, your life easier, and you happier. ### What's the point in `ServeStatic` when I can use `apache`/`nginx`? -There are two answers here. One is that ServeStatic is designed to work in situations where `apache`, `nginx`, and the like aren't easily available. But more importantly, it's easy to underestimate what's involved in serving static files correctly. Does your few lines of nginx configuration distinguish between files which might change and files which will never change and set the cache headers appropriately? Did you add the right CORS headers so that your fonts load correctly when served via a CDN? Did you turn on the special nginx setting which allows it to send gzip content in response to an `HTTP/1.0` request? Did you install, enable, and configure support for zstd and brotli compression? +There are two answers here. One is that ServeStatic is designed to work in situations where `apache`, `nginx`, and the like aren't easily available. But more importantly, it's easy to underestimate what's involved in serving static files correctly. Does your few lines of nginx configuration distinguish which files which will never change and set the cache headers appropriately? Did you add the right CORS headers so that your fonts load correctly when served via a CDN? Did you turn on the special nginx setting which allows it to send gzip content in response to an `HTTP/1.0` request? Did you install, enable, and configure support for zstd _and_ brotli compression? None of this is rocket science, but it's fiddly and annoying and `ServeStatic` takes care of all it for you. + +--- + +_This project is a [fork](https://github.com/evansd/whitenoise/pull/359#issuecomment-2226889088) of [WhiteNoise](https://github.com/evansd/whitenoise) for [ASGI support, bug fixes, security updates, new features, and performance upgrades](https://archmonger.github.io/ServeStatic/latest/changelog/)._ diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 65917f0..7bf00cb 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -2,8 +2,8 @@ nav: - Home: index.md - Installation: - - Quick Start: quick-start.md - - Install ServeStatic on your...: + - Install from PyPI: install.md + - Configure ServeStatic on your...: - ASGI Project: asgi.md - WSGI Project: wsgi.md - Django Project: django.md diff --git a/docs/src/asgi.md b/docs/src/asgi.md index b6b1bf2..0bd1558 100644 --- a/docs/src/asgi.md +++ b/docs/src/asgi.md @@ -4,22 +4,24 @@ `ServeStaticASGI` inherits its interface and features from the [WSGI variant](wsgi.md). -To enable ServeStatic you need to wrap your existing ASGI application in a `ServeStaticASGI` instance and tell it where to find your static files. For example: +To enable ServeStatic on an existing ASGI application, wrap it in a `ServeStaticASGI` instance and tell it where to find your static files. For example: -```python -from servestatic import ServeStaticASGI +=== "`my_project.py`" -from my_project import MyASGIApp + ```python + from servestatic import ServeStaticASGI -application = MyASGIApp() -application = ServeStaticASGI(application, root="/path/to/static/files") -application.add_files("/path/to/more/static/files", prefix="more-files/") -``` + from example_framework import ExampleApp + + asgi_app = ExampleApp() + asgi_app = ServeStaticASGI(asgi_app, root="/path/to/static/files") + asgi_app.add_files("/path/to/more/static/files", prefix="more-files/") + ``` Alternatively, you can use ServeStatic as a standalone file server by not providing a WSGI app. For example: ```python linenums="0" -application = ServeStaticASGI(None, root="/path/to/static/files") +asgi_app = ServeStaticASGI(application=None, root="/path/to/static/files") ``` {% include-markdown "./wsgi.md" start="" end="" %} @@ -27,7 +29,7 @@ application = ServeStaticASGI(None, root="/path/to/static/files") After configuring ServeStatic, you can use your favourite ASGI server (such as [`uvicorn`](https://pypi.org/project/uvicorn/) or [`hypercorn`](https://pypi.org/project/Hypercorn/)) to run your application. ```bash linenums="0" -uvicorn my_project:application +uvicorn my_project:asgi_app ``` See the [API reference documentation](servestatic-asgi.md) for detailed usage and features. diff --git a/docs/src/django.md b/docs/src/django.md index d78d115..4eacaab 100644 --- a/docs/src/django.md +++ b/docs/src/django.md @@ -8,7 +8,7 @@ We mention Heroku in a few places, but there's nothing Heroku-specific about Ser Edit your `settings.py` file and add ServeStatic to the `MIDDLEWARE` list. -!!! warning "Middleware order is important!" +??? warning "Middleware order is important!" The ServeStatic middleware should be placed directly after the Django [SecurityMiddleware](https://docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.security) (if you are using it) and before all other middleware. diff --git a/docs/src/install.md b/docs/src/install.md new file mode 100644 index 0000000..b1617e6 --- /dev/null +++ b/docs/src/install.md @@ -0,0 +1,50 @@ +The documentation below is a quick-start guide to using ServeStatic to serve your static files. For more detailed information see the [full installation docs](django.md). + +--- + +## Installation + +!!! note "Optional Extras" + + ServeStatic has optional extras (`brotli` and `minify`) for Brotli compression and minification. The example below shows how to install with these extras, but you can omit them if you don't need those features. + +To install from PyPI, run the following command: + +```bash linenums="0" + +pip install servestatic[brotli, minify] +``` + +## Using with ASGI + +For configuration instructions, see the [ASGI guide](asgi.md). + +## Using with WSGI + +For configuration instructions, see the [WSGI guide](wsgi.md). + +## Using with Django + +Below is the quick start guide for Django. For advanced configuration instructions, see the [full Django guide](django.md). + +Edit your `settings.py` file and add ServeStatic to the `MIDDLEWARE` list, above all other middleware apart from Django's [SecurityMiddleware](https://docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.security). + +```python linenums="0" +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + # ... +] +``` + +That's it, you're ready to go. + +Want forever-cacheable files and compression support? Just add this to your `settings.py`. + +```python linenums="0" +STORAGES = { + "staticfiles": { + "BACKEND": "servestatic.storage.CompressedManifestStaticFilesStorage", + }, +} +``` diff --git a/docs/src/quick-start.md b/docs/src/quick-start.md deleted file mode 100644 index dca1cf5..0000000 --- a/docs/src/quick-start.md +++ /dev/null @@ -1,74 +0,0 @@ -The documentation below is a quick-start guide to using ServeStatic to serve your static files. For more detailed information see the [full installation docs](django.md). - ---- - -## Installation - -Install with: - -```bash linenums="0" -# Note: 'brotli' and 'minify' are optional extras -pip install servestatic[brotli, minify] -``` - -## Using with Django - -Edit your `settings.py` file and add ServeStatic to the `MIDDLEWARE` list, above all other middleware apart from Django's [SecurityMiddleware](https://docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.security). - -```python linenums="0" -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "servestatic.middleware.ServeStaticMiddleware", - # ... -] -``` - -That's it, you're ready to go. - -Want forever-cacheable files and compression support? Just add this to your `settings.py`. - -```python linenums="0" -STORAGES = { - "staticfiles": { - "BACKEND": "servestatic.storage.CompressedManifestStaticFilesStorage", - }, -} -``` - -For more details, including on setting up CloudFront and other CDNs see the [full Django guide](django.md). - -## Using with WSGI - -To enable `ServeStatic` you need to wrap your existing WSGI application in a `ServeStatic` instance and tell it where to find your static files. For example... - -```python -from servestatic import ServeStatic - -from my_project import MyWSGIApp - -application = MyWSGIApp() -application = ServeStatic(application, root="/path/to/static/files") -application.add_files("/path/to/more/static/files", prefix="more-files/") -``` - -Alternatively, you can use ServeStatic as a standalone file server by not providing a WSGI app, such as via `#!python ServeStatic(None, root="/path/to/static/files")`. - -And that's it, you're ready to go. For more details see the [full WSGI guide](wsgi.md). - -## Using with ASGI - -To enable `ServeStatic` you need to wrap your existing ASGI application in a `ServeStatic` instance and tell it where to find your static files. For example... - -```python -from servestatic import ServeStaticASGI - -from my_project import MyASGIApp - -application = MyASGIApp() -application = ServeStaticASGI(application, root="/path/to/static/files") -application.add_files("/path/to/more/static/files", prefix="more-files/") -``` - -Alternatively, you can use ServeStatic as a standalone file server by not providing a ASGI app, such as via `#!python ServeStaticASGI(None, root="/path/to/static/files")`. - -And that's it, you're ready to go. For more details see the [full ASGI guide](asgi.md). diff --git a/docs/src/wsgi.md b/docs/src/wsgi.md index b9fd5c1..1303c1a 100644 --- a/docs/src/wsgi.md +++ b/docs/src/wsgi.md @@ -1,21 +1,23 @@ # Using ServeStatic with WSGI apps -To enable ServeStatic you need to wrap your existing WSGI application in a `ServeStatic` instance and tell it where to find your static files. For example: +To enable ServeStatic on an existing WSGI application, wrap it in a `ServeStatic` instance and tell it where to find your static files. For example: -```python -from servestatic import ServeStatic +=== "`my_project.py`" -from my_project import MyWSGIApp + ```python + from servestatic import ServeStatic -application = MyWSGIApp() -application = ServeStatic(application, root="/path/to/static/files") -application.add_files("/path/to/more/static/files", prefix="more-files/") -``` + from example_framework import ExampleApp + + wsgi_app = ExampleApp() + wsgi_app = ServeStatic(wsgi_app, root="/path/to/static/files") + wsgi_app.add_files("/path/to/more/static/files", prefix="more-files/") + ``` Alternatively, you can use ServeStatic as a standalone file server by not providing a WSGI app. For example: ```python linenums="0" -application = ServeStatic(None, root="/path/to/static/files") +wsgi_app = ServeStatic(application=None, root="/path/to/static/files") ``` @@ -27,7 +29,7 @@ On initialization, ServeStatic walks over all the files in the directories that After configuring ServeStatic, you can use your favourite WSGI server (such as [`gunicorn`](https://gunicorn.org/) or [`waitress`](https://pypi.org/project/waitress/)) to run your application. ```bash linenums="0" -gunicorn my_project:application +gunicorn my_project:wsgi_app ``` See the [API reference documentation](servestatic.md) for detailed usage and features. diff --git a/src/servestatic/__init__.py b/src/servestatic/__init__.py index e2c3890..d9392de 100644 --- a/src/servestatic/__init__.py +++ b/src/servestatic/__init__.py @@ -3,6 +3,6 @@ from servestatic.asgi import ServeStaticASGI from servestatic.wsgi import ServeStatic -__version__ = "4.3.0" +__version__ = "4.3.1" __all__ = ["ServeStatic", "ServeStaticASGI"] diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index 9127d36..54f5a13 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -99,7 +99,8 @@ def post_process(self, *args: Any, **kwargs: Any) -> _PostProcessT: # pyright: processed = self.make_helpful_exception(processed, name) # noqa: PLW2901 yield name, hashed_name, processed - self.add_stats_to_manifest() + if not kwargs.get("dry_run"): + self.add_stats_to_manifest() def add_stats_to_manifest(self) -> None: """Adds additional `stats` field to Django's manifest file.""" diff --git a/tests/test_storage.py b/tests/test_storage.py index 1d71e2c..d0dfab6 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -229,3 +229,45 @@ def test_storage_post_process_with_compression_handles_items_without_hashed_name results = list(storage.post_process_with_compression([("styles.css", None, True)])) assert results == [("styles.css", None, True)] + + +def test_storage_post_process_dry_run_skips_add_stats_to_manifest(monkeypatch): + storage = CompressedManifestStaticFilesStorage() + + monkeypatch.setattr( + ManifestStaticFilesStorage, + "post_process", + lambda self, *args, **kwargs: iter([]), + ) + + add_stats_called = {"value": False} + + def fake_add_stats_to_manifest(): + add_stats_called["value"] = True + + monkeypatch.setattr(storage, "add_stats_to_manifest", fake_add_stats_to_manifest) + + list(storage.post_process({}, dry_run=True)) + + assert add_stats_called["value"] is False + + +def test_storage_post_process_non_dry_run_calls_add_stats_to_manifest(monkeypatch): + storage = CompressedManifestStaticFilesStorage() + + monkeypatch.setattr( + ManifestStaticFilesStorage, + "post_process", + lambda self, *args, **kwargs: iter([]), + ) + + add_stats_called = {"value": False} + + def fake_add_stats_to_manifest(): + add_stats_called["value"] = True + + monkeypatch.setattr(storage, "add_stats_to_manifest", fake_add_stats_to_manifest) + + list(storage.post_process({}, dry_run=False)) + + assert add_stats_called["value"] is True