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/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]*$")