From a208bba8d01fc115b3a98038a2d3697de1a6934b Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 10:20:13 +0800 Subject: [PATCH 1/7] Ship Django integration: {% hashed_static %} tag and one-call ASGI setup Django projects can now add "staticware.contrib.django" to INSTALLED_APPS, set STATICWARE_DIRECTORY in settings, and get two things: a {% hashed_static "path" %} template tag for cache-busted URLs, and a get_asgi_application() that serves hashed files and rewrites HTML in one call. Key design decisions: - get_static() is a lazy singleton that reads Django settings on first call, so the HashedStatic instance is created once and reused across template renders and ASGI requests. - Django's ASGI handler spawns a background task that calls receive() a second time to listen for client disconnect. The combined app blocks that second call with asyncio.Future() so Django can cancel it cleanly after the response is sent. - STATICWARE_DIRECTORY is required (raises ImproperlyConfigured). STATICWARE_PREFIX falls back to STATIC_URL then "/static". --- pyproject.toml | 4 + src/staticware/contrib/__init__.py | 0 src/staticware/contrib/django/__init__.py | 115 ++++++++++ src/staticware/contrib/django/apps.py | 8 + .../contrib/django/templatetags/__init__.py | 0 .../django/templatetags/staticware_tags.py | 10 + tests/__init__.py | 0 tests/django/__init__.py | 0 tests/django/conftest.py | 19 ++ tests/django/test_django.py | 209 ++++++++++++++++++ tests/django/urls.py | 17 ++ uv.lock | 53 +++++ 12 files changed, 435 insertions(+) create mode 100644 src/staticware/contrib/__init__.py create mode 100644 src/staticware/contrib/django/__init__.py create mode 100644 src/staticware/contrib/django/apps.py create mode 100644 src/staticware/contrib/django/templatetags/__init__.py create mode 100644 src/staticware/contrib/django/templatetags/staticware_tags.py create mode 100644 tests/__init__.py create mode 100644 tests/django/__init__.py create mode 100644 tests/django/conftest.py create mode 100644 tests/django/test_django.py create mode 100644 tests/django/urls.py diff --git a/pyproject.toml b/pyproject.toml index 7814de1..0005489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ test = [ "pytest", "pytest-asyncio", ] +django = [ + { include-group = "test" }, + "django>=4.2", +] typecheck = [ "ty", ] diff --git a/src/staticware/contrib/__init__.py b/src/staticware/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/staticware/contrib/django/__init__.py b/src/staticware/contrib/django/__init__.py new file mode 100644 index 0000000..034dfd4 --- /dev/null +++ b/src/staticware/contrib/django/__init__.py @@ -0,0 +1,115 @@ +"""Django integration for staticware.""" + +from __future__ import annotations + +default_app_config = "staticware.contrib.django.apps.StaticwareDjangoConfig" + +_static_instance = None + + +def get_static(): + """Return a singleton HashedStatic configured from Django settings. + + Reads STATICWARE_DIRECTORY (required), STATICWARE_PREFIX (optional, + falls back to STATIC_URL then "/static"), and STATICWARE_HASH_LENGTH + (optional, defaults to 8) from django.conf.settings. + """ + global _static_instance + + if _static_instance is not None: + return _static_instance + + from django.conf import settings + from django.core.exceptions import ImproperlyConfigured + + from staticware import HashedStatic + + directory = getattr(settings, "STATICWARE_DIRECTORY", None) + if directory is None: + raise ImproperlyConfigured( + "STATICWARE_DIRECTORY must be set in your Django settings " + "to use staticware's Django integration." + ) + + prefix = getattr(settings, "STATICWARE_PREFIX", None) + if prefix is None: + static_url = getattr(settings, "STATIC_URL", None) + if static_url is not None: + prefix = static_url.rstrip("/") + else: + prefix = "/static" + + hash_length = getattr(settings, "STATICWARE_HASH_LENGTH", 8) + + _static_instance = HashedStatic( + directory, + prefix=prefix, + hash_length=hash_length, + ) + return _static_instance + + +def get_asgi_application(): + """Return an ASGI application that serves hashed static files and + rewrites HTML responses from Django. + + Combines Django's ASGI app with HashedStatic file serving and + StaticRewriteMiddleware for automatic URL rewriting. + """ + from django.core.asgi import get_asgi_application as django_get_asgi_application + + from staticware import StaticRewriteMiddleware + + django_app = django_get_asgi_application() + static = get_static() + + async def _normalized_django(scope, receive, send): + """Wrap Django's ASGI app to normalize header names to lowercase. + + The ASGI spec requires lowercase header names, but Django sends + mixed-case headers like ``Content-Type``. StaticRewriteMiddleware + looks for ``content-type`` per the spec, so we normalize here. + """ + async def normalizing_send(message): + if message.get("type") == "http.response.start" and "headers" in message: + message = { + **message, + "headers": [(k.lower(), v) for k, v in message["headers"]], + } + await send(message) + + await django_app(scope, receive, normalizing_send) + + wrapped = StaticRewriteMiddleware(_normalized_django, static=static) + + async def combined(scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith(static.prefix + "/"): + await static(scope, receive, send) + else: + import asyncio + + body_msg = await receive() + got_body = False + + async def django_receive(): + """Return the request body once, then block until cancelled. + + Django's ASGI handler spawns a background task that calls + receive() to listen for client disconnect. If that call + returns immediately with anything other than + http.disconnect, Django raises AssertionError. Blocking + here lets the response complete normally; Django cancels + this task once the response is sent. + """ + nonlocal got_body + if not got_body: + got_body = True + return body_msg + # Block until Django cancels this task after the response + # is sent. This prevents the disconnect listener from + # interfering with response processing. + await asyncio.Future() + + await wrapped(scope, django_receive, send) + + return combined diff --git a/src/staticware/contrib/django/apps.py b/src/staticware/contrib/django/apps.py new file mode 100644 index 0000000..eae6ac0 --- /dev/null +++ b/src/staticware/contrib/django/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class StaticwareDjangoConfig(AppConfig): + name = "staticware.contrib.django" + label = "staticware_django" + verbose_name = "Staticware" + default_auto_field = "django.db.models.BigAutoField" diff --git a/src/staticware/contrib/django/templatetags/__init__.py b/src/staticware/contrib/django/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/staticware/contrib/django/templatetags/staticware_tags.py b/src/staticware/contrib/django/templatetags/staticware_tags.py new file mode 100644 index 0000000..cca01b3 --- /dev/null +++ b/src/staticware/contrib/django/templatetags/staticware_tags.py @@ -0,0 +1,10 @@ +from django import template + +from staticware.contrib.django import get_static + +register = template.Library() + + +@register.simple_tag +def hashed_static(path): + return get_static().url(path) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django/__init__.py b/tests/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django/conftest.py b/tests/django/conftest.py new file mode 100644 index 0000000..397ee77 --- /dev/null +++ b/tests/django/conftest.py @@ -0,0 +1,19 @@ +import django +from django.conf import settings + + +def pytest_configure() -> None: + settings.configure( + SECRET_KEY="staticware-test-key", + INSTALLED_APPS=[ + "staticware.contrib.django", + ], + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": {}, + }, + ], + ) + django.setup() diff --git a/tests/django/test_django.py b/tests/django/test_django.py new file mode 100644 index 0000000..712d190 --- /dev/null +++ b/tests/django/test_django.py @@ -0,0 +1,209 @@ +"""Tests for the Django integration of staticware. + +Async test detection: + pytest-asyncio is configured with asyncio_mode = "auto" in pyproject.toml. + Use ``async def`` for tests that call ASGI apps (they are async callables). + Use plain ``def`` for tests that only exercise sync APIs. +""" + +from pathlib import Path +from typing import Any + +import pytest +from django.test import override_settings + +from staticware import HashedStatic + +# ── Helpers ────────────────────────────────────────────────────────────── + + +async def receive() -> dict[str, Any]: + return {"type": "http.request", "body": b""} + + +class ResponseCollector: + """Collect ASGI send() calls into status, headers, and body.""" + + def __init__(self) -> None: + self.status: int = 0 + self.headers: dict[bytes, bytes] = {} + self.body: bytes = b"" + + async def __call__(self, message: dict[str, Any]) -> None: + if message["type"] == "http.response.start": + self.status = message["status"] + self.headers = dict(message.get("headers", [])) + elif message["type"] == "http.response.body": + self.body += message.get("body", b"") + + @property + def text(self) -> str: + return self.body.decode("utf-8") + + +def make_scope(path: str) -> dict[str, Any]: + return {"type": "http", "path": path, "method": "GET"} + + +# ── Fixtures ───────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _reset_get_static_cache(): + """Clear the get_static() singleton between tests.""" + yield + try: + import staticware.contrib.django as django_mod + + if hasattr(django_mod, "_static_instance"): + django_mod._static_instance = None + except ImportError: + pass + + +# ── get_static() tests ─────────────────────────────────────────────────── + + +def test_get_static_returns_hashed_static_instance(static_dir: Path) -> None: + """get_static() returns a HashedStatic instance when STATICWARE_DIRECTORY is set.""" + with override_settings(STATICWARE_DIRECTORY=str(static_dir)): + from staticware.contrib.django import get_static + + result = get_static() + assert isinstance(result, HashedStatic) + + +def test_get_static_uses_static_url_as_default_prefix(static_dir: Path) -> None: + """When STATICWARE_PREFIX is not set but STATIC_URL is, uses STATIC_URL as prefix.""" + with override_settings( + STATICWARE_DIRECTORY=str(static_dir), + STATIC_URL="/assets/", + ): + from staticware.contrib.django import get_static + + result = get_static() + # STATIC_URL="/assets/" should become prefix="/assets" + assert result.prefix == "/assets" + + +def test_get_static_custom_prefix(static_dir: Path) -> None: + """STATICWARE_PREFIX takes precedence over STATIC_URL.""" + with override_settings( + STATICWARE_DIRECTORY=str(static_dir), + STATIC_URL="/assets/", + STATICWARE_PREFIX="/cdn", + ): + from staticware.contrib.django import get_static + + result = get_static() + assert result.prefix == "/cdn" + + +def test_get_static_custom_hash_length(static_dir: Path) -> None: + """STATICWARE_HASH_LENGTH is respected.""" + with override_settings( + STATICWARE_DIRECTORY=str(static_dir), + STATICWARE_HASH_LENGTH=4, + ): + from staticware.contrib.django import get_static + + result = get_static() + assert result.hash_length == 4 + # Verify by checking an actual hashed filename + url = result.url("styles.css") + stem = url.split("/")[-1] # styles.XXXX.css + hash_part = stem.split(".")[1] + assert len(hash_part) == 4 + + +def test_get_static_raises_without_directory() -> None: + """Missing STATICWARE_DIRECTORY raises ImproperlyConfigured.""" + from django.core.exceptions import ImproperlyConfigured + + with override_settings(): + from staticware.contrib.django import get_static + + with pytest.raises(ImproperlyConfigured): + get_static() + + +def test_get_static_is_singleton(static_dir: Path) -> None: + """Calling get_static() twice returns the same instance.""" + with override_settings(STATICWARE_DIRECTORY=str(static_dir)): + from staticware.contrib.django import get_static + + first = get_static() + second = get_static() + assert first is second + + +# ── Template tag tests ─────────────────────────────────────────────────── + + +def test_hashed_static_tag_renders_hashed_url(static_dir: Path) -> None: + """{% hashed_static 'styles.css' %} produces the hashed URL.""" + from django.template import Context, Template + + with override_settings(STATICWARE_DIRECTORY=str(static_dir)): + template = Template('{% load staticware_tags %}{% hashed_static "styles.css" %}') + result = template.render(Context()) + + # The result should be a hashed URL like /static/styles.a1b2c3d4.css + assert result.startswith("/static/styles.") + assert result.endswith(".css") + assert result != "/static/styles.css" + + +def test_hashed_static_tag_unknown_file(static_dir: Path) -> None: + """Unknown file returns path with prefix unchanged.""" + from django.template import Context, Template + + with override_settings(STATICWARE_DIRECTORY=str(static_dir)): + template = Template('{% load staticware_tags %}{% hashed_static "nonexistent.js" %}') + result = template.render(Context()) + + assert result == "/static/nonexistent.js" + + +# ── ASGI integration tests ─────────────────────────────────────────────── + + +async def test_get_asgi_application_serves_static(static_dir: Path) -> None: + """The wrapped ASGI app serves hashed static files.""" + with override_settings( + STATICWARE_DIRECTORY=str(static_dir), + ROOT_URLCONF="tests.django.urls", + ): + from staticware.contrib.django import get_asgi_application, get_static + + app = get_asgi_application() + static = get_static() + + hashed_name = static.file_map["styles.css"] + resp = ResponseCollector() + await app(make_scope(f"/static/{hashed_name}"), receive, resp) + assert resp.status == 200 + assert resp.text == "body { color: red; }" + assert resp.headers[b"cache-control"] == b"public, max-age=31536000, immutable" + + +async def test_get_asgi_application_rewrites_html(static_dir: Path) -> None: + """The wrapped ASGI app rewrites HTML responses to use hashed URLs.""" + with override_settings( + STATICWARE_DIRECTORY=str(static_dir), + ROOT_URLCONF="tests.django.urls", + ): + from staticware.contrib.django import get_asgi_application, get_static + + app = get_asgi_application() + static = get_static() + + # Request the Django view that returns HTML with /static/styles.css. + # The StaticRewriteMiddleware should rewrite it to the hashed URL. + resp = ResponseCollector() + await app(make_scope("/html/"), receive, resp) + assert resp.status == 200 + + hashed = static.file_map["styles.css"] + assert f"/static/{hashed}" in resp.text + assert "/static/styles.css" not in resp.text diff --git a/tests/django/urls.py b/tests/django/urls.py new file mode 100644 index 0000000..cf8b2d2 --- /dev/null +++ b/tests/django/urls.py @@ -0,0 +1,17 @@ +"""Minimal Django URL configuration for ASGI integration tests.""" + +from django.http import HttpResponse +from django.urls import path + + +def html_view(request): + """Return HTML containing a static file reference.""" + return HttpResponse( + '', + content_type="text/html; charset=utf-8", + ) + + +urlpatterns = [ + path("html/", html_view), +] diff --git a/uv.lock b/uv.lock index 65063e1..3065889 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -116,6 +125,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -512,6 +535,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + [[package]] name = "staticware" version = "0.1.0" @@ -525,6 +557,12 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +django = [ + { name = "coverage" }, + { name = "django" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] docs = [ { name = "mkdocstrings-python" }, { name = "zensical" }, @@ -551,6 +589,12 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +django = [ + { name = "coverage" }, + { name = "django", specifier = ">=4.2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] docs = [ { name = "mkdocstrings-python" }, { name = "zensical" }, @@ -596,6 +640,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From f9c7ce736f6612d1f0d8b3c0f1d63fbe8d5557c6 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 11:09:19 +0800 Subject: [PATCH 2/7] Strip Django AppConfig boilerplate that had no effect default_auto_field configures implicit primary keys for models, but this app has no models. default_app_config was the pre-3.2 mechanism for AppConfig discovery, deprecated in 3.2 and removed in 5.1. Since staticware requires django>=4.2, both lines were dead code. Tests assert neither attribute reappears, so this stays clean if future tooling regenerates the scaffolding. --- src/staticware/contrib/django/__init__.py | 4 ---- src/staticware/contrib/django/apps.py | 1 - tests/django/test_django.py | 17 +++++++++++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/staticware/contrib/django/__init__.py b/src/staticware/contrib/django/__init__.py index 034dfd4..4c70b41 100644 --- a/src/staticware/contrib/django/__init__.py +++ b/src/staticware/contrib/django/__init__.py @@ -1,9 +1,5 @@ -"""Django integration for staticware.""" - from __future__ import annotations -default_app_config = "staticware.contrib.django.apps.StaticwareDjangoConfig" - _static_instance = None diff --git a/src/staticware/contrib/django/apps.py b/src/staticware/contrib/django/apps.py index eae6ac0..63b70ee 100644 --- a/src/staticware/contrib/django/apps.py +++ b/src/staticware/contrib/django/apps.py @@ -5,4 +5,3 @@ class StaticwareDjangoConfig(AppConfig): name = "staticware.contrib.django" label = "staticware_django" verbose_name = "Staticware" - default_auto_field = "django.db.models.BigAutoField" diff --git a/tests/django/test_django.py b/tests/django/test_django.py index 712d190..8cd5873 100644 --- a/tests/django/test_django.py +++ b/tests/django/test_django.py @@ -207,3 +207,20 @@ async def test_get_asgi_application_rewrites_html(static_dir: Path) -> None: hashed = static.file_map["styles.css"] assert f"/static/{hashed}" in resp.text assert "/static/styles.css" not in resp.text + + +# ── Django AppConfig: no unnecessary attributes ────────────────────────── + + +def test_app_config_has_no_default_auto_field() -> None: + """StaticwareDjangoConfig should not set default_auto_field (no models).""" + from staticware.contrib.django.apps import StaticwareDjangoConfig + + assert "default_auto_field" not in vars(StaticwareDjangoConfig) + + +def test_no_deprecated_default_app_config() -> None: + """The django integration module should not set default_app_config (removed in Django 5.1).""" + import staticware.contrib.django as django_mod + + assert not hasattr(django_mod, "default_app_config") From 10e30123faf47a66816f4188be4e26ccf35aec9d Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 14:18:56 +0800 Subject: [PATCH 3/7] Tolerate mixed-case ASGI headers so Django needs no normalization wrapper The ASGI spec says header names should be lowercase bytes, but Django sends Content-Type and Content-Length with capital letters. The middleware now compares with .lower() at both header-reading sites: content-type detection and content-length update after rewriting. This made the _normalized_django wrapper in the Django integration redundant. The wrapper existed solely to lowercase Django's headers before they reached the middleware. With the fix in the middleware itself, django_app passes directly to StaticRewriteMiddleware and every ASGI framework benefits, not just Django. --- src/staticware/contrib/django/__init__.py | 20 +-------------- src/staticware/middleware.py | 11 +++++--- tests/test_staticware.py | 31 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/staticware/contrib/django/__init__.py b/src/staticware/contrib/django/__init__.py index 4c70b41..f26843d 100644 --- a/src/staticware/contrib/django/__init__.py +++ b/src/staticware/contrib/django/__init__.py @@ -58,25 +58,7 @@ def get_asgi_application(): django_app = django_get_asgi_application() static = get_static() - - async def _normalized_django(scope, receive, send): - """Wrap Django's ASGI app to normalize header names to lowercase. - - The ASGI spec requires lowercase header names, but Django sends - mixed-case headers like ``Content-Type``. StaticRewriteMiddleware - looks for ``content-type`` per the spec, so we normalize here. - """ - async def normalizing_send(message): - if message.get("type") == "http.response.start" and "headers" in message: - message = { - **message, - "headers": [(k.lower(), v) for k, v in message["headers"]], - } - await send(message) - - await django_app(scope, receive, normalizing_send) - - wrapped = StaticRewriteMiddleware(_normalized_django, static=static) + wrapped = StaticRewriteMiddleware(django_app, static=static) async def combined(scope, receive, send): if scope["type"] == "http" and scope["path"].startswith(static.prefix + "/"): diff --git a/src/staticware/middleware.py b/src/staticware/middleware.py index 68cdf48..d30c254 100644 --- a/src/staticware/middleware.py +++ b/src/staticware/middleware.py @@ -212,9 +212,12 @@ async def send_wrapper(message: dict[str, Any]) -> None: if message["type"] == "http.response.start": response_start = message - headers = dict(message.get("headers", [])) - content_type = headers.get(b"content-type", b"").decode("latin-1") - is_html = "text/html" in content_type + content_type = b"" + for hdr_name, hdr_value in message.get("headers", []): + if hdr_name.lower() == b"content-type": + content_type = hdr_value + break + is_html = b"text/html" in content_type if not is_html: # Not HTML — send the start immediately and short-circuit. await send(message) @@ -243,7 +246,7 @@ async def send_wrapper(message: dict[str, Any]) -> None: if response_start is None: raise RuntimeError("http.response.body received before http.response.start") new_headers = [ - (k, str(len(full_body)).encode("latin-1")) if k == b"content-length" else (k, v) + (k, str(len(full_body)).encode("latin-1")) if k.lower() == b"content-length" else (k, v) for k, v in response_start.get("headers", []) ] response_start["headers"] = new_headers diff --git a/tests/test_staticware.py b/tests/test_staticware.py index 09bdc2f..7c7fa95 100644 --- a/tests/test_staticware.py +++ b/tests/test_staticware.py @@ -397,6 +397,37 @@ async def bad_encoding_app(scope: dict, receive: Any, send: Any) -> None: assert resp.body == raw_body +# ── StaticRewriteMiddleware: mixed-case header tolerance ────────────── + + +async def test_rewrite_handles_mixed_case_content_type(static: HashedStatic) -> None: + """Middleware rewrites HTML even when headers use mixed case (e.g. Django).""" + html = '' + body = html.encode("utf-8") + + async def mixed_case_app(scope, receive, send): + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + (b"Content-Type", b"text/html; charset=utf-8"), + (b"Content-Length", str(len(body)).encode("latin-1")), + ], + }) + await send({"type": "http.response.body", "body": body}) + + app = StaticRewriteMiddleware(mixed_case_app, static=static) + resp = ResponseCollector() + await app(make_scope("/"), receive, resp) + + hashed = static.file_map["styles.css"] + assert f"/static/{hashed}" in resp.text + assert "/static/styles.css" not in resp.text + # Content-Length must reflect the rewritten body, not the original + declared_length = int(resp.headers[b"Content-Length"].decode()) + assert declared_length == len(resp.body) + + # ── HashedStatic: framework mount compatibility ─────────────────────── From 07f35c2705b48a7e13ee14b4e3fc58d1bc3fe330 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 14:29:40 +0800 Subject: [PATCH 4/7] Format files to pass ruff check --- src/staticware/contrib/django/__init__.py | 3 +-- tests/test_staticware.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/staticware/contrib/django/__init__.py b/src/staticware/contrib/django/__init__.py index f26843d..cbb98ef 100644 --- a/src/staticware/contrib/django/__init__.py +++ b/src/staticware/contrib/django/__init__.py @@ -23,8 +23,7 @@ def get_static(): directory = getattr(settings, "STATICWARE_DIRECTORY", None) if directory is None: raise ImproperlyConfigured( - "STATICWARE_DIRECTORY must be set in your Django settings " - "to use staticware's Django integration." + "STATICWARE_DIRECTORY must be set in your Django settings to use staticware's Django integration." ) prefix = getattr(settings, "STATICWARE_PREFIX", None) diff --git a/tests/test_staticware.py b/tests/test_staticware.py index 7c7fa95..e7eb8b0 100644 --- a/tests/test_staticware.py +++ b/tests/test_staticware.py @@ -406,14 +406,16 @@ async def test_rewrite_handles_mixed_case_content_type(static: HashedStatic) -> body = html.encode("utf-8") async def mixed_case_app(scope, receive, send): - await send({ - "type": "http.response.start", - "status": 200, - "headers": [ - (b"Content-Type", b"text/html; charset=utf-8"), - (b"Content-Length", str(len(body)).encode("latin-1")), - ], - }) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"Content-Type", b"text/html; charset=utf-8"), + (b"Content-Length", str(len(body)).encode("latin-1")), + ], + } + ) await send({"type": "http.response.body", "body": body}) app = StaticRewriteMiddleware(mixed_case_app, static=static) From c79aaaa72e9a4ac2f1d320f5213ed1ff10102d5f Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 14:32:19 +0800 Subject: [PATCH 5/7] Exclude Django contrib from ty and reformat for ruff The ty type checker runs without Django installed (it's an optional dependency), so the Django contrib imports fail resolution. Excluding that directory from ty's src scope lets CI pass without pulling Django into the typecheck group. The ruff formatter also needed two files reformatted (string concatenation and dict literal style). --- pyproject.toml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0005489..79d3844 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,10 +65,8 @@ Source = "https://github.com/feldroy/staticware" # No @pytest.mark.asyncio decorator needed. Plain `def test_*` runs normally. asyncio_mode = "auto" -[tool.ty] -# All rules are enabled as "error" by default; no need to specify unless overriding. -# Example override: relax a rule for the entire project (uncomment if needed). -# rules.TY015 = "warn" # For invalid-argument-type, warn instead of error. +[tool.ty.src] +exclude = ["src/staticware/contrib/django/"] [tool.ruff] line-length = 120 From b7a58f6f9c56430d8dcb74fd69c27d2b414f029f Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 14:34:59 +0800 Subject: [PATCH 6/7] Run Django tests in a separate CI job with the django dependency group Django is an optional dependency, so the default test job doesn't have it installed. The tests/conftest.py now skips the django/ directory when Django isn't importable, and a new test-django CI job runs with --group django to cover the integration tests. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- tests/conftest.py | 6 ++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23725b8..5135f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,33 @@ jobs: path: .coverage.* include-hidden-files: true + test-django: + name: Test Django (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + + - name: Run Django tests with coverage + run: uv run --python=${{ matrix.python-version }} --group django coverage run -m pytest tests/django/ + + - name: Upload coverage data + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: coverage-django-${{ matrix.python-version }} + path: .coverage.* + include-hidden-files: true + coverage: name: Coverage - needs: test + needs: [test, test-django] runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -102,7 +126,7 @@ jobs: all-checks-pass: name: All checks pass if: always() - needs: [lint, type-check, test, coverage] + needs: [lint, type-check, test, test-django, coverage] runs-on: ubuntu-latest steps: - name: Check job results diff --git a/tests/conftest.py b/tests/conftest.py index c0aba12..9eb28a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,12 @@ from staticware import HashedStatic +collect_ignore: list[str] = [] +try: + import django # noqa: F401 +except ImportError: + collect_ignore.append("django") + @pytest.fixture() def static_dir(tmp_path: Path) -> Path: From 81a421c0dca4225ccb7d45284c8005f98b2cc031 Mon Sep 17 00:00:00 2001 From: "Audrey M. Roy Greenfeld" Date: Fri, 13 Mar 2026 14:45:26 +0800 Subject: [PATCH 7/7] Exclude tests and Django contrib from ty type checking Both directories import Django, which is an optional dependency not available in the typecheck environment. Tests are covered by pytest at runtime; ty only needs to check the core library. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 79d3844..a6ad42e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ Source = "https://github.com/feldroy/staticware" asyncio_mode = "auto" [tool.ty.src] -exclude = ["src/staticware/contrib/django/"] +exclude = ["src/staticware/contrib/django/", "tests/"] [tool.ruff] line-length = 120