Coverage for src/paperap/resources/base.py: 67%
202 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -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 copy
25import logging
26from abc import ABC, ABCMeta
27from string import Template
28from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Iterator, overload, override
30from pydantic import HttpUrl, field_validator
31from typing_extensions import TypeVar
33from paperap.const import URLS, Endpoints
34from paperap.exceptions import (
35 ConfigurationError,
36 ModelValidationError,
37 ObjectNotFoundError,
38 ResourceNotFoundError,
39 ResponseParsingError,
40)
41from paperap.signals import registry
43if TYPE_CHECKING:
44 from paperap.client import PaperlessClient
45 from paperap.models.abstract import BaseModel, BaseQuerySet, StandardModel, StandardQuerySet
47_BaseModel = TypeVar("_BaseModel", bound="BaseModel", default="BaseModel")
48_BaseQuerySet = TypeVar("_BaseQuerySet", bound="BaseQuerySet", default="BaseQuerySet")
49_StandardModel = TypeVar("_StandardModel", bound="StandardModel", default="StandardModel")
50_StandardQuerySet = TypeVar("_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet")
52logger = logging.getLogger(__name__)
55class BaseResource(ABC, Generic[_BaseModel, _BaseQuerySet]):
56 """
57 Base class for API resources.
59 Args:
60 client: The PaperlessClient instance.
61 endpoint: The API endpoint for this resource.
62 model_class: The model class for this resource.
64 """
66 # The model class for this resource.
67 model_class: type[_BaseModel]
68 queryset_class: type[_BaseQuerySet]
70 # The PaperlessClient instance.
71 client: "PaperlessClient"
72 # The name of the model. This must line up with the API endpoint
73 # It will default to the model's name
74 name: str
75 # The API endpoint for this model.
76 # It will default to a standard schema used by the API
77 # Setting it will allow you to contact a different schema or even a completely different API.
78 # this will usually not need to be overridden
79 endpoints: ClassVar[Endpoints]
81 def __init__(self, client: "PaperlessClient") -> None:
82 self.client = client
83 if not hasattr(self, "name"):
84 self.name = f"{self._meta.name.lower()}s"
86 # Allow templating
87 for key, value in self.endpoints.items():
88 # endpoints is always dict[str, Template]
89 self.endpoints[key] = Template(value.safe_substitute(resource=self.name))
91 # Ensure the model has a link back to this resource
92 self.model_class._resource = self # type: ignore # allow private access
94 super().__init__()
96 @override
97 @classmethod
98 def __init_subclass__(cls, **kwargs: Any) -> None:
99 """
100 Initialize the subclass.
102 Args:
103 **kwargs: Arbitrary keyword arguments
105 """
106 super().__init_subclass__(**kwargs)
108 # Skip processing for the base class itself. TODO: This is a hack
109 if cls.__name__ in ["BaseResource", "StandardResource"]:
110 return
112 # model_class is required
113 if not (_model_class := getattr(cls, "model_class", None)):
114 raise ConfigurationError(f"model_class must be defined in {cls.__name__}")
116 # API Endpoint must be defined
117 if not (endpoints := getattr(cls, "endpoints", {})):
118 endpoints = {
119 "list": URLS.list,
120 "detail": URLS.detail,
121 "create": URLS.create,
122 "update": URLS.update,
123 "delete": URLS.delete,
124 }
126 cls.endpoints = cls._validate_endpoints(endpoints) # type: ignore # Allow assigning in subclass
128 @property
129 def _meta(self) -> "BaseModel.Meta[_BaseModel]":
130 return self.model_class._meta # pyright: ignore[reportPrivateUsage] # pylint: disable=protected-access
132 @classmethod
133 def _validate_endpoints(cls, value: Any) -> Endpoints:
134 if not isinstance(value, dict):
135 raise ModelValidationError("endpoints must be a dictionary")
137 converted: Endpoints = {}
138 for k, v in value.items():
139 if isinstance(v, Template):
140 converted[k] = v
141 continue
143 if not isinstance(v, str):
144 raise ModelValidationError(f"endpoints[{k}] must be a string or template")
146 try:
147 converted[k] = Template(v)
148 except ValueError as e:
149 raise ModelValidationError(f"endpoints[{k}] is not a valid template: {e}") from e
151 # We validated that converted matches endpoints above
152 return converted
154 def all(self) -> _BaseQuerySet:
155 """
156 Return a QuerySet representing all objects of this resource type.
158 Returns:
159 A QuerySet for this resource
161 """
162 return self.queryset_class(self) # type: ignore # _meta.queryset is always the right queryset type
164 def filter(self, **kwargs: Any) -> _BaseQuerySet:
165 """
166 Return a QuerySet filtered by the given parameters.
168 Args:
169 **kwargs: Filter parameters
171 Returns:
172 A filtered QuerySet
174 """
175 return self.all().filter(**kwargs)
177 def get(self, *args: Any, **kwargs: Any) -> _BaseModel:
178 """
179 Get a model by ID.
181 Raises NotImplementedError. Subclasses may implement this.
183 Raises:
184 NotImplementedError: Unless implemented by a subclass.
186 Returns:
187 The model retrieved.
189 """
190 raise NotImplementedError("get method not available for resources without an id")
192 def create(self, data: dict[str, Any]) -> _BaseModel:
193 """
194 Create a new resource.
196 Args:
197 data: Resource data.
199 Returns:
200 The created resource.
202 """
203 # Signal before creating resource
204 signal_params = {"resource": self.name, "data": data}
205 registry.emit("resource.create:before", "Emitted before creating a resource", kwargs=signal_params)
207 if not (template := self.endpoints.get("create")):
208 raise ConfigurationError(f"Create endpoint not defined for resource {self.name}")
210 url = template.safe_substitute(resource=self.name)
211 if not (response := self.client.request("POST", url, data=data)):
212 raise ResourceNotFoundError("Resource {resource} not found after create.", resource_name=self.name)
214 model = self.parse_to_model(response)
216 # Signal after creating resource
217 registry.emit(
218 "resource.create:after",
219 "Emitted after creating a resource",
220 args=[self],
221 kwargs={"model": model, **signal_params},
222 )
224 return model
226 def update(self, model: _BaseModel) -> _BaseModel:
227 """
228 Update a resource.
230 Args:
231 resource: The resource to update.
233 Returns:
234 The updated resource.
236 """
237 raise NotImplementedError("update method not available for resources without an id")
239 def update_dict(self, *args, **kwargs) -> _BaseModel:
240 """
241 Update a resource.
243 Subclasses may implement this.
244 """
245 raise NotImplementedError("update_dict method not available for resources without an id")
247 def delete(self, *args, **kwargs) -> None:
248 """
249 Delete a resource.
251 Args:
252 model_id: ID of the resource.
254 """
255 raise NotImplementedError("delete method not available for resources without an id")
257 def parse_to_model(self, item: dict[str, Any]) -> _BaseModel:
258 """
259 Parse an item dictionary into a model instance, handling date parsing.
261 Args:
262 item: The item dictionary.
264 Returns:
265 The parsed model instance.
267 """
268 try:
269 data = self.transform_data_input(**item)
270 return self.model_class.model_validate(data)
271 except ValueError as ve:
272 logger.error('Error parsing model "%s" with data: %s -> %s', self.name, item, ve)
273 raise
275 def transform_data_input(self, **data: Any) -> dict[str, Any]:
276 """
277 Transform data after receiving it from the API.
279 Args:
280 data: The data to transform.
282 Returns:
283 The transformed data.
285 """
286 for key, value in self._meta.field_map.items():
287 if key in data:
288 data[value] = data.pop(key)
289 return data
291 @overload
292 def transform_data_output(self, model: _BaseModel, exclude_unset: bool = True) -> dict[str, Any]: ...
294 @overload
295 def transform_data_output(self, **data: Any) -> dict[str, Any]: ...
297 def transform_data_output(
298 self, model: _BaseModel | None = None, exclude_unset: bool = True, **data: Any
299 ) -> dict[str, Any]:
300 """
301 Transform data before sending it to the API.
303 Args:
304 model: The model to transform.
305 exclude_unset: If model is provided, exclude unset fields when calling to_dict()
306 data: The data to transform.
308 Returns:
309 The transformed data.
311 """
312 if model:
313 if data:
314 # Combining model.to_dict() and data is ambiguous, so not allowed.
315 raise ValueError("Only one of model or data should be provided")
316 data = model.to_dict(exclude_unset=exclude_unset)
318 for key, value in self._meta.field_map.items():
319 if value in data:
320 data[key] = data.pop(value)
321 return data
323 def create_model(self, **kwargs: Any) -> _BaseModel:
324 """
325 Create a new model instance.
327 Args:
328 **kwargs: Model field values
330 Returns:
331 A new model instance.
333 """
334 # Mypy output:
335 # base.py:326:52: error: Argument "resource" to "BaseModel" has incompatible type
336 # "BaseResource[_BaseModel, _BaseQuerySet]"; expected "BaseResource[BaseModel, BaseQuerySet[BaseModel]] | None
337 return self.model_class(**kwargs, resource=self) # type: ignore
339 def request_raw(
340 self,
341 url: str | Template | HttpUrl | None = None,
342 method: str = "GET",
343 params: dict[str, Any] | None = None,
344 data: dict[str, Any] | None = None,
345 ) -> dict[str, Any] | None:
346 """
347 Make an HTTP request to the API, and return the raw json response.
349 Args:
350 method: The HTTP method to use
351 url: The full URL to request
352 params: Query parameters
353 data: Request body data
355 Returns:
356 The JSON-decoded response from the API
358 """
359 if not url:
360 if not (url := self.endpoints.get("list")):
361 raise ConfigurationError(f"List endpoint not defined for resource {self.name}")
363 if isinstance(url, Template):
364 url = url.safe_substitute(resource=self.name)
366 response = self.client.request(method, url, params=params, data=data)
367 return response
369 def handle_response(self, **response: Any) -> Iterator[_BaseModel]:
370 """
371 Handle a response from the API and yield results.
373 Override in subclasses to implement custom response logic.
374 """
375 registry.emit(
376 "resource._handle_response:before",
377 "Emitted before listing resources",
378 return_type=dict[str, Any],
379 args=[self],
380 kwargs={"response": response, "resource": self.name},
381 )
382 if not (results := response.get("results", response)):
383 return
385 # Signal after receiving response
386 registry.emit(
387 "resource._handle_response:after",
388 "Emitted after list response, before processing",
389 args=[self],
390 kwargs={"response": {**response}, "resource": self.name, "results": results},
391 )
393 yield from self.handle_results(results)
395 def handle_results(self, results: list[dict[str, Any]]) -> Iterator[_BaseModel]:
396 """
397 Yield parsed models from a list of results.
399 Override in subclasses to implement custom result handling.
400 """
401 if not isinstance(results, list):
402 raise ResponseParsingError(f"Expected results to be a list, got {type(results)}")
404 for item in results:
405 if not isinstance(item, dict):
406 raise ResponseParsingError(f"Expected type of elements in results is dict, got {type(item)}")
408 registry.emit(
409 "resource._handle_results:before",
410 "Emitted for each item in a list response",
411 args=[self],
412 kwargs={"resource": self.name, "item": {**item}},
413 )
414 yield self.parse_to_model(item)
416 def __call__(self, *args, **keywords) -> _BaseQuerySet:
417 """
418 Make the resource callable to get a BaseQuerySet.
420 This allows usage like: client.documents(title__contains='invoice')
422 Args:
423 *args: Unused
424 **keywords: Filter parameters
426 Returns:
427 A filtered QuerySet
429 """
430 return self.filter(**keywords)
433class StandardResource(BaseResource[_StandardModel, _StandardQuerySet]):
434 """
435 Base class for API resources.
437 Args:
438 client: The PaperlessClient instance.
439 endpoint: The API endpoint for this resource.
440 model_class: The model class for this resource.
442 """
444 @override
445 def get(self, model_id: int, *args, **kwargs: Any) -> _StandardModel:
446 """
447 Get a model within this resource by ID.
449 Args:
450 model_id: ID of the model to retrieve.
452 Returns:
453 The model retrieved
455 """
456 # Signal before getting resource
457 signal_params = {"resource": self.name, "model_id": model_id}
458 registry.emit("resource.get:before", "Emitted before getting a resource", args=[self], kwargs=signal_params)
460 if not (template := self.endpoints.get("detail")):
461 raise ConfigurationError(f"Get detail endpoint not defined for resource {self.name}")
463 # Provide template substitutions for endpoints
464 url = template.safe_substitute(resource=self.name, pk=model_id)
466 if not (response := self.client.request("GET", url)):
467 raise ObjectNotFoundError(resource_name=self.name, model_id=model_id)
469 # If the response doesn't have an ID, it's likely a 404
470 if not response.get("id"):
471 message = response.get("detail") or f"No ID found in {self.name} response"
472 raise ObjectNotFoundError(message, resource_name=self.name, model_id=model_id)
474 model = self.parse_to_model(response)
476 # Signal after getting resource
477 registry.emit(
478 "resource.get:after",
479 "Emitted after getting a single resource by id",
480 args=[self],
481 kwargs={**signal_params, "model": model},
482 )
484 return model
486 @override
487 def update(self, model: _StandardModel) -> _StandardModel:
488 """
489 Update a model.
491 Args:
492 model: The model to update.
494 Returns:
495 The updated model.
497 """
498 data = model.to_dict()
499 data = self.transform_data_output(**data)
500 return self.update_dict(model.id, **data)
502 def bulk_action(self, action: str, ids: list[int], **kwargs: Any) -> dict[str, Any]:
503 """
504 Perform a bulk action on multiple resources.
506 Args:
507 action: The action to perform (e.g., "delete", "update", etc.)
508 ids: List of resource IDs to perform the action on
509 **kwargs: Additional parameters for the action
511 Returns:
512 The API response
514 Raises:
515 ConfigurationError: If the bulk endpoint is not defined
517 """
518 # Signal before bulk action
519 signal_params = {"resource": self.name, "action": action, "ids": ids, "kwargs": kwargs}
520 registry.emit("resource.bulk_action:before", "Emitted before bulk action", args=[self], kwargs=signal_params)
522 # Use the bulk endpoint or fall back to the list endpoint
523 if not (template := self.endpoints.get("bulk", self.endpoints.get("list"))):
524 raise ConfigurationError(f"Bulk endpoint not defined for resource {self.name}")
526 url = template.safe_substitute(resource=self.name)
528 # Prepare the data for the bulk action
529 data = {"action": action, "documents": ids, **kwargs}
531 response = self.client.request("POST", f"{url}bulk_edit/", data=data)
533 # Signal after bulk action
534 registry.emit(
535 "resource.bulk_action:after",
536 "Emitted after bulk action",
537 args=[self],
538 kwargs={**signal_params, "response": response},
539 )
541 return response or {}
543 def bulk_delete(self, ids: list[int]) -> dict[str, Any]:
544 """
545 Delete multiple resources at once.
547 Args:
548 ids: List of resource IDs to delete
550 Returns:
551 The API response
553 """
554 return self.bulk_action("delete", ids)
556 def bulk_update(self, ids: list[int], **kwargs: Any) -> dict[str, Any]:
557 """
558 Update multiple resources at once.
560 Args:
561 ids: List of resource IDs to update
562 **kwargs: Fields to update on all resources
564 Returns:
565 The API response
567 """
568 # Transform the data before sending
569 data = self.transform_data_output(**kwargs)
570 return self.bulk_action("update", ids, **data)
572 def bulk_assign_tags(self, ids: list[int], tag_ids: list[int], remove_existing: bool = False) -> dict[str, Any]:
573 """
574 Assign tags to multiple resources.
576 Args:
577 ids: List of resource IDs to update
578 tag_ids: List of tag IDs to assign
579 remove_existing: If True, remove existing tags before assigning new ones
581 Returns:
582 The API response
584 """
585 action = "remove_tags" if remove_existing else "add_tags"
586 return self.bulk_action(action, ids, tags=tag_ids)
588 def bulk_assign_correspondent(self, ids: list[int], correspondent_id: int) -> dict[str, Any]:
589 """
590 Assign a correspondent to multiple resources.
592 Args:
593 ids: List of resource IDs to update
594 correspondent_id: Correspondent ID to assign
596 Returns:
597 The API response
599 """
600 return self.bulk_action("set_correspondent", ids, correspondent=correspondent_id)
602 def bulk_assign_document_type(self, ids: list[int], document_type_id: int) -> dict[str, Any]:
603 """
604 Assign a document type to multiple resources.
606 Args:
607 ids: List of resource IDs to update
608 document_type_id: Document type ID to assign
610 Returns:
611 The API response
613 """
614 return self.bulk_action("set_document_type", ids, document_type=document_type_id)
616 def bulk_assign_storage_path(self, ids: list[int], storage_path_id: int) -> dict[str, Any]:
617 """
618 Assign a storage path to multiple resources.
620 Args:
621 ids: List of resource IDs to update
622 storage_path_id: Storage path ID to assign
624 Returns:
625 The API response
627 """
628 return self.bulk_action("set_storage_path", ids, storage_path=storage_path_id)
630 def bulk_assign_owner(self, ids: list[int], owner_id: int) -> dict[str, Any]:
631 """
632 Assign an owner to multiple resources.
634 Args:
635 ids: List of resource IDs to update
636 owner_id: Owner ID to assign
638 Returns:
639 The API response
641 """
642 return self.bulk_action("set_owner", ids, owner=owner_id)
644 @override
645 def delete(self, model_id: int) -> None:
646 """
647 Delete a resource.
649 Args:
650 model_id: ID of the resource.
652 """
653 # Signal before deleting resource
654 signal_params = {"resource": self.name, "model_id": model_id}
655 registry.emit("resource.delete:before", "Emitted before deleting a resource", args=[self], kwargs=signal_params)
657 if not (template := self.endpoints.get("delete")):
658 raise ConfigurationError(f"Delete endpoint not defined for resource {self.name}")
660 url = template.safe_substitute(resource=self.name, pk=model_id)
661 self.client.request("DELETE", url)
663 # Signal after deleting resource
664 registry.emit("resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params)
666 @override
667 def update_dict(self, model_id: int, **data: dict[str, Any]) -> _StandardModel:
668 """
669 Update a resource.
671 Args:
672 model_id: ID of the resource.
673 data: Resource data.
675 Raises:
676 ResourceNotFoundError: If the resource with the given id is not found
678 Returns:
679 The updated resource.
681 """
682 # Signal before updating resource
683 signal_params = {"resource": self.name, "model_id": model_id, "data": data}
684 registry.emit("resource.update:before", "Emitted before updating a resource", kwargs=signal_params)
686 if not (template := self.endpoints.get("update")):
687 raise ConfigurationError(f"Update endpoint not defined for resource {self.name}")
689 url = template.safe_substitute(resource=self.name, pk=model_id)
690 if not (response := self.client.request("PUT", url, data=data)):
691 raise ResourceNotFoundError("Resource ${resource} not found after update.", resource_name=self.name)
693 model = self.parse_to_model(response)
695 # Signal after updating resource
696 registry.emit(
697 "resource.update:after",
698 "Emitted after updating a resource",
699 args=[self],
700 kwargs={**signal_params, "model": model},
701 )
703 return model