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/__init__.py +4 -0
- djhtmx/apps.py +13 -0
- djhtmx/command_queue.py +145 -0
- djhtmx/commands.py +49 -0
- djhtmx/component.py +515 -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 +439 -0
- djhtmx/json.py +56 -0
- djhtmx/management/commands/htmx.py +123 -0
- djhtmx/middleware.py +36 -0
- djhtmx/query.py +177 -0
- djhtmx/repo.py +585 -0
- djhtmx/settings.py +49 -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 +198 -0
- djhtmx/templates/htmx/headers.html +7 -0
- djhtmx/templates/htmx/lazy.html +3 -0
- djhtmx/templatetags/__init__.py +0 -0
- djhtmx/templatetags/htmx.py +291 -0
- djhtmx/testing.py +194 -0
- djhtmx/tracing.py +52 -0
- djhtmx/urls.py +108 -0
- djhtmx/utils.py +145 -0
- djhtmx-1.2.6.dist-info/METADATA +991 -0
- djhtmx-1.2.6.dist-info/RECORD +36 -0
- djhtmx-1.2.6.dist-info/WHEEL +4 -0
- djhtmx-1.2.6.dist-info/licenses/LICENSE +22 -0
djhtmx/__init__.py
ADDED
djhtmx/apps.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.module_loading import autodiscover_modules
|
|
3
|
+
|
|
4
|
+
from .utils import autodiscover_htmx_modules
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class App(AppConfig):
|
|
8
|
+
name = "djhtmx"
|
|
9
|
+
verbose_name = "Django HTMX"
|
|
10
|
+
|
|
11
|
+
def ready(self):
|
|
12
|
+
autodiscover_modules("live") # legacy
|
|
13
|
+
autodiscover_htmx_modules()
|
djhtmx/command_queue.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from typing import assert_never
|
|
2
|
+
|
|
3
|
+
from django.db import models
|
|
4
|
+
from django.db.models.signals import post_save, pre_delete
|
|
5
|
+
|
|
6
|
+
from djhtmx.commands import PushURL, ReplaceURL
|
|
7
|
+
|
|
8
|
+
from .component import (
|
|
9
|
+
BuildAndRender,
|
|
10
|
+
Command,
|
|
11
|
+
Destroy,
|
|
12
|
+
DispatchDOMEvent,
|
|
13
|
+
Emit,
|
|
14
|
+
Execute,
|
|
15
|
+
Focus,
|
|
16
|
+
Open,
|
|
17
|
+
Redirect,
|
|
18
|
+
Render,
|
|
19
|
+
Signal,
|
|
20
|
+
SkipRender,
|
|
21
|
+
)
|
|
22
|
+
from .introspection import get_related_fields
|
|
23
|
+
from .utils import get_model_subscriptions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandQueue:
|
|
27
|
+
def __init__(self, commands: list[Command]):
|
|
28
|
+
self.processing_component_id: str = ""
|
|
29
|
+
self._commands = commands
|
|
30
|
+
self._destroyed_ids: set[str] = set()
|
|
31
|
+
self._optimize()
|
|
32
|
+
|
|
33
|
+
# subscribe to signals changes
|
|
34
|
+
post_save.connect(self._listen_to_post_save_and_pre_delete, weak=True)
|
|
35
|
+
pre_delete.connect(self._listen_to_post_save_and_pre_delete, weak=True)
|
|
36
|
+
|
|
37
|
+
def _listen_to_post_save_and_pre_delete(
|
|
38
|
+
self,
|
|
39
|
+
sender: type[models.Model],
|
|
40
|
+
instance: models.Model,
|
|
41
|
+
created: bool | None = None,
|
|
42
|
+
**kwargs,
|
|
43
|
+
):
|
|
44
|
+
if created is None:
|
|
45
|
+
action = "deleted"
|
|
46
|
+
elif created:
|
|
47
|
+
action = "created"
|
|
48
|
+
else:
|
|
49
|
+
action = "updated"
|
|
50
|
+
|
|
51
|
+
signals = get_model_subscriptions(
|
|
52
|
+
instance, actions=(action, None)
|
|
53
|
+
) | get_model_subscriptions(type(instance), actions=(action, None))
|
|
54
|
+
|
|
55
|
+
for field in get_related_fields(sender):
|
|
56
|
+
fk_id = getattr(instance, field.name)
|
|
57
|
+
signal = f"{field.related_model_name}.{fk_id}.{field.relation_name}"
|
|
58
|
+
signals.update((signal, f"{signal}.{action}"))
|
|
59
|
+
|
|
60
|
+
if signals:
|
|
61
|
+
self.extend([Signal({(signal, self.processing_component_id) for signal in signals})])
|
|
62
|
+
|
|
63
|
+
def extend(self, commands: list[Command]):
|
|
64
|
+
if commands:
|
|
65
|
+
self._commands.extend(commands)
|
|
66
|
+
self._optimize()
|
|
67
|
+
|
|
68
|
+
def append(self, command: Command):
|
|
69
|
+
self._commands.append(command)
|
|
70
|
+
self._optimize()
|
|
71
|
+
|
|
72
|
+
def pop(self) -> Command:
|
|
73
|
+
return self._commands.pop(0)
|
|
74
|
+
|
|
75
|
+
def __bool__(self):
|
|
76
|
+
return bool(self._commands)
|
|
77
|
+
|
|
78
|
+
def _optimize(self):
|
|
79
|
+
self._commands.sort(key=self._priority)
|
|
80
|
+
new_commands = []
|
|
81
|
+
for i, command in enumerate(self._commands):
|
|
82
|
+
match command:
|
|
83
|
+
case (
|
|
84
|
+
Execute()
|
|
85
|
+
| Signal()
|
|
86
|
+
| Emit()
|
|
87
|
+
| SkipRender()
|
|
88
|
+
| Focus()
|
|
89
|
+
| Redirect()
|
|
90
|
+
| DispatchDOMEvent()
|
|
91
|
+
| Open()
|
|
92
|
+
| PushURL()
|
|
93
|
+
| ReplaceURL()
|
|
94
|
+
):
|
|
95
|
+
new_commands.append(command)
|
|
96
|
+
|
|
97
|
+
case Destroy(component_id) as command:
|
|
98
|
+
# Register destroyed ids
|
|
99
|
+
self._destroyed_ids.add(component_id)
|
|
100
|
+
new_commands.append(command)
|
|
101
|
+
|
|
102
|
+
case BuildAndRender(_, state, _) as command:
|
|
103
|
+
# Remove BuildAndRender of destroyed ids
|
|
104
|
+
if not (
|
|
105
|
+
(component_id := state.get("id")) and component_id in self._destroyed_ids
|
|
106
|
+
):
|
|
107
|
+
new_commands.append(command)
|
|
108
|
+
|
|
109
|
+
case Render(component) as command:
|
|
110
|
+
# Remove Render of destroyed ids
|
|
111
|
+
# Let the latest Render of the same component survive, kill the rest
|
|
112
|
+
if component.id not in self._destroyed_ids and not any(
|
|
113
|
+
isinstance(ahead_command, Render)
|
|
114
|
+
and ahead_command.component.id == component.id
|
|
115
|
+
and ahead_command.template is None
|
|
116
|
+
for ahead_command in self._commands[i + 1 :]
|
|
117
|
+
):
|
|
118
|
+
new_commands.append(command)
|
|
119
|
+
|
|
120
|
+
self._commands = new_commands
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _priority(command: Command) -> tuple[int, str, int]:
|
|
124
|
+
match command:
|
|
125
|
+
case Execute():
|
|
126
|
+
return 0, "", 0
|
|
127
|
+
case Destroy(component_id):
|
|
128
|
+
return 1, component_id, 0
|
|
129
|
+
case Signal(_, timestamp):
|
|
130
|
+
return 2, "", timestamp
|
|
131
|
+
case Emit(_, timestamp):
|
|
132
|
+
return 3, "", timestamp
|
|
133
|
+
case SkipRender():
|
|
134
|
+
return 4, "", 0
|
|
135
|
+
case BuildAndRender(_, _, _, _, timestamp):
|
|
136
|
+
return 5, "", timestamp
|
|
137
|
+
case Render(component, template, _, _, _, timestamp):
|
|
138
|
+
if template:
|
|
139
|
+
return 6, component.id, timestamp
|
|
140
|
+
else:
|
|
141
|
+
return 7, component.id, timestamp
|
|
142
|
+
case Focus() | Redirect() | DispatchDOMEvent() | Open() | ReplaceURL() | PushURL():
|
|
143
|
+
return 8, "", 0
|
|
144
|
+
case _ as unreachable:
|
|
145
|
+
assert_never(unreachable)
|
djhtmx/commands.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from django.db import models
|
|
9
|
+
from django.http import QueryDict
|
|
10
|
+
from django.shortcuts import resolve_url
|
|
11
|
+
from django.utils.safestring import SafeString
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class SendHtml:
|
|
18
|
+
content: SafeString
|
|
19
|
+
|
|
20
|
+
# debug trace for troubleshooting
|
|
21
|
+
debug_trace: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class PushURL:
|
|
26
|
+
url: str
|
|
27
|
+
command: Literal["push_url"] = "push_url"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_params(cls, params: QueryDict):
|
|
31
|
+
return cls("?" + params.urlencode())
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def to(cls, to: Callable[..., Any] | models.Model | str, *args, **kwargs):
|
|
35
|
+
return cls(resolve_url(to, *args, **kwargs))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class ReplaceURL:
|
|
40
|
+
url: str
|
|
41
|
+
command: Literal["replace_url"] = "replace_url"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_params(cls, params: QueryDict):
|
|
45
|
+
return cls("?" + params.urlencode())
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def to(cls, to: Callable[..., Any] | models.Model | str, *args, **kwargs):
|
|
49
|
+
return cls(resolve_url(to, *args, **kwargs))
|