djhtmx 1.3.0__py3-none-any.whl → 1.3.1__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 CHANGED
@@ -1,4 +1,4 @@
1
1
  from .middleware import middleware
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.3.1"
4
4
  __all__ = ("middleware",)
djhtmx/introspection.py CHANGED
@@ -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,52 @@ 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
230
296
  else:
231
297
  return annotation
232
298
 
@@ -404,10 +470,27 @@ def is_basic_type(ann):
404
470
  - Literal types with simple values
405
471
 
406
472
  """
473
+ # Check if it's a Union (e.g., Item | None)
474
+ origin_type = get_origin(ann)
475
+ if origin_type in (types.UnionType, Union):
476
+ args = get_args(ann)
477
+ # If it's Model | None, consider it a basic type
478
+ model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
479
+ if model_types and types.NoneType in args:
480
+ return True
481
+
482
+ # Check for Annotated[Model, ...] or Annotated[Model | None, ...] pattern
483
+ origin = getattr(ann, "__origin__", None)
484
+ if origin is not None and get_origin(origin) in (types.UnionType, Union):
485
+ args = get_args(origin)
486
+ model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
487
+ if model_types and types.NoneType in args:
488
+ return True
489
+
407
490
  return (
408
491
  ann in _SIMPLE_TYPES
409
492
  # __origin__ -> model in 'Annotated[model, BeforeValidator(...), PlainSerializer(...)]'
410
- or issubclass_safe(getattr(ann, "__origin__", None), models.Model)
493
+ or issubclass_safe(origin, models.Model)
411
494
  or issubclass_safe(ann, (enum.IntEnum, enum.StrEnum))
412
495
  or is_collection_annotation(ann)
413
496
  or is_literal_annotation(ann)
djhtmx/query.py CHANGED
@@ -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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: djhtmx
3
- Version: 1.3.0
3
+ Version: 1.3.1
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....
@@ -1,4 +1,4 @@
1
- djhtmx/__init__.py,sha256=ydHthne-W5gnbhYvHNryVOK740-7EmOWFeVGjQ_SgA4,84
1
+ djhtmx/__init__.py,sha256=sFHexB1yrCT8QBiug5AGMLj4zmcax_WDdxeUL6DYYoQ,84
2
2
  djhtmx/apps.py,sha256=hAyjzmInEstxLY9k8Qn58LvNlezgQLx5_NqyVL1WwYs,323
3
3
  djhtmx/command_queue.py,sha256=LSUkb2YMRt1lDyOg6WP7PoHsObynec0B55JyFtcshT0,5090
4
4
  djhtmx/commands.py,sha256=UxXbARd4Teetjh_zjvAWgI2KNbvdETH-WrGf4qD9Xr8,1206
@@ -7,10 +7,10 @@ djhtmx/consumer.py,sha256=0Yh8urgMH3khA6_pWeY049w3jqHWZL_K9dErOhNctQA,2898
7
7
  djhtmx/context.py,sha256=cWvz8Z0MC6x_G8sn5mvoH8Hu38qReY21_eNdThuba1A,214
8
8
  djhtmx/exceptions.py,sha256=UtyE1N-52OmzwgRM9xFxjUuhHTMDvD7Oy3rNpgthLcs,47
9
9
  djhtmx/global_events.py,sha256=bYb8WmQn_WsZ_Dadr0pGiGOPia01K-VanPpM97Lt324,342
10
- djhtmx/introspection.py,sha256=flVolO6xZiXsxMm876ZCEcWRmVfFsJWpAVmIdfcJNf8,13734
10
+ djhtmx/introspection.py,sha256=HGtKTFNG2FhJkLftQC7bUiJPhyyBZtix0BCOdlraTqE,17807
11
11
  djhtmx/json.py,sha256=7cjwWIJj7e0dk54INKYZJe6zKkIW7wlsNSlD05cbXfY,1374
12
12
  djhtmx/middleware.py,sha256=JuMtv9ZnpungTvQ1qD2Lg6LiFPB3knQlA1ERgH4iGl0,1274
13
- djhtmx/query.py,sha256=UyjN1jokh4wTwQJxcRwA9f-Zn-A7A4GLToeGrCnPhKA,6674
13
+ djhtmx/query.py,sha256=GKubKKZ1Z6krzLjG4GZpcxAyPy-pCMCtyfSVfXrpy90,6855
14
14
  djhtmx/repo.py,sha256=Qw8t9i5_upfvYJyUr4Cb9hZoI9WUHhNOpszxjhtx9r4,22478
15
15
  djhtmx/settings.py,sha256=Iti4LkcKBTy-dNyCZxFH_cUp56aTcXjB5ftbssWyDnU,1318
16
16
  djhtmx/testing.py,sha256=QmZHrH6Up8uUxkVOHM6CyfocU774GL-lopJvM2X9Mkw,8369
@@ -31,7 +31,7 @@ djhtmx/templates/htmx/headers.html,sha256=z7r9klwBDXDyjbHrzatZeHDvXB2DaZhgu55CFb
31
31
  djhtmx/templates/htmx/lazy.html,sha256=LfAThtKmFj-lCUZ7JWF_sC1Y6XsIpEz8A3IgWASn-J8,52
32
32
  djhtmx/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
33
  djhtmx/templatetags/htmx.py,sha256=-qFqz4T9mCJocG9XIIey81cCYwk07XUd_DMpxNdmbsM,8397
34
- djhtmx-1.3.0.dist-info/METADATA,sha256=uLzOMfncYHYicOiym9dofXHEMDSYu8CdZD9vOQP-PxQ,32245
35
- djhtmx-1.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
36
- djhtmx-1.3.0.dist-info/licenses/LICENSE,sha256=kCi_iSBUGsRZInQn96w7LXYzjiRjZ8FXl6vP--mFRPk,1085
37
- djhtmx-1.3.0.dist-info/RECORD,,
34
+ djhtmx-1.3.1.dist-info/METADATA,sha256=8ZRSOBEA730-0kym22awNPktiV6c715fDTvON-DI5o0,33050
35
+ djhtmx-1.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
36
+ djhtmx-1.3.1.dist-info/licenses/LICENSE,sha256=kCi_iSBUGsRZInQn96w7LXYzjiRjZ8FXl6vP--mFRPk,1085
37
+ djhtmx-1.3.1.dist-info/RECORD,,
File without changes