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/pyproject.toml b/pyproject.toml index 7814de1..a6ad42e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ test = [ "pytest", "pytest-asyncio", ] +django = [ + { include-group = "test" }, + "django>=4.2", +] typecheck = [ "ty", ] @@ -61,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/", "tests/"] [tool.ruff] line-length = 120 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..cbb98ef --- /dev/null +++ b/src/staticware/contrib/django/__init__.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +_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() + wrapped = StaticRewriteMiddleware(django_app, 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..63b70ee --- /dev/null +++ b/src/staticware/contrib/django/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class StaticwareDjangoConfig(AppConfig): + name = "staticware.contrib.django" + label = "staticware_django" + verbose_name = "Staticware" 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/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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 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: 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..8cd5873 --- /dev/null +++ b/tests/django/test_django.py @@ -0,0 +1,226 @@ +"""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 + + +# ── 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") 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/tests/test_staticware.py b/tests/test_staticware.py index 09bdc2f..e7eb8b0 100644 --- a/tests/test_staticware.py +++ b/tests/test_staticware.py @@ -397,6 +397,39 @@ 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 ─────────────────────── 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"