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

1""" 

2---------------------------------------------------------------------------- 

3 

4 METADATA: 

5 

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 

13 

14---------------------------------------------------------------------------- 

15 

16 LAST MODIFIED: 

17 

18 2025-03-04 By Jess Mann 

19 

20""" 

21 

22from __future__ import annotations 

23 

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 

29 

30from pydantic import HttpUrl, field_validator 

31from typing_extensions import TypeVar 

32 

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 

42 

43if TYPE_CHECKING: 

44 from paperap.client import PaperlessClient 

45 from paperap.models.abstract import BaseModel, BaseQuerySet, StandardModel, StandardQuerySet 

46 

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") 

51 

52logger = logging.getLogger(__name__) 

53 

54 

55class BaseResource(ABC, Generic[_BaseModel, _BaseQuerySet]): 

56 """ 

57 Base class for API resources. 

58 

59 Args: 

60 client: The PaperlessClient instance. 

61 endpoint: The API endpoint for this resource. 

62 model_class: The model class for this resource. 

63 

64 """ 

65 

66 # The model class for this resource. 

67 model_class: type[_BaseModel] 

68 queryset_class: type[_BaseQuerySet] 

69 

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] 

80 

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" 

85 

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)) 

90 

91 # Ensure the model has a link back to this resource 

92 self.model_class._resource = self # type: ignore # allow private access 

93 

94 super().__init__() 

95 

96 @override 

97 @classmethod 

98 def __init_subclass__(cls, **kwargs: Any) -> None: 

99 """ 

100 Initialize the subclass. 

101 

102 Args: 

103 **kwargs: Arbitrary keyword arguments 

104 

105 """ 

106 super().__init_subclass__(**kwargs) 

107 

108 # Skip processing for the base class itself. TODO: This is a hack 

109 if cls.__name__ in ["BaseResource", "StandardResource"]: 

110 return 

111 

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__}") 

115 

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 } 

125 

126 cls.endpoints = cls._validate_endpoints(endpoints) # type: ignore # Allow assigning in subclass 

127 

128 @property 

129 def _meta(self) -> "BaseModel.Meta[_BaseModel]": 

130 return self.model_class._meta # pyright: ignore[reportPrivateUsage] # pylint: disable=protected-access 

131 

132 @classmethod 

133 def _validate_endpoints(cls, value: Any) -> Endpoints: 

134 if not isinstance(value, dict): 

135 raise ModelValidationError("endpoints must be a dictionary") 

136 

137 converted: Endpoints = {} 

138 for k, v in value.items(): 

139 if isinstance(v, Template): 

140 converted[k] = v 

141 continue 

142 

143 if not isinstance(v, str): 

144 raise ModelValidationError(f"endpoints[{k}] must be a string or template") 

145 

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 

150 

151 # We validated that converted matches endpoints above 

152 return converted 

153 

154 def all(self) -> _BaseQuerySet: 

155 """ 

156 Return a QuerySet representing all objects of this resource type. 

157 

158 Returns: 

159 A QuerySet for this resource 

160 

161 """ 

162 return self.queryset_class(self) # type: ignore # _meta.queryset is always the right queryset type 

163 

164 def filter(self, **kwargs: Any) -> _BaseQuerySet: 

165 """ 

166 Return a QuerySet filtered by the given parameters. 

167 

168 Args: 

169 **kwargs: Filter parameters 

170 

171 Returns: 

172 A filtered QuerySet 

173 

174 """ 

175 return self.all().filter(**kwargs) 

176 

177 def get(self, *args: Any, **kwargs: Any) -> _BaseModel: 

178 """ 

179 Get a model by ID. 

180 

181 Raises NotImplementedError. Subclasses may implement this. 

182 

183 Raises: 

184 NotImplementedError: Unless implemented by a subclass. 

185 

186 Returns: 

187 The model retrieved. 

188 

189 """ 

190 raise NotImplementedError("get method not available for resources without an id") 

191 

192 def create(self, data: dict[str, Any]) -> _BaseModel: 

193 """ 

194 Create a new resource. 

195 

196 Args: 

197 data: Resource data. 

198 

199 Returns: 

200 The created resource. 

201 

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) 

206 

207 if not (template := self.endpoints.get("create")): 

208 raise ConfigurationError(f"Create endpoint not defined for resource {self.name}") 

209 

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) 

213 

214 model = self.parse_to_model(response) 

215 

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 ) 

223 

224 return model 

225 

226 def update(self, model: _BaseModel) -> _BaseModel: 

227 """ 

228 Update a resource. 

229 

230 Args: 

231 resource: The resource to update. 

232 

233 Returns: 

234 The updated resource. 

235 

236 """ 

237 raise NotImplementedError("update method not available for resources without an id") 

238 

239 def update_dict(self, *args, **kwargs) -> _BaseModel: 

240 """ 

241 Update a resource. 

242 

243 Subclasses may implement this. 

244 """ 

245 raise NotImplementedError("update_dict method not available for resources without an id") 

246 

247 def delete(self, *args, **kwargs) -> None: 

248 """ 

249 Delete a resource. 

250 

251 Args: 

252 model_id: ID of the resource. 

253 

254 """ 

255 raise NotImplementedError("delete method not available for resources without an id") 

256 

257 def parse_to_model(self, item: dict[str, Any]) -> _BaseModel: 

258 """ 

259 Parse an item dictionary into a model instance, handling date parsing. 

260 

261 Args: 

262 item: The item dictionary. 

263 

264 Returns: 

265 The parsed model instance. 

266 

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 

274 

275 def transform_data_input(self, **data: Any) -> dict[str, Any]: 

276 """ 

277 Transform data after receiving it from the API. 

278 

279 Args: 

280 data: The data to transform. 

281 

282 Returns: 

283 The transformed data. 

284 

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 

290 

291 @overload 

292 def transform_data_output(self, model: _BaseModel, exclude_unset: bool = True) -> dict[str, Any]: ... 

293 

294 @overload 

295 def transform_data_output(self, **data: Any) -> dict[str, Any]: ... 

296 

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. 

302 

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. 

307 

308 Returns: 

309 The transformed data. 

310 

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) 

317 

318 for key, value in self._meta.field_map.items(): 

319 if value in data: 

320 data[key] = data.pop(value) 

321 return data 

322 

323 def create_model(self, **kwargs: Any) -> _BaseModel: 

324 """ 

325 Create a new model instance. 

326 

327 Args: 

328 **kwargs: Model field values 

329 

330 Returns: 

331 A new model instance. 

332 

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 

338 

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. 

348 

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 

354 

355 Returns: 

356 The JSON-decoded response from the API 

357 

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}") 

362 

363 if isinstance(url, Template): 

364 url = url.safe_substitute(resource=self.name) 

365 

366 response = self.client.request(method, url, params=params, data=data) 

367 return response 

368 

369 def handle_response(self, **response: Any) -> Iterator[_BaseModel]: 

370 """ 

371 Handle a response from the API and yield results. 

372 

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 

384 

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 ) 

392 

393 yield from self.handle_results(results) 

394 

395 def handle_results(self, results: list[dict[str, Any]]) -> Iterator[_BaseModel]: 

396 """ 

397 Yield parsed models from a list of results. 

398 

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)}") 

403 

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)}") 

407 

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) 

415 

416 def __call__(self, *args, **keywords) -> _BaseQuerySet: 

417 """ 

418 Make the resource callable to get a BaseQuerySet. 

419 

420 This allows usage like: client.documents(title__contains='invoice') 

421 

422 Args: 

423 *args: Unused 

424 **keywords: Filter parameters 

425 

426 Returns: 

427 A filtered QuerySet 

428 

429 """ 

430 return self.filter(**keywords) 

431 

432 

433class StandardResource(BaseResource[_StandardModel, _StandardQuerySet]): 

434 """ 

435 Base class for API resources. 

436 

437 Args: 

438 client: The PaperlessClient instance. 

439 endpoint: The API endpoint for this resource. 

440 model_class: The model class for this resource. 

441 

442 """ 

443 

444 @override 

445 def get(self, model_id: int, *args, **kwargs: Any) -> _StandardModel: 

446 """ 

447 Get a model within this resource by ID. 

448 

449 Args: 

450 model_id: ID of the model to retrieve. 

451 

452 Returns: 

453 The model retrieved 

454 

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) 

459 

460 if not (template := self.endpoints.get("detail")): 

461 raise ConfigurationError(f"Get detail endpoint not defined for resource {self.name}") 

462 

463 # Provide template substitutions for endpoints 

464 url = template.safe_substitute(resource=self.name, pk=model_id) 

465 

466 if not (response := self.client.request("GET", url)): 

467 raise ObjectNotFoundError(resource_name=self.name, model_id=model_id) 

468 

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) 

473 

474 model = self.parse_to_model(response) 

475 

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 ) 

483 

484 return model 

485 

486 @override 

487 def update(self, model: _StandardModel) -> _StandardModel: 

488 """ 

489 Update a model. 

490 

491 Args: 

492 model: The model to update. 

493 

494 Returns: 

495 The updated model. 

496 

497 """ 

498 data = model.to_dict() 

499 data = self.transform_data_output(**data) 

500 return self.update_dict(model.id, **data) 

501 

502 def bulk_action(self, action: str, ids: list[int], **kwargs: Any) -> dict[str, Any]: 

503 """ 

504 Perform a bulk action on multiple resources. 

505 

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 

510 

511 Returns: 

512 The API response 

513 

514 Raises: 

515 ConfigurationError: If the bulk endpoint is not defined 

516 

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) 

521 

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}") 

525 

526 url = template.safe_substitute(resource=self.name) 

527 

528 # Prepare the data for the bulk action 

529 data = {"action": action, "documents": ids, **kwargs} 

530 

531 response = self.client.request("POST", f"{url}bulk_edit/", data=data) 

532 

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 ) 

540 

541 return response or {} 

542 

543 def bulk_delete(self, ids: list[int]) -> dict[str, Any]: 

544 """ 

545 Delete multiple resources at once. 

546 

547 Args: 

548 ids: List of resource IDs to delete 

549 

550 Returns: 

551 The API response 

552 

553 """ 

554 return self.bulk_action("delete", ids) 

555 

556 def bulk_update(self, ids: list[int], **kwargs: Any) -> dict[str, Any]: 

557 """ 

558 Update multiple resources at once. 

559 

560 Args: 

561 ids: List of resource IDs to update 

562 **kwargs: Fields to update on all resources 

563 

564 Returns: 

565 The API response 

566 

567 """ 

568 # Transform the data before sending 

569 data = self.transform_data_output(**kwargs) 

570 return self.bulk_action("update", ids, **data) 

571 

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. 

575 

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 

580 

581 Returns: 

582 The API response 

583 

584 """ 

585 action = "remove_tags" if remove_existing else "add_tags" 

586 return self.bulk_action(action, ids, tags=tag_ids) 

587 

588 def bulk_assign_correspondent(self, ids: list[int], correspondent_id: int) -> dict[str, Any]: 

589 """ 

590 Assign a correspondent to multiple resources. 

591 

592 Args: 

593 ids: List of resource IDs to update 

594 correspondent_id: Correspondent ID to assign 

595 

596 Returns: 

597 The API response 

598 

599 """ 

600 return self.bulk_action("set_correspondent", ids, correspondent=correspondent_id) 

601 

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. 

605 

606 Args: 

607 ids: List of resource IDs to update 

608 document_type_id: Document type ID to assign 

609 

610 Returns: 

611 The API response 

612 

613 """ 

614 return self.bulk_action("set_document_type", ids, document_type=document_type_id) 

615 

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. 

619 

620 Args: 

621 ids: List of resource IDs to update 

622 storage_path_id: Storage path ID to assign 

623 

624 Returns: 

625 The API response 

626 

627 """ 

628 return self.bulk_action("set_storage_path", ids, storage_path=storage_path_id) 

629 

630 def bulk_assign_owner(self, ids: list[int], owner_id: int) -> dict[str, Any]: 

631 """ 

632 Assign an owner to multiple resources. 

633 

634 Args: 

635 ids: List of resource IDs to update 

636 owner_id: Owner ID to assign 

637 

638 Returns: 

639 The API response 

640 

641 """ 

642 return self.bulk_action("set_owner", ids, owner=owner_id) 

643 

644 @override 

645 def delete(self, model_id: int) -> None: 

646 """ 

647 Delete a resource. 

648 

649 Args: 

650 model_id: ID of the resource. 

651 

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) 

656 

657 if not (template := self.endpoints.get("delete")): 

658 raise ConfigurationError(f"Delete endpoint not defined for resource {self.name}") 

659 

660 url = template.safe_substitute(resource=self.name, pk=model_id) 

661 self.client.request("DELETE", url) 

662 

663 # Signal after deleting resource 

664 registry.emit("resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params) 

665 

666 @override 

667 def update_dict(self, model_id: int, **data: dict[str, Any]) -> _StandardModel: 

668 """ 

669 Update a resource. 

670 

671 Args: 

672 model_id: ID of the resource. 

673 data: Resource data. 

674 

675 Raises: 

676 ResourceNotFoundError: If the resource with the given id is not found 

677 

678 Returns: 

679 The updated resource. 

680 

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) 

685 

686 if not (template := self.endpoints.get("update")): 

687 raise ConfigurationError(f"Update endpoint not defined for resource {self.name}") 

688 

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) 

692 

693 model = self.parse_to_model(response) 

694 

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 ) 

702 

703 return model