Coverage for src/paperap/client.py: 95%
237 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: client.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.9
10 Author: Jess Mann
11 Email: jess@jmann.me
12 Copyright (c) 2025 Jess Mann
14----------------------------------------------------------------------------
16 LAST MODIFIED:
18 2025-03-04 By Jess Mann
20"""
22from __future__ import annotations
24import logging
25import re
26from pathlib import Path
27from typing import TYPE_CHECKING, Any, Literal, Unpack, overload
29import requests
30from pydantic import HttpUrl
32from paperap.auth import AuthBase, BasicAuth, TokenAuth
33from paperap.exceptions import (
34 APIError,
35 AuthenticationError,
36 BadResponseError,
37 ConfigurationError,
38 InsufficientPermissionError,
39 RelationshipNotFoundError,
40 RequestError,
41 ResourceNotFoundError,
42 ResponseParsingError,
43)
44from paperap.resources import (
45 CorrespondentResource,
46 CustomFieldResource,
47 DocumentMetadataResource,
48 DocumentNoteResource,
49 DocumentResource,
50 DocumentSuggestionsResource,
51 DocumentTypeResource,
52 DownloadedDocumentResource,
53 GroupResource,
54 ProfileResource,
55 SavedViewResource,
56 ShareLinksResource,
57 StoragePathResource,
58 TagResource,
59 TaskResource,
60 UISettingsResource,
61 UserResource,
62 WorkflowActionResource,
63 WorkflowResource,
64 WorkflowTriggerResource,
65)
66from paperap.settings import Settings, SettingsArgs
67from paperap.signals import registry
69if TYPE_CHECKING:
70 from paperap.plugins.base import Plugin
71 from paperap.plugins.manager import PluginConfig
73logger = logging.getLogger(__name__)
76class PaperlessClient:
77 """
78 Client for interacting with the Paperless-NgX API.
80 Args:
81 settings: Settings object containing client configuration.
83 Examples:
84 ```python
85 # Using token authentication
86 client = PaperlessClient(
87 Settings(
88 base_url="https://paperless.example.com",
89 token="40characterslong40characterslong40charac"
90 )
91 )
93 # Using basic authentication
94 client = PaperlessClient(
95 Settings(
96 base_url="https://paperless.example.com",
97 username="user",
98 password="pass"
99 )
100 )
102 # Loading all settings from environment variables (e.g. PAPERLESS_TOKEN)
103 client = PaperlessClient()
105 # With context manager
106 with PaperlessClient(...) as client:
107 docs = client.documents.list()
108 ```
110 """
112 settings: Settings
113 auth: AuthBase
114 session: requests.Session
115 plugins: dict[str, "Plugin"]
117 # Resources
118 correspondents: CorrespondentResource
119 custom_fields: CustomFieldResource
120 document_types: DocumentTypeResource
121 document_metadata: DocumentMetadataResource
122 document_suggestions: DocumentSuggestionsResource
123 downloaded_documents: DownloadedDocumentResource
124 documents: DocumentResource
125 document_notes: DocumentNoteResource
126 groups: GroupResource
127 profile: ProfileResource
128 saved_views: SavedViewResource
129 share_links: ShareLinksResource
130 storage_paths: StoragePathResource
131 tags: TagResource
132 tasks: TaskResource
133 ui_settings: UISettingsResource
134 users: UserResource
135 workflow_actions: WorkflowActionResource
136 workflow_triggers: WorkflowTriggerResource
137 workflows: WorkflowResource
139 def __init__(self, settings: Settings | None = None, **kwargs: Unpack[SettingsArgs]) -> None:
140 if not settings:
141 # Any params not provided in kwargs will be loaded from env vars
142 settings = Settings(**kwargs)
144 self.settings = settings
145 # Prioritize username/password over token if both are provided
146 if self.settings.username and self.settings.password:
147 self.auth = BasicAuth(username=self.settings.username, password=self.settings.password)
148 elif self.settings.token:
149 self.auth = TokenAuth(token=self.settings.token)
150 else:
151 raise ValueError("Provide a token, or a username and password")
153 self.session = requests.Session()
155 # Set default headers
156 self.session.headers.update(
157 {
158 "Accept": "application/json; version=2",
159 # Don't set Content-Type here as it will be set appropriately per request
160 # "Content-Type": "application/json",
161 }
162 )
164 # Initialize resources
165 self._init_resources()
166 self._initialize_plugins()
167 super().__init__()
169 @property
170 def base_url(self) -> HttpUrl:
171 """Get the base URL."""
172 return self.settings.base_url
174 def __enter__(self) -> PaperlessClient:
175 return self
177 def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
178 self.close()
180 def _init_resources(self) -> None:
181 """Initialize all API resources."""
182 # Initialize resources
183 self.correspondents = CorrespondentResource(self)
184 self.custom_fields = CustomFieldResource(self)
185 self.document_types = DocumentTypeResource(self)
186 self.document_metadata = DocumentMetadataResource(self)
187 self.document_suggestions = DocumentSuggestionsResource(self)
188 self.downloaded_documents = DownloadedDocumentResource(self)
189 self.documents = DocumentResource(self)
190 self.document_notes = DocumentNoteResource(self)
191 self.groups = GroupResource(self)
192 self.profile = ProfileResource(self)
193 self.saved_views = SavedViewResource(self)
194 self.share_links = ShareLinksResource(self)
195 self.storage_paths = StoragePathResource(self)
196 self.tags = TagResource(self)
197 self.tasks = TaskResource(self)
198 self.ui_settings = UISettingsResource(self)
199 self.users = UserResource(self)
200 self.workflow_actions = WorkflowActionResource(self)
201 self.workflow_triggers = WorkflowTriggerResource(self)
202 self.workflows = WorkflowResource(self)
204 def _initialize_plugins(self, plugin_config: "PluginConfig | None" = None) -> None:
205 """
206 Initialize plugins based on configuration.
208 Args:
209 plugin_config: Optional configuration dictionary for plugins.
211 """
212 from paperap.plugins.manager import PluginManager # pylint: disable=import-outside-toplevel
214 PluginManager.model_rebuild()
216 # Create and configure the plugin manager
217 self.manager = PluginManager(client=self)
219 # Discover available plugins
220 self.manager.discover_plugins()
222 # Configure plugins
223 plugin_config = plugin_config or {
224 "enabled_plugins": ["SampleDataCollector"],
225 "settings": {
226 "SampleDataCollector": {
227 "test_dir": str(Path(__file__).parents[3] / "tests/sample_data"),
228 },
229 },
230 }
231 self.manager.configure(plugin_config)
233 # Initialize all enabled plugins
234 self.plugins = self.manager.initialize_all_plugins()
236 def _get_auth_params(self) -> dict[str, Any]:
237 """Get authentication parameters for requests."""
238 return self.auth.get_auth_params()
240 def get_headers(self) -> dict[str, str]:
241 """Get headers for requests."""
242 headers = {}
244 headers.update(self.auth.get_auth_headers())
246 return headers
248 def close(self) -> None:
249 """Close the client and release resources."""
250 if hasattr(self, "session"):
251 self.session.close()
253 def request_raw(
254 self,
255 method: str,
256 endpoint: str | HttpUrl,
257 *,
258 params: dict[str, Any] | None = None,
259 data: dict[str, Any] | None = None,
260 files: dict[str, Any] | None = None,
261 ) -> requests.Response | None:
262 """
263 Make a request to the Paperless-NgX API.
265 Args:
266 method: HTTP method (GET, POST, PUT, DELETE).
267 endpoint: API endpoint relative to base URL.
268 params: Query parameters for the request.
269 data: Request body data.
270 files: Files to upload.
271 json_response: Whether to parse the response as JSON.
273 Returns:
274 Response object or None if no content.
276 Raises:
277 AuthenticationError: If authentication fails.
278 ResourceNotFoundError: If the requested resource doesn't exist.
279 APIError: If the API returns an error.
280 PaperapError: For other errors.
282 """
283 if isinstance(endpoint, HttpUrl):
284 # Use URL object directly
285 url = str(endpoint)
286 elif isinstance(endpoint, str):
287 if endpoint.startswith("http"):
288 url = endpoint
289 else:
290 url = f"{self.base_url}{endpoint.lstrip('/')}"
291 else:
292 url = f"{self.base_url}{str(endpoint).lstrip('/')}"
294 logger.debug("Requesting %s %s", method, url)
296 # Add headers from authentication and session defaults
297 headers = {**self.session.headers, **self.get_headers()}
299 # Set the appropriate Content-Type header based on the request type
300 if files:
301 # For file uploads, let requests set the multipart/form-data Content-Type with boundary
302 headers.pop("Content-Type", None)
303 elif "Content-Type" not in headers:
304 # For JSON requests, explicitly set the Content-Type
305 headers["Content-Type"] = "application/json"
307 try:
308 # TODO: Temporary hack
309 params = params.get("params", params) if params else params
311 logger.debug(
312 "Request (%s) url %s, params %s, data %s, files %s, headers %s",
313 method,
314 url,
315 params,
316 data,
317 files,
318 headers,
319 )
320 # When uploading files, we need to pass data as form data, not JSON
321 # The key difference is that with files, we MUST use data parameter, not json
322 if files:
323 # For file uploads, use data parameter (not json) to ensure proper multipart/form-data encoding
324 response = self.session.request(
325 method=method,
326 url=url,
327 headers=headers,
328 params=params,
329 data=data, # Use data for form fields with files
330 files=files,
331 timeout=self.settings.timeout,
332 **self._get_auth_params(),
333 )
334 else:
335 # For regular JSON requests
336 response = self.session.request(
337 method=method,
338 url=url,
339 headers=headers,
340 params=params,
341 json=data, # Use json for regular requests
342 timeout=self.settings.timeout,
343 **self._get_auth_params(),
344 )
346 # Handle HTTP errors
347 if response.status_code >= 400:
348 return self._handle_request_errors(response, url, params=params, data=data, files=files)
350 # No content
351 if response.status_code == 204:
352 return None
354 except requests.exceptions.ConnectionError as ce:
355 logger.error(
356 "Unable to connect to Paperless server: %s url %s, params %s, data %s, files %s",
357 method,
358 url,
359 params,
360 data,
361 files,
362 )
363 raise RequestError(f"Connection error: {str(ce)}") from ce
364 except requests.exceptions.RequestException as re:
365 raise RequestError(f"Request failed: {str(re)}") from re
367 return response
369 def _handle_request_errors(
370 self,
371 response: requests.Response,
372 url: str,
373 *,
374 params: dict[str, Any] | None = None,
375 data: dict[str, Any] | None = None,
376 files: dict[str, Any] | None = None,
377 ) -> None:
378 error_message = self._extract_error_message(response)
380 if response.status_code == 400:
381 if "This field is required" in error_message:
382 raise ValueError(f"Required field missing: {error_message}")
383 if matches := re.match(r"([a-zA-Z_-]+): Invalid pk", error_message):
384 raise RelationshipNotFoundError(f"Invalid relationship {matches.group(1)}: {error_message}")
385 if response.status_code == 401:
386 raise AuthenticationError(f"Authentication failed: {error_message}")
387 if response.status_code == 403:
388 if "this site requires a CSRF" in error_message:
389 raise ConfigurationError(f"Response claims CSRF token required. Is the url correct? {url}")
390 raise InsufficientPermissionError(f"Permission denied: {error_message}")
391 if response.status_code == 404:
392 raise ResourceNotFoundError(f"Paperless returned 404 for {url}")
394 # All else...
395 raise BadResponseError(error_message, response.status_code)
397 @overload
398 def _handle_response(self, response: requests.Response, *, json_response: Literal[True] = True) -> dict[str, Any]: ...
400 @overload
401 def _handle_response(self, response: None, *, json_response: bool = True) -> None: ...
403 @overload
404 def _handle_response(self, response: requests.Response | None, *, json_response: Literal[False]) -> bytes | None: ...
406 @overload
407 def _handle_response(self, response: requests.Response | None, *, json_response: bool = True) -> dict[str, Any] | bytes | None: ...
409 def _handle_response(self, response: requests.Response | None, *, json_response: bool = True) -> dict[str, Any] | bytes | None:
410 """Handle the response based on the content type."""
411 if response is None:
412 return None
414 # Try to parse as JSON if requested
415 if json_response:
416 try:
417 return response.json() # type: ignore # mypy can't infer the return type correctly
418 except ValueError as e:
419 url = getattr(response, "url", "unknown URL")
420 logger.error("Failed to parse JSON response: %s -> url %s -> content: %s", e, url, response.content)
421 raise ResponseParsingError(f"Failed to parse JSON response: {str(e)} -> url {url}") from e
423 return response.content
425 @overload
426 def request(
427 self,
428 method: str,
429 endpoint: str | HttpUrl,
430 *,
431 params: dict[str, Any] | None = None,
432 data: dict[str, Any] | None = None,
433 files: dict[str, Any] | None = None,
434 ) -> dict[str, Any] | None: ...
436 @overload
437 def request(
438 self,
439 method: str,
440 endpoint: str | HttpUrl,
441 *,
442 params: dict[str, Any] | None = None,
443 data: dict[str, Any] | None = None,
444 files: dict[str, Any] | None = None,
445 json_response: Literal[False],
446 ) -> bytes | None: ...
448 @overload
449 def request(
450 self,
451 method: str,
452 endpoint: str | HttpUrl,
453 *,
454 params: dict[str, Any] | None = None,
455 data: dict[str, Any] | None = None,
456 files: dict[str, Any] | None = None,
457 json_response: bool = True,
458 ) -> dict[str, Any] | bytes | None: ...
460 def request(
461 self,
462 method: str,
463 endpoint: str | HttpUrl,
464 *,
465 params: dict[str, Any] | None = None,
466 data: dict[str, Any] | None = None,
467 files: dict[str, Any] | None = None,
468 json_response: bool = True,
469 ) -> dict[str, Any] | bytes | None:
470 """
471 Make a request to the Paperless-NgX API.
473 Generally, this should be done using resources, not by calling this method directly.
475 Args:
476 method: HTTP method (GET, POST, PUT, DELETE).
477 endpoint: API endpoint relative to base URL.
478 params: Query parameters for the request.
479 data: Request body data.
480 files: Files to upload.
481 json_response: Whether to parse the response as JSON.
483 Returns:
484 Parsed response data.
486 """
487 kwargs = {
488 "client": self,
489 "method": method,
490 "endpoint": endpoint,
491 "params": params,
492 "data": data,
493 "files": files,
494 "json_response": json_response,
495 }
497 registry.emit("client.request:before", "Before a request is sent to the Paperless server", args=[self], kwargs=kwargs)
499 if not (response := self.request_raw(method, endpoint, params=params, data=data, files=files)):
500 return None
502 registry.emit(
503 "client.request__response",
504 "After a response is received, before it is parsed",
505 args=[response],
506 kwargs=kwargs,
507 )
509 parsed_response = self._handle_response(response, json_response=json_response)
510 parsed_response = registry.emit(
511 "client.request:after",
512 "After a request is parsed.",
513 args=parsed_response,
514 kwargs=kwargs,
515 )
517 return parsed_response
519 def _extract_error_message(self, response: requests.Response) -> str:
520 """Extract error message from response."""
521 try:
522 error_data = response.json()
523 if isinstance(error_data, dict):
524 # Try different possible error formats
525 if "detail" in error_data:
526 return str(error_data["detail"])
527 if "error" in error_data:
528 return str(error_data["error"])
529 if "non_field_errors" in error_data:
530 return ", ".join(error_data["non_field_errors"])
532 # Handle nested error messages
533 messages = []
534 for key, value in error_data.items():
535 if isinstance(value, list):
536 values = [str(i) for i in value]
537 messages.append(f"{key}: {', '.join(values)}")
538 else:
539 messages.append(f"{key}: {value}")
540 return "; ".join(messages)
541 return str(error_data)
542 except ValueError:
543 return response.text or f"HTTP {response.status_code}"
545 def generate_token(
546 self,
547 base_url: str,
548 username: str,
549 password: str,
550 timeout: int | None = None,
551 ) -> str:
552 """
553 Generate an API token using username and password.
555 Args:
556 base_url: The base URL of the Paperless-NgX instance.
557 username: Username for authentication.
558 password: Password for authentication.
559 timeout: Request timeout in seconds.
561 Returns:
562 Generated API token.
564 Raises:
565 AuthenticationError: If authentication fails.
566 PaperapError: For other errors.
568 """
569 if timeout is None:
570 timeout = self.settings.timeout
572 if not base_url.startswith(("http://", "https://")):
573 base_url = f"https://{base_url}"
575 url = f"{base_url.rstrip('/')}/api/token/"
577 registry.emit(
578 "client.generate_token__before",
579 "Before a new token is generated",
580 kwargs={"url": url, "username": username},
581 )
583 try:
584 response = requests.post(
585 url,
586 json={"username": username, "password": password},
587 headers={"Accept": "application/json"},
588 timeout=timeout,
589 )
591 response.raise_for_status()
592 data = response.json()
594 registry.emit(
595 "client.generate_token__after",
596 "After a new token is generated",
597 kwargs={"url": url, "username": username, "response": data},
598 )
600 if "token" not in data:
601 raise ResponseParsingError("Token not found in response")
603 return str(data["token"])
604 except requests.exceptions.HTTPError as he:
605 if he.response.status_code == 401:
606 raise AuthenticationError("Invalid username or password") from he
607 try:
608 error_data = he.response.json()
609 error_message = error_data.get("detail", str(he))
610 except (ValueError, KeyError):
611 error_message = str(he)
613 raise RequestError(f"Failed to generate token: {error_message}") from he
614 except requests.exceptions.RequestException as re:
615 raise RequestError(f"Error while requesting a new token: {str(re)}") from re
616 except (ValueError, KeyError) as ve:
617 raise ResponseParsingError(f"Failed to parse response when generating token: {str(ve)}") from ve
619 def get_statistics(self) -> dict[str, Any]:
620 """
621 Get system statistics.
623 Returns:
624 Dictionary containing system statistics.
626 """
627 if result := self.request("GET", "api/statistics/"):
628 return result
629 raise APIError("Failed to get statistics")
631 def get_system_status(self) -> dict[str, Any]:
632 """
633 Get system status.
635 Returns:
636 Dictionary containing system status information.
638 """
639 if result := self.request("GET", "api/status/"):
640 return result
641 raise APIError("Failed to get system status")
643 def get_config(self) -> dict[str, Any]:
644 """
645 Get system configuration.
647 Returns:
648 Dictionary containing system configuration.
650 """
651 if result := self.request("GET", "api/config/"):
652 return result
653 raise APIError("Failed to get system configuration")