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

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, 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( 

257 "resource.delete:before", "Emitted before deleting a resource", args=[self], kwargs=signal_params 

258 ) 

259 

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

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

262 

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

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

265 

266 # Signal after deleting resource 

267 registry.emit( 

268 "resource.delete:after", "Emitted after deleting a resource", args=[self], kwargs=signal_params 

269 ) 

270 

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

272 """ 

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

274 

275 Args: 

276 item: The item dictionary. 

277 

278 Returns: 

279 The parsed model instance. 

280 

281 """ 

282 data = self.transform_data_input(**item) 

283 return self.model_class.model_validate(data) 

284 

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

286 """ 

287 Transform data after receiving it from the API. 

288 

289 Args: 

290 data: The data to transform. 

291 

292 Returns: 

293 The transformed data. 

294 

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 

300 

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

302 """ 

303 Transform data before sending it to the API. 

304 

305 Args: 

306 data: The data to transform. 

307 

308 Returns: 

309 The transformed data. 

310 

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 

316 

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

318 """ 

319 Create a new model instance. 

320 

321 Args: 

322 **kwargs: Model field values 

323 

324 Returns: 

325 A new model instance. 

326 

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 

332 

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. 

342 

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 

348 

349 Returns: 

350 The JSON-decoded response from the API 

351 

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

356 

357 if isinstance(url, Template): 

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

359 

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

361 return response 

362 

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

364 """ 

365 Handle a response from the API and yield results. 

366 

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 

378 

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 ) 

386 

387 yield from self.handle_results(results) 

388 

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

390 """ 

391 Yield parsed models from a list of results. 

392 

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

398 

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) 

406 

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

408 """ 

409 Make the resource callable to get a BaseQuerySet. 

410 

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

412 

413 Args: 

414 *args: Unused 

415 **keywords: Filter parameters 

416 

417 Returns: 

418 A filtered QuerySet 

419 

420 """ 

421 return self.filter(**keywords) 

422 

423 

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

425 """ 

426 Base class for API resources. 

427 

428 Args: 

429 client: The PaperlessClient instance. 

430 endpoint: The API endpoint for this resource. 

431 model_class: The model class for this resource. 

432 

433 """ 

434 

435 # The model class for this resource. 

436 model_class: type[_StandardModel] 

437 

438 @override 

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

440 """ 

441 Get a model within this resource by ID. 

442 

443 Args: 

444 model_id: ID of the model to retrieve. 

445 

446 Returns: 

447 The model retrieved 

448 

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 ) 

455 

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

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

458 

459 # Provide template substitutions for endpoints 

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

461 

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

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

464 

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) 

469 

470 model = self.parse_to_model(response) 

471 

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 ) 

479 

480 return model 

481 

482 @override 

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

484 """ 

485 Update a model. 

486 

487 Args: 

488 model: The model to update. 

489 

490 Returns: 

491 The updated model. 

492 

493 """ 

494 data = model.to_dict() 

495 data = self.transform_data_output(**data) 

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