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 ADDED
@@ -0,0 +1,4 @@
1
+ from .middleware import middleware
2
+
3
+ __version__ = "1.0.0"
4
+ __all__ = ("middleware",)
djhtmx/apps.py ADDED
@@ -0,0 +1,11 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.module_loading import autodiscover_modules
3
+
4
+
5
+ class App(AppConfig):
6
+ name = "djhtmx"
7
+ verbose_name = "Django HTMX"
8
+
9
+ def ready(self):
10
+ autodiscover_modules("live")
11
+ autodiscover_modules("htmx")
@@ -0,0 +1,142 @@
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(instance, actions=(action,))
52
+ for field in get_related_fields(sender):
53
+ fk_id = getattr(instance, field.name)
54
+ signal = f"{field.related_model_name}.{fk_id}.{field.relation_name}"
55
+ signals.update((signal, f"{signal}.{action}"))
56
+
57
+ if signals:
58
+ self.extend([Signal({(signal, self.processing_component_id) for signal in signals})])
59
+
60
+ def extend(self, commands: list[Command]):
61
+ if commands:
62
+ self._commands.extend(commands)
63
+ self._optimize()
64
+
65
+ def append(self, command: Command):
66
+ self._commands.append(command)
67
+ self._optimize()
68
+
69
+ def pop(self) -> Command:
70
+ return self._commands.pop(0)
71
+
72
+ def __bool__(self):
73
+ return bool(self._commands)
74
+
75
+ def _optimize(self):
76
+ self._commands.sort(key=self._priority)
77
+ new_commands = []
78
+ for i, command in enumerate(self._commands):
79
+ match command:
80
+ case (
81
+ Execute()
82
+ | Signal()
83
+ | Emit()
84
+ | SkipRender()
85
+ | Focus()
86
+ | Redirect()
87
+ | DispatchDOMEvent()
88
+ | Open()
89
+ | PushURL()
90
+ | ReplaceURL()
91
+ ):
92
+ new_commands.append(command)
93
+
94
+ case Destroy(component_id) as command:
95
+ # Register destroyed ids
96
+ self._destroyed_ids.add(component_id)
97
+ new_commands.append(command)
98
+
99
+ case BuildAndRender(_, state, _) as command:
100
+ # Remove BuildAndRender of destroyed ids
101
+ if not (
102
+ (component_id := state.get("id")) and component_id in self._destroyed_ids
103
+ ):
104
+ new_commands.append(command)
105
+
106
+ case Render(component) as command:
107
+ # Remove Render of destroyed ids
108
+ # Let the latest Render of the same component survive, kill the rest
109
+ if component.id not in self._destroyed_ids and not any(
110
+ isinstance(ahead_command, Render)
111
+ and ahead_command.component.id == component.id
112
+ and ahead_command.template is None
113
+ for ahead_command in self._commands[i + 1 :]
114
+ ):
115
+ new_commands.append(command)
116
+
117
+ self._commands = new_commands
118
+
119
+ @staticmethod
120
+ def _priority(command: Command) -> tuple[int, str, int]:
121
+ match command:
122
+ case Execute():
123
+ return 0, "", 0
124
+ case Signal(_, timestamp):
125
+ return 1, "", timestamp
126
+ case Emit(_, timestamp):
127
+ return 2, "", timestamp
128
+ case Destroy():
129
+ return 3, "", 0
130
+ case SkipRender():
131
+ return 4, "", 0
132
+ case BuildAndRender(_, _, _, _, timestamp):
133
+ return 5, "", timestamp
134
+ case Render(component, template, _, _, _, timestamp):
135
+ if template:
136
+ return 6, component.id, timestamp
137
+ else:
138
+ return 7, component.id, timestamp
139
+ case Focus() | Redirect() | DispatchDOMEvent() | Open() | ReplaceURL() | PushURL():
140
+ return 8, "", 0
141
+ case _ as unreachable:
142
+ 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))