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

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, Optional, cast, overload, override 

29 

30from pydantic import field_validator 

31from typing_extensions import TypeVar 

32from yarl import URL 

33 

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 

43 

44if TYPE_CHECKING: 

45 from paperap.client import PaperlessClient 

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

47 

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) 

54 

55logger = logging.getLogger(__name__) 

56 

57 

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

59 """ 

60 Base class for API resources. 

61 

62 Args: 

63 client: The PaperlessClient instance. 

64 endpoint: The API endpoint for this resource. 

65 model_class: The model class for this resource. 

66 

67 """ 

68 

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] 

81 

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" 

86 

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 

91 

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

93 self._meta.resource = self 

94 

95 super().__init__() 

96 

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

141 

142 if isinstance(v, Template): 

143 converted[k] = v 

144 continue 

145 

146 if not isinstance(v, str): 

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

148 

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 

153 

154 # list is required 

155 if "list" not in converted: 

156 raise ModelValidationError("list endpoint is required") 

157 

158 # We validated that converted matches endpoints above 

159 return cast(Endpoints, converted) 

160 

161 def all(self) -> _BaseQuerySet: 

162 """ 

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

164 

165 Returns: 

166 A QuerySet for this resource 

167 

168 """ 

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

170 

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

172 """ 

173 Return a QuerySet filtered by the given parameters. 

174 

175 Args: 

176 **kwargs: Filter parameters 

177 

178 Returns: 

179 A filtered QuerySet 

180 

181 """ 

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

183 

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

185 """ 

186 Get a model by ID. 

187 

188 Raises NotImplementedError. Subclasses may implement this. 

189 

190 Raises: 

191 NotImplementedError: Unless implemented by a subclass. 

192 

193 Returns: 

194 The model retrieved. 

195 

196 """ 

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

198 

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

200 """ 

201 Create a new resource. 

202 

203 Args: 

204 data: Resource data. 

205 

206 Returns: 

207 The created resource. 

208 

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) 

213 

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

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

216 

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) 

220 

221 model = self.parse_to_model(response) 

222 

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 ) 

230 

231 return model 

232 

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

234 """ 

235 Update a resource. 

236 

237 Args: 

238 resource: The resource to update. 

239 

240 Returns: 

241 The updated resource. 

242 

243 """ 

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

245 

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

247 """ 

248 Update a resource. 

249 

250 Args: 

251 model_id: ID of the resource. 

252 data: Resource data. 

253 

254 Raises: 

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

256 

257 Returns: 

258 The updated resource. 

259 

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) 

264 

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

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

267 

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) 

271 

272 model = self.parse_to_model(response) 

273 

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 ) 

281 

282 return model 

283 

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

285 """ 

286 Delete a resource. 

287 

288 Args: 

289 model_id: ID of the resource. 

290 

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) 

295 

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

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

298 

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

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

301 

302 # Signal after deleting resource 

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

304 

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

306 """ 

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

308 

309 Args: 

310 item: The item dictionary. 

311 

312 Returns: 

313 The parsed model instance. 

314 

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 

322 

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

324 """ 

325 Transform data after receiving it from the API. 

326 

327 Args: 

328 data: The data to transform. 

329 

330 Returns: 

331 The transformed data. 

332 

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 

338 

339 @overload 

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

341 

342 @overload 

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

344 

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. 

350 

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. 

355 

356 Returns: 

357 The transformed data. 

358 

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) 

365 

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

367 if value in data: 

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

369 return data 

370 

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

372 """ 

373 Create a new model instance. 

374 

375 Args: 

376 **kwargs: Model field values 

377 

378 Returns: 

379 A new model instance. 

380 

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 

386 

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. 

396 

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 

402 

403 Returns: 

404 The JSON-decoded response from the API 

405 

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

410 

411 if isinstance(url, Template): 

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

413 

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

415 return response 

416 

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

418 """ 

419 Handle a response from the API and yield results. 

420 

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 

432 

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 ) 

440 

441 yield from self.handle_results(results) 

442 

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

444 """ 

445 Yield parsed models from a list of results. 

446 

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

451 

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

455 

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) 

463 

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

465 """ 

466 Make the resource callable to get a BaseQuerySet. 

467 

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

469 

470 Args: 

471 *args: Unused 

472 **keywords: Filter parameters 

473 

474 Returns: 

475 A filtered QuerySet 

476 

477 """ 

478 return self.filter(**keywords) 

479 

480 

481class StandardResource(BaseResource[_StandardModel, _StandardQuerySet], Generic[_StandardModel, _StandardQuerySet]): 

482 """ 

483 Base class for API resources. 

484 

485 Args: 

486 client: The PaperlessClient instance. 

487 endpoint: The API endpoint for this resource. 

488 model_class: The model class for this resource. 

489 

490 """ 

491 

492 # The model class for this resource. 

493 model_class: type[_StandardModel] 

494 

495 @override 

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

497 """ 

498 Get a model within this resource by ID. 

499 

500 Args: 

501 model_id: ID of the model to retrieve. 

502 

503 Returns: 

504 The model retrieved 

505 

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) 

510 

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

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

513 

514 # Provide template substitutions for endpoints 

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

516 

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

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

519 

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) 

524 

525 model = self.parse_to_model(response) 

526 

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 ) 

534 

535 return model 

536 

537 @override 

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

539 """ 

540 Update a model. 

541 

542 Args: 

543 model: The model to update. 

544 

545 Returns: 

546 The updated model. 

547 

548 """ 

549 data = model.to_dict() 

550 data = self.transform_data_output(**data) 

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