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

1""" 

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

3 

4 METADATA: 

5 

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 

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

29 

30from typing_extensions import TypeVar 

31from yarl import URL 

32 

33from paperap.const import URLS, Endpoints 

34from paperap.exceptions import ConfigurationError, ObjectNotFoundError, ResourceNotFoundError, ResponseParsingError 

35from paperap.signals import registry 

36 

37if TYPE_CHECKING: 

38 from paperap.client import PaperlessClient 

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

40 

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) 

47 

48logger = logging.getLogger(__name__) 

49 

50 

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

52 """ 

53 Base class for API resources. 

54 

55 Args: 

56 client: The PaperlessClient instance. 

57 endpoint: The API endpoint for this resource. 

58 model_class: The model class for this resource. 

59 

60 """ 

61 

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] 

74 

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" 

79 

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 

84 

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

86 self._meta.resource = self 

87 

88 super().__init__() 

89 

90 @classmethod 

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

92 """ 

93 Initialize the subclass. 

94 

95 Args: 

96 **kwargs: Arbitrary keyword arguments 

97 

98 """ 

99 super().__init_subclass__(**kwargs) 

100 

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

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

103 return 

104 

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

108 

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 } 

118 

119 @property 

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

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

122 

123 def all(self) -> _BaseQuerySet: 

124 """ 

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

126 

127 Returns: 

128 A QuerySet for this resource 

129 

130 """ 

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

132 

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

134 """ 

135 Return a QuerySet filtered by the given parameters. 

136 

137 Args: 

138 **kwargs: Filter parameters 

139 

140 Returns: 

141 A filtered QuerySet 

142 

143 """ 

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

145 

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

147 """ 

148 Get a model by ID. 

149 

150 Raises NotImplementedError. Subclasses may implement this. 

151 

152 Raises: 

153 NotImplementedError: Unless implemented by a subclass. 

154 

155 Returns: 

156 The model retrieved. 

157 

158 """ 

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

160 

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

162 """ 

163 Create a new resource. 

164 

165 Args: 

166 data: Resource data. 

167 

168 Returns: 

169 The created resource. 

170 

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) 

175 

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

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

178 

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) 

182 

183 model = self.parse_to_model(response) 

184 

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 ) 

192 

193 return model 

194 

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

196 """ 

197 Update a resource. 

198 

199 Args: 

200 resource: The resource to update. 

201 

202 Returns: 

203 The updated resource. 

204 

205 """ 

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

207 

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

209 """ 

210 Update a resource. 

211 

212 Args: 

213 model_id: ID of the resource. 

214 data: Resource data. 

215 

216 Raises: 

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

218 

219 Returns: 

220 The updated resource. 

221 

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) 

226 

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

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

229 

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) 

233 

234 model = self.parse_to_model(response) 

235 

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 ) 

243 

244 return model 

245 

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

247 """ 

248 Delete a resource. 

249 

250 Args: 

251 model_id: ID of the resource. 

252 

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) 

257 

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

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

260 

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

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

263 

264 # Signal after deleting resource 

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

266 

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

268 """ 

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

270 

271 Args: 

272 item: The item dictionary. 

273 

274 Returns: 

275 The parsed model instance. 

276 

277 """ 

278 data = self.transform_data_input(**item) 

279 return self.model_class.model_validate(data) 

280 

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

282 """ 

283 Transform data after receiving it from the API. 

284 

285 Args: 

286 data: The data to transform. 

287 

288 Returns: 

289 The transformed data. 

290 

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 

296 

297 @overload 

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

299 

300 @overload 

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

302 

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. 

308 

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. 

313 

314 Returns: 

315 The transformed data. 

316 

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) 

323 

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

325 if value in data: 

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

327 return data 

328 

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

330 """ 

331 Create a new model instance. 

332 

333 Args: 

334 **kwargs: Model field values 

335 

336 Returns: 

337 A new model instance. 

338 

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 

344 

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. 

354 

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 

360 

361 Returns: 

362 The JSON-decoded response from the API 

363 

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

368 

369 if isinstance(url, Template): 

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

371 

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

373 return response 

374 

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

376 """ 

377 Handle a response from the API and yield results. 

378 

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 

390 

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 ) 

398 

399 yield from self.handle_results(results) 

400 

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

402 """ 

403 Yield parsed models from a list of results. 

404 

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

410 

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) 

418 

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

420 """ 

421 Make the resource callable to get a BaseQuerySet. 

422 

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

424 

425 Args: 

426 *args: Unused 

427 **keywords: Filter parameters 

428 

429 Returns: 

430 A filtered QuerySet 

431 

432 """ 

433 return self.filter(**keywords) 

434 

435 

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

437 """ 

438 Base class for API resources. 

439 

440 Args: 

441 client: The PaperlessClient instance. 

442 endpoint: The API endpoint for this resource. 

443 model_class: The model class for this resource. 

444 

445 """ 

446 

447 # The model class for this resource. 

448 model_class: type[_StandardModel] 

449 

450 @override 

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

452 """ 

453 Get a model within this resource by ID. 

454 

455 Args: 

456 model_id: ID of the model to retrieve. 

457 

458 Returns: 

459 The model retrieved 

460 

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) 

465 

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

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

468 

469 # Provide template substitutions for endpoints 

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

471 

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

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

474 

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) 

479 

480 model = self.parse_to_model(response) 

481 

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 ) 

489 

490 return model 

491 

492 @override 

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

494 """ 

495 Update a model. 

496 

497 Args: 

498 model: The model to update. 

499 

500 Returns: 

501 The updated model. 

502 

503 """ 

504 data = model.to_dict() 

505 data = self.transform_data_output(**data) 

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