django-datastar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from .middleware import DatastarDetails
4
+ from .middleware import DatastarHttpRequest
5
+ from .middleware import DatastarMiddleware
6
+ from .middleware import is_datastar
7
+
8
+ __all__ = [
9
+ "DatastarDetails",
10
+ "DatastarHttpRequest",
11
+ "DatastarMiddleware",
12
+ "is_datastar",
13
+ ]
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class DjangoDatastarConfig(AppConfig):
7
+ name = "django_datastar"
8
+ verbose_name = "Django Datastar"
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable
4
+ from collections.abc import Callable
5
+ from typing import cast
6
+
7
+ from asgiref.sync import iscoroutinefunction
8
+ from asgiref.sync import markcoroutinefunction
9
+ from django.http import HttpRequest
10
+ from django.http.response import HttpResponseBase
11
+
12
+ _SyncGetResponse = Callable[[HttpRequest], HttpResponseBase]
13
+ _AsyncGetResponse = Callable[[HttpRequest], Awaitable[HttpResponseBase]]
14
+ _GetResponse = _SyncGetResponse | _AsyncGetResponse
15
+
16
+
17
+ def is_datastar(request: HttpRequest) -> bool:
18
+ """Return whether a request has Datastar's canonical marker header.
19
+
20
+ The marker is client-controlled and must not authorize a request or bypass
21
+ CSRF. Cacheable views that vary by this result should use
22
+ ``@vary_on_headers("Datastar-Request")``.
23
+ """
24
+ return request.headers.get("Datastar-Request") == "true"
25
+
26
+
27
+ class DatastarDetails:
28
+ """Datastar metadata attached to a Django request."""
29
+
30
+ def __init__(self, request: HttpRequest) -> None:
31
+ self.request = request
32
+
33
+ def __bool__(self) -> bool:
34
+ return is_datastar(self.request)
35
+
36
+
37
+ class DatastarHttpRequest(HttpRequest):
38
+ """Typing contract for a request processed by :class:`DatastarMiddleware`.
39
+
40
+ Django continues to create the request object. Use this class as a view
41
+ annotation when the middleware is installed; do not use it for runtime
42
+ ``isinstance`` checks.
43
+ """
44
+
45
+ datastar: DatastarDetails
46
+
47
+
48
+ class DatastarMiddleware:
49
+ """Attach Datastar request metadata in sync and async middleware chains."""
50
+
51
+ sync_capable = True
52
+ async_capable = True
53
+
54
+ def __init__(self, get_response: _GetResponse) -> None:
55
+ self.get_response = get_response
56
+ self._async_mode = iscoroutinefunction(get_response)
57
+
58
+ if self._async_mode:
59
+ markcoroutinefunction(self)
60
+
61
+ def __call__(
62
+ self,
63
+ request: HttpRequest,
64
+ ) -> HttpResponseBase | Awaitable[HttpResponseBase]:
65
+ if self._async_mode:
66
+ return self.__acall__(request)
67
+
68
+ self._attach_details(request)
69
+ get_response = cast("_SyncGetResponse", self.get_response)
70
+ return get_response(request)
71
+
72
+ async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
73
+ self._attach_details(request)
74
+ get_response = cast("_AsyncGetResponse", self.get_response)
75
+ return await get_response(request)
76
+
77
+ @staticmethod
78
+ def _attach_details(request: HttpRequest) -> None:
79
+ typed_request = cast("DatastarHttpRequest", request)
80
+ typed_request.datastar = DatastarDetails(request)
File without changes
@@ -0,0 +1,154 @@
1
+ const BRIDGE_INSTALLATION = Symbol.for("django-datastar.csrf-bridge");
2
+ const CSRF_HEADER = "X-CSRFToken";
3
+ const CSRF_META_SELECTOR = 'meta[name="datastar-csrf-token"]';
4
+ const DATASTAR_REQUEST_HEADER = "Datastar-Request";
5
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
6
+ const REQUEST_URL_GETTER = Object.getOwnPropertyDescriptor(
7
+ Request.prototype,
8
+ "url",
9
+ ).get;
10
+
11
+ function installDatastarCsrfBridge() {
12
+ if (window[BRIDGE_INSTALLATION]) {
13
+ return;
14
+ }
15
+
16
+ const nativeFetch = window.fetch.bind(window);
17
+ window.fetch = createDatastarCsrfFetch(nativeFetch);
18
+ Object.defineProperty(window, BRIDGE_INSTALLATION, { value: true });
19
+ }
20
+
21
+ function createDatastarCsrfFetch(nativeFetch) {
22
+ return (...args) => {
23
+ const [input, init] = args;
24
+ let csrfInit;
25
+
26
+ try {
27
+ csrfInit = csrfRequestInitFor(input, init);
28
+ } catch {
29
+ return nativeFetch(...args);
30
+ }
31
+
32
+ if (!csrfInit) {
33
+ return nativeFetch(...args);
34
+ }
35
+
36
+ return nativeFetch(input, csrfInit);
37
+ };
38
+ }
39
+
40
+ function csrfRequestInitFor(input, init) {
41
+ if (hasAccessorRequestInitMember(init)) {
42
+ return null;
43
+ }
44
+
45
+ const request = effectiveRequest(input, init);
46
+ if (!requiresCsrfHeader(request)) {
47
+ return null;
48
+ }
49
+
50
+ const token = csrfTokenFromDom();
51
+ return token ? withCsrfHeader(init, request.headers, token) : null;
52
+ }
53
+
54
+ function effectiveRequest(input, init) {
55
+ return {
56
+ input,
57
+ init,
58
+ headers: effectiveHeaders(input, init),
59
+ method: effectiveMethod(input, init),
60
+ url: effectiveUrl(input),
61
+ };
62
+ }
63
+
64
+ function requiresCsrfHeader(request) {
65
+ return (
66
+ request.headers.get(DATASTAR_REQUEST_HEADER) === "true" &&
67
+ !request.headers.has(CSRF_HEADER) &&
68
+ !SAFE_METHODS.has(request.method) &&
69
+ request.url.origin === window.location.origin &&
70
+ hasCompatibleMode(request.input, request.init)
71
+ );
72
+ }
73
+
74
+ function withCsrfHeader(init, headers, token) {
75
+ headers.set(CSRF_HEADER, token);
76
+ const csrfInit = Object.create(init ?? null);
77
+ Object.defineProperties(csrfInit, {
78
+ headers: { enumerable: true, value: headers },
79
+ mode: { enumerable: true, value: "same-origin" },
80
+ });
81
+ return csrfInit;
82
+ }
83
+
84
+ function effectiveHeaders(input, init) {
85
+ if (init?.headers !== undefined) {
86
+ return new Headers(init.headers);
87
+ }
88
+
89
+ return new Headers(isRequest(input) ? input.headers : undefined);
90
+ }
91
+
92
+ function effectiveMethod(input, init) {
93
+ const initMethod = init?.method;
94
+ const method =
95
+ initMethod !== undefined
96
+ ? initMethod
97
+ : isRequest(input)
98
+ ? input.method
99
+ : "GET";
100
+ return String(method).toUpperCase();
101
+ }
102
+
103
+ function effectiveUrl(input) {
104
+ const url = isRequest(input) ? input.url : input;
105
+ return new URL(url, document.baseURI);
106
+ }
107
+
108
+ function hasCompatibleMode(input, init) {
109
+ if (init?.mode !== undefined) {
110
+ return init.mode === "same-origin";
111
+ }
112
+
113
+ return !isRequest(input) || input.mode === "same-origin";
114
+ }
115
+
116
+ function csrfTokenFromDom() {
117
+ return document.querySelector(CSRF_META_SELECTOR)?.getAttribute("content") || "";
118
+ }
119
+
120
+ function hasAccessorRequestInitMember(init) {
121
+ if (init === undefined || init === null) {
122
+ return false;
123
+ }
124
+
125
+ if (typeof init !== "object" && typeof init !== "function") {
126
+ return true;
127
+ }
128
+
129
+ let owner = init;
130
+ while (owner !== null && owner !== Object.prototype) {
131
+ const descriptors = Object.values(Object.getOwnPropertyDescriptors(owner));
132
+ if (
133
+ descriptors.some(
134
+ (descriptor) =>
135
+ descriptor.get !== undefined || descriptor.set !== undefined,
136
+ )
137
+ ) {
138
+ return true;
139
+ }
140
+ owner = Object.getPrototypeOf(owner);
141
+ }
142
+ return false;
143
+ }
144
+
145
+ function isRequest(input) {
146
+ try {
147
+ REQUEST_URL_GETTER.call(input);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ installDatastarCsrfBridge();
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from django import template
4
+ from django.http import HttpRequest
5
+ from django.middleware.csrf import get_token
6
+ from django.templatetags.static import static
7
+ from django.utils.html import escape
8
+ from django.utils.html import format_html
9
+ from django.utils.safestring import SafeString
10
+
11
+ register = template.Library()
12
+
13
+
14
+ @register.simple_tag(takes_context=True)
15
+ def datastar_csrf(
16
+ context: template.Context,
17
+ nonce: object | None = None,
18
+ ) -> SafeString:
19
+ """Render the masked CSRF token and Datastar fetch bridge module."""
20
+ request = getattr(context, "request", None)
21
+ if not isinstance(request, HttpRequest):
22
+ raise template.TemplateSyntaxError(
23
+ "{% datastar_csrf %} requires a request-aware template context."
24
+ )
25
+
26
+ token = get_token(request)
27
+ meta = format_html(
28
+ '<meta name="datastar-csrf-token" content="{}" />',
29
+ token,
30
+ )
31
+ script_url = static("django_datastar/datastar-csrf.js")
32
+ script = _script_element(script_url, nonce)
33
+ return format_html("{}\n{}", meta, script)
34
+
35
+
36
+ def _script_element(script_url: str, nonce: object | None) -> SafeString:
37
+ if nonce is None:
38
+ return format_html('<script type="module" src="{}"></script>', script_url)
39
+
40
+ return format_html(
41
+ '<script type="module" src="{}" nonce="{}"></script>',
42
+ script_url,
43
+ escape(nonce),
44
+ )
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-datastar
3
+ Version: 0.1.0
4
+ Summary: Request metadata and opt-in CSRF integration for Django and Datastar
5
+ Project-URL: Documentation, https://django-datastar.readthedocs.io/
6
+ Project-URL: Repository, https://github.com/MarcusL11/django_datastar
7
+ Project-URL: Issues, https://github.com/MarcusL11/django_datastar/issues
8
+ Author-email: "Marcus A. Lee" <hello@marcusalee.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 5.2
14
+ Classifier: Framework :: Django :: 6.0
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: django<6.1,>=5.2
23
+ Provides-Extra: docs
24
+ Requires-Dist: furo>=2024.8.6; extra == 'docs'
25
+ Requires-Dist: sphinx>=8.1; extra == 'docs'
26
+ Provides-Extra: tests
27
+ Requires-Dist: django-stubs>=5.2; extra == 'tests'
28
+ Requires-Dist: mypy>=1.15; extra == 'tests'
29
+ Requires-Dist: pytest-django>=4.10; extra == 'tests'
30
+ Requires-Dist: pytest>=8.3; extra == 'tests'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # django-datastar
34
+
35
+ `django-datastar` provides small, focused integration points between Django and
36
+ [Datastar](https://data-star.dev/):
37
+
38
+ - exact request classification through `Datastar-Request: true`;
39
+ - sync/async middleware-backed `request.datastar` metadata; and
40
+ - an opt-in bridge that supplies Django CSRF tokens to qualifying Datastar
41
+ backend-action requests.
42
+
43
+ It does not provide Datastar response or SSE APIs. Use
44
+ [`datastar-py`](https://github.com/starfederation/datastar/tree/main/sdk/python)
45
+ as a companion when those APIs are needed.
46
+
47
+ > The `Datastar-Request` header is client-controlled metadata. Never use it for
48
+ > authentication, authorization, permissions, or a CSRF bypass.
49
+
50
+ ## Installation
51
+
52
+ Install the package from PyPI:
53
+
54
+ ```console
55
+ python -m pip install django-datastar
56
+ ```
57
+
58
+ To install a development checkout instead, run `python -m pip install .` from
59
+ the repository root.
60
+
61
+ Add the middleware before Django's CSRF middleware:
62
+
63
+ ```python
64
+ MIDDLEWARE = [
65
+ # ...
66
+ "django_datastar.middleware.DatastarMiddleware",
67
+ "django.middleware.csrf.CsrfViewMiddleware",
68
+ # ...
69
+ ]
70
+ ```
71
+
72
+ Use either the helper or the attached details in a view:
73
+
74
+ ```python
75
+ from django.http import HttpResponse
76
+ from django_datastar import DatastarHttpRequest
77
+
78
+
79
+ def update(request: DatastarHttpRequest) -> HttpResponse:
80
+ if request.datastar:
81
+ return HttpResponse("Datastar request")
82
+ return HttpResponse("ordinary request")
83
+ ```
84
+
85
+ `DatastarHttpRequest` is an annotation for requests processed by the middleware;
86
+ Django still creates the actual request object.
87
+
88
+ When middleware-backed request details are not needed, classify a request directly:
89
+
90
+ ```python
91
+ from django.http import HttpRequest
92
+ from django.http import HttpResponse
93
+ from django_datastar import is_datastar
94
+
95
+
96
+ def update(request: HttpRequest) -> HttpResponse:
97
+ if is_datastar(request):
98
+ return HttpResponse("Datastar request")
99
+ return HttpResponse("ordinary request")
100
+ ```
101
+
102
+ ## Automatic CSRF headers
103
+
104
+ The bridge is optional. Add the app when you want its template tag and static
105
+ module:
106
+
107
+ ```python
108
+ INSTALLED_APPS = [
109
+ # ...
110
+ "django_datastar",
111
+ ]
112
+ ```
113
+
114
+ Load the tag before your chosen Datastar bundle:
115
+
116
+ ```django
117
+ {% load django_datastar static %}
118
+
119
+ {% datastar_csrf %}
120
+ <script type="module" src="{% static 'js/datastar.js' %}"></script>
121
+ ```
122
+
123
+ For nonce-based Content Security Policies:
124
+
125
+ ```django
126
+ {% datastar_csrf nonce=request.csp_nonce %}
127
+ ```
128
+
129
+ The tag calls Django's CSRF token machinery and emits a masked token in the DOM,
130
+ so it works with `CSRF_COOKIE_HTTPONLY=True`. The external module reads that token
131
+ at request time and injects `X-CSRFToken` only when all of these conditions hold:
132
+
133
+ - the effective method is unsafe;
134
+ - `Datastar-Request` is exactly `true`;
135
+ - the effective target is same-origin;
136
+ - the request mode is compatible with `same-origin`;
137
+ - no CSRF header was supplied explicitly; and
138
+ - a DOM token is present.
139
+
140
+ Django's `CsrfViewMiddleware` remains solely responsible for validation. The
141
+ bridge assumes Datastar resolves `window.fetch` at request time; see the
142
+ [compatibility documentation](https://django-datastar.readthedocs.io/en/latest/compatibility.html)
143
+ before upgrading Datastar.
144
+
145
+ ## Documentation
146
+
147
+ Build the documentation locally with:
148
+
149
+ ```console
150
+ python -m pip install ".[docs]"
151
+ sphinx-build -W --keep-going -b html docs docs/_build/html
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```console
157
+ uv sync --group dev
158
+ uv run pytest
159
+ node --test tests/test_datastar_csrf.mjs
160
+ uv run ruff check .
161
+ uv run ruff format --check .
162
+ uv run mypy
163
+ ```
164
+
165
+ Node is contributor and CI tooling only. It is not a runtime dependency for
166
+ Django applications.
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,11 @@
1
+ django_datastar/__init__.py,sha256=QbtE3iNVEFRlzsgHTYKgTz5Evu54T9Qval0bFhEfl7E,309
2
+ django_datastar/apps.py,sha256=dbFIfNS4ZE83SmfWJHvW23TjbVX3vbyXYfM5Ry1J8sg,177
3
+ django_datastar/middleware.py,sha256=iFqFL4ht_DSuUlopnC8R6A6h-SEI5bxygiZph9v170g,2603
4
+ django_datastar/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ django_datastar/static/django_datastar/datastar-csrf.js,sha256=Q_JAxGJTena94Dtl0FIN9rrueJBlkcuQI6XKUClsX1U,3755
6
+ django_datastar/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ django_datastar/templatetags/django_datastar.py,sha256=tpWwrDRCZYf-zjvvulVoeC44Hi1EtjEs0KSn-cC9BhA,1398
8
+ django_datastar-0.1.0.dist-info/METADATA,sha256=BhIeJ9cJRGS_4YDfNJd1wVC2vaD3SHBvDjQpPZ3Ar9A,4943
9
+ django_datastar-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ django_datastar-0.1.0.dist-info/licenses/LICENSE,sha256=STFRmHA7eloSbM0gNFGVzABChKCG84hCoJhQPzXuoKI,1070
11
+ django_datastar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcus A. Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.