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/component.py ADDED
@@ -0,0 +1,515 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import time
6
+ from collections import defaultdict
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from dataclasses import field as dataclass_field
10
+ from functools import cache, cached_property, partial
11
+ from os.path import basename
12
+ from typing import (
13
+ TYPE_CHECKING,
14
+ Annotated,
15
+ Any,
16
+ Literal,
17
+ ParamSpec,
18
+ TypeVar,
19
+ cast,
20
+ get_type_hints,
21
+ )
22
+
23
+ from django.core.exceptions import ImproperlyConfigured
24
+ from django.db import models
25
+ from django.shortcuts import resolve_url
26
+ from django.template import Context, loader
27
+ from django.utils.safestring import SafeString, mark_safe
28
+ from pydantic import BaseModel, ConfigDict, Field, validate_call
29
+ from pydantic.fields import ModelPrivateAttr
30
+
31
+ from . import json, settings
32
+ from .commands import PushURL, ReplaceURL
33
+ from .exceptions import ComponentNotFound
34
+ from .introspection import (
35
+ ModelConfig,
36
+ Unset,
37
+ annotate_model,
38
+ get_event_handler_event_types,
39
+ get_function_parameters,
40
+ )
41
+ from .query import Query, QueryPatcher
42
+ from .tracing import tracing_span
43
+ from .utils import generate_id
44
+
45
+ __all__ = ("ComponentNotFound", "HtmxComponent", "ModelConfig", "Query")
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class Destroy:
50
+ "Destroys the given component in the browser and in the caches."
51
+
52
+ component_id: str
53
+ command: Literal["destroy"] = "destroy"
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class Redirect:
58
+ "Executes a browser redirection to the given URL."
59
+
60
+ url: str
61
+ command: Literal["redirect"] = "redirect"
62
+
63
+ @classmethod
64
+ def to(cls, to: Callable[[], Any] | models.Model | str, *args, **kwargs):
65
+ return cls(resolve_url(to, *args, **kwargs))
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class Open:
70
+ "Open a new window with the URL."
71
+
72
+ url: str
73
+ name: str = ""
74
+ rel: str = "noopener noreferrer"
75
+ target: str = "_blank"
76
+
77
+ command: Literal["open-tab"] = "open-tab"
78
+
79
+ @classmethod
80
+ def to(cls, to: Callable[[], Any] | models.Model | str, *args, **kwargs):
81
+ return cls(resolve_url(to, *args, **kwargs))
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class Focus:
86
+ "Executes a '.focus()' on the browser element that matches `selector`"
87
+
88
+ selector: str
89
+ command: Literal["focus"] = "focus"
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class Execute:
94
+ component_id: str
95
+ event_handler: str
96
+ event_data: dict[str, Any]
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class DispatchDOMEvent:
101
+ "Dispatches a DOM CustomEvent in the given target."
102
+
103
+ target: str
104
+ event: str
105
+ detail: Any
106
+ bubbles: bool = False
107
+ cancelable: bool = False
108
+ composed: bool = False
109
+ command: Literal["dispatch_dom_event"] = "dispatch_dom_event"
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class SkipRender:
114
+ "Instruct the HTMX engine to avoid the render of the component."
115
+
116
+ component: HtmxComponent
117
+
118
+
119
+ @dataclass(slots=True)
120
+ class BuildAndRender:
121
+ component: type[HtmxComponent]
122
+ state: dict[str, Any]
123
+ oob: str = "true"
124
+ parent_id: str | None = None
125
+ timestamp: int = dataclass_field(default_factory=time.monotonic_ns)
126
+
127
+ @classmethod
128
+ def append(
129
+ cls, target_: str, component_: type[HtmxComponent], parent_id: str | None = None, **state
130
+ ):
131
+ return cls(
132
+ component=component_, state=state, oob=f"beforeend: {target_}", parent_id=parent_id
133
+ )
134
+
135
+ @classmethod
136
+ def prepend(
137
+ cls, target_: str, component_: type[HtmxComponent], parent_id: str | None = None, **state
138
+ ):
139
+ return cls(
140
+ component=component_, state=state, oob=f"afterbegin: {target_}", parent_id=parent_id
141
+ )
142
+
143
+ @classmethod
144
+ def after(
145
+ cls, target_: str, component_: type[HtmxComponent], parent_id: str | None = None, **state
146
+ ):
147
+ return cls(
148
+ component=component_, state=state, oob=f"afterend: {target_}", parent_id=parent_id
149
+ )
150
+
151
+ @classmethod
152
+ def before(
153
+ cls, target_: str, component_: type[HtmxComponent], parent_id: str | None = None, **state
154
+ ):
155
+ return cls(
156
+ component=component_, state=state, oob=f"beforebegin: {target_}", parent_id=parent_id
157
+ )
158
+
159
+ @classmethod
160
+ def update(cls, component: type[HtmxComponent], **state):
161
+ return cls(component=component, state=state)
162
+
163
+
164
+ @dataclass(slots=True)
165
+ class Render:
166
+ component: HtmxComponent
167
+ template: str | None = None
168
+ oob: str = "true"
169
+ lazy: bool | None = None
170
+ context: dict[str, Any] | None = None
171
+ timestamp: int = dataclass_field(default_factory=time.monotonic_ns)
172
+
173
+
174
+ @dataclass(slots=True)
175
+ class Emit:
176
+ "Emit a backend-only event."
177
+
178
+ event: Any
179
+ timestamp: int = dataclass_field(default_factory=time.monotonic_ns)
180
+
181
+
182
+ @dataclass(slots=True)
183
+ class Signal:
184
+ "Emit a backend-only signal."
185
+
186
+ names: set[tuple[str, str]] # set[tuple[signal name, emitter component id]]
187
+ timestamp: int = dataclass_field(default_factory=time.monotonic_ns)
188
+
189
+
190
+ Command = (
191
+ Destroy
192
+ | Redirect
193
+ | Focus
194
+ | DispatchDOMEvent
195
+ | SkipRender
196
+ | BuildAndRender
197
+ | Render
198
+ | Emit
199
+ | Signal
200
+ | Execute
201
+ | Open
202
+ | PushURL
203
+ | ReplaceURL
204
+ )
205
+
206
+
207
+ RenderFunction = Callable[[Context | dict[str, Any] | None], SafeString]
208
+
209
+ PYDANTIC_MODEL_METHODS = {
210
+ attr_name for attr_name in dir(BaseModel) if not attr_name.startswith("_")
211
+ }
212
+
213
+ REGISTRY: dict[str, type[HtmxComponent]] = {}
214
+ LISTENERS: dict[type, set[str]] = defaultdict(set)
215
+ FQN: dict[type[HtmxComponent], str] = {}
216
+
217
+
218
+ @cache
219
+ def _get_query_patchers(component_name: str) -> list[QueryPatcher]:
220
+ return list(QueryPatcher.for_component(REGISTRY[component_name]))
221
+
222
+
223
+ @cache
224
+ def _get_querystring_subscriptions(component_name: str) -> frozenset[str]:
225
+ return frozenset({
226
+ patcher.signal_name
227
+ for patcher in _get_query_patchers(component_name)
228
+ if patcher.auto_subscribe
229
+ })
230
+
231
+
232
+ A = TypeVar("A")
233
+ B = TypeVar("B")
234
+ P = ParamSpec("P")
235
+
236
+
237
+ def _compose(f: Callable[P, A], g: Callable[[A], B]) -> Callable[P, B]:
238
+ def result(*args: P.args, **kwargs: P.kwargs):
239
+ return g(f(*args, **kwargs))
240
+
241
+ return result
242
+
243
+
244
+ RENDER_FUNC: dict[str, RenderFunction] = {}
245
+
246
+
247
+ def get_template(template: str) -> RenderFunction: # pragma: no cover
248
+ if settings.DEBUG:
249
+ return cast(RenderFunction, _compose(loader.get_template(template).render, mark_safe))
250
+ else:
251
+ if (render := RENDER_FUNC.get(template)) is None:
252
+ render = cast(RenderFunction, _compose(loader.get_template(template).render, mark_safe))
253
+ RENDER_FUNC[template] = render
254
+ return render
255
+
256
+
257
+ class HtmxComponent(BaseModel):
258
+ _template_name: str = ... # type: ignore
259
+ _template_name_lazy: str = settings.DEFAULT_LAZY_TEMPLATE
260
+
261
+ # tracks which attributes are properties, to expose them in a lazy way to the _get_context
262
+ # during rendering
263
+ _properties: set[str] = ... # type: ignore
264
+
265
+ # tracks what are the names of the event handlers of the class
266
+ _event_handler_params: dict[str, frozenset[str]] = ... # type: ignore
267
+
268
+ # fields to exclude from component state during serialization
269
+ model_config = ConfigDict(
270
+ arbitrary_types_allowed=True,
271
+ )
272
+
273
+ def __init_subclass__(cls, public=None):
274
+ FQN[cls] = f"{cls.__module__}.{cls.__name__}"
275
+
276
+ component_name = cls.__name__
277
+
278
+ if public is None:
279
+ # Detect concrete versions of generic classes, they are non public
280
+ if "[" in component_name and "]" in component_name:
281
+ public = False
282
+ elif _ABSTRACT_BASE_REGEX.match(component_name):
283
+ if settings.STRICT_PUBLIC_BASE:
284
+ raise TypeError(
285
+ f"HTMX Component: {FQN[cls]} Automatically detected as non public",
286
+ )
287
+ logger.info(
288
+ "HTMX Component: <%s> Automatically detected as non public",
289
+ FQN[cls],
290
+ )
291
+ public = False
292
+ else:
293
+ public = True
294
+
295
+ if public:
296
+ REGISTRY[component_name] = cls
297
+
298
+ # Warn of components that do not have event handlers and are public
299
+ if (
300
+ not any(cls.__own_event_handlers(get_parent_ones=True))
301
+ and not hasattr(cls, "_handle_event")
302
+ and not hasattr(cls, "subscriptions")
303
+ ):
304
+ logger.warning(
305
+ "HTMX Component <%s> has no event handlers, probably should not exist and be just a template",
306
+ FQN[cls],
307
+ )
308
+
309
+ assert isinstance(cls._template_name, ModelPrivateAttr)
310
+ if isinstance(cls._template_name.default, str) and (
311
+ basename(cls._template_name.default)
312
+ not in (f"{klass.__name__}.html" for klass in cls.__mro__)
313
+ ):
314
+ raise ImproperlyConfigured(
315
+ f"HTMX Component <{FQN[cls]}> template name does not match the component name"
316
+ )
317
+
318
+ # We use 'get_type_hints' to resolve the forward refs if needed, but
319
+ # we only need to rewrite the actual annotations of the current class,
320
+ # that's why we iter over the '__annotations__' names.
321
+ hints = get_type_hints(cls, include_extras=True)
322
+ for name in list(cls.__annotations__):
323
+ if not name.startswith("_"):
324
+ annotation = hints[name]
325
+ cls.__annotations__[name] = annotate_model(annotation)
326
+
327
+ cls._event_handler_params = {
328
+ name: get_function_parameters(event_handler)
329
+ for name, event_handler in cls.__own_event_handlers(get_parent_ones=True)
330
+ }
331
+
332
+ for name, params in cls._event_handler_params.items():
333
+ if params and not hasattr((attr := getattr(cls, name)), "raw_function"):
334
+ setattr(
335
+ cls,
336
+ name,
337
+ validate_call(config={"arbitrary_types_allowed": True})(attr),
338
+ )
339
+
340
+ cls.__check_consistent_event_handler(strict=settings.STRICT_EVENT_HANDLER_CONSISTENCY_CHECK)
341
+ if public:
342
+ if handle_event := getattr(cls, "_handle_event", None):
343
+ for event_type in get_event_handler_event_types(handle_event):
344
+ LISTENERS[event_type].add(component_name)
345
+
346
+ cls._properties = {
347
+ attr
348
+ for attr in dir(cls)
349
+ if not attr.startswith("_")
350
+ if attr not in PYDANTIC_MODEL_METHODS
351
+ if isinstance(getattr(cls, attr), property | cached_property)
352
+ }
353
+
354
+ return super().__init_subclass__()
355
+
356
+ @classmethod
357
+ def __own_event_handlers(cls, get_parent_ones=False):
358
+ attr_names = dir(cls) if get_parent_ones else vars(cls)
359
+ for attr_name in attr_names:
360
+ if (
361
+ not attr_name.startswith("_")
362
+ and attr_name not in PYDANTIC_MODEL_METHODS
363
+ and attr_name.islower()
364
+ and callable(attr := getattr(cls, attr_name))
365
+ ):
366
+ yield attr_name, attr
367
+
368
+ @classmethod
369
+ def __check_consistent_event_handler(cls, *, strict: bool = False):
370
+ """Check that '_handle_event' is consistent.
371
+
372
+ If the class inherits from one that super-class, and it gets
373
+ `_handle_event` from several of those branches, it must override it to
374
+ resolve the ambiguity.
375
+
376
+ Raise an error if there is no self-defined method.
377
+
378
+ """
379
+ parents = {
380
+ method
381
+ for base in cls.__bases__
382
+ if (method := getattr(base, "_handle_event", None)) is not None
383
+ }
384
+ if len(parents) > 1:
385
+ resolved = cls._handle_event # type: ignore
386
+ if resolved in parents:
387
+ bases = ", ".join(
388
+ base.__name__
389
+ for base in cls.__bases__
390
+ if (method := getattr(base, "_handle_event", None)) is not None
391
+ )
392
+ if strict:
393
+ raise TypeError(
394
+ f"Component {cls.__name__} doesn't override "
395
+ f"_handle_event to reconcile the base classes ({bases})."
396
+ )
397
+ else:
398
+ logger.error(
399
+ "Component %s doesn't override _handle_event to reconcile the base classes (%s)",
400
+ cls.__name__,
401
+ bases,
402
+ )
403
+
404
+ # State
405
+ id: Annotated[str, Field(default_factory=generate_id)]
406
+
407
+ user: Annotated[Any | None, Field(exclude=True)] # type: ignore
408
+ if TYPE_CHECKING:
409
+ from django.contrib.auth.models import AbstractBaseUser
410
+
411
+ user: Annotated[AbstractBaseUser | None, Field(exclude=True)]
412
+
413
+ hx_name: str
414
+ lazy: bool = False
415
+
416
+ def __repr__(self) -> str:
417
+ return f"{self.hx_name}(\n{self.model_dump_json(indent=2, exclude={'hx_name'})})\n"
418
+
419
+ @property
420
+ def subscriptions(self) -> set[str]:
421
+ return set()
422
+
423
+ def render(self): ...
424
+
425
+ def _get_all_subscriptions(self) -> set[str]:
426
+ return self.subscriptions | _get_querystring_subscriptions(self.hx_name)
427
+
428
+ def _get_template(self, template: str | None = None) -> Callable[..., SafeString]:
429
+ return get_template(template or self._template_name)
430
+
431
+ def _get_lazy_context(self):
432
+ return {}
433
+
434
+ def _get_context(self):
435
+ # This render-local cache, supports lazy properties but avoids the same property to be
436
+ # computed more than once. It doesn't survive several renders which is good, because it
437
+ # doesn't require invalidation.
438
+ def get_property(cache, attr):
439
+ result = cache.get(attr, Unset)
440
+ if result is Unset:
441
+ result = getattr(self, attr)
442
+ cache[attr] = result
443
+ return result
444
+
445
+ with tracing_span(f"{FQN[type(self)]}._get_context"):
446
+ render_cache = {}
447
+ return {
448
+ attr: (
449
+ partial(get_property, render_cache, attr) # do lazy evaluation of properties
450
+ if attr in self._properties
451
+ else getattr(self, attr)
452
+ )
453
+ for attr in dir(self)
454
+ if not attr.startswith("_") and attr not in PYDANTIC_MODEL_METHODS
455
+ }
456
+
457
+
458
+ @dataclass(slots=True)
459
+ class Triggers:
460
+ """HTMX triggers.
461
+
462
+ Allow to trigger events on the client from the server. See
463
+ https://htmx.org/attributes/hx-trigger/
464
+
465
+ """
466
+
467
+ _trigger: dict[str, list[Any]] = dataclass_field(default_factory=lambda: defaultdict(list))
468
+ _after_swap: dict[str, list[Any]] = dataclass_field(default_factory=lambda: defaultdict(list))
469
+ _after_settle: dict[str, list[Any]] = dataclass_field(default_factory=lambda: defaultdict(list))
470
+
471
+ def add(self, name, what: Any):
472
+ self._trigger[name].append(what)
473
+
474
+ def after_swap(self, name, what: Any):
475
+ self._after_swap[name].append(what)
476
+
477
+ def after_settle(self, name, what: Any):
478
+ self._after_settle[name].append(what)
479
+
480
+ @property
481
+ def headers(self):
482
+ headers = [
483
+ ("HX-Trigger", self._trigger),
484
+ ("HX-Trigger-After-Swap", self._after_swap),
485
+ ("HX-Trigger-After-Settle", self._after_settle),
486
+ ]
487
+ return {header: json.dumps(value) for header, value in headers if value}
488
+
489
+
490
+ F = TypeVar("F")
491
+
492
+
493
+ def annotated_handler(**annotations) -> Callable[[F], F]:
494
+ """Annotate the HTMX handler with customized values.
495
+
496
+ Some of these annotations are HtmxUnhandledError use the annotations so that the application can
497
+ have more detailed error recovery handlers.
498
+
499
+ """
500
+
501
+ def decorator(fn):
502
+ if not hasattr(fn, "_htmx_annotations_"):
503
+ fn._htmx_annotations_ = htmx_annotations = {}
504
+ else:
505
+ htmx_annotations = fn._htmx_annotations_
506
+ htmx_annotations.update(annotations)
507
+ return fn
508
+
509
+ return decorator
510
+
511
+
512
+ logger = logging.getLogger(__name__)
513
+
514
+
515
+ _ABSTRACT_BASE_REGEX = re.compile(r"^(_)?(Base|Abstract)[A-Z0-9_]")
djhtmx/consumer.py ADDED
@@ -0,0 +1,84 @@
1
+ import logging
2
+ from typing import Any, Literal, assert_never
3
+
4
+ from channels.generic.websocket import AsyncJsonWebsocketConsumer
5
+ from pydantic import BaseModel, TypeAdapter
6
+
7
+ from . import json
8
+ from .commands import PushURL, ReplaceURL, SendHtml
9
+ from .component import Command, Destroy, DispatchDOMEvent, Focus, Open, Redirect
10
+ from .introspection import parse_request_data
11
+ from .repo import Repository
12
+ from .utils import get_params
13
+
14
+
15
+ class ComponentsRemoved(BaseModel):
16
+ type: Literal["removed"]
17
+ component_ids: list[str]
18
+
19
+
20
+ class ComponentsAdded(BaseModel):
21
+ type: Literal["added"]
22
+ states: list[str]
23
+ subscriptions: dict[str, str]
24
+
25
+
26
+ Event = ComponentsRemoved | ComponentsAdded
27
+ EventAdapter = TypeAdapter(Event)
28
+
29
+
30
+ class Consumer(AsyncJsonWebsocketConsumer):
31
+ async def connect(self):
32
+ await self.accept()
33
+ self.repo = Repository.from_websocket(self.scope["user"])
34
+
35
+ async def disconnect(self, code):
36
+ await super().disconnect(code)
37
+
38
+ async def receive_json(self, event_data: dict[str, Any]):
39
+ if headers := event_data.pop("HEADERS", None):
40
+ url = headers["HX-Current-URL"]
41
+ component_id = headers["HX-Component-Id"]
42
+ event_handler = headers["HX-Component-Handler"]
43
+ params = get_params(url)
44
+ logger.debug(">>>> Call: %s %s", component_id, event_handler)
45
+ self.repo.params.clear()
46
+ self.repo.params.update(params) # type: ignore
47
+ # Command dispatching
48
+ async for command in self.repo.adispatch_event(
49
+ component_id, event_handler, parse_request_data(event_data)
50
+ ):
51
+ match command:
52
+ case SendHtml(html, debug_trace):
53
+ logger.debug(
54
+ "< Command: %s", f"SendHtml[{debug_trace}](... {len(html)} ...)"
55
+ )
56
+ await self.send(html)
57
+ case (
58
+ Destroy()
59
+ | Redirect()
60
+ | Focus()
61
+ | DispatchDOMEvent()
62
+ | PushURL()
63
+ | Open()
64
+ | ReplaceURL()
65
+ ):
66
+ logger.debug("< Command: %s", command)
67
+ await self.send_json(command)
68
+ case _ as unreachable:
69
+ assert_never(unreachable)
70
+
71
+ async def send_commands(self, commands: list[Command]):
72
+ for command in commands:
73
+ await self.send_json(command)
74
+
75
+ @classmethod
76
+ async def decode_json(cls, text_data) -> dict[str, Any]:
77
+ return json.loads(text_data)
78
+
79
+ @classmethod
80
+ async def encode_json(cls, content) -> str:
81
+ return json.dumps(content)
82
+
83
+
84
+ logger = logging.getLogger(__name__)
djhtmx/context.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.http import HttpRequest
2
+
3
+ from djhtmx.repo import Repository
4
+
5
+
6
+ def component_repo(request: HttpRequest):
7
+ return {"htmx_repo": getattr(request, "htmx_repo", None) or Repository.from_request(request)}
djhtmx/exceptions.py ADDED
@@ -0,0 +1,2 @@
1
+ class ComponentNotFound(LookupError):
2
+ pass
@@ -0,0 +1,14 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class HtmxUnhandledError:
6
+ """HTMX triggers this event for any HTMX handler that fails unhandled.
7
+
8
+ Applications could subscribe to this event to have last-resource general error recovery
9
+ mechanism.
10
+
11
+ """
12
+
13
+ error: BaseException | None
14
+ handler_annotations: dict | None = None