Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ test = [
"pytest",
"pytest-asyncio",
]
django = [
{ include-group = "test" },
"django>=4.2",
]
typecheck = [
"ty",
]
Expand All @@ -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
Expand Down
Empty file.
92 changes: 92 additions & 0 deletions src/staticware/contrib/django/__init__.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions src/staticware/contrib/django/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class StaticwareDjangoConfig(AppConfig):
name = "staticware.contrib.django"
label = "staticware_django"
verbose_name = "Staticware"
Empty file.
10 changes: 10 additions & 0 deletions src/staticware/contrib/django/templatetags/staticware_tags.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 7 additions & 4 deletions src/staticware/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Empty file added tests/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Empty file added tests/django/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions tests/django/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
Loading