djhtmx 1.3.0__tar.gz → 1.3.2__tar.gz
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-1.3.0 → djhtmx-1.3.2}/CHANGELOG.md +18 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/PKG-INFO +30 -1
- {djhtmx-1.3.0 → djhtmx-1.3.2}/README.md +29 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/__init__.py +1 -1
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/introspection.py +111 -26
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/query.py +4 -1
- {djhtmx-1.3.0 → djhtmx-1.3.2}/.gitignore +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/LICENSE +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/MANIFEST.in +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/pyproject.toml +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/apps.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/command_queue.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/commands.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/component.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/consumer.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/context.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/exceptions.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/global_events.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/json.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/management/commands/htmx.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/middleware.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/repo.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/settings.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/ext/idiomorph-ext.min.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/ext/ws.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.amd.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.cjs.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.esm.d.ts +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.esm.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.min.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/django.js +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templates/htmx/headers.html +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templates/htmx/lazy.html +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templatetags/__init__.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templatetags/htmx.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/testing.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/tracing.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/urls.py +0 -0
- {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/utils.py +0 -0
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.2] - 2026-01-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Generic Type Handling**: Fixed `annotate_model()` to preserve non-Model generic types (like `defaultdict`, `list`, `dict`) when used with `Annotated` wrappers. Previously, these types would be incorrectly transformed to `None`, causing validation errors.
|
|
14
|
+
|
|
15
|
+
## [1.3.1] - 2026-01-14
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Optional Model Loading**: Fixed `Model | None` annotations to return `None` when an object with the provided ID doesn't exist (e.g., was deleted), instead of raising `DoesNotExist` exception. Uses `.filter().first()` approach for graceful handling of missing objects.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Comprehensive test coverage for optional model handling in both lazy and non-lazy loading scenarios
|
|
22
|
+
- Documentation for model loading optimization (lazy loading, `select_related`, `prefetch_related`) in README
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Query parameter handling now preserves full annotation metadata for proper serialization of `Model | None` fields
|
|
26
|
+
- Enhanced `is_basic_type()` to recognize `Model | None` unions as valid simple types for query parameters
|
|
27
|
+
|
|
10
28
|
## [1.3.0] - 2026-01-07
|
|
11
29
|
|
|
12
30
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: djhtmx
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: Interactive UI Components for Django using HTMX
|
|
5
5
|
Project-URL: Homepage, https://github.com/edelvalle/djhtmx
|
|
6
6
|
Project-URL: Documentation, https://github.com/edelvalle/djhtmx#readme
|
|
@@ -282,6 +282,35 @@ class BaseComponent(HtmxComponent, public=False):
|
|
|
282
282
|
...
|
|
283
283
|
```
|
|
284
284
|
|
|
285
|
+
### Model Loading Optimization
|
|
286
|
+
|
|
287
|
+
Components can optimize database queries when loading Django models:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from typing import Annotated
|
|
291
|
+
from djhtmx.introspection import ModelConfig
|
|
292
|
+
|
|
293
|
+
class TodoComponent(HtmxComponent):
|
|
294
|
+
# Basic: loads immediately when component is created
|
|
295
|
+
item: Item
|
|
296
|
+
|
|
297
|
+
# Optional: returns None if object doesn't exist (e.g., was deleted)
|
|
298
|
+
archived_item: Item | None = None
|
|
299
|
+
|
|
300
|
+
# Lazy: defers loading until accessed
|
|
301
|
+
user: Annotated[User, ModelConfig(lazy=True)]
|
|
302
|
+
|
|
303
|
+
# Optimized: use select_related for foreign keys, prefetch_related for reverse FKs/M2M
|
|
304
|
+
todo_list: Annotated[
|
|
305
|
+
TodoList,
|
|
306
|
+
ModelConfig(
|
|
307
|
+
lazy=True,
|
|
308
|
+
select_related=["owner", "category"],
|
|
309
|
+
prefetch_related=["items", "items__tags"]
|
|
310
|
+
)
|
|
311
|
+
]
|
|
312
|
+
```
|
|
313
|
+
|
|
285
314
|
## Component nesting
|
|
286
315
|
|
|
287
316
|
Components can contain components inside to decompose the behavior in more granular and specialized parts, for this you don't have to do anything but to a component inside the template of other component....
|
|
@@ -235,6 +235,35 @@ class BaseComponent(HtmxComponent, public=False):
|
|
|
235
235
|
...
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
### Model Loading Optimization
|
|
239
|
+
|
|
240
|
+
Components can optimize database queries when loading Django models:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from typing import Annotated
|
|
244
|
+
from djhtmx.introspection import ModelConfig
|
|
245
|
+
|
|
246
|
+
class TodoComponent(HtmxComponent):
|
|
247
|
+
# Basic: loads immediately when component is created
|
|
248
|
+
item: Item
|
|
249
|
+
|
|
250
|
+
# Optional: returns None if object doesn't exist (e.g., was deleted)
|
|
251
|
+
archived_item: Item | None = None
|
|
252
|
+
|
|
253
|
+
# Lazy: defers loading until accessed
|
|
254
|
+
user: Annotated[User, ModelConfig(lazy=True)]
|
|
255
|
+
|
|
256
|
+
# Optimized: use select_related for foreign keys, prefetch_related for reverse FKs/M2M
|
|
257
|
+
todo_list: Annotated[
|
|
258
|
+
TodoList,
|
|
259
|
+
ModelConfig(
|
|
260
|
+
lazy=True,
|
|
261
|
+
select_related=["owner", "category"],
|
|
262
|
+
prefetch_related=["items", "items__tags"]
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
```
|
|
266
|
+
|
|
238
267
|
## Component nesting
|
|
239
268
|
|
|
240
269
|
Components can contain components inside to decompose the behavior in more granular and specialized parts, for this you don't have to do anything but to a component inside the template of other component....
|
|
@@ -29,6 +29,7 @@ from django.db import models
|
|
|
29
29
|
from django.db.models import Prefetch
|
|
30
30
|
from django.utils.datastructures import MultiValueDict
|
|
31
31
|
from pydantic import BeforeValidator, PlainSerializer, TypeAdapter
|
|
32
|
+
from pydantic_core import PydanticCustomError
|
|
32
33
|
|
|
33
34
|
M = TypeVar("M", bound=models.Model)
|
|
34
35
|
|
|
@@ -106,14 +107,19 @@ class _LazyModelProxy(Generic[M]): # noqa
|
|
|
106
107
|
return getattr(self.__instance, name)
|
|
107
108
|
|
|
108
109
|
def __ensure_instance(self):
|
|
109
|
-
if
|
|
110
|
+
if self.__instance:
|
|
111
|
+
return self.__instance
|
|
112
|
+
elif self.__pk is None:
|
|
113
|
+
# If pk is None, don't try to load anything
|
|
114
|
+
return None
|
|
115
|
+
else:
|
|
110
116
|
manager = self.__model.objects
|
|
111
117
|
if select_related := self.__select_related:
|
|
112
118
|
manager = manager.select_related(*select_related)
|
|
113
119
|
if prefetch_related := self.__prefetch_related:
|
|
114
120
|
manager = manager.prefetch_related(*prefetch_related)
|
|
115
|
-
self.__instance = manager.
|
|
116
|
-
|
|
121
|
+
self.__instance = manager.filter(pk=self.__pk).first()
|
|
122
|
+
return self.__instance
|
|
117
123
|
|
|
118
124
|
def __repr__(self) -> str:
|
|
119
125
|
return f"<_LazyModelProxy model={self.__model}, pk={self.__pk}, instance={self.__instance}>"
|
|
@@ -123,6 +129,7 @@ class _LazyModelProxy(Generic[M]): # noqa
|
|
|
123
129
|
class _ModelBeforeValidator(Generic[M]): # noqa
|
|
124
130
|
model: type[M]
|
|
125
131
|
model_config: ModelConfig
|
|
132
|
+
allow_none: bool = False
|
|
126
133
|
|
|
127
134
|
def __call__(self, value):
|
|
128
135
|
if self.model_config.lazy:
|
|
@@ -131,11 +138,14 @@ class _ModelBeforeValidator(Generic[M]): # noqa
|
|
|
131
138
|
return self._get_instance(value)
|
|
132
139
|
|
|
133
140
|
def _get_lazy_proxy(self, value):
|
|
134
|
-
if
|
|
141
|
+
if value is None:
|
|
142
|
+
# Don't create a proxy for explicit None
|
|
143
|
+
return None
|
|
144
|
+
elif isinstance(value, _LazyModelProxy):
|
|
135
145
|
instance = value._LazyModelProxy__instance or value._LazyModelProxy__pk
|
|
136
|
-
return _LazyModelProxy(self.model, instance)
|
|
146
|
+
return _LazyModelProxy(self.model, instance, model_annotation=self.model_config)
|
|
137
147
|
else:
|
|
138
|
-
return _LazyModelProxy(self.model, value)
|
|
148
|
+
return _LazyModelProxy(self.model, value, model_annotation=self.model_config)
|
|
139
149
|
|
|
140
150
|
def _get_instance(self, value):
|
|
141
151
|
if value is None or isinstance(value, self.model):
|
|
@@ -150,12 +160,25 @@ class _ModelBeforeValidator(Generic[M]): # noqa
|
|
|
150
160
|
manager = manager.select_related(*select_related)
|
|
151
161
|
if prefetch_related := self.model_config.prefetch_related:
|
|
152
162
|
manager = manager.prefetch_related(*prefetch_related)
|
|
153
|
-
|
|
163
|
+
# Use filter().first() instead of get() to avoid exceptions
|
|
164
|
+
instance = manager.filter(pk=value).first()
|
|
165
|
+
if instance is None:
|
|
166
|
+
if self.allow_none:
|
|
167
|
+
# For Model | None fields, return None when object doesn't exist
|
|
168
|
+
return None
|
|
169
|
+
else:
|
|
170
|
+
# For required Model fields, raise validation error
|
|
171
|
+
raise PydanticCustomError(
|
|
172
|
+
"model_not_found",
|
|
173
|
+
f"{self.model.__name__} with pk={{pk}} does not exist",
|
|
174
|
+
{"pk": value},
|
|
175
|
+
)
|
|
176
|
+
return instance
|
|
154
177
|
|
|
155
178
|
@classmethod
|
|
156
179
|
@cache
|
|
157
|
-
def from_modelclass(cls, model: type[M], model_config: ModelConfig):
|
|
158
|
-
return cls(model, model_config=model_config)
|
|
180
|
+
def from_modelclass(cls, model: type[M], model_config: ModelConfig, allow_none: bool = False):
|
|
181
|
+
return cls(model, model_config=model_config, allow_none=allow_none)
|
|
159
182
|
|
|
160
183
|
|
|
161
184
|
@dataclass(slots=True)
|
|
@@ -163,7 +186,11 @@ class _ModelPlainSerializer(Generic[M]): # noqa
|
|
|
163
186
|
model: type[M]
|
|
164
187
|
|
|
165
188
|
def __call__(self, value):
|
|
166
|
-
|
|
189
|
+
# Handle None for Model | None fields
|
|
190
|
+
if value is None:
|
|
191
|
+
return None
|
|
192
|
+
else:
|
|
193
|
+
return value.pk
|
|
167
194
|
|
|
168
195
|
@classmethod
|
|
169
196
|
@cache
|
|
@@ -171,12 +198,18 @@ class _ModelPlainSerializer(Generic[M]): # noqa
|
|
|
171
198
|
return cls(model)
|
|
172
199
|
|
|
173
200
|
|
|
174
|
-
def _Model(
|
|
201
|
+
def _Model(
|
|
202
|
+
model: type[models.Model], model_config: ModelConfig | None = None, allow_none: bool = False
|
|
203
|
+
):
|
|
175
204
|
assert issubclass_safe(model, models.Model)
|
|
176
205
|
model_config = model_config or _DEFAULT_MODEL_CONFIG
|
|
206
|
+
base_type = model if not model_config.lazy else _LazyModelProxy[model]
|
|
207
|
+
# If allow_none is True, the base type can be None (for Model | None unions)
|
|
208
|
+
if allow_none:
|
|
209
|
+
base_type = base_type | None # type: ignore
|
|
177
210
|
return Annotated[
|
|
178
|
-
|
|
179
|
-
BeforeValidator(_ModelBeforeValidator.from_modelclass(model, model_config)),
|
|
211
|
+
base_type,
|
|
212
|
+
BeforeValidator(_ModelBeforeValidator.from_modelclass(model, model_config, allow_none)),
|
|
180
213
|
PlainSerializer(
|
|
181
214
|
func=_ModelPlainSerializer.from_modelclass(model),
|
|
182
215
|
return_type=guess_pk_type(model),
|
|
@@ -214,19 +247,54 @@ def annotate_model(annotation, *, model_config: ModelConfig | None = None):
|
|
|
214
247
|
},
|
|
215
248
|
)
|
|
216
249
|
elif type_ := get_origin(annotation):
|
|
217
|
-
|
|
250
|
+
# Handle Annotated types like Annotated[Item | None, Query("editing")]
|
|
251
|
+
if type_ is Annotated:
|
|
252
|
+
args = get_args(annotation)
|
|
253
|
+
if args:
|
|
254
|
+
# Process the base type (first arg) and keep other metadata
|
|
255
|
+
base_type = args[0]
|
|
256
|
+
metadata = args[1:]
|
|
257
|
+
processed_base = annotate_model(base_type, model_config=model_config)
|
|
258
|
+
|
|
259
|
+
# If processed_base is also Annotated, merge the metadata
|
|
260
|
+
if get_origin(processed_base) is Annotated:
|
|
261
|
+
processed_args = get_args(processed_base)
|
|
262
|
+
inner_base = processed_args[0]
|
|
263
|
+
inner_metadata = processed_args[1:]
|
|
264
|
+
# Merge: inner metadata first, then original metadata
|
|
265
|
+
return Annotated[inner_base, *inner_metadata, *metadata] # type: ignore
|
|
266
|
+
else:
|
|
267
|
+
# Reconstruct the Annotated with processed base type
|
|
268
|
+
return Annotated[processed_base, *metadata] # type: ignore
|
|
269
|
+
return annotation
|
|
270
|
+
elif type_ is types.UnionType or type_ is Union:
|
|
218
271
|
type_ = Union
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
match get_args(annotation):
|
|
273
|
+
case ():
|
|
274
|
+
return type_
|
|
275
|
+
case (param,):
|
|
276
|
+
return type_[annotate_model(param)] # type: ignore
|
|
277
|
+
case params:
|
|
278
|
+
model_annotation = next(
|
|
279
|
+
(p for p in params if isinstance(p, ModelConfig)),
|
|
280
|
+
None,
|
|
281
|
+
)
|
|
282
|
+
# Check if this is a Model | None union
|
|
283
|
+
has_none = types.NoneType in params
|
|
284
|
+
model_params = [p for p in params if issubclass_safe(p, models.Model)]
|
|
285
|
+
|
|
286
|
+
if has_none and len(model_params) == 1:
|
|
287
|
+
# This is a Model | None union - use allow_none=True
|
|
288
|
+
# Use the model_config parameter passed to annotate_model, not model_annotation from Union params
|
|
289
|
+
model = model_params[0]
|
|
290
|
+
return _Model(model, model_config or model_annotation, allow_none=True)
|
|
291
|
+
else:
|
|
292
|
+
# Regular union - process each param independently
|
|
293
|
+
return type_[
|
|
294
|
+
*(annotate_model(p, model_config=model_annotation) for p in params)
|
|
295
|
+
] # type: ignore
|
|
296
|
+
# Other generic types (list, dict, defaultdict, etc.) - return as-is
|
|
297
|
+
return annotation
|
|
230
298
|
else:
|
|
231
299
|
return annotation
|
|
232
300
|
|
|
@@ -404,10 +472,27 @@ def is_basic_type(ann):
|
|
|
404
472
|
- Literal types with simple values
|
|
405
473
|
|
|
406
474
|
"""
|
|
475
|
+
# Check if it's a Union (e.g., Item | None)
|
|
476
|
+
origin_type = get_origin(ann)
|
|
477
|
+
if origin_type in (types.UnionType, Union):
|
|
478
|
+
args = get_args(ann)
|
|
479
|
+
# If it's Model | None, consider it a basic type
|
|
480
|
+
model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
|
|
481
|
+
if model_types and types.NoneType in args:
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
# Check for Annotated[Model, ...] or Annotated[Model | None, ...] pattern
|
|
485
|
+
origin = getattr(ann, "__origin__", None)
|
|
486
|
+
if origin is not None and get_origin(origin) in (types.UnionType, Union):
|
|
487
|
+
args = get_args(origin)
|
|
488
|
+
model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
|
|
489
|
+
if model_types and types.NoneType in args:
|
|
490
|
+
return True
|
|
491
|
+
|
|
407
492
|
return (
|
|
408
493
|
ann in _SIMPLE_TYPES
|
|
409
494
|
# __origin__ -> model in 'Annotated[model, BeforeValidator(...), PlainSerializer(...)]'
|
|
410
|
-
or issubclass_safe(
|
|
495
|
+
or issubclass_safe(origin, models.Model)
|
|
411
496
|
or issubclass_safe(ann, (enum.IntEnum, enum.StrEnum))
|
|
412
497
|
or is_collection_annotation(ann)
|
|
413
498
|
or is_literal_annotation(ann)
|
|
@@ -107,7 +107,10 @@ class QueryPatcher:
|
|
|
107
107
|
# Prefix with the component name if not shared
|
|
108
108
|
if not query.shared:
|
|
109
109
|
param_name = f"{param_name}-{compact_hash(component.__name__)}"
|
|
110
|
-
|
|
110
|
+
|
|
111
|
+
# Use the full annotation from __annotations__ to preserve serializers
|
|
112
|
+
full_annotation = component.__annotations__.get(field_name, field.annotation)
|
|
113
|
+
adapter = get_annotation_adapter(full_annotation)
|
|
111
114
|
yield cls(
|
|
112
115
|
field_name=field_name,
|
|
113
116
|
param_name=param_name,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|