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/query.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.http import QueryDict
|
|
8
|
+
from pydantic import BaseModel, TypeAdapter
|
|
9
|
+
from pydantic.fields import FieldInfo
|
|
10
|
+
from pydantic_core import PydanticUndefined
|
|
11
|
+
|
|
12
|
+
from djhtmx.introspection import (
|
|
13
|
+
get_annotation_adapter,
|
|
14
|
+
is_collection_annotation,
|
|
15
|
+
is_simple_annotation,
|
|
16
|
+
)
|
|
17
|
+
from djhtmx.utils import compact_hash
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, unsafe_hash=True)
|
|
21
|
+
class Query:
|
|
22
|
+
"""Annotation to integrate the state with the URL's query string.
|
|
23
|
+
|
|
24
|
+
By default the query string name can be shared across many components,
|
|
25
|
+
provided the have the same type annotation.
|
|
26
|
+
|
|
27
|
+
You can set `shared` to False, to make this a specific (by component id)
|
|
28
|
+
param. In this case the URL is `<name>__<ns>=value`.
|
|
29
|
+
|
|
30
|
+
If `auto_subscribe` is True (the default), the component is automatically
|
|
31
|
+
subscribed to changes in the query string. Otherwise, changes in the
|
|
32
|
+
query string won't be signaled.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
shared: bool = True
|
|
38
|
+
auto_subscribe: bool = True
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
assert _VALID_QS_NAME_RX.match(self.name) is not None, self.name
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def extract_from_field_info(cls, name: str, field: FieldInfo):
|
|
45
|
+
done = False
|
|
46
|
+
for meta in field.metadata:
|
|
47
|
+
if isinstance(meta, cls):
|
|
48
|
+
if done:
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Field '{name}' in component {cls.__qualname__} "
|
|
51
|
+
" has more than one Query annotation."
|
|
52
|
+
)
|
|
53
|
+
if not (
|
|
54
|
+
field.default is not PydanticUndefined or field.default_factory is not None
|
|
55
|
+
):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"Field '{name}' of {cls.__qualname__} must have "
|
|
58
|
+
"a default or default_factory."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
yield meta
|
|
62
|
+
done = True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class QueryPatcher:
|
|
67
|
+
field_name: str
|
|
68
|
+
param_name: str
|
|
69
|
+
signal_name: str
|
|
70
|
+
auto_subscribe: bool
|
|
71
|
+
|
|
72
|
+
default_value: Any
|
|
73
|
+
adapter: TypeAdapter[Any]
|
|
74
|
+
|
|
75
|
+
use_json: bool
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def for_component(cls, component: type[BaseModel]):
|
|
79
|
+
seen = set()
|
|
80
|
+
for field_name, field in component.model_fields.items():
|
|
81
|
+
for query in Query.extract_from_field_info(field_name, field):
|
|
82
|
+
name = query.name
|
|
83
|
+
if name in seen:
|
|
84
|
+
raise TypeError(
|
|
85
|
+
f"Component {component.__name__} has multiple "
|
|
86
|
+
f"fields with the same query param '{name}'"
|
|
87
|
+
)
|
|
88
|
+
seen.add(name)
|
|
89
|
+
|
|
90
|
+
# Check the type annotation. It must be something that can
|
|
91
|
+
# reasonably be put in the URL: basic types or union of basic
|
|
92
|
+
# types.
|
|
93
|
+
annotation = field.annotation
|
|
94
|
+
if not is_simple_annotation(annotation):
|
|
95
|
+
raise TypeError(f"Invalid type annotation {annotation} for a query string")
|
|
96
|
+
|
|
97
|
+
# The field must have a default to be Query.
|
|
98
|
+
if field.default is PydanticUndefined and field.default_factory is None:
|
|
99
|
+
raise TypeError(
|
|
100
|
+
f"Field '{name}' of {component.__name__} must have "
|
|
101
|
+
"a default or default_factory."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Convert parameter from `search_query` to `search-query`
|
|
105
|
+
param_name = name.replace("_", "-")
|
|
106
|
+
|
|
107
|
+
# Prefix with the component name if not shared
|
|
108
|
+
if not query.shared:
|
|
109
|
+
param_name = f"{param_name}-{compact_hash(component.__name__)}"
|
|
110
|
+
adapter = get_annotation_adapter(field.annotation)
|
|
111
|
+
yield cls(
|
|
112
|
+
field_name=field_name,
|
|
113
|
+
param_name=param_name,
|
|
114
|
+
signal_name=f"querystring.{param_name}",
|
|
115
|
+
auto_subscribe=query.shared and query.auto_subscribe,
|
|
116
|
+
default_value=field.get_default(call_default_factory=True),
|
|
117
|
+
adapter=adapter,
|
|
118
|
+
use_json=is_collection_annotation(annotation),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def get_update_for_state(self, params: QueryDict):
|
|
122
|
+
if (raw_param := params.get(self.param_name)) is not None:
|
|
123
|
+
# We need to perform the validation during patching, otherwise
|
|
124
|
+
# ill-formed values in the query will cause a Pydantic
|
|
125
|
+
# ValidationError, but we should just simply ignore invalid
|
|
126
|
+
# values.
|
|
127
|
+
try:
|
|
128
|
+
return {
|
|
129
|
+
self.field_name: self.adapter.validate_json(raw_param)
|
|
130
|
+
if self.use_json
|
|
131
|
+
else self.adapter.validate_python(raw_param)
|
|
132
|
+
}
|
|
133
|
+
except ValueError:
|
|
134
|
+
# Preserve the last good known state in the component
|
|
135
|
+
return {}
|
|
136
|
+
else:
|
|
137
|
+
return {self.field_name: self.default_value}
|
|
138
|
+
|
|
139
|
+
def get_updates_for_params(self, value: Any, params: QueryDict) -> list[str]:
|
|
140
|
+
# If we're setting the default value, let remove it from the query
|
|
141
|
+
# string completely, and trigger the signal if needed.
|
|
142
|
+
if value == self.default_value:
|
|
143
|
+
if self.param_name in params:
|
|
144
|
+
params.pop(self.param_name, None)
|
|
145
|
+
return [self.signal_name]
|
|
146
|
+
else:
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
# Otherwise, let's serialize the value and only update it if it is
|
|
150
|
+
# different.
|
|
151
|
+
if self.use_json:
|
|
152
|
+
serialized_value = self.adapter.dump_json(value)
|
|
153
|
+
else:
|
|
154
|
+
serialized_value = self.adapter.dump_python(value, mode="json")
|
|
155
|
+
try:
|
|
156
|
+
# We need to validate and dump back to get the exact JSON-friendly
|
|
157
|
+
# type representation. Otherwise dates, enums, and other types
|
|
158
|
+
# won't match the serialized value.
|
|
159
|
+
param = params.get(self.param_name)
|
|
160
|
+
if self.use_json:
|
|
161
|
+
previous_value = self.adapter.dump_json(self.adapter.validate_json(param or ""))
|
|
162
|
+
else:
|
|
163
|
+
previous_value = self.adapter.dump_python(
|
|
164
|
+
self.adapter.validate_python(param),
|
|
165
|
+
mode="json",
|
|
166
|
+
)
|
|
167
|
+
except ValueError:
|
|
168
|
+
previous_value = self.default_value
|
|
169
|
+
|
|
170
|
+
if serialized_value == previous_value:
|
|
171
|
+
return []
|
|
172
|
+
else:
|
|
173
|
+
params[self.param_name] = serialized_value # type: ignore
|
|
174
|
+
return [self.signal_name]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_VALID_QS_NAME_RX = re.compile(r"^[a-zA-Z_\d][-a-zA-Z_\d]*$")
|