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"