Skip to content

Commit 7b810d2

Browse files
edelvalleclaude
andcommitted
Merge fix-1.3.0 branch with correct Model | None implementation (1.3.4)
This merge brings the correct implementation of Model | None handling, superseding the incomplete implementations in versions 1.3.1, 1.3.2, and 1.3.3. Version 1.3.4 is now the recommended version for all users. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2 parents 4231790 + 186921f commit 7b810d2

File tree

4 files changed

+357
-420
lines changed

4 files changed

+357
-420
lines changed

CHANGELOG.md

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
### Added
11-
- **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.
12-
13-
### Fixed
14-
- **Type Safety**: Fixed pyright type errors in introspection and utils modules:
15-
- Changed `PydanticCustomError` to use literal string template instead of f-string for LiteralString type compatibility
16-
- Added None check for `app_config.module` in `autodiscover_htmx_modules()` to prevent AttributeError
10+
## [1.3.4] - 2026-01-19
1711

18-
## [1.3.3] - 2026-01-14
19-
20-
### Fixed
21-
- **Lazy Model Deleted Object Handling**: Fixed lazy models to gracefully handle deleted database objects:
22-
- Required lazy models (`Annotated[Model, ModelConfig(lazy=True)]`): Raise clear `ObjectDoesNotExist` exception when checking truthiness if object was deleted
23-
- Optional lazy models (`Annotated[Model | None, ModelConfig(lazy=True)]`): Become falsy and return `None` for field accesses when object was deleted
24-
- Accessing `.pk` on lazy proxies always works without triggering database queries, even for deleted objects
25-
- **ModelConfig Extraction**: Fixed `annotate_model()` to properly extract and apply `ModelConfig` from `Annotated` type metadata. Previously, `ModelConfig` in annotations like `Annotated[Item, ModelConfig(lazy=True)]` was ignored.
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.
2613

2714
### Added
28-
- Comprehensive test coverage for lazy model deleted object handling using real `HtmxComponent` with user-facing API
29-
- `__bool__()` method on `_LazyModelProxy` to detect deleted objects immediately when checking truthiness
30-
31-
## [1.3.2] - 2026-01-14
32-
33-
### Fixed
34-
- **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.
35-
36-
## [1.3.1] - 2026-01-14
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
3717

3818
### Fixed
39-
- **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+
- **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
4026

41-
### Added
42-
- Comprehensive test coverage for optional model handling in both lazy and non-lazy loading scenarios
43-
- Documentation for model loading optimization (lazy loading, `select_related`, `prefetch_related`) in README
44-
45-
### Changed
46-
- Query parameter handling now preserves full annotation metadata for proper serialization of `Model | None` fields
47-
- 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
4832

4933
## [1.3.0] - 2026-01-07
5034

src/djhtmx/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .middleware import middleware
22

3-
__version__ = "1.3.3"
3+
__version__ = "1.3.4"
44
__all__ = ("middleware",)

src/djhtmx/introspection.py

Lines changed: 62 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from django.db.models import Prefetch
3030
from django.utils.datastructures import MultiValueDict
3131
from pydantic import BeforeValidator, PlainSerializer, TypeAdapter
32-
from pydantic_core import PydanticCustomError
3332

3433
M = TypeVar("M", bound=models.Model)
3534

@@ -88,6 +87,7 @@ def __init__(
8887
allow_none: bool = False,
8988
):
9089
self.__model = model
90+
self.__allow_none = allow_none
9191
if value is None or isinstance(value, model):
9292
self.__instance = value
9393
self.__pk = getattr(value, "pk", None)
@@ -100,60 +100,31 @@ def __init__(
100100
else:
101101
self.__select_related = None
102102
self.__prefetch_related = None
103-
self.__allow_none = allow_none
104-
105-
def __bool__(self) -> bool:
106-
"""Check if the instance exists. Called when proxy is used in boolean context."""
107-
if self.__instance is None:
108-
self.__ensure_instance()
109-
if self.__instance is None:
110-
# Object doesn't exist
111-
if not self.__allow_none:
112-
# Required field - raise exception
113-
from django.core.exceptions import ObjectDoesNotExist
114-
115-
raise ObjectDoesNotExist(
116-
f"{self.__model.__name__} with pk={self.__pk} does not exist "
117-
"(object may have been deleted)"
118-
)
119-
# Optional field - return False (proxy is falsy)
120-
return False
121-
return True
122103

123104
def __getattr__(self, name: str) -> Any:
124105
if name == "pk":
125106
return self.__pk
126107
if self.__instance is None:
127108
self.__ensure_instance()
128-
if self.__instance is None:
129-
# Object doesn't exist (was deleted or never existed)
130-
if self.__allow_none:
131-
# Optional field (Model | None) - return None gracefully
132-
return None
133-
else:
134-
# Required field (Model) - raise explicit exception
135-
from django.core.exceptions import ObjectDoesNotExist
136-
137-
raise ObjectDoesNotExist(
138-
f"{self.__model.__name__} with pk={self.__pk} does not exist "
139-
"(object may have been deleted)"
140-
)
141109
return getattr(self.__instance, name)
142110

143111
def __ensure_instance(self):
144-
if self.__instance:
145-
return self.__instance
146-
elif self.__pk is None:
147-
# If pk is None, don't try to load anything
148-
return None
149-
else:
112+
if not self.__instance:
150113
manager = self.__model.objects
151114
if select_related := self.__select_related:
152115
manager = manager.select_related(*select_related)
153116
if prefetch_related := self.__prefetch_related:
154117
manager = manager.prefetch_related(*prefetch_related)
118+
# Use filter().first() instead of get() to avoid exceptions
155119
self.__instance = manager.filter(pk=self.__pk).first()
156-
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
157128

158129
def __repr__(self) -> str:
159130
return f"<_LazyModelProxy model={self.__model}, pk={self.__pk}, instance={self.__instance}>"
@@ -172,18 +143,11 @@ def __call__(self, value):
172143
return self._get_instance(value)
173144

174145
def _get_lazy_proxy(self, value):
175-
if value is None:
176-
# Don't create a proxy for explicit None
177-
return None
178-
elif isinstance(value, _LazyModelProxy):
146+
if isinstance(value, _LazyModelProxy):
179147
instance = value._LazyModelProxy__instance or value._LazyModelProxy__pk
180-
return _LazyModelProxy(
181-
self.model, instance, model_annotation=self.model_config, allow_none=self.allow_none
182-
)
148+
return _LazyModelProxy(self.model, instance, allow_none=self.allow_none)
183149
else:
184-
return _LazyModelProxy(
185-
self.model, value, model_annotation=self.model_config, allow_none=self.allow_none
186-
)
150+
return _LazyModelProxy(self.model, value, allow_none=self.allow_none)
187151

188152
def _get_instance(self, value):
189153
if value is None or isinstance(value, self.model):
@@ -206,11 +170,7 @@ def _get_instance(self, value):
206170
return None
207171
else:
208172
# For required Model fields, raise validation error
209-
raise PydanticCustomError(
210-
"model_not_found",
211-
"{model_name} with pk={pk} does not exist",
212-
{"pk": value, "model_name": self.model.__name__},
213-
)
173+
raise ValueError(f"{self.model.__name__} with pk={value} does not exist")
214174
return instance
215175

216176
@classmethod
@@ -224,11 +184,7 @@ class _ModelPlainSerializer(Generic[M]): # noqa
224184
model: type[M]
225185

226186
def __call__(self, value):
227-
# Handle None for Model | None fields
228-
if value is None:
229-
return None
230-
else:
231-
return value.pk
187+
return value.pk
232188

233189
@classmethod
234190
@cache
@@ -237,16 +193,20 @@ def from_modelclass(cls, model: type[M]):
237193

238194

239195
def _Model(
240-
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,
241199
):
242200
assert issubclass_safe(model, models.Model)
243201
model_config = model_config or _DEFAULT_MODEL_CONFIG
202+
203+
# Determine the base type
244204
base_type = model if not model_config.lazy else _LazyModelProxy[model]
245-
# If allow_none is True, the base type can be None (for Model | None unions)
246-
if allow_none:
247-
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+
248208
return Annotated[
249-
base_type,
209+
annotated_type,
250210
BeforeValidator(_ModelBeforeValidator.from_modelclass(model, model_config, allow_none)),
251211
PlainSerializer(
252212
func=_ModelPlainSerializer.from_modelclass(model),
@@ -285,63 +245,45 @@ def annotate_model(annotation, *, model_config: ModelConfig | None = None):
285245
},
286246
)
287247
elif type_ := get_origin(annotation):
288-
# Handle Annotated types like Annotated[Item | None, Query("editing")]
289-
if type_ is Annotated:
290-
args = get_args(annotation)
291-
if args:
292-
# Process the base type (first arg) and keep other metadata
293-
base_type = args[0]
294-
metadata = args[1:]
295-
296-
# Extract ModelConfig from metadata if present
297-
extracted_model_config = next(
298-
(m for m in metadata if isinstance(m, ModelConfig)),
248+
if type_ is types.UnionType or type_ is Union:
249+
type_ = Union
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)),
299259
None,
300260
)
301-
# Use extracted config, falling back to passed parameter
302-
config_to_use = extracted_model_config or model_config
303-
304-
processed_base = annotate_model(base_type, model_config=config_to_use)
305-
306-
# If processed_base is also Annotated, merge the metadata
307-
if get_origin(processed_base) is Annotated:
308-
processed_args = get_args(processed_base)
309-
inner_base = processed_args[0]
310-
inner_metadata = processed_args[1:]
311-
# Merge: inner metadata first, then original metadata
312-
return Annotated[inner_base, *inner_metadata, *metadata] # type: ignore
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
313283
else:
314-
# Reconstruct the Annotated with processed base type
315-
return Annotated[processed_base, *metadata] # type: ignore
316-
return annotation
317-
elif type_ is types.UnionType or type_ is Union:
318-
type_ = Union
319-
match get_args(annotation):
320-
case ():
321-
return type_
322-
case (param,):
323-
return type_[annotate_model(param)] # type: ignore
324-
case params:
325-
model_annotation = next(
326-
(p for p in params if isinstance(p, ModelConfig)),
327-
None,
328-
)
329-
# Check if this is a Model | None union
330-
has_none = types.NoneType in params
331-
model_params = [p for p in params if issubclass_safe(p, models.Model)]
332-
333-
if has_none and len(model_params) == 1:
334-
# This is a Model | None union - use allow_none=True
335-
# Use the model_config parameter passed to annotate_model, not model_annotation from Union params
336-
model = model_params[0]
337-
return _Model(model, model_config or model_annotation, allow_none=True)
338-
else:
339-
# Regular union - process each param independently
340-
return type_[
341-
*(annotate_model(p, model_config=model_annotation) for p in params)
342-
] # type: ignore
343-
# Other generic types (list, dict, defaultdict, etc.) - return as-is
344-
return annotation
284+
return type_[
285+
*(annotate_model(p, model_config=effective_model_config) for p in params)
286+
] # type: ignore
345287
else:
346288
return annotation
347289

@@ -519,27 +461,10 @@ def is_basic_type(ann):
519461
- Literal types with simple values
520462
521463
"""
522-
# Check if it's a Union (e.g., Item | None)
523-
origin_type = get_origin(ann)
524-
if origin_type in (types.UnionType, Union):
525-
args = get_args(ann)
526-
# If it's Model | None, consider it a basic type
527-
model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
528-
if model_types and types.NoneType in args:
529-
return True
530-
531-
# Check for Annotated[Model, ...] or Annotated[Model | None, ...] pattern
532-
origin = getattr(ann, "__origin__", None)
533-
if origin is not None and get_origin(origin) in (types.UnionType, Union):
534-
args = get_args(origin)
535-
model_types = [arg for arg in args if issubclass_safe(arg, models.Model)]
536-
if model_types and types.NoneType in args:
537-
return True
538-
539464
return (
540465
ann in _SIMPLE_TYPES
541466
# __origin__ -> model in 'Annotated[model, BeforeValidator(...), PlainSerializer(...)]'
542-
or issubclass_safe(origin, models.Model)
467+
or issubclass_safe(getattr(ann, "__origin__", None), models.Model)
543468
or issubclass_safe(ann, (enum.IntEnum, enum.StrEnum))
544469
or is_collection_annotation(ann)
545470
or is_literal_annotation(ann)

0 commit comments

Comments
 (0)