Coverage for src/paperap/resources/base.py: 72%
174 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 copy
25import logging
26from abc import ABC, ABCMeta
27from string import Template
28from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Iterator, Optional, cast, overload, override
30from pydantic import field_validator
31from typing_extensions import TypeVar
32from yarl import URL
34from paperap.const import URLS, Endpoints
35from paperap.exceptions import (
36 ConfigurationError,
37 ModelValidationError,
38 ObjectNotFoundError,
39 ResourceNotFoundError,
40 ResponseParsingError,
41)
42from paperap.signals import registry
44if TYPE_CHECKING:
45 from paperap.client import PaperlessClient
46 from paperap.models.abstract import BaseModel, BaseQuerySet, StandardModel, StandardQuerySet
48_BaseModel = TypeVar("_BaseModel", bound="BaseModel")
49_StandardModel = TypeVar("_StandardModel", bound="StandardModel")
50_BaseQuerySet = TypeVar("_BaseQuerySet", bound="BaseQuerySet", default="BaseQuerySet[_BaseModel]", covariant=True)
51_StandardQuerySet = TypeVar(
52 "_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet[_StandardModel]", covariant=True
53)
55logger = logging.getLogger(__name__)
58class BaseResource(ABC, Generic[_BaseModel, _BaseQuerySet]):
59 """
60 Base class for API resources.
62 Args:
63 client: The PaperlessClient instance.
64 endpoint: The API endpoint for this resource.
65 model_class: The model class for this resource.
67 """
69 # The model class for this resource.
70 model_class: type[_BaseModel]
71 # The PaperlessClient instance.
72 client: "PaperlessClient"
73 # The name of the model. This must line up with the API endpoint
74 # It will default to the model's name
75 name: str
76 # The API endpoint for this model.
77 # It will default to a standard schema used by the API
78 # Setting it will allow you to contact a different schema or even a completely different API.
79 # this will usually not need to be overridden
80 endpoints: ClassVar[Endpoints]
82 def __init__(self, client: "PaperlessClient") -> None:
83 self.client = client
84 if not hasattr(self, "name"):
85 self.name = f"{self._meta.name.lower()}s"
87 # Allow templating
88 for key, value in self.endpoints.items():
89 # endpoints is always dict[str, Template]
90 self.endpoints[key] = Template(value.safe_substitute(resource=self.name)) # type: ignore
92 # Ensure the model has a link back to this resource
93 self._meta.resource = self
95 super().__init__()
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: dict[str, Template] = {}
138 for k, v in value.items():
139 if k not in ["list", "detail", "create", "update", "delete"]:
140 raise ModelValidationError("endpoint keys must be list, detail, create, update, or delete")
142 if isinstance(v, Template):
143 converted[k] = v
144 continue
146 if not isinstance(v, str):
147 raise ModelValidationError(f"endpoints[{k}] must be a string or template")
149 try:
150 converted[k] = Template(v)
151 except ValueError as e:
152 raise ModelValidationError(f"endpoints[{k}] is not a valid template: {e}") from e
154 # list is required
155 if "list" not in converted:
156 raise ModelValidationError("list endpoint is required")
158 # We validated that converted matches endpoints above
159 return cast(Endpoints, converted)
161 def all(self) -> _BaseQuerySet:
162 """
163 Return a QuerySet representing all objects of this resource type.
165 Returns:
166 A QuerySet for this resource
168 """
169 return self._meta.queryset(self) # type: ignore # _meta.queryset is always the right queryset type
171 def filter(self, **kwargs: Any) -> _BaseQuerySet:
172 """
173 Return a QuerySet filtered by the given parameters.
175 Args:
176 **kwargs: Filter parameters
178 Returns:
179 A filtered QuerySet
181 """
182 return self.all().filter(**kwargs)
184 def get(self, *args: Any, **kwargs: Any) -> _BaseModel:
185 """
186 Get a model by ID.
188 Raises NotImplementedError. Subclasses may implement this.
190 Raises:
191 NotImplementedError: Unless implemented by a subclass.
193 Returns:
194 The model retrieved.
196 """
197 raise NotImplementedError("get method not available for resources without an id")
199 def create(self, data: dict[str, Any]) -> _BaseModel:
200 """
201 Create a new resource.
203 Args:
204 data: Resource data.
206 Returns:
207 The created resource.
209 """
210 # Signal before creating resource
211 signal_params = {"resource": self.name, "data": data}
212 registry.emit("resource.create:before", "Emitted before creating a resource", kwargs=signal_params)
214 if not (template := self.endpoints.get("create")):
215 raise ConfigurationError(f"Create endpoint not defined for resource {self.name}")
217 url = template.safe_substitute(resource=self.name)
218 if not (response := self.client.request("POST", url, data=data)):
219 raise ResourceNotFoundError("Resource {resource} not found after create.", resource_name=self.name)
221 model = self.parse_to_model(response)
223 # Signal after creating resource
224 registry.emit(
225 "resource.create:after",
226 "Emitted after creating a resource",
227 args=[self],
228 kwargs={"model": model, **signal_params},
229 )
231 return model
233 def update(self, model: _BaseModel) -> _BaseModel:
234 """
235 Update a resource.
237 Args:
238 resource: The resource to update.
240 Returns:
241 The updated resource.
243 """
244 raise NotImplementedError("update method not available for resources without an id")
246 def update_dict(self, model_id: int, **data: dict[str, Any]) -> _BaseModel:
247 """
248 Update a resource.
250 Args:
251 model_id: ID of the resource.
252 data: Resource data.
254 Raises:
255 ResourceNotFoundError: If the resource with the given id is not found
257 Returns:
258 The updated resource.
260 """
261 # Signal before updating resource
262 signal_params = {"resource": self.name, "model_id": model_id, "data": data}
263 registry.emit("resource.update:before", "Emitted before updating a resource", kwargs=signal_params)
265 if not (template := self.endpoints.get("update")):
266 raise ConfigurationError(f"Update endpoint not defined for resource {self.name}")
268 url = template.safe_substitute(resource=self.name, pk=model_id)
269 if not (response := self.client.request("PUT", url, data=data)):
270 raise ResourceNotFoundError("Resource ${resource} not found after update.", resource_name=self.name)
272 model = self.parse_to_model(response)
274 # Signal after updating resource
275 registry.emit(
276 "resource.update:after",
277 "Emitted after updating a resource",
278 args=[self],
279 kwargs={**signal_params, "model": model},
280 )
282 return model
284 def delete(self, model_id: int) -> None:
285 """
286 Delete a resource.
288 Args:
289 model_id: ID of the resource.
291 """
292 # Signal before deleting resource
293 signal_params = {"resource": self.name, "model_id": model_id}
294 registry.emit("resource.delete:before", "Emitted before deleting a resource", args=[self], kwargs=signal_params)
296 if not (template := self.endpoints.get("delete")):
297 raise ConfigurationError(f"Delete endpoint not defined for resource {self.name}")
299 url = template.safe_substitute(resource=self.name, pk=model_id)
300 self.client.request("DELETE", url)
302 # Signal after deleting resource
303 registry.emit("resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params)
305 def parse_to_model(self, item: dict[str, Any]) -> _BaseModel:
306 """
307 Parse an item dictionary into a model instance, handling date parsing.
309 Args:
310 item: The item dictionary.
312 Returns:
313 The parsed model instance.
315 """
316 try:
317 data = self.transform_data_input(**item)
318 return self.model_class.model_validate(data)
319 except ValueError as ve:
320 logger.error('Error parsing model "%s" with data: %s -> %s', self.name, item, ve)
321 raise
323 def transform_data_input(self, **data: Any) -> dict[str, Any]:
324 """
325 Transform data after receiving it from the API.
327 Args:
328 data: The data to transform.
330 Returns:
331 The transformed data.
333 """
334 for key, value in self._meta.field_map.items():
335 if key in data:
336 data[value] = data.pop(key)
337 return data
339 @overload
340 def transform_data_output(self, model: _BaseModel, exclude_unset: bool = True) -> dict[str, Any]: ...
342 @overload
343 def transform_data_output(self, **data: Any) -> dict[str, Any]: ...
345 def transform_data_output(
346 self, model: _BaseModel | None = None, exclude_unset: bool = True, **data: Any
347 ) -> dict[str, Any]:
348 """
349 Transform data before sending it to the API.
351 Args:
352 model: The model to transform.
353 exclude_unset: If model is provided, exclude unset fields when calling to_dict()
354 data: The data to transform.
356 Returns:
357 The transformed data.
359 """
360 if model:
361 if data:
362 # Combining model.to_dict() and data is ambiguous, so not allowed.
363 raise ValueError("Only one of model or data should be provided")
364 data = model.to_dict(exclude_unset=exclude_unset)
366 for key, value in self._meta.field_map.items():
367 if value in data:
368 data[key] = data.pop(value)
369 return data
371 def create_model(self, **kwargs: Any) -> _BaseModel:
372 """
373 Create a new model instance.
375 Args:
376 **kwargs: Model field values
378 Returns:
379 A new model instance.
381 """
382 # Mypy output:
383 # base.py:326:52: error: Argument "resource" to "BaseModel" has incompatible type
384 # "BaseResource[_BaseModel, _BaseQuerySet]"; expected "BaseResource[BaseModel, BaseQuerySet[BaseModel]] | None
385 return self.model_class(**kwargs, resource=self) # type: ignore
387 def request_raw(
388 self,
389 url: str | Template | URL | None = None,
390 method: str = "GET",
391 params: dict[str, Any] | None = None,
392 data: dict[str, Any] | None = None,
393 ) -> dict[str, Any] | None:
394 """
395 Make an HTTP request to the API, and return the raw json response.
397 Args:
398 method: The HTTP method to use
399 url: The full URL to request
400 params: Query parameters
401 data: Request body data
403 Returns:
404 The JSON-decoded response from the API
406 """
407 if not url:
408 if not (url := self.endpoints.get("list")):
409 raise ConfigurationError(f"List endpoint not defined for resource {self.name}")
411 if isinstance(url, Template):
412 url = url.safe_substitute(resource=self.name)
414 response = self.client.request(method, url, params=params, data=data)
415 return response
417 def handle_response(self, **response: Any) -> Iterator[_BaseModel]:
418 """
419 Handle a response from the API and yield results.
421 Override in subclasses to implement custom response logic.
422 """
423 registry.emit(
424 "resource._handle_response:before",
425 "Emitted before listing resources",
426 return_type=dict[str, Any],
427 args=[self],
428 kwargs={"response": response, "resource": self.name},
429 )
430 if not (results := response.get("results", response)):
431 return
433 # Signal after receiving response
434 registry.emit(
435 "resource._handle_response:after",
436 "Emitted after list response, before processing",
437 args=[self],
438 kwargs={"response": {**response}, "resource": self.name, "results": results},
439 )
441 yield from self.handle_results(results)
443 def handle_results(self, results: list[dict[str, Any]]) -> Iterator[_BaseModel]:
444 """
445 Yield parsed models from a list of results.
447 Override in subclasses to implement custom result handling.
448 """
449 if not isinstance(results, list):
450 raise ResponseParsingError(f"Expected results to be a list, got {type(results)}")
452 for item in results:
453 if not isinstance(item, dict):
454 raise ResponseParsingError(f"Expected type of elements in results is dict, got {type(item)}")
456 registry.emit(
457 "resource._handle_results:before",
458 "Emitted for each item in a list response",
459 args=[self],
460 kwargs={"resource": self.name, "item": {**item}},
461 )
462 yield self.parse_to_model(item)
464 def __call__(self, *args, **keywords) -> _BaseQuerySet:
465 """
466 Make the resource callable to get a BaseQuerySet.
468 This allows usage like: client.documents(title__contains='invoice')
470 Args:
471 *args: Unused
472 **keywords: Filter parameters
474 Returns:
475 A filtered QuerySet
477 """
478 return self.filter(**keywords)
481class StandardResource(BaseResource[_StandardModel, _StandardQuerySet], Generic[_StandardModel, _StandardQuerySet]):
482 """
483 Base class for API resources.
485 Args:
486 client: The PaperlessClient instance.
487 endpoint: The API endpoint for this resource.
488 model_class: The model class for this resource.
490 """
492 # The model class for this resource.
493 model_class: type[_StandardModel]
495 @override
496 def get(self, model_id: int, *args, **kwargs: Any) -> _StandardModel:
497 """
498 Get a model within this resource by ID.
500 Args:
501 model_id: ID of the model to retrieve.
503 Returns:
504 The model retrieved
506 """
507 # Signal before getting resource
508 signal_params = {"resource": self.name, "model_id": model_id}
509 registry.emit("resource.get:before", "Emitted before getting a resource", args=[self], kwargs=signal_params)
511 if not (template := self.endpoints.get("detail")):
512 raise ConfigurationError(f"Get detail endpoint not defined for resource {self.name}")
514 # Provide template substitutions for endpoints
515 url = template.safe_substitute(resource=self.name, pk=model_id)
517 if not (response := self.client.request("GET", url)):
518 raise ObjectNotFoundError(resource_name=self.name, model_id=model_id)
520 # If the response doesn't have an ID, it's likely a 404
521 if not response.get("id"):
522 message = response.get("detail") or f"No ID found in {self.name} response"
523 raise ObjectNotFoundError(message, resource_name=self.name, model_id=model_id)
525 model = self.parse_to_model(response)
527 # Signal after getting resource
528 registry.emit(
529 "resource.get:after",
530 "Emitted after getting a single resource by id",
531 args=[self],
532 kwargs={**signal_params, "model": model},
533 )
535 return model
537 @override
538 def update(self, model: _StandardModel) -> _StandardModel:
539 """
540 Update a model.
542 Args:
543 model: The model to update.
545 Returns:
546 The updated model.
548 """
549 data = model.to_dict()
550 data = self.transform_data_output(**data)
551 return self.update_dict(model.id, **data)