Coverage for src/paperap/models/abstract/model.py: 87%
306 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: base.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.8
10 Author: Jess Mann
11 Email: jess@jmann.me
12 Copyright (c) 2025 Jess Mann
14----------------------------------------------------------------------------
16 LAST MODIFIED:
18 2025-03-04 By Jess Mann
20"""
22from __future__ import annotations
24import concurrent.futures
25import logging
26import threading
27import time
28import types
29from abc import ABC, abstractmethod
30from datetime import datetime
31from decimal import Decimal
32from enum import StrEnum
33from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, Optional, Self, TypedDict, cast, override
35import pydantic
36from pydantic import Field, PrivateAttr
37from typing_extensions import TypeVar
38from yarl import URL
40from paperap.const import FilteringStrategies, ModelStatus
41from paperap.exceptions import APIError, ConfigurationError, RequestError, ResourceNotFoundError
42from paperap.models.abstract.meta import StatusContext
43from paperap.models.abstract.queryset import BaseQuerySet
44from paperap.signals import registry
46if TYPE_CHECKING:
47 from paperap.client import PaperlessClient
48 from paperap.resources.base import BaseResource, StandardResource
50logger = logging.getLogger(__name__)
52_Self = TypeVar("_Self", bound="BaseModel")
55class ModelConfigType(TypedDict):
56 populate_by_name: bool
57 validate_assignment: bool
58 validate_default: bool
59 use_enum_values: bool
60 extra: Literal["ignore"]
61 arbitrary_types_allowed: bool
64BASE_MODEL_CONFIG: ModelConfigType = {
65 "populate_by_name": True,
66 "validate_assignment": True,
67 "validate_default": True,
68 "use_enum_values": True,
69 "extra": "ignore",
70 "arbitrary_types_allowed": True,
71}
74class BaseModel(pydantic.BaseModel, ABC):
75 """
76 Base model for all Paperless-ngx API objects.
78 Provides automatic serialization, deserialization, and API interactions
79 with minimal configuration needed.
81 Attributes:
82 _meta: Metadata for the model, including filtering and resource information.
83 _save_lock: Lock for saving operations.
84 _pending_save: Future object for pending save operations.
86 Raises:
87 ValueError: If resource is not provided.
89 """
91 _meta: "ClassVar[Meta[Self]]"
92 _save_lock: threading.RLock = PrivateAttr(default_factory=threading.RLock)
93 _pending_save: concurrent.futures.Future | None = PrivateAttr(default=None)
94 _save_executor: concurrent.futures.ThreadPoolExecutor | None = None
95 # Updating attributes will not trigger save()
96 _status: ModelStatus = ModelStatus.INITIALIZING # The last data we retrieved from the db
97 # this is used to calculate if the model is dirty
98 _original_data: dict[str, Any] = {}
99 # The last data we sent to the db to save
100 # This is used to determine if the model has been changed in the time it took to perform a save
101 _saved_data: dict[str, Any] = {}
103 if TYPE_CHECKING:
104 # This is actually set in Meta, but declaring it here helps pydantic handle dynamic __init__ arguments
105 # TODO: Provide a better solution for pydantic, so there isn't confusion with intellisense
106 resource: "BaseResource[Self]"
108 class Meta(Generic[_Self]):
109 """
110 Metadata for the Model.
112 Attributes:
113 name: The name of the model.
114 read_only_fields: Fields that should not be modified.
115 filtering_disabled: Fields disabled for filtering.
116 filtering_fields: Fields allowed for filtering.
117 supported_filtering_params: Params allowed during queryset filtering.
118 blacklist_filtering_params: Params disallowed during queryset filtering.
119 filtering_strategies: Strategies for filtering.
120 resource: The BaseResource instance.
121 queryset: The type of QuerySet for the model.
123 Raises:
124 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set.
126 """
128 # The name of the model.
129 # It will default to the classname
130 name: str
131 # Fields that should not be modified. These will be appended to read_only_fields for all parent classes.
132 read_only_fields: ClassVar[set[str]] = set()
133 # Fields that are disabled by Paperless NGX for filtering.
134 # These will be appended to filtering_disabled for all parent classes.
135 filtering_disabled: ClassVar[set[str]] = set()
136 # Fields allowed for filtering. Generated automatically during class init.
137 filtering_fields: ClassVar[set[str]] = set()
138 # If set, only these params will be allowed during queryset filtering. (e.g. {"content__icontains", "id__gt"})
139 # These will be appended to supported_filtering_params for all parent classes.
140 supported_filtering_params: ClassVar[set[str]] = set()
141 # If set, these params will be disallowed during queryset filtering (e.g. {"content__icontains", "id__gt"})
142 # These will be appended to blacklist_filtering_params for all parent classes.
143 blacklist_filtering_params: ClassVar[set[str]] = set()
144 # Strategies for filtering.
145 # This determines which of the above lists will be used to allow or deny filters to QuerySets.
146 filtering_strategies: ClassVar[set[FilteringStrategies]] = {FilteringStrategies.BLACKLIST}
147 resource: "BaseResource"
148 queryset: type[BaseQuerySet[_Self]] = BaseQuerySet
149 # A map of field names to their attribute names.
150 # Parser uses this to transform input and output data.
151 # This will be populated from all parent classes.
152 field_map: dict[str, str] = {}
153 # If true, updating attributes will trigger save(). If false, save() must be called manually
154 # True or False will override client.settings.save_on_write (PAPERLESS_SAVE_ON_WRITE)
155 # None will respect client.settings.save_on_write
156 save_on_write: bool | None = None
157 save_timeout: int = PrivateAttr(default=60) # seconds
159 __type_hints_cache__: dict[str, type] = {}
161 def __init__(self, model: type[_Self]):
162 self.model = model
164 # Validate filtering strategies
165 if all(
166 x in self.filtering_strategies for x in (FilteringStrategies.ALLOW_ALL, FilteringStrategies.ALLOW_NONE)
167 ):
168 raise ValueError(f"Cannot have ALLOW_ALL and ALLOW_NONE filtering strategies in {self.model.__name__}")
170 super().__init__()
172 def filter_allowed(self, filter_param: str) -> bool:
173 """
174 Check if a filter is allowed based on the filtering strategies.
176 Args:
177 filter_param: The filter parameter to check.
179 Returns:
180 True if the filter is allowed, False otherwise.
182 """
183 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies:
184 return True
186 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies:
187 return False
189 # If we have a whitelist, check if the filter_param is in it
190 if FilteringStrategies.WHITELIST in self.filtering_strategies:
191 if self.supported_filtering_params and filter_param not in self.supported_filtering_params:
192 return False
193 # Allow other rules to fire
195 # If we have a blacklist, check if the filter_param is in it
196 if FilteringStrategies.BLACKLIST in self.filtering_strategies:
197 if self.blacklist_filtering_params and filter_param in self.blacklist_filtering_params:
198 return False
199 # Allow other rules to fire
201 # Check if the filtering key is disabled
202 split_key = filter_param.split("__")
203 if len(split_key) > 1:
204 field, _lookup = split_key[-2:]
205 else:
206 field, _lookup = filter_param, None
208 # If key is in filtering_disabled, throw an error
209 if field in self.filtering_disabled:
210 return False
212 # Not disabled, so it's allowed
213 return True
215 @classmethod
216 def __init_subclass__(cls, **kwargs: Any) -> None:
217 """
218 Initialize subclass and set up metadata.
220 Args:
221 **kwargs: Additional keyword arguments.
223 """
224 super().__init_subclass__(**kwargs)
225 # Ensure the subclass has its own Meta definition.
226 # If not, create a new one inheriting from the parent’s Meta.
227 # If the subclass hasn't defined its own Meta, auto-generate one.
228 if "Meta" not in cls.__dict__:
229 top_meta: type[BaseModel.Meta[Self]] | None = None
230 # Iterate over ancestors to get the top-most explicitly defined Meta.
231 for base in cls.__mro__[1:]:
232 if "Meta" in base.__dict__:
233 top_meta = cast(type[BaseModel.Meta[Self]], base.Meta)
234 break
235 if top_meta is None:
236 # This should never happen.
237 raise ConfigurationError(f"Meta class not found in {cls.__name__} or its bases")
239 # Create a new Meta class that inherits from the top-most Meta.
240 meta_attrs = {
241 k: v
242 for k, v in vars(top_meta).items()
243 if not k.startswith("_") # Avoid special attributes like __parameters__
244 }
245 cls.Meta = type("Meta", (top_meta,), meta_attrs) # type: ignore # mypy complains about setting to a type
246 logger.debug(
247 "Auto-generated Meta for %s inheriting from %s",
248 cls.__name__,
249 top_meta.__name__,
250 )
252 # Append read_only_fields from all parents to Meta
253 # Same with filtering_disabled
254 # Retrieve filtering_fields from the attributes of the class
255 read_only_fields = (cls.Meta.read_only_fields or set[str]()).copy()
256 filtering_disabled = (cls.Meta.filtering_disabled or set[str]()).copy()
257 filtering_fields = set(cls.__annotations__.keys())
258 supported_filtering_params = cls.Meta.supported_filtering_params
259 blacklist_filtering_params = cls.Meta.blacklist_filtering_params
260 field_map = cls.Meta.field_map
261 for base in cls.__bases__:
262 _meta: BaseModel.Meta[Self] | None
263 if _meta := getattr(base, "Meta", None):
264 if hasattr(_meta, "read_only_fields"):
265 read_only_fields.update(_meta.read_only_fields)
266 if hasattr(_meta, "filtering_disabled"):
267 filtering_disabled.update(_meta.filtering_disabled)
268 if hasattr(_meta, "filtering_fields"):
269 filtering_fields.update(_meta.filtering_fields)
270 if hasattr(_meta, "supported_filtering_params"):
271 supported_filtering_params.update(_meta.supported_filtering_params)
272 if hasattr(_meta, "blacklist_filtering_params"):
273 blacklist_filtering_params.update(_meta.blacklist_filtering_params)
274 if hasattr(_meta, "field_map"):
275 field_map.update(_meta.field_map)
277 cls.Meta.read_only_fields = read_only_fields
278 cls.Meta.filtering_disabled = filtering_disabled
279 # excluding filtering_disabled from filtering_fields
280 cls.Meta.filtering_fields = filtering_fields - filtering_disabled
281 cls.Meta.supported_filtering_params = supported_filtering_params
282 cls.Meta.blacklist_filtering_params = blacklist_filtering_params
283 cls.Meta.field_map = field_map
285 # Instantiate _meta
286 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776)
288 # Set name defaults
289 if not hasattr(cls._meta, "name"):
290 cls._meta.name = cls.__name__.lower()
292 # Configure Pydantic behavior
293 # type ignore because mypy complains about non-required keys
294 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG) # type: ignore
296 def __init__(self, resource: "BaseResource[Self] | None" = None, **data: Any) -> None:
297 """
298 Initialize the model with resource and data.
300 Args:
301 resource: The BaseResource instance.
302 **data: Additional data to initialize the model.
304 Raises:
305 ValueError: If resource is not provided.
307 """
308 super().__init__(**data)
310 if resource:
311 self._meta.resource = resource
313 if not getattr(self._meta, "resource", None):
314 raise ValueError(
315 f"Resource required. Initialize resource for {self.__class__.__name__} before instantiating models."
316 )
318 @property
319 def _resource(self) -> "BaseResource[Self]":
320 """
321 Get the resource associated with this model.
323 Returns:
324 The BaseResource instance.
326 """
327 return self._meta.resource
329 @property
330 def _client(self) -> "PaperlessClient":
331 """
332 Get the client associated with this model.
334 Returns:
335 The PaperlessClient instance.
337 """
338 return self._meta.resource.client
340 @property
341 def save_executor(self) -> concurrent.futures.ThreadPoolExecutor:
342 if not self._save_executor:
343 self._save_executor = concurrent.futures.ThreadPoolExecutor(
344 max_workers=5, thread_name_prefix="model_save_worker"
345 )
346 return self._save_executor
348 def cleanup(self) -> None:
349 """Clean up resources used by the model class."""
350 if self._save_executor:
351 self._save_executor.shutdown(wait=True)
352 self._save_executor = None
354 @override
355 def model_post_init(self, __context) -> None:
356 super().model_post_init(__context)
358 # Save original_data to support dirty fields
359 self._original_data = self.model_dump()
361 # Allow updating attributes to trigger save() automatically
362 self._status = ModelStatus.READY
364 super().model_post_init(__context)
366 @classmethod
367 def from_dict(cls, data: dict[str, Any]) -> Self:
368 """
369 Create a model instance from API response data.
371 Args:
372 data: Dictionary containing the API response data.
374 Returns:
375 A model instance initialized with the provided data.
377 Examples:
378 # Create a Document instance from API data
379 doc = Document.from_dict(api_data)
381 """
382 return cls._meta.resource.parse_to_model(data)
384 def to_dict(
385 self,
386 *,
387 include_read_only: bool = True,
388 exclude_none: bool = False,
389 exclude_unset: bool = True,
390 ) -> dict[str, Any]:
391 """
392 Convert the model to a dictionary for API requests.
394 Args:
395 include_read_only: Whether to include read-only fields.
396 exclude_none: Whether to exclude fields with None values.
397 exclude_unset: Whether to exclude fields that are not set.
399 Returns:
400 A dictionary with model data ready for API submission.
402 Examples:
403 # Convert a Document instance to a dictionary
404 data = doc.to_dict()
406 """
407 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields)
409 return self.model_dump(
410 exclude=exclude,
411 exclude_none=exclude_none,
412 exclude_unset=exclude_unset,
413 )
415 def dirty_fields(self, comparison: Literal["saved", "db", "both"] = "both") -> dict[str, tuple[Any, Any]]:
416 """
417 Show which fields have changed since last update from the paperless ngx db.
419 Args:
420 comparison:
421 Specify the data to compare ('saved' or 'db').
422 Db is the last data retrieved from Paperless NGX
423 Saved is the last data sent to Paperless NGX to be saved
425 Returns:
426 A dictionary {field: (original_value, new_value)} of fields that have
427 changed since last update from the paperless ngx db.
429 """
430 current_data = self.model_dump()
432 if comparison == "saved":
433 compare_dict = self._saved_data
434 elif comparison == "db":
435 compare_dict = self._original_data
436 else:
437 # For 'both', we want to compare against both original and saved data
438 # A field is dirty if it differs from either original or saved data
439 compare_dict = {}
440 for field in set(list(self._original_data.keys()) + list(self._saved_data.keys())):
441 # Prefer original data (from DB) over saved data when both exist
442 compare_dict[field] = self._original_data.get(field, self._saved_data.get(field))
444 return {
445 field: (compare_dict[field], current_data[field])
446 for field in current_data
447 if field in compare_dict and compare_dict[field] != current_data[field]
448 }
450 def is_dirty(self, comparison: Literal["saved", "db", "both"] = "both") -> bool:
451 """
452 Check if any field has changed since last update from the paperless ngx db.
454 Args:
455 comparison:
456 Specify the data to compare ('saved' or 'db').
457 Db is the last data retrieved from Paperless NGX
458 Saved is the last data sent to Paperless NGX to be saved
460 Returns:
461 True if any field has changed.
463 """
464 return bool(self.dirty_fields(comparison=comparison))
466 @classmethod
467 def create(cls, **kwargs: Any) -> Self:
468 """
469 Create a new model instance.
471 Args:
472 **kwargs: Field values to set.
474 Returns:
475 A new model instance.
477 Examples:
478 # Create a new Document instance
479 doc = Document.create(filename="example.pdf", contents=b"PDF data")
481 """
482 # TODO save
483 return cls(**kwargs)
485 def update_locally(self, *, from_db: bool | None = None, skip_changed_fields: bool = False, **kwargs: Any) -> None:
486 """
487 Update model attributes without triggering automatic save.
489 Args:
490 **kwargs: Field values to update
492 Returns:
493 Self with updated values
495 """
496 from_db = from_db if from_db is not None else False
498 # Avoid infinite saving loops
499 with StatusContext(self, ModelStatus.UPDATING):
500 # If the field contains unsaved changes, skip updating it
501 # Determine unsaved changes based on the dirty fields before we last called save
502 if skip_changed_fields:
503 unsaved_changes = self.dirty_fields(comparison="saved")
504 kwargs = {k: v for k, v in kwargs.items() if k not in unsaved_changes}
506 for name, value in kwargs.items():
507 setattr(self, name, value)
509 # Dirty has been reset
510 if from_db:
511 self._original_data = self.model_dump()
513 def update(self, **kwargs: Any) -> None:
514 """
515 Update this model with new values.
517 Subclasses implement this with auto-saving features.
518 However, base BaseModel instances simply call update_locally.
520 Args:
521 **kwargs: New field values.
523 Examples:
524 # Update a Document instance
525 doc.update(filename="new_example.pdf")
527 """
528 # Since we have no id, we can't save. Therefore, all updates are silent updates
529 # subclasses may implement this.
530 self.update_locally(**kwargs)
532 @abstractmethod
533 def is_new(self) -> bool:
534 """
535 Check if this model represents a new (unsaved) object.
537 Returns:
538 True if the model is new, False otherwise.
540 Examples:
541 # Check if a Document instance is new
542 is_new = doc.is_new()
544 """
546 def should_save_on_write(self) -> bool:
547 """
548 Check if the model should save on attribute write, factoring in the client settings.
549 """
550 if self._meta.save_on_write is not None:
551 return self._meta.save_on_write
552 return self._resource.client.settings.save_on_write
554 def enable_save_on_write(self) -> None:
555 """
556 Enable automatic saving on attribute write.
557 """
558 self._meta.save_on_write = True
560 def disable_save_on_write(self) -> None:
561 """
562 Disable automatic saving on attribute write.
563 """
564 self._meta.save_on_write = False
566 def matches_dict(self, data: dict[str, Any]) -> bool:
567 """
568 Check if the model matches the provided data.
570 Args:
571 data: Dictionary containing the data to compare.
573 Returns:
574 True if the model matches the data, False otherwise.
576 Examples:
577 # Check if a Document instance matches API data
578 matches = doc.matches_dict(api_data)
580 """
581 return self.to_dict() == data
583 @override
584 def __str__(self) -> str:
585 """
586 Human-readable string representation.
588 Returns:
589 A string representation of the model.
591 """
592 return f"{self._meta.name.capitalize()}"
595class StandardModel(BaseModel, ABC):
596 """
597 Standard model for Paperless-ngx API objects with an ID field.
599 Attributes:
600 id: Unique identifier for the model.
602 """
604 id: int = Field(description="Unique identifier from Paperless NGX", default=0)
606 class Meta(BaseModel.Meta):
607 """
608 Metadata for the StandardModel.
610 Attributes:
611 read_only_fields: Fields that should not be modified.
612 supported_filtering_params: Params allowed during queryset filtering.
614 """
616 # Fields that should not be modified
617 read_only_fields: ClassVar[set[str]] = {"id"}
618 supported_filtering_params = {"id__in", "id"}
620 @override
621 def update(self, **kwargs: Any) -> None:
622 """
623 Update this model with new values and save changes.
625 NOTE: new instances will not be saved automatically.
626 (I'm not sure if that's the right design decision or not)
628 Args:
629 **kwargs: New field values.
631 """
632 # Hold off on saving until all updates are complete
633 self.update_locally(**kwargs)
634 if not self.is_new():
635 self.save()
637 def save(self):
638 return self.save_sync()
640 def save_sync(self) -> None:
641 """
642 Save this model instance synchronously.
644 Changes are sent to the server immediately, and the model is updated
645 when the server responds.
647 Raises:
648 ResourceNotFoundError: If the resource doesn't exist on the server
649 RequestError: If there's a communication error with the server
650 PermissionError: If the user doesn't have permission to update the resource
652 """
653 if self._status == ModelStatus.SAVING:
654 return
656 # Only start a save if there are changes
657 if not self.is_dirty():
658 return
660 with StatusContext(self, ModelStatus.SAVING):
661 # Prepare and send the update to the server
662 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
663 self._saved_data = {**current_data}
665 registry.emit(
666 "model.save:before",
667 "Fired before the model data is sent to paperless ngx to be saved.",
668 kwargs={"model": self, "current_data": current_data},
669 )
671 new_model = self._meta.resource.update(self)
673 if not new_model:
674 logger.warning(f"Result of save was none for model id {self.id}")
675 return
677 if not isinstance(new_model, StandardModel):
678 # This should never happen
679 logger.error("Result of save was not a StandardModel instance")
680 return
682 try:
683 # Update the model with the server response
684 new_data = new_model.to_dict()
685 self.update_locally(from_db=True, **new_data)
687 registry.emit(
688 "model.save:after",
689 "Fired after the model data is saved in paperless ngx.",
690 kwargs={"model": self, "updated_data": new_data},
691 )
693 except APIError as e:
694 logger.error(f"API error during save of {self}: {e}")
695 registry.emit(
696 "model.save:error",
697 "Fired when a network error occurs during save.",
698 kwargs={"model": self, "error": e},
699 )
701 except Exception as e:
702 # Log unexpected errors but don't swallow them
703 logger.exception(f"Unexpected error during save of {self}")
704 registry.emit(
705 "model.save:error",
706 "Fired when an unexpected error occurs during save.",
707 kwargs={"model": self, "error": e},
708 )
709 # Re-raise so the executor can handle it properly
710 raise
712 def save_async(self) -> None:
713 """
714 Save this model instance asynchronously.
716 Changes are sent to the server in a background thread, and the model
717 is updated when the server responds.
718 """
719 if self._status == ModelStatus.SAVING:
720 return
722 # Only start a save if there are changes
723 if not self.is_dirty():
724 if hasattr(self, "_save_lock") and self._save_lock._is_owned(): # type: ignore # temporary TODO
725 self._save_lock.release()
726 return
728 self._status = ModelStatus.SAVING
729 self._save_lock.acquire(timeout=30)
731 # If there's a pending save, skip saving until it finishes
732 if self._pending_save is not None and not self._pending_save.done():
733 return
735 # Start a new save operation
736 executor = self.save_executor
737 future = executor.submit(self._perform_save_async)
738 self._pending_save = future
739 future.add_done_callback(self._handle_save_result_async)
741 def _perform_save_async(self) -> Optional["StandardModel"]:
742 """
743 Perform the actual save operation.
745 Returns:
746 The updated model from the server or None if no save was needed.
748 Raises:
749 ResourceNotFoundError: If the resource doesn't exist on the server
750 RequestError: If there's a communication error with the server
751 PermissionError: If the user doesn't have permission to update the resource
753 """
754 # Prepare and send the update to the server
755 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
756 self._saved_data = {**current_data}
758 registry.emit(
759 "model.save:before",
760 "Fired before the model data is sent to paperless ngx to be saved.",
761 kwargs={"model": self, "current_data": current_data},
762 )
764 return self._meta.resource.update(self)
766 def _handle_save_result_async(self, future: concurrent.futures.Future) -> None:
767 """
768 Handle the result of an asynchronous save operation.
770 Args:
771 future: The completed Future object containing the save result.
773 """
774 try:
775 # Get the result with a timeout
776 new_model = future.result(timeout=self._meta.save_timeout)
778 if not new_model:
779 logger.warning(f"Result of save was none for model id {self.id}")
780 return
782 if not isinstance(new_model, StandardModel):
783 # This should never happen
784 logger.error("Result of save was not a StandardModel instance")
785 return
787 # Update the model with the server response
788 new_data = new_model.to_dict()
789 # Use direct attribute setting instead of update_locally to avoid mocking issues
790 with StatusContext(self, ModelStatus.UPDATING):
791 for name, value in new_data.items():
792 if self.is_dirty("saved") and name in self.dirty_fields("saved"):
793 continue # Skip fields changed during save
794 setattr(self, name, value)
795 # Mark as from DB
796 self._original_data = self.model_dump()
798 registry.emit(
799 "model.save:after",
800 "Fired after the model data is saved in paperless ngx.",
801 kwargs={"model": self, "updated_data": new_data},
802 )
804 except concurrent.futures.TimeoutError:
805 logger.error(f"Save operation timed out for {self}")
806 registry.emit(
807 "model.save:error",
808 "Fired when a save operation times out.",
809 kwargs={"model": self, "error": "Timeout"},
810 )
812 except APIError as e:
813 logger.error(f"API error during save of {self}: {e}")
814 registry.emit(
815 "model.save:error",
816 "Fired when a network error occurs during save.",
817 kwargs={"model": self, "error": e},
818 )
820 except Exception as e:
821 # Log unexpected errors but don't swallow them
822 logger.exception(f"Unexpected error during save of {self}")
823 registry.emit(
824 "model.save:error",
825 "Fired when an unexpected error occurs during save.",
826 kwargs={"model": self, "error": e},
827 )
828 # Re-raise so the executor can handle it properly
829 raise
831 finally:
832 self._pending_save = None
833 try:
834 self._save_lock.release()
835 except RuntimeError:
836 logger.debug("Save lock already released")
837 self._status = ModelStatus.READY
839 # If the model was changed while the save was in progress,
840 # we need to save again
841 if self.is_dirty("saved"):
842 # Small delay to avoid hammering the server
843 time.sleep(0.1)
844 # Save, and reset unsaved data
845 self.save()
847 @override
848 def is_new(self) -> bool:
849 """
850 Check if this model represents a new (unsaved) object.
852 Returns:
853 True if the model is new, False otherwise.
855 Examples:
856 # Check if a Document instance is new
857 is_new = doc.is_new()
859 """
860 return self.id == 0
862 def _autosave(self) -> None:
863 # Skip autosave for:
864 # - New models (not yet saved)
865 # - When auto-save is disabled
866 if self.is_new() or self.should_save_on_write() is False or not self.is_dirty():
867 return
869 self.save()
871 @override
872 def __setattr__(self, name: str, value: Any) -> None:
873 """
874 Override attribute setting to automatically trigger async save.
876 Args:
877 name: Attribute name
878 value: New attribute value
880 """
881 # Set the new value
882 super().__setattr__(name, value)
884 # Autosave logic below
885 if self._status != ModelStatus.READY:
886 return
888 # Skip autosave for private fields
889 if not name.startswith("_"):
890 self._autosave()
892 @override
893 def __str__(self) -> str:
894 """
895 Human-readable string representation.
897 Returns:
898 A string representation of the model.
900 """
901 return f"{self._meta.name.capitalize()} #{self.id}"