Coverage for src/paperap/resources/base.py: 72%
138 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -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, 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(
257 "resource.delete:before", "Emitted before deleting a resource", args=[self], kwargs=signal_params
258 )
260 if not (template := self.endpoints.get("delete")):
261 raise ConfigurationError(f"Delete endpoint not defined for resource {self.name}")
263 url = template.safe_substitute(resource=self.name, pk=model_id)
264 self.client.request("DELETE", url)
266 # Signal after deleting resource
267 registry.emit(
268 "resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params
269 )
271 def parse_to_model(self, item: dict[str, Any]) -> _BaseModel:
272 """
273 Parse an item dictionary into a model instance, handling date parsing.
275 Args:
276 item: The item dictionary.
278 Returns:
279 The parsed model instance.
281 """
282 data = self.transform_data_input(**item)
283 return self.model_class.model_validate(data)
285 def transform_data_input(self, **data: Any) -> dict[str, Any]:
286 """
287 Transform data after receiving it from the API.
289 Args:
290 data: The data to transform.
292 Returns:
293 The transformed data.
295 """
296 for key, value in self._meta.field_map.items():
297 if key in data:
298 data[value] = data.pop(key)
299 return data
301 def transform_data_output(self, **data: Any) -> dict[str, Any]:
302 """
303 Transform data before sending it to the API.
305 Args:
306 data: The data to transform.
308 Returns:
309 The transformed data.
311 """
312 for key, value in self._meta.field_map.items():
313 if value in data:
314 data[key] = data.pop(value)
315 return data
317 def create_model(self, **kwargs: Any) -> _BaseModel:
318 """
319 Create a new model instance.
321 Args:
322 **kwargs: Model field values
324 Returns:
325 A new model instance.
327 """
328 # Mypy output:
329 # base.py:326:52: error: Argument "resource" to "BaseModel" has incompatible type
330 # "BaseResource[_BaseModel, _BaseQuerySet]"; expected "BaseResource[BaseModel, BaseQuerySet[BaseModel]] | None
331 return self.model_class(**kwargs, resource=self) # type: ignore
333 def request_raw(
334 self,
335 url: str | Template | URL | None = None,
336 method: str = "GET",
337 params: dict[str, Any] | None = None,
338 data: dict[str, Any] | None = None,
339 ) -> dict[str, Any] | None:
340 """
341 Make an HTTP request to the API, and return the raw json response.
343 Args:
344 method: The HTTP method to use
345 url: The full URL to request
346 params: Query parameters
347 data: Request body data
349 Returns:
350 The JSON-decoded response from the API
352 """
353 if not url:
354 if not (url := self.endpoints.get("list")):
355 raise ConfigurationError(f"List endpoint not defined for resource {self.name}")
357 if isinstance(url, Template):
358 url = url.safe_substitute(resource=self.name)
360 response = self.client.request(method, url, params=params, data=data)
361 return response
363 def handle_response(self, **response: Any) -> Iterator[_BaseModel]:
364 """
365 Handle a response from the API and yield results.
367 Override in subclasses to implement custom response logic.
368 """
369 registry.emit(
370 "resource._handle_response:before",
371 "Emitted before listing resources",
372 return_type=dict[str, Any],
373 args=[self],
374 kwargs={"response": response, "resource": self.name},
375 )
376 if not (results := response.get("results", response)):
377 return
379 # Signal after receiving response
380 registry.emit(
381 "resource._handle_response:after",
382 "Emitted after list response, before processing",
383 args=[self],
384 kwargs={"response": {**response}, "resource": self.name, "results": results},
385 )
387 yield from self.handle_results(results)
389 def handle_results(self, results: list[dict[str, Any]]) -> Iterator[_BaseModel]:
390 """
391 Yield parsed models from a list of results.
393 Override in subclasses to implement custom result handling.
394 """
395 for item in results:
396 if not isinstance(item, dict):
397 raise ResponseParsingError(f"Expected type of elements in results is dict, got {type(item)}")
399 registry.emit(
400 "resource._handle_results:before",
401 "Emitted for each item in a list response",
402 args=[self],
403 kwargs={"resource": self.name, "item": {**item}},
404 )
405 yield self.parse_to_model(item)
407 def __call__(self, *args, **keywords) -> _BaseQuerySet:
408 """
409 Make the resource callable to get a BaseQuerySet.
411 This allows usage like: client.documents(title__contains='invoice')
413 Args:
414 *args: Unused
415 **keywords: Filter parameters
417 Returns:
418 A filtered QuerySet
420 """
421 return self.filter(**keywords)
424class StandardResource(BaseResource[_StandardModel, _StandardQuerySet], Generic[_StandardModel, _StandardQuerySet]):
425 """
426 Base class for API resources.
428 Args:
429 client: The PaperlessClient instance.
430 endpoint: The API endpoint for this resource.
431 model_class: The model class for this resource.
433 """
435 # The model class for this resource.
436 model_class: type[_StandardModel]
438 @override
439 def get(self, model_id: int, *args, **kwargs: Any) -> _StandardModel:
440 """
441 Get a model within this resource by ID.
443 Args:
444 model_id: ID of the model to retrieve.
446 Returns:
447 The model retrieved
449 """
450 # Signal before getting resource
451 signal_params = {"resource": self.name, "model_id": model_id}
452 registry.emit(
453 "resource.get:before", "Emitted before getting a resource", args=[self], kwargs=signal_params
454 )
456 if not (template := self.endpoints.get("detail")):
457 raise ConfigurationError(f"Get detail endpoint not defined for resource {self.name}")
459 # Provide template substitutions for endpoints
460 url = template.safe_substitute(resource=self.name, pk=model_id)
462 if not (response := self.client.request("GET", url)):
463 raise ObjectNotFoundError(resource_name=self.name, model_id=model_id)
465 # If the response doesn't have an ID, it's likely a 404
466 if not response.get("id"):
467 message = response.get("detail") or f"No ID found in {self.name} response"
468 raise ObjectNotFoundError(message, resource_name=self.name, model_id=model_id)
470 model = self.parse_to_model(response)
472 # Signal after getting resource
473 registry.emit(
474 "resource.get:after",
475 "Emitted after getting a single resource by id",
476 args=[self],
477 kwargs={**signal_params, "model": model},
478 )
480 return model
482 @override
483 def update(self, model: _StandardModel) -> _StandardModel:
484 """
485 Update a model.
487 Args:
488 model: The model to update.
490 Returns:
491 The updated model.
493 """
494 data = model.to_dict()
495 data = self.transform_data_output(**data)
496 return self.update_dict(model.id, **data)