From b24626c7c7edade02b0d3bc4e9721479d9376e23 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 30 Aug 2024 15:45:37 -0700 Subject: [PATCH 01/30] Begin initial work at utilizing Django's manifest file for file detection --- src/servestatic/middleware.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 4bb790d9..19809d52 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -4,6 +4,7 @@ import concurrent.futures import contextlib import os +from itertools import chain from posixpath import basename, normpath from typing import AsyncIterable from urllib.parse import urlparse @@ -14,7 +15,10 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.conf import settings from django.contrib.staticfiles import finders -from django.contrib.staticfiles.storage import staticfiles_storage +from django.contrib.staticfiles.storage import ( + ManifestStaticFilesStorage, + staticfiles_storage, +) from django.http import FileResponse from servestatic.responders import MissingFileError @@ -139,6 +143,11 @@ def __init__(self, get_response, settings=settings): except AttributeError: self.use_finders = settings.DEBUG + try: + self.use_manifest = settings.SERVESTATIC_USE_MANIFEST + except AttributeError: + self.use_manifest = not settings.DEBUG + try: self.static_prefix = settings.SERVESTATIC_STATIC_PREFIX except AttributeError: @@ -160,6 +169,9 @@ def __init__(self, get_response, settings=settings): if root: self.add_files(root) + if self.use_manifest and not self.autorefresh: + self.add_files_from_manifest() + if self.use_finders and not self.autorefresh: self.add_files_from_finders() @@ -223,6 +235,19 @@ def add_files_from_finders(self): for url, path in files.items(): self.add_file_to_dictionary(url, path, stat_cache=stat_cache) + def add_files_from_manifest(self): + if isinstance(staticfiles_storage, ManifestStaticFilesStorage): + serve_unhashed = not getattr( + settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False + ) + return { + f"{self.static_prefix}{n}" + for n in chain( + staticfiles_storage.hashed_files.values(), + (staticfiles_storage.hashed_files.keys() if serve_unhashed else []), + ) + } + def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): relative_url = url[len(self.static_prefix) :] From 007876c31d7ebbf2c6d76e8e30ef3c4456cd3d5c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 3 Sep 2024 00:35:02 -0700 Subject: [PATCH 02/30] simplify middleware init --- src/servestatic/middleware.py | 91 +++++++++++------------------------ 1 file changed, 29 insertions(+), 62 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 19809d52..db16dbb7 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -13,7 +13,7 @@ import django from aiofiles.base import AiofilesContextManager from asgiref.sync import iscoroutinefunction, markcoroutinefunction -from django.conf import settings +from django.conf import settings as django_settings from django.contrib.staticfiles import finders from django.contrib.staticfiles.storage import ( ManifestStaticFilesStorage, @@ -82,7 +82,7 @@ class ServeStaticMiddleware(ServeStatic): async_capable = True sync_capable = False - def __init__(self, get_response, settings=settings): + def __init__(self, get_response, settings=django_settings): self.get_response = get_response if not iscoroutinefunction(get_response): raise ValueError( @@ -90,41 +90,23 @@ def __init__(self, get_response, settings=settings): ) markcoroutinefunction(self) - try: - autorefresh: bool = settings.SERVESTATIC_AUTOREFRESH - except AttributeError: - autorefresh = settings.DEBUG - try: - max_age = settings.SERVESTATIC_MAX_AGE - except AttributeError: - if settings.DEBUG: - max_age = 0 - else: - max_age = 60 - try: - allow_all_origins = settings.SERVESTATIC_ALLOW_ALL_ORIGINS - except AttributeError: - allow_all_origins = True - try: - charset = settings.SERVESTATIC_CHARSET - except AttributeError: - charset = "utf-8" - try: - mimetypes = settings.SERVESTATIC_MIMETYPES - except AttributeError: - mimetypes = None - try: - add_headers_function = settings.SERVESTATIC_ADD_HEADERS_FUNCTION - except AttributeError: - add_headers_function = None - try: - index_file = settings.SERVESTATIC_INDEX_FILE - except AttributeError: - index_file = None - try: - immutable_file_test = settings.SERVESTATIC_IMMUTABLE_FILE_TEST - except AttributeError: - immutable_file_test = None + autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", settings.DEBUG) + max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if settings.DEBUG else 60) + allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) + charset = getattr(settings, "SERVESTATIC_CHARSET", "utf-8") + mimetypes = getattr(settings, "SERVESTATIC_MIMETYPES", None) + add_headers_function = getattr( + settings, "SERVESTATIC_ADD_HEADERS_FUNCTION", None + ) + index_file = getattr(settings, "SERVESTATIC_INDEX_FILE", None) + immutable_file_test = getattr(settings, "SERVESTATIC_IMMUTABLE_FILE_TEST", None) + self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", settings.DEBUG) + self.use_manifest = getattr( + settings, "SERVESTATIC_USE_MANIFEST", not settings.DEBUG + ) + self.static_prefix = getattr(settings, "SERVESTATIC_STATIC_PREFIX", None) + self.static_root = getattr(settings, "STATIC_ROOT", None) + root = getattr(settings, "SERVESTATIC_ROOT", None) super().__init__( application=None, @@ -138,19 +120,7 @@ def __init__(self, get_response, settings=settings): immutable_file_test=immutable_file_test, ) - try: - self.use_finders = settings.SERVESTATIC_USE_FINDERS - except AttributeError: - self.use_finders = settings.DEBUG - - try: - self.use_manifest = settings.SERVESTATIC_USE_MANIFEST - except AttributeError: - self.use_manifest = not settings.DEBUG - - try: - self.static_prefix = settings.SERVESTATIC_STATIC_PREFIX - except AttributeError: + if self.static_prefix is None: self.static_prefix = urlparse(settings.STATIC_URL or "").path if settings.FORCE_SCRIPT_NAME: script_name = settings.FORCE_SCRIPT_NAME.rstrip("/") @@ -158,20 +128,15 @@ def __init__(self, get_response, settings=settings): self.static_prefix = self.static_prefix[len(script_name) :] self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) - self.static_root = settings.STATIC_ROOT - if self.static_root: + if self.use_manifest and not self.autorefresh: + self.add_files_from_manifest() + + if self.static_root and not self.use_manifest: self.add_files(self.static_root, prefix=self.static_prefix) - try: - root = settings.SERVESTATIC_ROOT - except AttributeError: - root = None if root: self.add_files(root) - if self.use_manifest and not self.autorefresh: - self.add_files_from_manifest() - if self.use_finders and not self.autorefresh: self.add_files_from_finders() @@ -188,7 +153,9 @@ async def __call__(self, request): if static_file is not None: return await self.aserve(static_file, request) - if settings.DEBUG and request.path.startswith(settings.STATIC_URL): + if django_settings.DEBUG and request.path.startswith( + django_settings.STATIC_URL + ): current_finders = finders.get_finders() app_dirs = [ storage.location @@ -197,7 +164,7 @@ async def __call__(self, request): ] app_dirs = "\n• ".join(sorted(app_dirs)) raise MissingFileError( - f"ServeStatic did not find the file '{request.path.lstrip(settings.STATIC_URL)}' within the following paths:\n• {app_dirs}" + f"ServeStatic did not find the file '{request.path.lstrip(django_settings.STATIC_URL)}' within the following paths:\n• {app_dirs}" ) return await self.get_response(request) @@ -238,7 +205,7 @@ def add_files_from_finders(self): def add_files_from_manifest(self): if isinstance(staticfiles_storage, ManifestStaticFilesStorage): serve_unhashed = not getattr( - settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False + django_settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False ) return { f"{self.static_prefix}{n}" From 201b64c8d11c5119e7f697706d3caac8419a93f2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 3 Sep 2024 00:54:25 -0700 Subject: [PATCH 03/30] broken draft implementation --- src/servestatic/base.py | 33 +++++++++++++-------------------- src/servestatic/middleware.py | 26 ++++++++++++++++---------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/servestatic/base.py b/src/servestatic/base.py index 4523505b..bd408a97 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import re import warnings @@ -8,10 +9,7 @@ from wsgiref.headers import Headers from .media_types import MediaTypes -from .responders import IsDirectoryError -from .responders import MissingFileError -from .responders import Redirect -from .responders import StaticFile +from .responders import IsDirectoryError, MissingFileError, Redirect, StaticFile from .string_utils import ensure_leading_trailing_slash @@ -81,17 +79,16 @@ def add_files(self, root, prefix=None): # to store the list of directories in reverse order so later ones # match first when they're checked in "autorefresh" mode self.directories.insert(0, (root, prefix)) + elif os.path.isdir(root): + self.update_files_dictionary(root, prefix) else: - if os.path.isdir(root): - self.update_files_dictionary(root, prefix) - else: - warnings.warn(f"No directory at: {root}", stacklevel=3) + warnings.warn(f"No directory at: {root}", stacklevel=3) def update_files_dictionary(self, root, prefix): # Build a mapping from paths to the results of `os.stat` calls # so we only have to touch the filesystem once stat_cache = dict(scantree(root)) - for path in stat_cache.keys(): + for path in stat_cache: relative_path = path[len(root) :] relative_url = relative_path.replace("\\", "/") url = prefix + relative_url @@ -100,7 +97,7 @@ def update_files_dictionary(self, root, prefix): def add_file_to_dictionary(self, url, path, stat_cache=None): if self.is_compressed_variant(path, stat_cache=stat_cache): return - if self.index_file is not None and url.endswith("/" + self.index_file): + if self.index_file is not None and url.endswith(f"/{self.index_file}"): index_url = url[: -len(self.index_file)] index_no_slash = index_url.rstrip("/") self.files[url] = self.redirect(url, index_url) @@ -116,10 +113,8 @@ def find_file(self, url): if not self.url_is_canonical(url): return for path in self.candidate_paths_for_url(url): - try: + with contextlib.suppress(MissingFileError): return self.find_file_at_path(path, url) - except MissingFileError: - pass def candidate_paths_for_url(self, url): for root, prefix in self.directories: @@ -136,7 +131,7 @@ def find_file_at_path(self, path, url): if url.endswith("/"): path = os.path.join(path, self.index_file) return self.get_static_file(path, url) - elif url.endswith("/" + self.index_file): + elif url.endswith(f"/{self.index_file}"): if os.path.isfile(path): return self.redirect(url, url[: -len(self.index_file)]) else: @@ -144,7 +139,7 @@ def find_file_at_path(self, path, url): return self.get_static_file(path, url) except IsDirectoryError: if os.path.isfile(os.path.join(path, self.index_file)): - return self.redirect(url, url + "/") + return self.redirect(url, f"{url}/") raise MissingFileError(path) return self.get_static_file(path, url) @@ -187,7 +182,7 @@ def get_static_file(self, path, url, stat_cache=None): path, headers.items(), stat_cache=stat_cache, - encodings={"gzip": path + ".gz", "br": path + ".br"}, + encodings={"gzip": f"{path}.gz", "br": f"{path}.br"}, ) def add_mime_headers(self, headers, path, url): @@ -200,9 +195,7 @@ def add_mime_headers(self, headers, path, url): def add_cache_headers(self, headers, path, url): if self.immutable_file_test(path, url): - headers["Cache-Control"] = "max-age={}, public, immutable".format( - self.FOREVER - ) + headers["Cache-Control"] = f"max-age={self.FOREVER}, public, immutable" elif self.max_age is not None: headers["Cache-Control"] = f"max-age={self.max_age}, public" @@ -220,7 +213,7 @@ def redirect(self, from_url, to_url): We use relative redirects as we don't know the absolute URL the app is being hosted under """ - if to_url == from_url + "/": + if to_url == f"{from_url}/": relative_url = from_url.split("/")[-1] + "/" elif from_url == to_url + self.index_file: relative_url = "./" diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index db16dbb7..01a3112e 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -203,17 +203,23 @@ def add_files_from_finders(self): self.add_file_to_dictionary(url, path, stat_cache=stat_cache) def add_files_from_manifest(self): - if isinstance(staticfiles_storage, ManifestStaticFilesStorage): - serve_unhashed = not getattr( - django_settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False - ) - return { - f"{self.static_prefix}{n}" - for n in chain( - staticfiles_storage.hashed_files.values(), - (staticfiles_storage.hashed_files.keys() if serve_unhashed else []), + if not isinstance(staticfiles_storage, ManifestStaticFilesStorage): + return + + # Dictionary with hashed filenames as keys and unhashed filenames as values + django_file_storage: dict = staticfiles_storage.hashed_files + serve_unhashed = not getattr( + django_settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False + ) + + for hashed_name, unhashed_name in django_file_storage.items(): + if serve_unhashed: + self.add_file_to_dictionary( + f"{self.static_prefix}{unhashed_name}", unhashed_name ) - } + self.add_file_to_dictionary( + f"{self.static_prefix}{hashed_name}", unhashed_name + ) def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): From dbba75214f0d7a1e049df8bae5d8c06d83c3f68b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 6 Sep 2024 00:31:08 -0700 Subject: [PATCH 04/30] Semi-functional solution --- src/servestatic/middleware.py | 29 ++++++++++++++--------------- tox.ini | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 01a3112e..3100700d 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -4,7 +4,6 @@ import concurrent.futures import contextlib import os -from itertools import chain from posixpath import basename, normpath from typing import AsyncIterable from urllib.parse import urlparse @@ -15,10 +14,7 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.conf import settings as django_settings from django.contrib.staticfiles import finders -from django.contrib.staticfiles.storage import ( - ManifestStaticFilesStorage, - staticfiles_storage, -) +from django.contrib.staticfiles.storage import staticfiles_storage from django.http import FileResponse from servestatic.responders import MissingFileError @@ -82,14 +78,14 @@ class ServeStaticMiddleware(ServeStatic): async_capable = True sync_capable = False - def __init__(self, get_response, settings=django_settings): - self.get_response = get_response + def __init__(self, get_response=None, settings=django_settings): if not iscoroutinefunction(get_response): raise ValueError( "ServeStaticMiddleware requires an async compatible version of Django." ) markcoroutinefunction(self) + self.get_response = get_response autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", settings.DEBUG) max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if settings.DEBUG else 60) allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) @@ -107,6 +103,7 @@ def __init__(self, get_response, settings=django_settings): self.static_prefix = getattr(settings, "SERVESTATIC_STATIC_PREFIX", None) self.static_root = getattr(settings, "STATIC_ROOT", None) root = getattr(settings, "SERVESTATIC_ROOT", None) + self.load_manifest_files = False super().__init__( application=None, @@ -129,7 +126,7 @@ def __init__(self, get_response, settings=django_settings): self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) if self.use_manifest and not self.autorefresh: - self.add_files_from_manifest() + self.load_manifest_files = True if self.static_root and not self.use_manifest: self.add_files(self.static_root, prefix=self.static_prefix) @@ -143,6 +140,9 @@ def __init__(self, get_response, settings=django_settings): async def __call__(self, request): """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" + if self.load_manifest_files: + self.add_files_from_manifest() + self.load_manifest_files = False if self.autorefresh and hasattr(asyncio, "to_thread"): # Use a thread while searching disk for files on Python 3.9+ static_file = await asyncio.to_thread(self.find_file, request.path_info) @@ -203,22 +203,21 @@ def add_files_from_finders(self): self.add_file_to_dictionary(url, path, stat_cache=stat_cache) def add_files_from_manifest(self): - if not isinstance(staticfiles_storage, ManifestStaticFilesStorage): - return - - # Dictionary with hashed filenames as keys and unhashed filenames as values django_file_storage: dict = staticfiles_storage.hashed_files + serve_unhashed = not getattr( django_settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False ) - for hashed_name, unhashed_name in django_file_storage.items(): + for unhashed_name, hashed_name in django_file_storage.items(): if serve_unhashed: self.add_file_to_dictionary( - f"{self.static_prefix}{unhashed_name}", unhashed_name + f"{self.static_prefix}{unhashed_name}", + staticfiles_storage.path(unhashed_name), ) self.add_file_to_dictionary( - f"{self.static_prefix}{hashed_name}", unhashed_name + f"{self.static_prefix}{hashed_name}", + staticfiles_storage.path(unhashed_name), ) def candidate_paths_for_url(self, url): diff --git a/tox.ini b/tox.ini index 13bc6597..65c50865 100644 --- a/tox.ini +++ b/tox.ini @@ -21,4 +21,4 @@ commands = -W error::PendingDeprecationWarning \ -W 'ignore:path is deprecated. Use files() instead.:DeprecationWarning' \ -m coverage run \ - -m pytest {posargs:tests} + -m pytest {posargs:tests} -v From 3c93adee65c311c24383e9360dd9311f05bd5553 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 6 Sep 2024 00:59:57 -0700 Subject: [PATCH 05/30] Always use asyncio.to_thread --- src/servestatic/asgi.py | 7 +++---- src/servestatic/middleware.py | 26 +++++++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/servestatic/asgi.py b/src/servestatic/asgi.py index 252c43e7..261b94f2 100644 --- a/src/servestatic/asgi.py +++ b/src/servestatic/asgi.py @@ -4,9 +4,10 @@ from asgiref.compatibility import guarantee_single_callable -from .string_utils import decode_path_info from servestatic.base import BaseServeStatic +from .string_utils import decode_path_info + # This is the same size as wsgiref.FileWrapper BLOCK_SIZE = 8192 @@ -23,11 +24,9 @@ async def __call__(self, scope, receive, send): path = decode_path_info(scope["path"]) static_file = None if scope["type"] == "http": - if self.autorefresh and hasattr(asyncio, "to_thread"): + if self.autorefresh: # Use a thread while searching disk for files on Python 3.9+ static_file = await asyncio.to_thread(self.find_file, path) - elif self.autorefresh: - static_file = self.find_file(path) else: static_file = self.files.get(path) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 3100700d..d784e28b 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -14,7 +14,10 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.conf import settings as django_settings from django.contrib.staticfiles import finders -from django.contrib.staticfiles.storage import staticfiles_storage +from django.contrib.staticfiles.storage import ( + ManifestStaticFilesStorage, + staticfiles_storage, +) from django.http import FileResponse from servestatic.responders import MissingFileError @@ -102,6 +105,9 @@ def __init__(self, get_response=None, settings=django_settings): ) self.static_prefix = getattr(settings, "SERVESTATIC_STATIC_PREFIX", None) self.static_root = getattr(settings, "STATIC_ROOT", None) + self.keep_only_hashed_files = getattr( + django_settings, "SERVESTATIC_KEEP_ONLY_HASHED_FILES", False + ) root = getattr(settings, "SERVESTATIC_ROOT", None) self.load_manifest_files = False @@ -141,13 +147,10 @@ async def __call__(self, request): """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" if self.load_manifest_files: - self.add_files_from_manifest() + await asyncio.to_thread(self.add_files_from_manifest) self.load_manifest_files = False - if self.autorefresh and hasattr(asyncio, "to_thread"): - # Use a thread while searching disk for files on Python 3.9+ + if self.autorefresh: static_file = await asyncio.to_thread(self.find_file, request.path_info) - elif self.autorefresh: - static_file = self.find_file(request.path_info) else: static_file = self.files.get(request.path_info) if static_file is not None: @@ -203,14 +206,15 @@ def add_files_from_finders(self): self.add_file_to_dictionary(url, path, stat_cache=stat_cache) def add_files_from_manifest(self): + if not isinstance(staticfiles_storage, ManifestStaticFilesStorage): + raise ValueError( + "SERVESTATIC_USE_MANIFEST is set to True but " + "staticfiles storage is not using a manifest." + ) django_file_storage: dict = staticfiles_storage.hashed_files - serve_unhashed = not getattr( - django_settings, "WHITENOISE_KEEP_ONLY_HASHED_FILES", False - ) - for unhashed_name, hashed_name in django_file_storage.items(): - if serve_unhashed: + if not self.keep_only_hashed_files: self.add_file_to_dictionary( f"{self.static_prefix}{unhashed_name}", staticfiles_storage.path(unhashed_name), From d667d9aeb7d6af37b9436761cf4063e4ef3573d8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 6 Sep 2024 01:03:17 -0700 Subject: [PATCH 06/30] drop python 3.8 support --- .github/workflows/test-src.yml | 1 - .pre-commit-config.yaml | 130 ++++++++++++++++----------------- pyproject.toml | 6 +- requirements/py38-django32.txt | 1 - requirements/py38-django40.txt | 1 - requirements/py38-django41.txt | 1 - requirements/py38-django42.txt | 1 - setup.cfg | 2 +- tox.ini | 1 - 9 files changed, 68 insertions(+), 76 deletions(-) delete mode 100644 requirements/py38-django32.txt delete mode 100644 requirements/py38-django40.txt delete mode 100644 requirements/py38-django41.txt delete mode 100644 requirements/py38-django42.txt diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index 76a6bdf5..3ebfea95 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -21,7 +21,6 @@ jobs: - ubuntu-latest - windows-latest python-version: - - "3.8" - "3.9" - "3.10" - "3.11" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a2f1516..fee7a030 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,68 +1,68 @@ default_language_version: - python: python3.12 + python: python3.12 repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-added-large-files - - id: check-case-conflict - - id: check-json - - id: check-merge-conflict - - id: check-symlinks - - id: check-toml - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.8.0 - hooks: - - id: pyproject-fmt - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.3.1 - hooks: - - id: tox-ini-fmt - - repo: https://github.com/rstcheck/rstcheck - rev: v6.2.0 - hooks: - - id: rstcheck - additional_dependencies: - - sphinx==6.1.3 - - tomli==2.0.1 - - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.9.1 - hooks: - - id: sphinx-lint - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 - hooks: - - id: pyupgrade - args: [--py38-plus] - - repo: https://github.com/adamchainz/django-upgrade - rev: 1.16.0 - hooks: - - id: django-upgrade - args: [--target-version, "3.2"] - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.4.2 - hooks: - - id: black - - repo: https://github.com/adamchainz/blacken-docs - rev: 1.16.0 - hooks: - - id: blacken-docs - additional_dependencies: - - black==23.1.0 - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - name: isort (python) - - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-bugbear - - flake8-comprehensions - - flake8-logging - - flake8-tidy-imports + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 1.8.0 + hooks: + - id: pyproject-fmt + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 1.3.1 + hooks: + - id: tox-ini-fmt + - repo: https://github.com/rstcheck/rstcheck + rev: v6.2.0 + hooks: + - id: rstcheck + additional_dependencies: + - sphinx==6.1.3 + - tomli==2.0.1 + - repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v0.9.1 + hooks: + - id: sphinx-lint + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: [--py39-plus] + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.16.0 + hooks: + - id: django-upgrade + args: [--target-version, "3.2"] + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 24.4.2 + hooks: + - id: black + - repo: https://github.com/adamchainz/blacken-docs + rev: 1.16.0 + hooks: + - id: blacken-docs + additional_dependencies: + - black==23.1.0 + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort (python) + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-logging + - flake8-tidy-imports diff --git a/pyproject.toml b/pyproject.toml index 70a608eb..51e00bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,9 @@ [build-system] build-backend = "setuptools.build_meta" -requires = [ - "setuptools", -] +requires = ["setuptools"] [tool.black] -target-version = ['py38'] +target-version = ['py39'] [tool.pytest.ini_options] addopts = """\ diff --git a/requirements/py38-django32.txt b/requirements/py38-django32.txt deleted file mode 100644 index fec27438..00000000 --- a/requirements/py38-django32.txt +++ /dev/null @@ -1 +0,0 @@ -django==3.2.* diff --git a/requirements/py38-django40.txt b/requirements/py38-django40.txt deleted file mode 100644 index 8b4cd5af..00000000 --- a/requirements/py38-django40.txt +++ /dev/null @@ -1 +0,0 @@ -django==4.0.* diff --git a/requirements/py38-django41.txt b/requirements/py38-django41.txt deleted file mode 100644 index 433d7a18..00000000 --- a/requirements/py38-django41.txt +++ /dev/null @@ -1 +0,0 @@ -django==4.1.* diff --git a/requirements/py38-django42.txt b/requirements/py38-django42.txt deleted file mode 100644 index 8054d795..00000000 --- a/requirements/py38-django42.txt +++ /dev/null @@ -1 +0,0 @@ -django==4.2.* diff --git a/setup.cfg b/setup.cfg index 9c1b83c2..fac6dc3a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ project_urls = packages = find: install_requires = aiofiles>=22.1.0 -python_requires = >=3.8 +python_requires = >=3.9 include_package_data = True package_dir = =src diff --git a/tox.ini b/tox.ini index 79944ab3..506ce1e4 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,6 @@ env_list = py311-django{42, 41, 50, 51} py310-django{42, 41, 40, 32, 50, 51} py39-django{42, 41, 40, 32} - py38-django{42, 41, 40, 32} [testenv] package = wheel From 08a405036cea8ecd707b546297e67f2e3a2ee7e6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 6 Sep 2024 17:33:29 -0700 Subject: [PATCH 07/30] non-functional index file serving --- src/servestatic/middleware.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index d784e28b..3417f501 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -97,7 +97,7 @@ def __init__(self, get_response=None, settings=django_settings): add_headers_function = getattr( settings, "SERVESTATIC_ADD_HEADERS_FUNCTION", None ) - index_file = getattr(settings, "SERVESTATIC_INDEX_FILE", None) + self.index_file = getattr(settings, "SERVESTATIC_INDEX_FILE", None) immutable_file_test = getattr(settings, "SERVESTATIC_IMMUTABLE_FILE_TEST", None) self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", settings.DEBUG) self.use_manifest = getattr( @@ -119,7 +119,7 @@ def __init__(self, get_response=None, settings=django_settings): charset=charset, mimetypes=mimetypes, add_headers_function=add_headers_function, - index_file=index_file, + index_file=self.index_file, immutable_file_test=immutable_file_test, ) @@ -211,18 +211,25 @@ def add_files_from_manifest(self): "SERVESTATIC_USE_MANIFEST is set to True but " "staticfiles storage is not using a manifest." ) - django_file_storage: dict = staticfiles_storage.hashed_files + staticfiles: dict = staticfiles_storage.hashed_files + + for unhashed_name, hashed_name in staticfiles.items(): + file_path = staticfiles_storage.path(unhashed_name) + if self.index_file is not None and unhashed_name.endswith(self.index_file): + index_url = ( + f"{self.static_prefix}{unhashed_name[: -len(self.index_file)]}" + ) + index_no_slash = index_url.rstrip("/") + url = f"{self.static_prefix}{unhashed_name}" + self.files[url] = self.redirect(url, index_url) + self.files[index_no_slash] = self.redirect(index_no_slash, index_url) + self.add_file_to_dictionary(index_url, file_path) - for unhashed_name, hashed_name in django_file_storage.items(): if not self.keep_only_hashed_files: self.add_file_to_dictionary( - f"{self.static_prefix}{unhashed_name}", - staticfiles_storage.path(unhashed_name), + f"{self.static_prefix}{unhashed_name}", file_path ) - self.add_file_to_dictionary( - f"{self.static_prefix}{hashed_name}", - staticfiles_storage.path(unhashed_name), - ) + self.add_file_to_dictionary(f"{self.static_prefix}{hashed_name}", file_path) def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): From 02a7cbb8dcb3def71e5b8cabc1b3d3c2577bdb96 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 6 Sep 2024 18:02:12 -0700 Subject: [PATCH 08/30] Remove unneeded code block --- src/servestatic/middleware.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 3417f501..28bb6071 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -215,15 +215,6 @@ def add_files_from_manifest(self): for unhashed_name, hashed_name in staticfiles.items(): file_path = staticfiles_storage.path(unhashed_name) - if self.index_file is not None and unhashed_name.endswith(self.index_file): - index_url = ( - f"{self.static_prefix}{unhashed_name[: -len(self.index_file)]}" - ) - index_no_slash = index_url.rstrip("/") - url = f"{self.static_prefix}{unhashed_name}" - self.files[url] = self.redirect(url, index_url) - self.files[index_no_slash] = self.redirect(index_no_slash, index_url) - self.add_file_to_dictionary(index_url, file_path) if not self.keep_only_hashed_files: self.add_file_to_dictionary( From f38dd3f7193b543dda3e95a3ac34522db910af7c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:36:07 -0700 Subject: [PATCH 09/30] fix tests --- tests/test_django_whitenoise.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_django_whitenoise.py b/tests/test_django_whitenoise.py index 55a42ea5..2f6ab492 100644 --- a/tests/test_django_whitenoise.py +++ b/tests/test_django_whitenoise.py @@ -202,8 +202,8 @@ def test_no_content_disposition_header(server, static_files, _collect_static): @pytest.fixture() -def finder_application(finder_static_files): - return get_wsgi_application() +def finder_application(finder_static_files, application): + return application @pytest.fixture() From f52dff9962fedd9e68cfc1936cd8e4a372829765 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:39:44 -0700 Subject: [PATCH 10/30] reorder manifest defaults --- src/servestatic/middleware.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 92741da1..2388c683 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -58,7 +58,10 @@ def __init__(self, get_response=None, settings=django_settings): immutable_file_test = getattr(settings, "SERVESTATIC_IMMUTABLE_FILE_TEST", None) self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", settings.DEBUG) self.use_manifest = getattr( - settings, "SERVESTATIC_USE_MANIFEST", not settings.DEBUG + settings, + "SERVESTATIC_USE_MANIFEST", + not settings.DEBUG + and isinstance(staticfiles_storage, ManifestStaticFilesStorage), ) self.static_prefix = getattr(settings, "SERVESTATIC_STATIC_PREFIX", None) self.static_root = getattr(settings, "STATIC_ROOT", None) @@ -66,7 +69,6 @@ def __init__(self, get_response=None, settings=django_settings): django_settings, "SERVESTATIC_KEEP_ONLY_HASHED_FILES", False ) root = getattr(settings, "SERVESTATIC_ROOT", None) - self.load_manifest_files = False super().__init__( application=None, @@ -89,7 +91,7 @@ def __init__(self, get_response=None, settings=django_settings): self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) if self.use_manifest and not self.autorefresh: - self.load_manifest_files = True + self.add_files_from_manifest() if self.static_root and not self.use_manifest: self.add_files(self.static_root, prefix=self.static_prefix) @@ -103,9 +105,6 @@ def __init__(self, get_response=None, settings=django_settings): async def __call__(self, request): """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" - if self.load_manifest_files: - await asyncio.to_thread(self.add_files_from_manifest) - self.load_manifest_files = False if self.autorefresh: static_file = await asyncio.to_thread(self.find_file, request.path_info) else: From df751cf3041ede75bdbfe97e62062347cde6797c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:42:58 -0700 Subject: [PATCH 11/30] concatenate settings.debug --- src/servestatic/middleware.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 2388c683..24a652ec 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -46,8 +46,9 @@ def __init__(self, get_response=None, settings=django_settings): markcoroutinefunction(self) self.get_response = get_response - autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", settings.DEBUG) - max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if settings.DEBUG else 60) + debug = getattr(settings, "DEBUG") + autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", debug) + max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if debug else 60) allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True) charset = getattr(settings, "SERVESTATIC_CHARSET", "utf-8") mimetypes = getattr(settings, "SERVESTATIC_MIMETYPES", None) @@ -56,12 +57,11 @@ def __init__(self, get_response=None, settings=django_settings): ) self.index_file = getattr(settings, "SERVESTATIC_INDEX_FILE", None) immutable_file_test = getattr(settings, "SERVESTATIC_IMMUTABLE_FILE_TEST", None) - self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", settings.DEBUG) + self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", debug) self.use_manifest = getattr( settings, "SERVESTATIC_USE_MANIFEST", - not settings.DEBUG - and isinstance(staticfiles_storage, ManifestStaticFilesStorage), + not debug and isinstance(staticfiles_storage, ManifestStaticFilesStorage), ) self.static_prefix = getattr(settings, "SERVESTATIC_STATIC_PREFIX", None) self.static_root = getattr(settings, "STATIC_ROOT", None) From 82b98017d8d68c38af8f3af476c972e33bf83d8b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:48:17 -0700 Subject: [PATCH 12/30] remove unneeded comment --- src/servestatic/asgi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/servestatic/asgi.py b/src/servestatic/asgi.py index 02a69ab7..daa0157f 100644 --- a/src/servestatic/asgi.py +++ b/src/servestatic/asgi.py @@ -24,7 +24,6 @@ async def __call__(self, scope, receive, send): static_file = None if scope["type"] == "http": if self.autorefresh: - # Use a thread while searching disk for files on Python 3.9+ static_file = await asyncio.to_thread(self.find_file, path) else: static_file = self.files.get(path) From 98edffeadfb3509b4f8db1cb0963a70507c16514 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:49:43 -0700 Subject: [PATCH 13/30] no relative imports --- src/servestatic/__init__.py | 4 ++-- src/servestatic/base.py | 11 ++++++++--- src/servestatic/storage.py | 2 +- src/servestatic/wsgi.py | 4 ++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/servestatic/__init__.py b/src/servestatic/__init__.py index 67c41c73..713c1cd7 100644 --- a/src/servestatic/__init__.py +++ b/src/servestatic/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .asgi import ServeStaticASGI -from .wsgi import ServeStatic +from servestatic.asgi import ServeStaticASGI +from servestatic.wsgi import ServeStatic __all__ = ["ServeStaticASGI", "ServeStatic"] diff --git a/src/servestatic/base.py b/src/servestatic/base.py index 03869e2e..5ed6f28f 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -8,9 +8,14 @@ from typing import Callable from wsgiref.headers import Headers -from .media_types import MediaTypes -from .responders import IsDirectoryError, MissingFileError, Redirect, StaticFile -from .utils import ensure_leading_trailing_slash, scantree +from servestatic.media_types import MediaTypes +from servestatic.responders import ( + IsDirectoryError, + MissingFileError, + Redirect, + StaticFile, +) +from servestatic.utils import ensure_leading_trailing_slash, scantree class BaseServeStatic: diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index c5efe6b4..3f759e54 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -13,7 +13,7 @@ StaticFilesStorage, ) -from .compress import Compressor +from servestatic.compress import Compressor _PostProcessT = Iterator[Union[Tuple[str, str, bool], Tuple[str, None, RuntimeError]]] diff --git a/src/servestatic/wsgi.py b/src/servestatic/wsgi.py index e07d1ad9..4d0a0008 100644 --- a/src/servestatic/wsgi.py +++ b/src/servestatic/wsgi.py @@ -2,8 +2,8 @@ from wsgiref.util import FileWrapper -from .base import BaseServeStatic -from .utils import decode_path_info +from servestatic.base import BaseServeStatic +from servestatic.utils import decode_path_info class ServeStatic(BaseServeStatic): From 4997335fbed80ef64cc1abf7cc5af0205a7a26b0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:01:51 -0700 Subject: [PATCH 14/30] Add no manifest test variants --- tests/test_django_whitenoise.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_django_whitenoise.py b/tests/test_django_whitenoise.py index 2f6ab492..428e9b74 100644 --- a/tests/test_django_whitenoise.py +++ b/tests/test_django_whitenoise.py @@ -91,6 +91,12 @@ def test_get_root_file(server, root_files, _collect_static): assert response.content == root_files.robots_content +@override_settings(SERVESTATIC_USE_MANIFEST=False) +def test_get_root_file_no_manifest(server, root_files, _collect_static): + response = server.get(root_files.robots_url) + assert response.content == root_files.robots_content + + def test_versioned_file_cached_forever(server, static_files, _collect_static): url = storage.staticfiles_storage.url(static_files.js_path) response = server.get(url) @@ -219,6 +225,13 @@ def test_file_served_from_static_dir(finder_static_files, finder_server): assert response.content == finder_static_files.js_content +@override_settings(SERVESTATIC_USE_MANIFEST=False) +def test_file_served_from_static_dir_no_manifest(finder_static_files, finder_server): + url = settings.STATIC_URL + finder_static_files.js_path + response = finder_server.get(url) + assert response.content == finder_static_files.js_content + + def test_non_ascii_requests_safely_ignored(finder_server): response = finder_server.get(settings.STATIC_URL + "test\u263a") assert 404 == response.status_code @@ -236,6 +249,15 @@ def test_index_file_served_at_directory_path(finder_static_files, finder_server) assert response.content == finder_static_files.index_content +@override_settings(SERVESTATIC_USE_MANIFEST=False) +def test_index_file_served_at_directory_path_no_manifest( + finder_static_files, finder_server +): + path = finder_static_files.index_path.rpartition("/")[0] + "/" + response = finder_server.get(settings.STATIC_URL + path) + assert response.content == finder_static_files.index_content + + def test_index_file_path_redirected(finder_static_files, finder_server): directory_path = finder_static_files.index_path.rpartition("/")[0] + "/" index_url = settings.STATIC_URL + finder_static_files.index_path @@ -265,11 +287,11 @@ def test_servestatic_file_response_has_only_one_header(): assert headers == {"content-type"} +@override_settings(STATIC_URL="static/") def test_relative_static_url(server, static_files, _collect_static): - with override_settings(STATIC_URL="static/"): - url = storage.staticfiles_storage.url(static_files.js_path) - response = server.get(url) - assert response.content == static_files.js_content + url = storage.staticfiles_storage.url(static_files.js_path) + response = server.get(url) + assert response.content == static_files.js_content def test_404_in_prod(server): From a2c94963fcb04a391ddbcec6b1b397a4d130379a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 8 Sep 2024 23:32:51 -0700 Subject: [PATCH 15/30] add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a31368..d34d41ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ Using the following categories, list your changes in this order: ## [Unreleased](https://github.com/Archmonger/ServeStatic/compare/1.2.0...HEAD) +### Added + +- Utilize Django manifest rather than scanning the directories for files when using `SERVESTATIC_USE_MANIFEST`. + ### Changed - Minimum python version is now 3.9. From 9a9965a25c64c3b84dc33a88c9f9fa13d9afc717 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 02:02:58 -0700 Subject: [PATCH 16/30] Fix manifest autorefresh --- src/servestatic/middleware.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 24a652ec..5df81c2f 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -90,18 +90,18 @@ def __init__(self, get_response=None, settings=django_settings): self.static_prefix = self.static_prefix[len(script_name) :] self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) - if self.use_manifest and not self.autorefresh: - self.add_files_from_manifest() - if self.static_root and not self.use_manifest: self.add_files(self.static_root, prefix=self.static_prefix) - if root: - self.add_files(root) + if self.use_manifest: + self.add_files_from_manifest() if self.use_finders and not self.autorefresh: self.add_files_from_finders() + if root: + self.add_files(root) + async def __call__(self, request): """If the URL contains a static file, serve it. Otherwise, continue to the next middleware.""" @@ -178,6 +178,14 @@ def add_files_from_manifest(self): ) self.add_file_to_dictionary(f"{self.static_prefix}{hashed_name}", file_path) + if staticfiles_storage.location: + # Later calls to `add_files` overwrite earlier ones, hence we need + # to store the list of directories in reverse order so later ones + # match first when they're checked in "autorefresh" mode + self.directories.insert( + 0, (staticfiles_storage.location, self.static_prefix) + ) + def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): relative_url = url[len(self.static_prefix) :] From 77d39094cde36835b8db7d2fddef3d8264542508 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 02:22:41 -0700 Subject: [PATCH 17/30] Remove useless stat from add_files_from_finders --- src/servestatic/middleware.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 5df81c2f..d7c80e69 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -157,9 +157,8 @@ def add_files_from_finders(self): ) # Use setdefault as only first matching file should be used files.setdefault(url, storage.path(path)) - stat_cache = {path: os.stat(path) for path in files.values()} for url, path in files.items(): - self.add_file_to_dictionary(url, path, stat_cache=stat_cache) + self.add_file_to_dictionary(url, path) def add_files_from_manifest(self): if not isinstance(staticfiles_storage, ManifestStaticFilesStorage): From 7bd460c45dc2295d13a1269e8a1715387628de12 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 02:23:45 -0700 Subject: [PATCH 18/30] insert_directory generic --- src/servestatic/base.py | 11 +++++++---- src/servestatic/middleware.py | 4 +--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/servestatic/base.py b/src/servestatic/base.py index 5ed6f28f..4c3337d6 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -76,15 +76,18 @@ def __init__( if root is not None: self.add_files(root, prefix) + def insert_directory(self, root, prefix): + # Later calls to `add_files` overwrite earlier ones, hence we need + # to store the list of directories in reverse order so later ones + # match first when they're checked in "autorefresh" mode + self.directories.insert(0, (root, prefix)) + def add_files(self, root, prefix=None): root = os.path.abspath(root) root = root.rstrip(os.path.sep) + os.path.sep prefix = ensure_leading_trailing_slash(prefix) if self.autorefresh: - # Later calls to `add_files` overwrite earlier ones, hence we need - # to store the list of directories in reverse order so later ones - # match first when they're checked in "autorefresh" mode - self.directories.insert(0, (root, prefix)) + self.insert_directory(root, prefix) elif os.path.isdir(root): self.update_files_dictionary(root, prefix) else: diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index d7c80e69..1542de1c 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -181,9 +181,7 @@ def add_files_from_manifest(self): # Later calls to `add_files` overwrite earlier ones, hence we need # to store the list of directories in reverse order so later ones # match first when they're checked in "autorefresh" mode - self.directories.insert( - 0, (staticfiles_storage.location, self.static_prefix) - ) + self.insert_directory(staticfiles_storage.location, self.static_prefix) def candidate_paths_for_url(self, url): if self.use_finders and url.startswith(self.static_prefix): From 4d22a388fb04505a9700a96b08c9c7b1e59c713f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:41:48 -0700 Subject: [PATCH 19/30] Use stored stat cache --- src/servestatic/middleware.py | 22 +++++++++++++----- src/servestatic/storage.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 1542de1c..d73b88a9 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -167,20 +167,30 @@ def add_files_from_manifest(self): "staticfiles storage is not using a manifest." ) staticfiles: dict = staticfiles_storage.hashed_files + stat_cache = None + + if hasattr(staticfiles_storage, "load_manifest_stats"): + stat_cache: dict = staticfiles_storage.load_manifest_stats() + stat_cache = { + staticfiles_storage.path(k): os.stat_result(v) + for k, v in stat_cache.items() + } for unhashed_name, hashed_name in staticfiles.items(): file_path = staticfiles_storage.path(unhashed_name) - if not self.keep_only_hashed_files: self.add_file_to_dictionary( - f"{self.static_prefix}{unhashed_name}", file_path + f"{self.static_prefix}{unhashed_name}", + file_path, + stat_cache=stat_cache, ) - self.add_file_to_dictionary(f"{self.static_prefix}{hashed_name}", file_path) + self.add_file_to_dictionary( + f"{self.static_prefix}{hashed_name}", + file_path, + stat_cache=stat_cache, + ) if staticfiles_storage.location: - # Later calls to `add_files` overwrite earlier ones, hence we need - # to store the list of directories in reverse order so later ones - # match first when they're checked in "autorefresh" mode self.insert_directory(staticfiles_storage.location, self.static_prefix) def candidate_paths_for_url(self, url): diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index 3f759e54..b0ef8202 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -2,6 +2,7 @@ import concurrent.futures import errno +import json import os import re import textwrap @@ -12,6 +13,7 @@ ManifestStaticFilesStorage, StaticFilesStorage, ) +from django.core.files.base import ContentFile from servestatic.compress import Compressor @@ -88,6 +90,48 @@ def post_process(self, *args, **kwargs): processed = self.make_helpful_exception(processed, name) yield name, hashed_name, processed + def save_manifest(self): + """Identical to Django's implementation, but this adds additional `stats` field.""" + self.manifest_hash = self.file_hash( + None, ContentFile(json.dumps(sorted(self.hashed_files.items())).encode()) + ) + payload = { + "paths": self.hashed_files, + "version": self.manifest_version, + "hash": self.manifest_hash, + "stats": self.stat_files(self.hashed_files.keys()), + } + if self.manifest_storage.exists(self.manifest_name): + self.manifest_storage.delete(self.manifest_name) + contents = json.dumps(payload).encode() + self.manifest_storage._save(self.manifest_name, ContentFile(contents)) + + def load_manifest_stats(self): + """Derivative of Django's `load_manifest` but for the `stats` field.""" + content = self.read_manifest() + if content is None: + return {}, "" + try: + stored = json.loads(content) + except json.JSONDecodeError: + pass + else: + version = stored.get("version") + if version in ("1.0", "1.1"): + return stored.get("stats", {}) + raise ValueError( + f"Couldn't load manifest '{self.manifest_name}' (version {self.manifest_version})" + ) + + def stat_files(self, relative_paths) -> dict: + """Stat all files in `relative_paths` concurrently.""" + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = { + rel_path: executor.submit(os.stat, self.path(rel_path)) + for rel_path in relative_paths + } + return {rel_path: future.result() for rel_path, future in futures.items()} + def post_process_with_compression(self, files): # Files may get hashed multiple times, we want to keep track of all the # intermediate files generated during the process and which of these From 99ab52a847415e100c2cb87aae72d1db39758882 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:02:22 -0700 Subject: [PATCH 20/30] Change `use_finders` behavior --- CHANGELOG.md | 11 ++++++----- docs/src/django-settings.md | 2 +- src/servestatic/base.py | 5 +++++ src/servestatic/middleware.py | 9 +++++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34d41ce..55e7bc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,34 +36,35 @@ Using the following categories, list your changes in this order: ### Added -- Utilize Django manifest rather than scanning the directories for files when using `SERVESTATIC_USE_MANIFEST`. +- Utilize Django manifest rather than scanning the directories for files when using `SERVESTATIC_USE_MANIFEST`. (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/275)) ### Changed - Minimum python version is now 3.9. +- Django `setings.py:SERVESTATIC_USE_FINDERS` will now strictly use Django finders to locate files. ServeStatic will no longer manually traverse the `STATIC_ROOT` directory to add additional files when this settings is enabled. ## [1.2.0](https://github.com/Archmonger/ServeStatic/compare/1.1.0...1.2.0) - 2024-08-30 ### Added -- Verbose Django `404` error page when `settings.py:DEBUG` is `True` ([Upstream PR](https://github.com/evansd/whitenoise/pull/366)) +- Verbose Django `404` error page when `settings.py:DEBUG` is `True` (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/366)) ### Fixed - Fix Django compatibility with third-party sync middleware - ServeStatic Django middleware now only runs in async mode to avoid clashing with Django's internal usage of `asgiref.AsyncToSync` -- Respect Django `settings.py:FORCE_SCRIPT_NAME` configuration value ([Upstream PR](https://github.com/evansd/whitenoise/pull/486)) +- Respect Django `settings.py:FORCE_SCRIPT_NAME` configuration value (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/486)) ## [1.1.0](https://github.com/Archmonger/ServeStatic/compare/1.0.0...1.1.0) - 2024-08-27 ### Added -- Files are now compressed within a thread pool to increase performance ([Upstream PR](https://github.com/evansd/whitenoise/pull/484)) +- Files are now compressed within a thread pool to increase performance (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/484)) ### Fixed - Fix Django `StreamingHttpResponse must consume synchronous iterators` warning -- Fix Django bug where file paths could fail to be followed on Windows ([Upstream PR](https://github.com/evansd/whitenoise/pull/474)) +- Fix Django bug where file paths could fail to be followed on Windows (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/474)) ## [1.0.0](https://github.com/Archmonger/ServeStatic/releases/tag/1.0.0) - 2024-05-08 diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index 3e707780..b98643b0 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -26,7 +26,7 @@ Recheck the filesystem to see if any files have changed before responding. This **Default:** `settings.py:DEBUG` -Instead of only picking up files collected into `STATIC_ROOT`, find and serve files in their original directories using Django's "finders" API. This is useful in development where it matches the behaviour of the old `runserver` command. It's also possible to use this setting in production, avoiding the need to run the `collectstatic` command during the build, so long as you do not wish to use any of the caching and compression features provided by the storage backends. +Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. It's possible to use this setting in production, but be mindful of the [`settings.py:STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`settings.py:STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each app, which are not the copies post-processed by ServeStatic. --- diff --git a/src/servestatic/base.py b/src/servestatic/base.py index 4c3337d6..3815817d 100644 --- a/src/servestatic/base.py +++ b/src/servestatic/base.py @@ -77,6 +77,11 @@ def __init__( self.add_files(root, prefix) def insert_directory(self, root, prefix): + # Exit early if the directory is already in the list + for existing_root, existing_prefix in self.directories: + if existing_root == root and existing_prefix == prefix: + return + # Later calls to `add_files` overwrite earlier ones, hence we need # to store the list of directories in reverse order so later ones # match first when they're checked in "autorefresh" mode diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index d73b88a9..24e4346a 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -90,13 +90,16 @@ def __init__(self, get_response=None, settings=django_settings): self.static_prefix = self.static_prefix[len(script_name) :] self.static_prefix = ensure_leading_trailing_slash(self.static_prefix) - if self.static_root and not self.use_manifest: + if self.static_root: + self.insert_directory(self.static_root, self.static_prefix) + + if self.static_root and not self.use_manifest and not self.use_finders: self.add_files(self.static_root, prefix=self.static_prefix) if self.use_manifest: self.add_files_from_manifest() - if self.use_finders and not self.autorefresh: + if self.use_finders: self.add_files_from_finders() if root: @@ -157,6 +160,8 @@ def add_files_from_finders(self): ) # Use setdefault as only first matching file should be used files.setdefault(url, storage.path(path)) + self.insert_directory(storage.location, self.static_prefix) + for url, path in files.items(): self.add_file_to_dictionary(url, path) From 3b5539d8ca4ff96f6a02930c8df54f02d0e320ed Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:13:07 -0700 Subject: [PATCH 21/30] Generic stat_files function --- src/servestatic/middleware.py | 4 +++- src/servestatic/storage.py | 12 ++---------- src/servestatic/utils.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 24e4346a..999768e6 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -23,6 +23,7 @@ AsyncToSyncIterator, EmptyAsyncIterator, ensure_leading_trailing_slash, + stat_files, ) from servestatic.wsgi import ServeStatic @@ -162,8 +163,9 @@ def add_files_from_finders(self): files.setdefault(url, storage.path(path)) self.insert_directory(storage.location, self.static_prefix) + stat_cache = stat_files(files.values()) for url, path in files.items(): - self.add_file_to_dictionary(url, path) + self.add_file_to_dictionary(url, path, stat_cache=stat_cache) def add_files_from_manifest(self): if not isinstance(staticfiles_storage, ManifestStaticFilesStorage): diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index b0ef8202..85276b83 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -16,6 +16,7 @@ from django.core.files.base import ContentFile from servestatic.compress import Compressor +from servestatic.utils import stat_files _PostProcessT = Iterator[Union[Tuple[str, str, bool], Tuple[str, None, RuntimeError]]] @@ -99,7 +100,7 @@ def save_manifest(self): "paths": self.hashed_files, "version": self.manifest_version, "hash": self.manifest_hash, - "stats": self.stat_files(self.hashed_files.keys()), + "stats": stat_files(self.hashed_files.keys(), path_resolver=self.path), } if self.manifest_storage.exists(self.manifest_name): self.manifest_storage.delete(self.manifest_name) @@ -123,15 +124,6 @@ def load_manifest_stats(self): f"Couldn't load manifest '{self.manifest_name}' (version {self.manifest_version})" ) - def stat_files(self, relative_paths) -> dict: - """Stat all files in `relative_paths` concurrently.""" - with concurrent.futures.ThreadPoolExecutor() as executor: - futures = { - rel_path: executor.submit(os.stat, self.path(rel_path)) - for rel_path in relative_paths - } - return {rel_path: future.result() for rel_path, future in futures.items()} - def post_process_with_compression(self, files): # Files may get hashed multiple times, we want to keep track of all the # intermediate files generated during the process and which of these diff --git a/src/servestatic/utils.py b/src/servestatic/utils.py index 7d1d08e2..5a297d22 100644 --- a/src/servestatic/utils.py +++ b/src/servestatic/utils.py @@ -32,6 +32,17 @@ def scantree(root): yield entry.path, entry.stat() +def stat_files(paths, path_resolver=lambda path: path) -> dict: + """Stat all files in `relative_paths` via threads.""" + + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = { + rel_path: executor.submit(os.stat, path_resolver(rel_path)) + for rel_path in paths + } + return {rel_path: future.result() for rel_path, future in futures.items()} + + class AsyncToSyncIterator: """Converts any async iterator to sync as efficiently as possible while retaining full compatibility with any environment. From dda35ca66a45f2dbf483c81723dc46925d413e54 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 20:20:28 -0700 Subject: [PATCH 22/30] new use_finders docs --- docs/src/django-settings.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index b98643b0..e854627c 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -26,7 +26,11 @@ Recheck the filesystem to see if any files have changed before responding. This **Default:** `settings.py:DEBUG` -Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. It's possible to use this setting in production, but be mindful of the [`settings.py:STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`settings.py:STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each app, which are not the copies post-processed by ServeStatic. +Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API. + +It's possible to use this setting in production, but be mindful of the [`settings.py:STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`settings.py:STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each app, which are not the copies post-processed by ServeStatic. + +Note that `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command. --- From f2712969a64028a64f89305039ab069f8d0e3b83 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:16:13 -0700 Subject: [PATCH 23/30] run stat on all postprocessed files --- src/servestatic/storage.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index 85276b83..b3f2b4de 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -91,22 +91,37 @@ def post_process(self, *args, **kwargs): processed = self.make_helpful_exception(processed, name) yield name, hashed_name, processed - def save_manifest(self): + self.add_stats_to_manifest() + + def add_stats_to_manifest(self): """Identical to Django's implementation, but this adds additional `stats` field.""" - self.manifest_hash = self.file_hash( - None, ContentFile(json.dumps(sorted(self.hashed_files.items())).encode()) - ) + _paths, _hash = self.load_manifest() payload = { - "paths": self.hashed_files, + "paths": _paths, "version": self.manifest_version, - "hash": self.manifest_hash, - "stats": stat_files(self.hashed_files.keys(), path_resolver=self.path), + "hash": _hash, + "stats": self.stat_static_root(), } - if self.manifest_storage.exists(self.manifest_name): - self.manifest_storage.delete(self.manifest_name) + self.manifest_storage.delete(self.manifest_name) contents = json.dumps(payload).encode() self.manifest_storage._save(self.manifest_name, ContentFile(contents)) + def stat_static_root(self): + """Stats all the files within the static root folder.""" + static_root = getattr(settings, "STATIC_ROOT", None) + if static_root is None: + return {} + + file_paths = [] + for root, _, files in os.walk(static_root): + file_paths.extend( + os.path.join(root, f) for f in files if f != self.manifest_name + ) + stats = stat_files(file_paths) + + # Remove the static root folder from the path + return {path[len(static_root) + 1 :]: stat for path, stat in stats.items()} + def load_manifest_stats(self): """Derivative of Django's `load_manifest` but for the `stats` field.""" content = self.read_manifest() From 6916d663defec21843411b10a48c133583d5bb31 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:30:29 -0700 Subject: [PATCH 24/30] remove path resolver --- src/servestatic/utils.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/servestatic/utils.py b/src/servestatic/utils.py index 5a297d22..103591ab 100644 --- a/src/servestatic/utils.py +++ b/src/servestatic/utils.py @@ -32,14 +32,11 @@ def scantree(root): yield entry.path, entry.stat() -def stat_files(paths, path_resolver=lambda path: path) -> dict: +def stat_files(paths) -> dict: """Stat all files in `relative_paths` via threads.""" with concurrent.futures.ThreadPoolExecutor() as executor: - futures = { - rel_path: executor.submit(os.stat, path_resolver(rel_path)) - for rel_path in paths - } + futures = {rel_path: executor.submit(os.stat, rel_path) for rel_path in paths} return {rel_path: future.result() for rel_path, future in futures.items()} From 8a45192528574bb5b93e59cc3350ea49fbeb6edc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:48:52 -0700 Subject: [PATCH 25/30] Fix legacy Django support --- src/servestatic/storage.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index b3f2b4de..b754e9e3 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -94,17 +94,15 @@ def post_process(self, *args, **kwargs): self.add_stats_to_manifest() def add_stats_to_manifest(self): - """Identical to Django's implementation, but this adds additional `stats` field.""" - _paths, _hash = self.load_manifest() - payload = { - "paths": _paths, - "version": self.manifest_version, - "hash": _hash, + """Adds additional `stats` field to Django's manifest file.""" + current = self.read_manifest() + current = json.loads(current) if current else {} + payload = current | { "stats": self.stat_static_root(), } + new = json.dumps(payload).encode() self.manifest_storage.delete(self.manifest_name) - contents = json.dumps(payload).encode() - self.manifest_storage._save(self.manifest_name, ContentFile(contents)) + self.manifest_storage._save(self.manifest_name, ContentFile(new)) def stat_static_root(self): """Stats all the files within the static root folder.""" From 8a9f168956a8ef2b7478424c4c45933795b2b133 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:59:24 -0700 Subject: [PATCH 26/30] Django 3.2 compatibility --- src/servestatic/middleware.py | 12 +++++++----- src/servestatic/storage.py | 6 ++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index 999768e6..dd68c06c 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -176,12 +176,14 @@ def add_files_from_manifest(self): staticfiles: dict = staticfiles_storage.hashed_files stat_cache = None + # Fetch stats from manifest if using ServeStatic's manifest storage if hasattr(staticfiles_storage, "load_manifest_stats"): - stat_cache: dict = staticfiles_storage.load_manifest_stats() - stat_cache = { - staticfiles_storage.path(k): os.stat_result(v) - for k, v in stat_cache.items() - } + manifest_stats: dict = staticfiles_storage.load_manifest_stats() + if manifest_stats: + stat_cache = { + staticfiles_storage.path(k): os.stat_result(v) + for k, v in manifest_stats.items() + } for unhashed_name, hashed_name in staticfiles.items(): file_path = staticfiles_storage.path(unhashed_name) diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index b754e9e3..23a35c8c 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -101,8 +101,10 @@ def add_stats_to_manifest(self): "stats": self.stat_static_root(), } new = json.dumps(payload).encode() - self.manifest_storage.delete(self.manifest_name) - self.manifest_storage._save(self.manifest_name, ContentFile(new)) + # Django < 3.2 doesn't have a manifest_storage attribute + manifest_storage = getattr(self, "manifest_storage", self) + manifest_storage.delete(self.manifest_name) + manifest_storage._save(self.manifest_name, ContentFile(new)) def stat_static_root(self): """Stats all the files within the static root folder.""" From 28f0dfbc1602acbc2d37b540b1894d5a66fa87a5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 22:28:16 -0700 Subject: [PATCH 27/30] self-review --- CHANGELOG.md | 5 +++-- src/servestatic/middleware.py | 2 ++ src/servestatic/storage.py | 16 +++++----------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e7bc79..6e3231fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,12 +36,13 @@ Using the following categories, list your changes in this order: ### Added -- Utilize Django manifest rather than scanning the directories for files when using `SERVESTATIC_USE_MANIFEST`. (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/275)) +- You can now utilize the Django manifest rather than scanning the filesystem when using `settings.py:SERVESTATIC_USE_MANIFEST`. (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/275)) + - Note: If also using `CompressedManifestStaticFilesStorage` storage backend, ServeStatic will no longer need to call `os.stat`. ### Changed - Minimum python version is now 3.9. -- Django `setings.py:SERVESTATIC_USE_FINDERS` will now strictly use Django finders to locate files. ServeStatic will no longer manually traverse the `STATIC_ROOT` directory to add additional files when this settings is enabled. +- Django `setings.py:SERVESTATIC_USE_FINDERS` will now pre-populate known files strictly using the [finders API](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module). Previously, ServeStatic would also scan `settings.py:STATIC_ROOT` for files not found by the finders API. ## [1.2.0](https://github.com/Archmonger/ServeStatic/compare/1.1.0...1.2.0) - 2024-08-30 diff --git a/src/servestatic/middleware.py b/src/servestatic/middleware.py index dd68c06c..4a5ba092 100644 --- a/src/servestatic/middleware.py +++ b/src/servestatic/middleware.py @@ -185,6 +185,7 @@ def add_files_from_manifest(self): for k, v in manifest_stats.items() } + # Add files to ServeStatic for unhashed_name, hashed_name in staticfiles.items(): file_path = staticfiles_storage.path(unhashed_name) if not self.keep_only_hashed_files: @@ -199,6 +200,7 @@ def add_files_from_manifest(self): stat_cache=stat_cache, ) + # Add the static directory to ServeStatic if staticfiles_storage.location: self.insert_directory(staticfiles_storage.location, self.static_prefix) diff --git a/src/servestatic/storage.py b/src/servestatic/storage.py index 23a35c8c..8ba7caf4 100644 --- a/src/servestatic/storage.py +++ b/src/servestatic/storage.py @@ -1,6 +1,7 @@ from __future__ import annotations import concurrent.futures +import contextlib import errno import json import os @@ -126,18 +127,11 @@ def load_manifest_stats(self): """Derivative of Django's `load_manifest` but for the `stats` field.""" content = self.read_manifest() if content is None: - return {}, "" - try: + return {} + with contextlib.suppress(json.JSONDecodeError): stored = json.loads(content) - except json.JSONDecodeError: - pass - else: - version = stored.get("version") - if version in ("1.0", "1.1"): - return stored.get("stats", {}) - raise ValueError( - f"Couldn't load manifest '{self.manifest_name}' (version {self.manifest_version})" - ) + return stored.get("stats", {}) + raise ValueError(f"Couldn't load stats from manifest '{self.manifest_name}'") def post_process_with_compression(self, files): # Files may get hashed multiple times, we want to keep track of all the From 49f871bbe7d752a67798fdec62fe0b6b3a04dc50 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 22:31:48 -0700 Subject: [PATCH 28/30] fix tests failure due to spelling --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3231fa..a8d17d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ Using the following categories, list your changes in this order: ### Changed - Minimum python version is now 3.9. -- Django `setings.py:SERVESTATIC_USE_FINDERS` will now pre-populate known files strictly using the [finders API](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module). Previously, ServeStatic would also scan `settings.py:STATIC_ROOT` for files not found by the finders API. +- Django `setings.py:SERVESTATIC_USE_FINDERS` will now discover files strictly using the [finders API](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module). Previously, ServeStatic would also scan `settings.py:STATIC_ROOT` for files not found by the finders API. ## [1.2.0](https://github.com/Archmonger/ServeStatic/compare/1.1.0...1.2.0) - 2024-08-30 From 9e6214781dca07e704117031005671217497b2be Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:01:22 -0700 Subject: [PATCH 29/30] add docs for use_manifest --- docs/src/django-settings.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/src/django-settings.md b/docs/src/django-settings.md index e854627c..000d5424 100644 --- a/docs/src/django-settings.md +++ b/docs/src/django-settings.md @@ -22,6 +22,18 @@ Recheck the filesystem to see if any files have changed before responding. This --- +## `SERVESTATIC_USE_MANIFEST` + +**Default:** `not settings.py:DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)` + +Find and serve files using Django's manifest file. + +This is the most efficient way to determine what files are available, but it requires that you are using a [manifest-compatible](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) storage backend. + +When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-2-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup which improves startup speeds. + +--- + ## `SERVESTATIC_USE_FINDERS` **Default:** `settings.py:DEBUG` From ab8bb899e3003c336ad1f278568b9fcfd46594aa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:07:54 -0700 Subject: [PATCH 30/30] Less busy changelog --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d17d8a..9ef53ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,8 @@ Using the following categories, list your changes in this order: ### Added -- You can now utilize the Django manifest rather than scanning the filesystem when using `settings.py:SERVESTATIC_USE_MANIFEST`. (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/275)) - - Note: If also using `CompressedManifestStaticFilesStorage` storage backend, ServeStatic will no longer need to call `os.stat`. +- You can now utilize the Django manifest rather than scanning the filesystem when using `settings.py:SERVESTATIC_USE_MANIFEST`. + - When also using ServeStatic's `CompressedManifestStaticFilesStorage` backend, ServeStatic will no longer need to call `os.stat`. ### Changed @@ -48,24 +48,24 @@ Using the following categories, list your changes in this order: ### Added -- Verbose Django `404` error page when `settings.py:DEBUG` is `True` (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/366)) +- Verbose Django `404` error page when `settings.py:DEBUG` is `True` ### Fixed -- Fix Django compatibility with third-party sync middleware - - ServeStatic Django middleware now only runs in async mode to avoid clashing with Django's internal usage of `asgiref.AsyncToSync` -- Respect Django `settings.py:FORCE_SCRIPT_NAME` configuration value (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/486)) +- Fix Django compatibility with third-party sync middleware. + - ServeStatic Django middleware now only runs in async mode to avoid clashing with Django's internal usage of `asgiref.AsyncToSync`. +- Respect Django `settings.py:FORCE_SCRIPT_NAME` configuration value. ## [1.1.0](https://github.com/Archmonger/ServeStatic/compare/1.0.0...1.1.0) - 2024-08-27 ### Added -- Files are now compressed within a thread pool to increase performance (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/484)) +- Files are now compressed within a thread pool to increase performance. ### Fixed -- Fix Django `StreamingHttpResponse must consume synchronous iterators` warning -- Fix Django bug where file paths could fail to be followed on Windows (Derivative of [upstream PR](https://github.com/evansd/whitenoise/pull/474)) +- Fix Django `StreamingHttpResponse must consume synchronous iterators` warning. +- Fix Django bug where file paths could fail to be followed on Windows. ## [1.0.0](https://github.com/Archmonger/ServeStatic/releases/tag/1.0.0) - 2024-05-08