Coverage for src/paperap/resources/base.py: 72%

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

22 from __future__ import annotations

23

24 import copy

25 import logging

26 from abc import ABC, ABCMeta

27 from string import Template

28 from typing import TYPE_CHECKING, Any, ClassVar, Generic, Iterator, Optional, overload, override

29

30 from typing_extensions import TypeVar

31 from yarl import URL

32

33 from paperap.const import URLS, Endpoints

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

35 from paperap.signals import registry

36

37 if 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

48 logger = logging.getLogger(__name__)

49

50

51 class 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

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