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