Coverage for src/paperap/resources/base.py: 72%
146 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-12 23:40 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-12 23:40 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: base.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.5
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, Generic, Iterator, Optional, overload, override
30from typing_extensions import TypeVar
31from yarl import URL
33from paperap.const import URLS, Endpoints
34from paperap.exceptions import ConfigurationError, ObjectNotFoundError, ResourceNotFoundError, ResponseParsingError
35from paperap.signals import registry
37if TYPE_CHECKING:
38 from paperap.client import PaperlessClient
39 from paperap.models.abstract import BaseModel, BaseQuerySet, StandardModel, StandardQuerySet
41_BaseModel = TypeVar("_BaseModel", bound="BaseModel")
42_StandardModel = TypeVar("_StandardModel", bound="StandardModel")
43_BaseQuerySet = TypeVar("_BaseQuerySet", bound="BaseQuerySet", default="BaseQuerySet[_BaseModel]", covariant=True)
44_StandardQuerySet = TypeVar(
45 "_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet[_StandardModel]", covariant=True
46)
48logger = logging.getLogger(__name__)
51class BaseResource(ABC, Generic[_BaseModel, _BaseQuerySet]):
52 """
53 Base class for API resources.
55 Args:
56 client: The PaperlessClient instance.
57 endpoint: The API endpoint for this resource.
58 model_class: The model class for this resource.
60 """
62 # The model class for this resource.
63 model_class: type[_BaseModel]
64 # The PaperlessClient instance.
65 client: "PaperlessClient"
66 # The name of the model. This must line up with the API endpoint
67 # It will default to the model's name
68 name: str
69 # The API endpoint for this model.
70 # It will default to a standard schema used by the API
71 # Setting it will allow you to contact a different schema or even a completely different API.
72 # this will usually not need to be overridden
73 endpoints: ClassVar[Endpoints]
75 def __init__(self, client: "PaperlessClient") -> None:
76 self.client = client
77 if not hasattr(self, "name"):
78 self.name = f"{self._meta.name.lower()}s"
80 # Allow templating
81 for key, value in self.endpoints.items():
82 # endpoints is always dict[str, Template]
83 self.endpoints[key] = Template(value.safe_substitute(resource=self.name)) # type: ignore
85 # Ensure the model has a link back to this resource
86 self._meta.resource = self
88 super().__init__()
90 @classmethod
91 def __init_subclass__(cls, **kwargs: Any) -> None:
92 """
93 Initialize the subclass.
95 Args:
96 **kwargs: Arbitrary keyword arguments
98 """
99 super().__init_subclass__(**kwargs)
101 # Skip processing for the base class itself. TODO: This is a hack
102 if cls.__name__ in ["BaseResource", "StandardResource"]:
103 return
105 # model_class is required
106 if not (_model_class := getattr(cls, "model_class", None)):
107 raise ConfigurationError(f"model_class must be defined in {cls.__name__}")
109 # API Endpoint must be defined
110 if not hasattr(cls, "endpoints"):
111 cls.endpoints = {
112 "list": URLS.list,
113 "detail": URLS.detail,
114 "create": URLS.create,
115 "update": URLS.update,
116 "delete": URLS.delete,
117 }
119 @property
120 def _meta(self) -> "BaseModel.Meta[_BaseModel]":
121 return self.model_class._meta # pyright: ignore[reportPrivateUsage] # pylint: disable=protected-access
123 def all(self) -> _BaseQuerySet:
124 """
125 Return a QuerySet representing all objects of this resource type.
127 Returns:
128 A QuerySet for this resource
130 """
131 return self._meta.queryset(self) # type: ignore # _meta.queryset is always the right queryset type
133 def filter(self, **kwargs: Any) -> _BaseQuerySet:
134 """
135 Return a QuerySet filtered by the given parameters.
137 Args:
138 **kwargs: Filter parameters
140 Returns:
141 A filtered QuerySet
143 """
144 return self.all().filter(**kwargs)
146 def get(self, *args: Any, **kwargs: Any) -> _BaseModel:
147 """
148 Get a model by ID.
150 Raises NotImplementedError. Subclasses may implement this.
152 Raises:
153 NotImplementedError: Unless implemented by a subclass.
155 Returns:
156 The model retrieved.
158 """
159 raise NotImplementedError("get method not available for resources without an id")
161 def create(self, data: dict[str, Any]) -> _BaseModel:
162 """
163 Create a new resource.
165 Args:
166 data: Resource data.
168 Returns:
169 The created resource.
171 """
172 # Signal before creating resource
173 signal_params = {"resource": self.name, "data": data}
174 registry.emit("resource.create:before", "Emitted before creating a resource", kwargs=signal_params)
176 if not (template := self.endpoints.get("create")):
177 raise ConfigurationError(f"Create endpoint not defined for resource {self.name}")
179 url = template.safe_substitute(resource=self.name)
180 if not (response := self.client.request("POST", url, data=data)):
181 raise ResourceNotFoundError("Resource {resource} not found after create.", resource_name=self.name)
183 model = self.parse_to_model(response)
185 # Signal after creating resource
186 registry.emit(
187 "resource.create:after",
188 "Emitted after creating a resource",
189 args=[self],
190 kwargs={"model": model, **signal_params},
191 )
193 return model
195 def update(self, model: _BaseModel) -> _BaseModel:
196 """
197 Update a resource.
199 Args:
200 resource: The resource to update.
202 Returns:
203 The updated resource.
205 """
206 raise NotImplementedError("update method not available for resources without an id")
208 def update_dict(self, model_id: int, **data: dict[str, Any]) -> _BaseModel:
209 """
210 Update a resource.
212 Args:
213 model_id: ID of the resource.
214 data: Resource data.
216 Raises:
217 ResourceNotFoundError: If the resource with the given id is not found
219 Returns:
220 The updated resource.
222 """
223 # Signal before updating resource
224 signal_params = {"resource": self.name, "model_id": model_id, "data": data}
225 registry.emit("resource.update:before", "Emitted before updating a resource", kwargs=signal_params)
227 if not (template := self.endpoints.get("update")):
228 raise ConfigurationError(f"Update endpoint not defined for resource {self.name}")
230 url = template.safe_substitute(resource=self.name, pk=model_id)
231 if not (response := self.client.request("PUT", url, data=data)):
232 raise ResourceNotFoundError("Resource ${resource} not found after update.", resource_name=self.name)
234 model = self.parse_to_model(response)
236 # Signal after updating resource
237 registry.emit(
238 "resource.update:after",
239 "Emitted after updating a resource",
240 args=[self],
241 kwargs={**signal_params, "model": model},
242 )
244 return model
246 def delete(self, model_id: int) -> None:
247 """
248 Delete a resource.
250 Args:
251 model_id: ID of the resource.
253 """
254 # Signal before deleting resource
255 signal_params = {"resource": self.name, "model_id": model_id}
256 registry.emit("resource.delete:before", "Emitted before deleting a resource", args=[self], kwargs=signal_params)
258 if not (template := self.endpoints.get("delete")):
259 raise ConfigurationError(f"Delete endpoint not defined for resource {self.name}")
261 url = template.safe_substitute(resource=self.name, pk=model_id)
262 self.client.request("DELETE", url)
264 # Signal after deleting resource
265 registry.emit("resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params)
267 def parse_to_model(self, item: dict[str, Any]) -> _BaseModel:
268 """
269 Parse an item dictionary into a model instance, handling date parsing.
271 Args:
272 item: The item dictionary.
274 Returns:
275 The parsed model instance.
277 """
278 data = self.transform_data_input(**item)
279 return self.model_class.model_validate(data)
281 def transform_data_input(self, **data: Any) -> dict[str, Any]:
282 """
283 Transform data after receiving it from the API.
285 Args:
286 data: The data to transform.
288 Returns:
289 The transformed data.
291 """
292 for key, value in self._meta.field_map.items():
293 if key in data:
294 data[value] = data.pop(key)
295 return data
297 @overload
298 def transform_data_output(self, model: _BaseModel, exclude_unset: bool = True) -> dict[str, Any]: ...
300 @overload
301 def transform_data_output(self, **data: Any) -> dict[str, Any]: ...
303 def transform_data_output(
304 self, model: _BaseModel | None = None, exclude_unset: bool = True, **data: Any
305 ) -> dict[str, Any]:
306 """
307 Transform data before sending it to the API.
309 Args:
310 model: The model to transform.
311 exclude_unset: If model is provided, exclude unset fields when calling to_dict()
312 data: The data to transform.
314 Returns:
315 The transformed data.
317 """
318 if model:
319 if data:
320 # Combining model.to_dict() and data is ambiguous, so not allowed.
321 raise ValueError("Only one of model or data should be provided")
322 data = model.to_dict(exclude_unset=exclude_unset)
324 for key, value in self._meta.field_map.items():
325 if value in data:
326 data[key] = data.pop(value)
327 return data
329 def create_model(self, **kwargs: Any) -> _BaseModel:
330 """
331 Create a new model instance.
333 Args:
334 **kwargs: Model field values
336 Returns:
337 A new model instance.
339 """
340 # Mypy output:
341 # base.py:326:52: error: Argument "resource" to "BaseModel" has incompatible type
342 # "BaseResource[_BaseModel, _BaseQuerySet]"; expected "BaseResource[BaseModel, BaseQuerySet[BaseModel]] | None
343 return self.model_class(**kwargs, resource=self) # type: ignore
345 def request_raw(
346 self,
347 url: str | Template | URL | None = None,
348 method: str = "GET",
349 params: dict[str, Any] | None = None,
350 data: dict[str, Any] | None = None,
351 ) -> dict[str, Any] | None:
352 """
353 Make an HTTP request to the API, and return the raw json response.
355 Args:
356 method: The HTTP method to use
357 url: The full URL to request
358 params: Query parameters
359 data: Request body data
361 Returns:
362 The JSON-decoded response from the API
364 """
365 if not url:
366 if not (url := self.endpoints.get("list")):
367 raise ConfigurationError(f"List endpoint not defined for resource {self.name}")
369 if isinstance(url, Template):
370 url = url.safe_substitute(resource=self.name)
372 response = self.client.request(method, url, params=params, data=data)
373 return response
375 def handle_response(self, **response: Any) -> Iterator[_BaseModel]:
376 """
377 Handle a response from the API and yield results.
379 Override in subclasses to implement custom response logic.
380 """
381 registry.emit(
382 "resource._handle_response:before",
383 "Emitted before listing resources",
384 return_type=dict[str, Any],
385 args=[self],
386 kwargs={"response": response, "resource": self.name},
387 )
388 if not (results := response.get("results", response)):
389 return
391 # Signal after receiving response
392 registry.emit(
393 "resource._handle_response:after",
394 "Emitted after list response, before processing",
395 args=[self],
396 kwargs={"response": {**response}, "resource": self.name, "results": results},
397 )
399 yield from self.handle_results(results)
401 def handle_results(self, results: list[dict[str, Any]]) -> Iterator[_BaseModel]:
402 """
403 Yield parsed models from a list of results.
405 Override in subclasses to implement custom result handling.
406 """
407 for item in results:
408 if not isinstance(item, dict):
409 raise ResponseParsingError(f"Expected type of elements in results is dict, got {type(item)}")
411 registry.emit(
412 "resource._handle_results:before",
413 "Emitted for each item in a list response",
414 args=[self],
415 kwargs={"resource": self.name, "item": {**item}},
416 )
417 yield self.parse_to_model(item)
419 def __call__(self, *args, **keywords) -> _BaseQuerySet:
420 """
421 Make the resource callable to get a BaseQuerySet.
423 This allows usage like: client.documents(title__contains='invoice')
425 Args:
426 *args: Unused
427 **keywords: Filter parameters
429 Returns:
430 A filtered QuerySet
432 """
433 return self.filter(**keywords)
436class StandardResource(BaseResource[_StandardModel, _StandardQuerySet], Generic[_StandardModel, _StandardQuerySet]):
437 """
438 Base class for API resources.
440 Args:
441 client: The PaperlessClient instance.
442 endpoint: The API endpoint for this resource.
443 model_class: The model class for this resource.
445 """
447 # The model class for this resource.
448 model_class: type[_StandardModel]
450 @override
451 def get(self, model_id: int, *args, **kwargs: Any) -> _StandardModel:
452 """
453 Get a model within this resource by ID.
455 Args:
456 model_id: ID of the model to retrieve.
458 Returns:
459 The model retrieved
461 """
462 # Signal before getting resource
463 signal_params = {"resource": self.name, "model_id": model_id}
464 registry.emit("resource.get:before", "Emitted before getting a resource", args=[self], kwargs=signal_params)
466 if not (template := self.endpoints.get("detail")):
467 raise ConfigurationError(f"Get detail endpoint not defined for resource {self.name}")
469 # Provide template substitutions for endpoints
470 url = template.safe_substitute(resource=self.name, pk=model_id)
472 if not (response := self.client.request("GET", url)):
473 raise ObjectNotFoundError(resource_name=self.name, model_id=model_id)
475 # If the response doesn't have an ID, it's likely a 404
476 if not response.get("id"):
477 message = response.get("detail") or f"No ID found in {self.name} response"
478 raise ObjectNotFoundError(message, resource_name=self.name, model_id=model_id)
480 model = self.parse_to_model(response)
482 # Signal after getting resource
483 registry.emit(
484 "resource.get:after",
485 "Emitted after getting a single resource by id",
486 args=[self],
487 kwargs={**signal_params, "model": model},
488 )
490 return model
492 @override
493 def update(self, model: _StandardModel) -> _StandardModel:
494 """
495 Update a model.
497 Args:
498 model: The model to update.
500 Returns:
501 The updated model.
503 """
504 data = model.to_dict()
505 data = self.transform_data_output(**data)
506 return self.update_dict(model.id, **data)