djhtmx 1.3.2__tar.gz → 1.3.4__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.2 → djhtmx-1.3.4}/CHANGELOG.md +17 -12
  2. {djhtmx-1.3.2 → djhtmx-1.3.4}/PKG-INFO +18 -1
  3. {djhtmx-1.3.2 → djhtmx-1.3.4}/README.md +17 -0
  4. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/__init__.py +1 -1
  5. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/introspection.py +66 -94
  6. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/repo.py +13 -2
  7. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/utils.py +2 -0
  8. {djhtmx-1.3.2 → djhtmx-1.3.4}/.gitignore +0 -0
  9. {djhtmx-1.3.2 → djhtmx-1.3.4}/LICENSE +0 -0
  10. {djhtmx-1.3.2 → djhtmx-1.3.4}/MANIFEST.in +0 -0
  11. {djhtmx-1.3.2 → djhtmx-1.3.4}/pyproject.toml +0 -0
  12. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/apps.py +0 -0
  13. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/command_queue.py +0 -0
  14. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/commands.py +0 -0
  15. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/component.py +0 -0
  16. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/consumer.py +0 -0
  17. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/context.py +0 -0
  18. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/exceptions.py +0 -0
  19. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/global_events.py +0 -0
  20. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/json.py +0 -0
  21. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/management/commands/htmx.py +0 -0
  22. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/middleware.py +0 -0
  23. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/query.py +0 -0
  24. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/settings.py +0 -0
  25. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/ext/idiomorph-ext.min.js +0 -0
  26. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/ext/ws.js +0 -0
  27. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.amd.js +0 -0
  28. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.cjs.js +0 -0
  29. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.esm.d.ts +0 -0
  30. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.esm.js +0 -0
  31. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.js +0 -0
  32. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/2.0.4/htmx.min.js +0 -0
  33. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/static/htmx/django.js +0 -0
  34. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/templates/htmx/headers.html +0 -0
  35. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/templates/htmx/lazy.html +0 -0
  36. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/templatetags/__init__.py +0 -0
  37. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/templatetags/htmx.py +0 -0
  38. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/testing.py +0 -0
  39. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/tracing.py +0 -0
  40. {djhtmx-1.3.2 → djhtmx-1.3.4}/src/djhtmx/urls.py +0 -0
@@ -7,23 +7,28 @@ 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
10
+ ## [1.3.4] - 2026-01-19
11
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.
12
+ **Note**: This release supersedes versions 1.3.1, 1.3.2, and 1.3.3, which contained incomplete implementations of the `Model | None` handling feature. Users on 1.3.1-1.3.3 should upgrade to 1.3.4 immediately.
14
13
 
15
- ## [1.3.1] - 2026-01-14
14
+ ### Added
15
+ - **Yield Logging**: Added debug logging for commands yielded from component methods during event handling. Logs format: `< YIELD: ComponentName.method_name -> Command(...)`. Helps developers track command flow and debug issues when components emit multiple commands.
16
+ - Comprehensive test coverage for `Model | None` and lazy model handling with 12 new tests
16
17
 
17
18
  ### 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
19
+ - **Model | None Handling**: Fixed components with `Model | None` fields to gracefully return `None` when objects don't exist or have been deleted, instead of raising `DoesNotExist` exceptions
20
+ - Changed database lookups from `manager.get()` to `manager.filter().first()` for graceful handling
21
+ - For optional fields (`Model | None`), returns `None` when object doesn't exist
22
+ - For required fields (`Model`), raises clear `ValueError` with descriptive message
23
+ - Works correctly with both eager and lazy loading (`ModelConfig(lazy=True)`)
24
+ - Lazy models create proxies that handle non-existent objects when attributes are accessed
25
+ - **Type Safety**: Added None check for `app_config.module` in `autodiscover_htmx_modules()` to prevent AttributeError
23
26
 
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
+ ### Technical Details
28
+ - Added `allow_none` parameter to `_ModelBeforeValidator` and `_LazyModelProxy` classes
29
+ - Enhanced `annotate_model()` to detect `Model | None` unions and pass `allow_none=True`
30
+ - Updated lazy proxy `__ensure_instance()` to use `filter().first()` and handle missing objects gracefully
31
+ - QuerySet fields continue to work correctly by silently filtering out non-existent IDs
27
32
 
28
33
  ## [1.3.0] - 2026-01-07
29
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djhtmx
3
- Version: 1.3.2
3
+ Version: 1.3.4
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
@@ -311,6 +311,23 @@ class TodoComponent(HtmxComponent):
311
311
  ]
312
312
  ```
313
313
 
314
+ **Handling Deleted Objects:**
315
+
316
+ Lazy models handle deleted database objects gracefully:
317
+
318
+ ```python
319
+ class MyComponent(HtmxComponent):
320
+ # Required: raises ObjectDoesNotExist when checking if object was deleted
321
+ item: Annotated[Item, ModelConfig(lazy=True)]
322
+
323
+ # Optional: becomes falsy and returns None when object was deleted
324
+ archived_item: Annotated[Item | None, ModelConfig(lazy=True)]
325
+ ```
326
+
327
+ - **Required lazy models**: Checking truthiness (`if component.item:`) raises `ObjectDoesNotExist` with a clear message
328
+ - **Optional lazy models**: Checking truthiness returns `False`, field accesses return `None`
329
+ - **Both**: Accessing `.pk` always works without triggering database queries
330
+
314
331
  ## Component nesting
315
332
 
316
333
  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....
@@ -264,6 +264,23 @@ class TodoComponent(HtmxComponent):
264
264
  ]
265
265
  ```
266
266
 
267
+ **Handling Deleted Objects:**
268
+
269
+ Lazy models handle deleted database objects gracefully:
270
+
271
+ ```python
272
+ class MyComponent(HtmxComponent):
273
+ # Required: raises ObjectDoesNotExist when checking if object was deleted
274
+ item: Annotated[Item, ModelConfig(lazy=True)]
275
+
276
+ # Optional: becomes falsy and returns None when object was deleted
277
+ archived_item: Annotated[Item | None, ModelConfig(lazy=True)]
278
+ ```
279
+
280
+ - **Required lazy models**: Checking truthiness (`if component.item:`) raises `ObjectDoesNotExist` with a clear message
281
+ - **Optional lazy models**: Checking truthiness returns `False`, field accesses return `None`
282
+ - **Both**: Accessing `.pk` always works without triggering database queries
283
+
267
284
  ## Component nesting
268
285
 
269
286
  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.2"
3
+ __version__ = "1.3.4"
4
4
  __all__ = ("middleware",)
@@ -29,7 +29,6 @@ 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
33
32
 
34
33
  M = TypeVar("M", bound=models.Model)
35
34
 
@@ -78,14 +77,17 @@ class _LazyModelProxy(Generic[M]): # noqa
78
77
  __pk: Any | None
79
78
  __select_related: Sequence[str] | None
80
79
  __prefetch_related: Sequence[str | Prefetch] | None
80
+ __allow_none: bool
81
81
 
82
82
  def __init__(
83
83
  self,
84
84
  model: type[M],
85
85
  value: Any,
86
86
  model_annotation: ModelConfig | None = None,
87
+ allow_none: bool = False,
87
88
  ):
88
89
  self.__model = model
90
+ self.__allow_none = allow_none
89
91
  if value is None or isinstance(value, model):
90
92
  self.__instance = value
91
93
  self.__pk = getattr(value, "pk", None)
@@ -107,19 +109,22 @@ class _LazyModelProxy(Generic[M]): # noqa
107
109
  return getattr(self.__instance, name)
108
110
 
109
111
  def __ensure_instance(self):
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:
112
+ if not self.__instance:
116
113
  manager = self.__model.objects
117
114
  if select_related := self.__select_related:
118
115
  manager = manager.select_related(*select_related)
119
116
  if prefetch_related := self.__prefetch_related:
120
117
  manager = manager.prefetch_related(*prefetch_related)
118
+ # Use filter().first() instead of get() to avoid exceptions
121
119
  self.__instance = manager.filter(pk=self.__pk).first()
122
- return self.__instance
120
+ if self.__instance is None:
121
+ if self.__allow_none:
122
+ # For Model | None, object doesn't exist - proxy becomes None-like
123
+ pass
124
+ else:
125
+ # For required Model fields, raise error
126
+ raise ValueError(f"{self.__model.__name__} with pk={self.__pk} does not exist")
127
+ return self.__instance
123
128
 
124
129
  def __repr__(self) -> str:
125
130
  return f"<_LazyModelProxy model={self.__model}, pk={self.__pk}, instance={self.__instance}>"
@@ -138,14 +143,11 @@ class _ModelBeforeValidator(Generic[M]): # noqa
138
143
  return self._get_instance(value)
139
144
 
140
145
  def _get_lazy_proxy(self, value):
141
- if value is None:
142
- # Don't create a proxy for explicit None
143
- return None
144
- elif isinstance(value, _LazyModelProxy):
146
+ if isinstance(value, _LazyModelProxy):
145
147
  instance = value._LazyModelProxy__instance or value._LazyModelProxy__pk
146
- return _LazyModelProxy(self.model, instance, model_annotation=self.model_config)
148
+ return _LazyModelProxy(self.model, instance, allow_none=self.allow_none)
147
149
  else:
148
- return _LazyModelProxy(self.model, value, model_annotation=self.model_config)
150
+ return _LazyModelProxy(self.model, value, allow_none=self.allow_none)
149
151
 
150
152
  def _get_instance(self, value):
151
153
  if value is None or isinstance(value, self.model):
@@ -168,11 +170,7 @@ class _ModelBeforeValidator(Generic[M]): # noqa
168
170
  return None
169
171
  else:
170
172
  # 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
- )
173
+ raise ValueError(f"{self.model.__name__} with pk={value} does not exist")
176
174
  return instance
177
175
 
178
176
  @classmethod
@@ -186,11 +184,7 @@ class _ModelPlainSerializer(Generic[M]): # noqa
186
184
  model: type[M]
187
185
 
188
186
  def __call__(self, value):
189
- # Handle None for Model | None fields
190
- if value is None:
191
- return None
192
- else:
193
- return value.pk
187
+ return value.pk
194
188
 
195
189
  @classmethod
196
190
  @cache
@@ -199,16 +193,20 @@ class _ModelPlainSerializer(Generic[M]): # noqa
199
193
 
200
194
 
201
195
  def _Model(
202
- model: type[models.Model], model_config: ModelConfig | None = None, allow_none: bool = False
196
+ model: type[models.Model],
197
+ model_config: ModelConfig | None = None,
198
+ allow_none: bool = False,
203
199
  ):
204
200
  assert issubclass_safe(model, models.Model)
205
201
  model_config = model_config or _DEFAULT_MODEL_CONFIG
202
+
203
+ # Determine the base type
206
204
  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
205
+ # If allow_none, make it optional
206
+ annotated_type = base_type | None if allow_none else base_type
207
+
210
208
  return Annotated[
211
- base_type,
209
+ annotated_type,
212
210
  BeforeValidator(_ModelBeforeValidator.from_modelclass(model, model_config, allow_none)),
213
211
  PlainSerializer(
214
212
  func=_ModelPlainSerializer.from_modelclass(model),
@@ -247,54 +245,45 @@ def annotate_model(annotation, *, model_config: ModelConfig | None = None):
247
245
  },
248
246
  )
249
247
  elif type_ := get_origin(annotation):
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:
248
+ if type_ is types.UnionType or type_ is Union:
271
249
  type_ = Union
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
250
+ match get_args(annotation):
251
+ case ():
252
+ return type_
253
+ case (param,):
254
+ return type_[annotate_model(param)] # type: ignore
255
+ case params:
256
+ # Check for ModelConfig in params (for Annotated types)
257
+ param_model_config = next(
258
+ (p for p in params if isinstance(p, ModelConfig)),
259
+ None,
260
+ )
261
+ # Use param ModelConfig if found, otherwise use the passed model_config
262
+ effective_model_config = param_model_config or model_config
263
+
264
+ # Check if this is a Model | None union
265
+ has_none = types.NoneType in params
266
+ model_types = [p for p in params if issubclass_safe(p, models.Model)]
267
+
268
+ # If we have Model | None, annotate the model with allow_none=True
269
+ if has_none and len(model_types) == 1:
270
+ annotated_params = []
271
+ for p in params:
272
+ if issubclass_safe(p, models.Model):
273
+ annotated_params.append(
274
+ _Model(p, effective_model_config, allow_none=True)
275
+ )
276
+ elif p is not types.NoneType:
277
+ annotated_params.append(
278
+ annotate_model(p, model_config=effective_model_config)
279
+ )
280
+ else:
281
+ annotated_params.append(p)
282
+ return type_[*annotated_params] # type: ignore
283
+ else:
284
+ return type_[
285
+ *(annotate_model(p, model_config=effective_model_config) for p in params)
286
+ ] # type: ignore
298
287
  else:
299
288
  return annotation
300
289
 
@@ -472,27 +461,10 @@ def is_basic_type(ann):
472
461
  - Literal types with simple values
473
462
 
474
463
  """
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
-
492
464
  return (
493
465
  ann in _SIMPLE_TYPES
494
466
  # __origin__ -> model in 'Annotated[model, BeforeValidator(...), PlainSerializer(...)]'
495
- or issubclass_safe(origin, models.Model)
467
+ or issubclass_safe(getattr(ann, "__origin__", None), models.Model)
496
468
  or issubclass_safe(ann, (enum.IntEnum, enum.StrEnum))
497
469
  or is_collection_annotation(ann)
498
470
  or is_literal_annotation(ann)
@@ -240,7 +240,11 @@ class Repository:
240
240
  Emit(HtmxUnhandledError(error, handler_annotations=annotations))
241
241
  ]
242
242
  yield from self._process_emited_commands(
243
- component, emited_commands, commands, during_execute=True
243
+ component,
244
+ emited_commands,
245
+ commands,
246
+ during_execute=True,
247
+ method_name=event_handler,
244
248
  )
245
249
 
246
250
  case SkipRender(component):
@@ -286,7 +290,11 @@ class Repository:
286
290
  else:
287
291
  raise
288
292
  yield from self._process_emited_commands(
289
- component, emited_commands, commands, during_execute=False
293
+ component,
294
+ emited_commands,
295
+ commands,
296
+ during_execute=False,
297
+ method_name="_handle_event",
290
298
  )
291
299
 
292
300
  case Signal(signals):
@@ -320,10 +328,13 @@ class Repository:
320
328
  emmited_commands: Iterable[Command] | None,
321
329
  commands: CommandQueue,
322
330
  during_execute: bool,
331
+ method_name: str | None = None,
323
332
  ) -> Iterable[ProcessedCommand]:
324
333
  component_was_rendered = False
325
334
  commands_to_add: list[Command] = []
326
335
  for command in emmited_commands or []:
336
+ if method_name:
337
+ logger.debug("< YIELD: %s.%s -> %s", component.hx_name, method_name, command)
327
338
  component_was_rendered = component_was_rendered or (
328
339
  isinstance(command, SkipRender | Render) and command.component.id == component.id
329
340
  )
@@ -130,6 +130,8 @@ def autodiscover_htmx_modules():
130
130
  - All Python files under htmx/ directories in apps (recursively)
131
131
  """
132
132
  for app_config in apps.get_app_configs():
133
+ if app_config.module is None:
134
+ continue
133
135
  module_name = f"{app_config.module.__name__}.htmx"
134
136
  spec = importlib.util.find_spec(module_name)
135
137
  if spec is None:
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