djhtmx 1.2.6__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.
djhtmx/testing.py ADDED
@@ -0,0 +1,194 @@
1
+ from collections import defaultdict
2
+ from collections.abc import Callable, Iterable
3
+ from functools import reduce
4
+ from typing import Any, ParamSpec, TypeVar
5
+ from urllib.parse import urlparse
6
+
7
+ from django.contrib.auth.models import AnonymousUser
8
+ from django.test import Client
9
+ from lxml import html
10
+ from pygments import highlight
11
+ from pygments.formatters import TerminalTrueColorFormatter
12
+ from pygments.lexers import HtmlLexer
13
+
14
+ from . import json
15
+ from .commands import PushURL, ReplaceURL, SendHtml
16
+ from .component import Destroy, DispatchDOMEvent, Focus, HtmxComponent, Open, Redirect
17
+ from .introspection import parse_request_data
18
+ from .repo import Repository, Session, signer
19
+ from .utils import get_params
20
+
21
+ P = ParamSpec("P")
22
+ TPComponent = TypeVar("TPComponent", bound=HtmxComponent)
23
+
24
+
25
+ class Htmx:
26
+ def __init__(self, client: Client):
27
+ self.client = client
28
+
29
+ def navigate_to(self, url: str, *args, **kwargs):
30
+ kwargs.setdefault("follow", True)
31
+ response = self.client.get(url, *args, **kwargs)
32
+ assert 200 <= response.status_code < 300
33
+ self.path = response.request["PATH_INFO"]
34
+ self.query_string = response.request["QUERY_STRING"]
35
+
36
+ self.dom = html.fromstring(response.content)
37
+ session_id = reduce(
38
+ lambda session, element: (
39
+ session or json.loads(element.attrib["hx-headers"]).get("HX-Session")
40
+ ),
41
+ self.dom.cssselect("[hx-headers]"),
42
+ None,
43
+ )
44
+ assert session_id, "Can't find djhtmx session id"
45
+ session_id = signer.unsign(session_id)
46
+
47
+ self.repo = Repository(
48
+ user=response.context.get("user") or AnonymousUser(),
49
+ session=Session(session_id),
50
+ params=get_params(self.query_string),
51
+ )
52
+
53
+ def get_component_by_type(self, component_type: type[TPComponent]) -> TPComponent:
54
+ [component] = self.repo.get_components_by_names(component_type.__name__)
55
+ return component # type: ignore
56
+
57
+ def get_components_by_type(self, component_type: type[TPComponent]) -> Iterable[TPComponent]:
58
+ return self.repo.get_components_by_names(component_type.__name__) # type: ignore
59
+
60
+ def get_component_by_id(self, component_id: str):
61
+ component = self.repo.get_component_by_id(component_id)
62
+ assert isinstance(component, HtmxComponent)
63
+ return component
64
+
65
+ def type(self, selector: str | html.HtmlElement, text: str, clear=False):
66
+ """Sets the value of an input, by "typing" in to it"""
67
+ element = self._select(selector)
68
+ if (
69
+ element.tag == "input" and element.attrib.get("type", "text") == "text"
70
+ ) or element.tag == "textarea":
71
+ if clear:
72
+ element.attrib["value"] = text
73
+ else:
74
+ element.attrib["value"] = element.attrib.get("value", "") + text
75
+ else:
76
+ assert False, f"Can't type in element {element}"
77
+
78
+ def find_by_text(self, text: str) -> html.HtmlElement:
79
+ return self.dom.xpath(f"//*[text()='{text}']")
80
+
81
+ def select(self, selector: str) -> list[html.HtmlElement]:
82
+ return self.dom.cssselect(selector)
83
+
84
+ def print(self, element: html.HtmlElement):
85
+ print(
86
+ highlight(
87
+ html.tostring(element, pretty_print=True, encoding="unicode"),
88
+ HtmlLexer(),
89
+ TerminalTrueColorFormatter(),
90
+ )
91
+ )
92
+
93
+ def _select(self, selector: str | html.HtmlElement) -> html.HtmlElement:
94
+ if isinstance(selector, str):
95
+ [element] = self.dom.cssselect(selector)
96
+ else:
97
+ element = selector
98
+ return element
99
+
100
+ def trigger(self, selector: str | html.HtmlElement):
101
+ element = self._select(selector)
102
+
103
+ # mutate in case of a checkbox and radios
104
+ match element.tag, element.attrib.get("type"):
105
+ case "input", "checkbox":
106
+ if "checked" in element.attrib:
107
+ element.attrib.pop("checked")
108
+ else:
109
+ element.attrib["checked"] = ""
110
+ case "input", "radio":
111
+ if name := element.attrib.get("name"):
112
+ for radio in self.dom.cssselect(f'input[type=radio][name="{name}"]'):
113
+ radio.attrib.pop("checked", None)
114
+ element.attrib["checked"] = ""
115
+ case _:
116
+ pass
117
+
118
+ [_, component_id, event_handler] = element.attrib["hx-post"].rsplit("/", 2)
119
+
120
+ # gather values
121
+ vals = defaultdict(list)
122
+ if include := element.attrib.get("hx-include"):
123
+ for element in self.dom.cssselect(include):
124
+ name = element.attrib["name"]
125
+ value = element.attrib.get("value", "")
126
+ match element.tag, element.attrib.get("type"):
127
+ case _, "checkbox":
128
+ if "checked" in element.attrib:
129
+ vals[name].append(value or "on")
130
+ case _, "radio":
131
+ if "checked" in element.attrib:
132
+ vals[name].append(value)
133
+ case "select", _:
134
+ for option in element.cssselect("option[selected]"):
135
+ vals[name].append(option.attrib.get("value", ""))
136
+ case _, _:
137
+ vals[name].append(value)
138
+
139
+ vals |= json.loads(element.attrib.get("hx-vals", "{}"))
140
+ self.dispatch_event(component_id, event_handler, parse_request_data(vals))
141
+
142
+ def send(self, method: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
143
+ assert not args, "All parameters have to be passed by name"
144
+ self.dispatch_event(method.__self__.id, method.__name__, kwargs) # type: ignore
145
+
146
+ def dispatch_event(self, component_id: str, event_handler: str, kwargs: dict[str, Any]):
147
+ commands = self.repo.dispatch_event(component_id, event_handler, kwargs)
148
+ navigate_to_url = None
149
+ for command in commands:
150
+ match command:
151
+ case SendHtml(content):
152
+ incoming = html.fromstring(content)
153
+ oob: str = incoming.attrib["hx-swap-oob"]
154
+ if oob == "true":
155
+ target = self.dom.get_element_by_id(incoming.attrib["id"])
156
+ if parent := target.getparent():
157
+ parent.replace(target, incoming)
158
+ elif oob.startswith("beforeend: "):
159
+ target_selector = oob.removeprefix("beforeend: ")
160
+ [target] = self.dom.cssselect(target_selector)
161
+ target.append(incoming.getchildren()[0])
162
+ elif oob.startswith("afterbegin: "):
163
+ target_selector = oob.removeprefix("afterbegin: ")
164
+ [target] = self.dom.cssselect(target_selector)
165
+ target.insert(0, incoming.getchildren()[0])
166
+ elif oob.startswith("afterend: "):
167
+ target_selector = oob.removeprefix("afterend: ")
168
+ [target] = self.dom.cssselect(target_selector)
169
+ target.addnext(incoming.getchildren()[0])
170
+ elif oob.startswith("beforebegin: "):
171
+ target_selector = oob.removeprefix("afterend: ")
172
+ [target] = self.dom.cssselect(target_selector)
173
+ target.addprevious(incoming.getchildren()[0])
174
+ else:
175
+ assert False, "Unknown swap strategy, please define it here"
176
+
177
+ case Destroy(component_id):
178
+ target = self.dom.get_element_by_id(component_id)
179
+ if parent := target.getparent():
180
+ parent.remove(target)
181
+
182
+ case Redirect(url) | Open(url):
183
+ navigate_to_url = url
184
+
185
+ case PushURL(url) | ReplaceURL(url):
186
+ parsed_url = urlparse(url)
187
+ self.path = parsed_url.path
188
+ self.query_string = parsed_url.query
189
+
190
+ case Focus() | DispatchDOMEvent():
191
+ pass
192
+
193
+ if navigate_to_url:
194
+ self.navigate_to(navigate_to_url)
djhtmx/tracing.py ADDED
@@ -0,0 +1,52 @@
1
+ import contextlib
2
+
3
+ from djhtmx import settings
4
+
5
+ # pragma: no cover
6
+
7
+ try:
8
+ import sentry_sdk # pyright: ignore[reportMissingImports]
9
+ except ImportError:
10
+ sentry_sdk = None
11
+
12
+
13
+ if settings.ENABLE_SENTRY_TRACING and sentry_sdk is not None:
14
+ sentry_start_span = sentry_sdk.start_span
15
+
16
+ @contextlib.contextmanager
17
+ def _sentry_span(name: str, **tags: str): # pyright: ignore[reportRedeclaration]
18
+ with sentry_start_span(op="djhtmx", name=name) as span:
19
+ for tag, value in tags.items():
20
+ span.set_tag(tag, value)
21
+ yield span
22
+
23
+ else:
24
+
25
+ @contextlib.contextmanager
26
+ def _sentry_span(description, **tags):
27
+ yield
28
+
29
+
30
+ try:
31
+ import logfire # pyright: ignore[reportMissingImports]
32
+ except ImportError:
33
+ logfire = None
34
+
35
+
36
+ if settings.ENABLE_LOGFIRE_TRACING and logfire is not None:
37
+ logfire_span = logfire.span
38
+
39
+ def _logfire_span(name: str, **tags): # pyright: ignore[reportRedeclaration]
40
+ return logfire_span(name, op="djhtmx", **tags)
41
+
42
+ else:
43
+
44
+ @contextlib.contextmanager
45
+ def _logfire_span(description, **tags):
46
+ yield
47
+
48
+
49
+ @contextlib.contextmanager
50
+ def tracing_span(name: str, **tags: str):
51
+ with _sentry_span(name, **tags), _logfire_span(name, **tags):
52
+ yield
djhtmx/urls.py ADDED
@@ -0,0 +1,108 @@
1
+ from functools import partial
2
+ from http import HTTPStatus
3
+ from typing import assert_never
4
+
5
+ from django.apps import apps
6
+ from django.core.signing import Signer
7
+ from django.http.request import HttpRequest
8
+ from django.http.response import HttpResponse
9
+ from django.urls import path, re_path
10
+ from django.utils.html import format_html
11
+ from django.views.decorators.csrf import csrf_exempt
12
+
13
+ from .commands import PushURL, ReplaceURL, SendHtml
14
+ from .component import REGISTRY, Destroy, DispatchDOMEvent, Focus, Open, Redirect, Triggers
15
+ from .consumer import Consumer
16
+ from .introspection import parse_request_data
17
+ from .repo import Repository
18
+ from .tracing import tracing_span
19
+
20
+ signer = Signer()
21
+
22
+
23
+ def endpoint(request: HttpRequest, component_name: str, component_id: str, event_handler: str):
24
+ if "HTTP_HX_SESSION" not in request.META:
25
+ return HttpResponse("Missing header HX-Session", status=HTTPStatus.BAD_REQUEST)
26
+
27
+ with tracing_span(f"{component_name}.{event_handler}"):
28
+ repo = Repository.from_request(request)
29
+ content: list[str] = []
30
+ headers: dict[str, str] = {}
31
+ triggers = Triggers()
32
+
33
+ for command in repo.dispatch_event(
34
+ component_id,
35
+ event_handler,
36
+ parse_request_data(request.POST | request.FILES) # type: ignore[reportOperatorIssues]
37
+ | (
38
+ {"prompt": prompt}
39
+ if (prompt := request.META.get("HTTP_HX_PROMPT", None)) is not None
40
+ else {}
41
+ ),
42
+ ):
43
+ # Command loop
44
+ match command:
45
+ case Destroy(component_id):
46
+ content.append(
47
+ format_html(
48
+ '<div id="{component_id}" hx-swap-oob="delete"></div>',
49
+ component_id=component_id,
50
+ )
51
+ )
52
+ case Redirect(url):
53
+ headers["HX-Redirect"] = url
54
+ case Focus(selector):
55
+ triggers.after_settle("hxFocus", selector)
56
+ case Open(url, name, target, rel):
57
+ triggers.after_settle(
58
+ "hxOpenURL",
59
+ {"url": url, "name": name, "target": target, "rel": rel},
60
+ )
61
+ case DispatchDOMEvent(target, event, detail, bubbles, cancelable, composed):
62
+ triggers.after_settle(
63
+ "hxDispatchDOMEvent",
64
+ {
65
+ "event": event,
66
+ "target": target,
67
+ "detail": detail,
68
+ "bubbles": bubbles,
69
+ "cancelable": cancelable,
70
+ "composed": composed,
71
+ },
72
+ )
73
+ case SendHtml(html):
74
+ content.append(html)
75
+ case PushURL(url):
76
+ headers["HX-Push-Url"] = url
77
+ case ReplaceURL(url):
78
+ headers["HX-Replace-Url"] = url
79
+ case _ as unreachable:
80
+ assert_never(unreachable)
81
+
82
+ return HttpResponse("\n\n".join(content), headers=headers | triggers.headers)
83
+
84
+
85
+ APP_CONFIGS = sorted(apps.app_configs.values(), key=lambda app_config: -len(app_config.name))
86
+
87
+
88
+ def app_name_of_component(cls: type):
89
+ cls_module = cls.__module__
90
+ for app_config in APP_CONFIGS:
91
+ if cls_module.startswith(app_config.name):
92
+ return app_config.label
93
+ return cls_module
94
+
95
+
96
+ urlpatterns = [
97
+ path(
98
+ f"{app_name_of_component(component)}/{component_name}/<component_id>/<event_handler>",
99
+ csrf_exempt(partial(endpoint, component_name=component_name)),
100
+ name=f"djhtmx.{component_name}",
101
+ )
102
+ for component_name, component in REGISTRY.items()
103
+ ]
104
+
105
+
106
+ ws_urlpatterns = [
107
+ re_path("ws", Consumer.as_asgi(), name="djhtmx.ws"), # type: ignore
108
+ ]
djhtmx/utils.py ADDED
@@ -0,0 +1,145 @@
1
+ import contextlib
2
+ import importlib
3
+ import pkgutil
4
+ import typing as t
5
+ from urllib.parse import urlparse
6
+
7
+ import mmh3
8
+ from channels.db import database_sync_to_async as db # type: ignore
9
+ from django.apps import apps
10
+ from django.db import models
11
+ from django.http.request import HttpRequest, QueryDict
12
+ from uuid6 import uuid7
13
+
14
+ from . import json
15
+
16
+ if t.TYPE_CHECKING:
17
+ T = t.TypeVar("T")
18
+ P = t.ParamSpec("P")
19
+
20
+ def db(f: t.Callable[P, T]) -> t.Callable[P, t.Awaitable[T]]: ... # noqa: UP047
21
+
22
+
23
+ def get_params(obj: HttpRequest | QueryDict | str | None) -> QueryDict:
24
+ if isinstance(obj, HttpRequest):
25
+ is_htmx_request = json.loads(obj.META.get("HTTP_HX_REQUEST", "false"))
26
+ if is_htmx_request:
27
+ return QueryDict(
28
+ urlparse(obj.META["HTTP_HX_CURRENT_URL"]).query,
29
+ mutable=True,
30
+ )
31
+ else:
32
+ qd = QueryDict(None, mutable=True)
33
+ qd.update(obj.GET)
34
+ return qd
35
+ elif isinstance(obj, QueryDict):
36
+ qd = QueryDict(None, mutable=True)
37
+ qd.update(obj) # type: ignore
38
+ return qd
39
+ elif isinstance(obj, str):
40
+ return QueryDict(
41
+ query_string=urlparse(obj).query if obj else None,
42
+ mutable=True,
43
+ )
44
+ else:
45
+ return QueryDict(None, mutable=True)
46
+
47
+
48
+ def get_instance_subscriptions(
49
+ obj: models.Model,
50
+ actions: t.Sequence[str] = ("created", "updated", "deleted"),
51
+ ):
52
+ """Get the subscriptions to actions of a single instance of a model.
53
+
54
+ This won't return model-level subscriptions.
55
+
56
+ The `actions` is the set of actions to subscribe to, including any possible relation (e.g
57
+ 'users.deleted'). If actions is empty, return only instance-level subscription.
58
+
59
+ """
60
+ cls = type(obj)
61
+ app = cls._meta.app_label
62
+ name = cls._meta.model_name
63
+ prefix = f"{app}.{name}.{obj.pk}"
64
+ if not actions:
65
+ return {prefix}
66
+ else:
67
+ return {f"{prefix}.{action}" for action in actions}
68
+
69
+
70
+ def get_model_subscriptions(
71
+ obj: type[models.Model] | models.Model,
72
+ actions: t.Sequence[str | None] = (),
73
+ ) -> set[str]:
74
+ """Get the subscriptions to actions of the model.
75
+
76
+ If the `obj` is an instance of the model, return all the subscriptions
77
+ from actions. If `obj` is just the model class, return the top-level
78
+ subscription.
79
+
80
+ The `actions` is the set of actions to subscribe to, including any
81
+ possible relation (e.g 'users.deleted').
82
+
83
+ """
84
+ actions = actions or (None,)
85
+ if isinstance(obj, models.Model):
86
+ cls = type(obj)
87
+ instance = obj
88
+ else:
89
+ cls = obj
90
+ instance = None
91
+ app = cls._meta.app_label
92
+ name = cls._meta.model_name
93
+ model_prefix = f"{app}.{name}"
94
+ prefix = f"{model_prefix}.{instance.pk}" if instance else model_prefix
95
+ result = {(f"{prefix}.{action}" if action else prefix) for action in actions}
96
+ return result
97
+
98
+
99
+ def generate_id():
100
+ return f"hx-{uuid7().hex}"
101
+
102
+
103
+ def compact_hash(value: str) -> str:
104
+ """Return a SHA1 using a base with 64+ symbols"""
105
+ # this returns a signed 32 bit number, we convert it to unsigned with `& 0xffffffff`
106
+ hashed_value = mmh3.hash(value) & 0xFFFFFFFF
107
+
108
+ # Convert the integer to the custom base
109
+ base_len = len(_BASE)
110
+ encoded = []
111
+ while hashed_value > 0:
112
+ hashed_value, rem = divmod(hashed_value, base_len)
113
+ encoded.append(_BASE[rem])
114
+
115
+ return "".join(encoded)
116
+
117
+
118
+ # The order of the base is random so that it doesn't match anything out there.
119
+ # The symbols are chosen to avoid extra encoding in the URL and HTML, and
120
+ # allowed in plain CSS selectors.
121
+ _BASE = "ZmBeUHhTgusXNW_Y1b05KPiFcQJD86joqnIRE7Lfkrdp3AOMCvltSwzVG9yxa42"
122
+
123
+
124
+ def autodiscover_htmx_modules():
125
+ """
126
+ Auto-discover HTMX modules in Django apps.
127
+
128
+ This discovers both:
129
+ - htmx.py files (like standard autodiscover_modules("htmx"))
130
+ - All Python files under htmx/ directories in apps (recursively)
131
+ """
132
+
133
+ def _import_modules_recursively(module_name):
134
+ """Recursively import a module and all its submodules."""
135
+ with contextlib.suppress(ImportError):
136
+ module = importlib.import_module(module_name)
137
+
138
+ # If this is a package, recursively import all modules in it
139
+ if hasattr(module, "__path__"):
140
+ for _finder, submodule_name, _is_pkg in pkgutil.iter_modules(module.__path__):
141
+ _import_modules_recursively(f"{module_name}.{submodule_name}")
142
+
143
+ for app_config in apps.get_app_configs():
144
+ # Import htmx module and all its submodules recursively
145
+ _import_modules_recursively(f"{app_config.name}.htmx")