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/__init__.py +4 -0
- djhtmx/apps.py +11 -0
- djhtmx/command_queue.py +142 -0
- djhtmx/commands.py +49 -0
- djhtmx/component.py +511 -0
- djhtmx/consumer.py +84 -0
- djhtmx/context.py +7 -0
- djhtmx/exceptions.py +2 -0
- djhtmx/global_events.py +14 -0
- djhtmx/introspection.py +427 -0
- djhtmx/json.py +56 -0
- djhtmx/management/commands/htmx.py +71 -0
- djhtmx/middleware.py +36 -0
- djhtmx/query.py +177 -0
- djhtmx/repo.py +596 -0
- djhtmx/settings.py +47 -0
- djhtmx/static/htmx/2.0.4/ext/ws.js +467 -0
- djhtmx/static/htmx/2.0.4/htmx.amd.js +5264 -0
- djhtmx/static/htmx/2.0.4/htmx.cjs.js +5262 -0
- djhtmx/static/htmx/2.0.4/htmx.esm.d.ts +206 -0
- djhtmx/static/htmx/2.0.4/htmx.esm.js +5262 -0
- djhtmx/static/htmx/2.0.4/htmx.js +5261 -0
- djhtmx/static/htmx/2.0.4/htmx.min.js +1 -0
- djhtmx/static/htmx/django.js +209 -0
- djhtmx/templates/htmx/headers.html +9 -0
- djhtmx/templates/htmx/lazy.html +3 -0
- djhtmx/templatetags/__init__.py +0 -0
- djhtmx/templatetags/htmx.py +289 -0
- djhtmx/testing.py +194 -0
- djhtmx/tracing.py +52 -0
- djhtmx/urls.py +107 -0
- djhtmx/utils.py +117 -0
- djhtmx-1.0.0.dist-info/METADATA +879 -0
- djhtmx-1.0.0.dist-info/RECORD +36 -0
- djhtmx-1.0.0.dist-info/WHEEL +4 -0
- djhtmx-1.0.0.dist-info/licenses/LICENSE +22 -0
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
djhtmx/exceptions.py
ADDED
djhtmx/global_events.py
ADDED
|
@@ -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
|