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.
Files changed (40) hide show
  1. {djhtmx-1.3.0 → djhtmx-1.3.2}/CHANGELOG.md +18 -0
  2. {djhtmx-1.3.0 → djhtmx-1.3.2}/PKG-INFO +30 -1
  3. {djhtmx-1.3.0 → djhtmx-1.3.2}/README.md +29 -0
  4. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/__init__.py +1 -1
  5. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/introspection.py +111 -26
  6. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/query.py +4 -1
  7. {djhtmx-1.3.0 → djhtmx-1.3.2}/.gitignore +0 -0
  8. {djhtmx-1.3.0 → djhtmx-1.3.2}/LICENSE +0 -0
  9. {djhtmx-1.3.0 → djhtmx-1.3.2}/MANIFEST.in +0 -0
  10. {djhtmx-1.3.0 → djhtmx-1.3.2}/pyproject.toml +0 -0
  11. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/apps.py +0 -0
  12. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/command_queue.py +0 -0
  13. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/commands.py +0 -0
  14. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/component.py +0 -0
  15. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/consumer.py +0 -0
  16. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/context.py +0 -0
  17. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/exceptions.py +0 -0
  18. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/global_events.py +0 -0
  19. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/json.py +0 -0
  20. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/management/commands/htmx.py +0 -0
  21. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/middleware.py +0 -0
  22. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/repo.py +0 -0
  23. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/settings.py +0 -0
  24. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/ext/idiomorph-ext.min.js +0 -0
  25. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/ext/ws.js +0 -0
  26. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.amd.js +0 -0
  27. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.cjs.js +0 -0
  28. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.esm.d.ts +0 -0
  29. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.esm.js +0 -0
  30. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.js +0 -0
  31. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/2.0.4/htmx.min.js +0 -0
  32. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/static/htmx/django.js +0 -0
  33. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templates/htmx/headers.html +0 -0
  34. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templates/htmx/lazy.html +0 -0
  35. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templatetags/__init__.py +0 -0
  36. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/templatetags/htmx.py +0 -0
  37. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/testing.py +0 -0
  38. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/tracing.py +0 -0
  39. {djhtmx-1.3.0 → djhtmx-1.3.2}/src/djhtmx/urls.py +0 -0
  40. {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.0
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....
@@ -1,4 +1,4 @@
1
1
  from .middleware import middleware
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.3.2"
4
4
  __all__ = ("middleware",)
@@ -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 not self.__instance:
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.get(pk=self.__pk)
116
- return self.__instance
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 isinstance(value, _LazyModelProxy):
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
- return manager.get(pk=value)
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
- return value.pk
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(model: type[models.Model], model_config: ModelConfig | None = None):
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
- model if not model_config.lazy else _LazyModelProxy[model],
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
- if type_ is types.UnionType or type_ is Union:
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
- match get_args(annotation):
220
- case ():
221
- return type_
222
- case (param,):
223
- return type_[annotate_model(param)] # type: ignore
224
- case params:
225
- model_annotation = next(
226
- (p for p in params if isinstance(p, ModelConfig)),
227
- None,
228
- )
229
- return type_[*(annotate_model(p, model_config=model_annotation) for p in params)] # type: ignore
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(getattr(ann, "__origin__", None), models.Model)
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
- adapter = get_annotation_adapter(field.annotation)
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