karakeep-python-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1630 @@
1
+ import time
2
+ import os
3
+ import requests
4
+ import time
5
+ import inspect
6
+ import functools
7
+ import re
8
+ import sys
9
+ import pathlib
10
+ import json # Still needed for spec loading if parse_spec not available, and _call
11
+ import datetime
12
+ import typing # Need full import for get_type_hints resolution with forward refs
13
+ from typing import Optional, Dict, Any, List, Callable, Tuple, Union, Type, Literal
14
+ from urllib.parse import urljoin, urlparse
15
+ from loguru import logger
16
+ from pydantic import BaseModel # Import BaseModel for type checking and serialization
17
+ from . import datatypes # Import the generated Pydantic models
18
+
19
+ # --- Custom Exceptions ---
20
+
21
+
22
+ class APIError(Exception):
23
+ """Base exception class for Karakeep API errors."""
24
+
25
+ def __init__(self, message: str, status_code: Optional[int] = None):
26
+ super().__init__(message)
27
+ self.status_code = status_code
28
+ self.message = message
29
+
30
+ def __str__(self) -> str:
31
+ if self.status_code:
32
+ return f"[Status Code: {self.status_code}] {self.message}"
33
+ return self.message
34
+
35
+
36
+ class AuthenticationError(APIError):
37
+ """Exception raised for authentication errors (401)."""
38
+
39
+ def __init__(self, message: str):
40
+ super().__init__(message, status_code=401)
41
+
42
+
43
+ # --- Optional Imports ---
44
+
45
+ # Optional type checking with beartype
46
+ try:
47
+ from beartype import beartype as optional_typecheck
48
+ except ImportError:
49
+
50
+ def optional_typecheck(callable_obj: Callable) -> Callable:
51
+ """Dummy decorator if beartype is not installed."""
52
+ return callable_obj
53
+
54
+
55
+ # Optional BeautifulSoup for parsing HTML errors
56
+ try:
57
+ from bs4 import BeautifulSoup
58
+
59
+ BS4_AVAILABLE = True
60
+ except ImportError:
61
+ BS4_AVAILABLE = False
62
+ BeautifulSoup = None # Define as None if not available
63
+
64
+
65
+ @optional_typecheck
66
+ class KarakeepAPI:
67
+ """
68
+ A Python client for the Karakeep API.
69
+
70
+ Provides methods to interact with a Karakeep instance, based on its OpenAPI specification.
71
+
72
+ The official API documentation can be found at:
73
+ https://docs.karakeep.app/API/
74
+
75
+ The OpenAPI specification used by this client is based on:
76
+ https://github.com/karakeep-app/karakeep/blob/main/packages/open-api/karakeep-openapi-spec.json
77
+
78
+ Attributes:
79
+ api_key (str): The API key used for authentication.
80
+ api_base_url (str): The base URL of the Karakeep API instance.
81
+ openapi_spec (dict): The parsed content of the OpenAPI specification file.
82
+ verify_ssl (bool): Whether SSL verification is enabled.
83
+ verbose (bool): Whether verbose logging is enabled.
84
+ disable_response_validation (bool): Whether Pydantic response validation is disabled.
85
+ """
86
+
87
+ # Version reflects the client library version, updated by bumpver
88
+ VERSION: str = "0.1.0"
89
+
90
+ def __init__(
91
+ self,
92
+ api_key: Optional[str] = None,
93
+ base_url: Optional[str] = None,
94
+ openapi_spec_path: Optional[str] = None, # Allow None, default handled below
95
+ verify_ssl: bool = True,
96
+ verbose: bool = False,
97
+ strict_response_parsing: bool = False, # Kept for potential future use
98
+ disable_response_validation: Optional[bool] = None,
99
+ ):
100
+ """
101
+ Initialize the Karakeep API client.
102
+
103
+ Args:
104
+ api_key: Karakeep API key (Bearer token).
105
+ Defaults to KARAKEEP_PYTHON_API_KEY environment variable if not provided.
106
+ base_url: Override the base URL for the API. Must be provided either as an argument
107
+ or via the KARAKEEP_PYTHON_API_BASE_URL environment variable.
108
+ openapi_spec_path: Path to the OpenAPI JSON specification file.
109
+ Defaults to 'openapi_reference.json' alongside the package code if not provided.
110
+ The loaded spec is available via the `openapi_spec` attribute.
111
+ verify_ssl: Whether to verify SSL certificates (default: True).
112
+ Can be overridden with KARAKEEP_PYTHON_API_VERIFY_SSL environment variable (true/false).
113
+ verbose: Enable verbose logging (default: False).
114
+ Can be overridden with KARAKEEP_PYTHON_API_VERBOSE environment variable (true/false).
115
+ strict_response_parsing: (Currently unused) If True, raise an APIError when response parsing fails.
116
+ disable_response_validation: If True, skip Pydantic validation of API responses and return raw data.
117
+ Defaults to False. Can be overridden by setting the
118
+ KARAKEEP_PYTHON_API_DISABLE_RESPONSE_VALIDATION environment variable to "true".
119
+ """
120
+ # --- API Key Validation ---
121
+ resolved_api_key = api_key or os.environ.get("KARAKEEP_PYTHON_API_KEY")
122
+ if not resolved_api_key:
123
+ raise ValueError(
124
+ "API Key is required. Provide 'api_key' argument or set KARAKEEP_PYTHON_API_KEY environment variable."
125
+ )
126
+ self.api_key = resolved_api_key
127
+ logger.debug("API Key loaded successfully.")
128
+
129
+ # --- Base URL Validation ---
130
+ env_base_url = os.environ.get("KARAKEEP_PYTHON_API_BASE_URL")
131
+ logger.debug(
132
+ f"Checked KARAKEEP_PYTHON_API_BASE_URL environment variable, found: '{env_base_url}'"
133
+ )
134
+ logger.debug(f"Base URL provided as argument: '{base_url}'")
135
+
136
+ if base_url:
137
+ self.api_base_url = base_url
138
+ logger.info(f"Using provided base URL: {self.api_base_url}")
139
+ elif env_base_url:
140
+ self.api_base_url = env_base_url
141
+ logger.info(
142
+ f"Using base URL from KARAKEEP_PYTHON_API_BASE_URL: {self.api_base_url}"
143
+ )
144
+ else:
145
+ # No base_url from arg or env var - raise error as per requirement
146
+ raise ValueError(
147
+ "API base URL is required. Provide 'base_url' argument or set KARAKEEP_PYTHON_API_BASE_URL environment variable."
148
+ )
149
+
150
+ # Ensure base URL ends with /v1/
151
+ resolved_url = self.api_base_url # Use a temporary variable for checks
152
+ if resolved_url.endswith("/v1"):
153
+ # Ends with /v1, needs a slash
154
+ self.api_base_url = resolved_url + "/"
155
+ logger.info(
156
+ f"Appended trailing slash to base URL ending in /v1: {self.api_base_url}"
157
+ )
158
+ elif resolved_url.endswith("/v1/"):
159
+ # Already ends correctly, do nothing
160
+ logger.debug(f"Base URL already ends with /v1/: {self.api_base_url}")
161
+ else:
162
+ # Doesn't end with /v1 or /v1/, append /v1/
163
+ # First, remove any existing trailing slash to avoid //v1/
164
+ if resolved_url.endswith("/"):
165
+ resolved_url = resolved_url[:-1]
166
+ self.api_base_url = resolved_url + "/v1/"
167
+ logger.info(f"Appended /v1/ to base URL: {self.api_base_url}")
168
+
169
+ logger.debug(f"Final API Base URL after /v1/ check: {self.api_base_url}")
170
+
171
+ # --- Load and Parse OpenAPI Spec ---
172
+ if openapi_spec_path is None:
173
+ # Default path relative to this file
174
+ openapi_spec_path = os.path.join(
175
+ os.path.dirname(__file__), "openapi_reference.json"
176
+ )
177
+ logger.debug(
178
+ f"OpenAPI spec path not provided, using default: {openapi_spec_path}"
179
+ )
180
+ else:
181
+ logger.debug(f"Using provided OpenAPI spec path: {openapi_spec_path}")
182
+
183
+ self.openapi_spec: Optional[Dict[str, Any]] = None # Initialize attribute
184
+ try:
185
+ with open(openapi_spec_path, "r", encoding="utf-8") as f:
186
+ self.openapi_spec = json.load(f)
187
+ logger.info(f"Successfully loaded OpenAPI spec from: {openapi_spec_path}")
188
+ except FileNotFoundError:
189
+ logger.error(
190
+ f"OpenAPI specification file not found at: {openapi_spec_path}"
191
+ )
192
+ # Decide if this should be a fatal error or just a warning
193
+ # For now, log error and continue, self.openapi_spec remains None
194
+ # raise APIError(f"OpenAPI specification file not found: {openapi_spec_path}")
195
+ except json.JSONDecodeError as e:
196
+ logger.error(
197
+ f"Failed to parse OpenAPI specification file at {openapi_spec_path}: {e}"
198
+ )
199
+ # Decide if this should be a fatal error
200
+ # raise APIError(f"Invalid JSON in OpenAPI specification file: {openapi_spec_path}") from e
201
+ except Exception as e:
202
+ logger.error(
203
+ f"An unexpected error occurred while loading the OpenAPI spec from {openapi_spec_path}: {e}"
204
+ )
205
+ # raise APIError(f"Failed to load OpenAPI spec: {openapi_spec_path}") from e
206
+
207
+ self.verify_ssl = verify_ssl
208
+ self.verbose = verbose
209
+ self.strict_response_parsing = (
210
+ strict_response_parsing # Currently unused but kept
211
+ )
212
+ self.last_request_time: float = 0.0 # Initialize timestamp for rate limiting
213
+
214
+ # --- Response Validation Setting ---
215
+ # Argument takes precedence over environment variable
216
+ if disable_response_validation is not None:
217
+ self.disable_response_validation = disable_response_validation
218
+ logger.debug(
219
+ f"Response validation explicitly set to {not self.disable_response_validation} via argument."
220
+ )
221
+ else:
222
+ env_disable_validation = os.environ.get(
223
+ "KARAKEEP_PYTHON_API_DISABLE_RESPONSE_VALIDATION", "false"
224
+ ).lower()
225
+ self.disable_response_validation = env_disable_validation == "true"
226
+ logger.debug(
227
+ f"Response validation set to {not self.disable_response_validation} via environment variable (KARAKEEP_PYTHON_API_DISABLE_RESPONSE_VALIDATION={env_disable_validation})."
228
+ )
229
+
230
+ # Configure logger based on verbosity
231
+ if self.verbose:
232
+ logger.add(
233
+ sys.stderr,
234
+ level="DEBUG",
235
+ format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
236
+ )
237
+ logger.info("Verbose logging enabled.")
238
+ else:
239
+ logger.add(sys.stderr, level="INFO") # Default level
240
+
241
+ logger.debug("KarakeepAPI client initialized.")
242
+ logger.debug(f" Base URL: {self.api_base_url}")
243
+ logger.debug(f" Verify SSL: {self.verify_ssl}")
244
+ logger.debug(f" Verbose: {self.verbose}")
245
+ logger.debug(
246
+ f" Disable Response Validation: {self.disable_response_validation}"
247
+ )
248
+ # API Key is intentionally not logged for security
249
+
250
+ # --- Initial Connection Check ---
251
+ try:
252
+ logger.debug("Performing initial connection check by fetching user info...")
253
+ # Call the user info endpoint to verify connection and authentication
254
+ user_info = self.get_current_user_info() # Use the correct method name
255
+ logger.info(
256
+ f"Successfully connected to Karakeep API as user ID: {user_info.get('id', 'N/A')}"
257
+ )
258
+ except (APIError, AuthenticationError) as e:
259
+ logger.error(f"Initial connection check failed: {e}")
260
+ # Re-raise the exception to indicate initialization failure
261
+ raise e
262
+ except Exception as e:
263
+ # Catch any other unexpected errors during the initial check
264
+ logger.error(f"Unexpected error during initial connection check: {e}")
265
+ raise APIError(
266
+ f"Unexpected error during client initialization check: {e}"
267
+ ) from e
268
+
269
+ @optional_typecheck
270
+ def _call(
271
+ self,
272
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"],
273
+ endpoint: str,
274
+ params: Optional[Dict[str, Any]] = None,
275
+ data: Optional[
276
+ Union[BaseModel, dict, list, str, bytes]
277
+ ] = None, # More specific type hint
278
+ extra_headers: Optional[Dict[str, str]] = None,
279
+ ) -> Any:
280
+ """
281
+ Internal method to make an HTTP call to the Karakeep API. Handles authentication,
282
+ request formatting, response parsing, and error handling.
283
+
284
+ Args:
285
+ method: HTTP method ('GET', 'POST', 'PUT', 'PATCH', 'DELETE').
286
+ endpoint: API endpoint path relative to the base URL (e.g., 'bookmarks' or 'bookmarks/some_id').
287
+ Path parameters (like {bookmarkId}) MUST be substituted *before* calling _call.
288
+ params: Dictionary of URL query parameters. Values should be primitive types suitable for URLs.
289
+ data: Request body data. Can be a Pydantic model, dict, list, bytes, or str.
290
+ - Pydantic models, dicts, and lists will be automatically JSON-encoded
291
+ with 'Content-Type: application/json' unless overridden in extra_headers.
292
+ - For bytes or str, ensure 'Content-Type' is set correctly via extra_headers if needed.
293
+ extra_headers: Additional headers to include or override default headers.
294
+
295
+ Returns:
296
+ The parsed JSON response from the API as a dict or list, or None for 204 No Content responses.
297
+ The calling wrapper method is responsible for further parsing/validation into specific Pydantic models.
298
+
299
+ Raises:
300
+ AuthenticationError: If authentication fails (401).
301
+ APIError: For other HTTP errors or request issues.
302
+ """
303
+ # Ensure endpoint doesn't start with / if base_url ends with /
304
+ safe_endpoint = endpoint.lstrip("/")
305
+ url = urljoin(self.api_base_url, safe_endpoint)
306
+
307
+ # Default headers
308
+ headers = {
309
+ "Accept": "application/json", # Default accept type
310
+ "Authorization": f"Bearer {self.api_key}",
311
+ "User-Agent": f"KarakeepPythonAPI/{self.VERSION}",
312
+ }
313
+
314
+ # Merge extra headers, allowing overrides (extra_headers takes precedence)
315
+ if extra_headers:
316
+ # Ensure header keys and values are strings
317
+ stringified_extra_headers = {
318
+ str(k): str(v) for k, v in extra_headers.items()
319
+ }
320
+ headers.update(stringified_extra_headers)
321
+
322
+ # Prepare request body (data) and Content-Type header
323
+ request_body_arg: Optional[Union[bytes, str]] = (
324
+ None # requests takes bytes or str for data arg
325
+ )
326
+ # Determine Content-Type, prioritizing extra_headers
327
+ content_type = headers.get("Content-Type")
328
+
329
+ if data is not None:
330
+ if isinstance(data, BaseModel):
331
+ # Serialize Pydantic model to JSON bytes
332
+ request_body_arg = data.model_dump_json(
333
+ by_alias=True, exclude_none=True
334
+ ).encode("utf-8")
335
+ # Set Content-Type to application/json if not already set differently
336
+ if content_type is None:
337
+ headers["Content-Type"] = "application/json"
338
+ content_type = "application/json" # Update local var for logging
339
+ elif isinstance(data, (dict, list)):
340
+ # Serialize dict/list to JSON bytes
341
+ try:
342
+ request_body_arg = json.dumps(data, ensure_ascii=False).encode(
343
+ "utf-8"
344
+ )
345
+ except TypeError as e:
346
+ raise APIError(
347
+ f"Failed to JSON encode request data (dict/list): {e}"
348
+ ) from e
349
+ # Set Content-Type to application/json if not already set differently
350
+ if content_type is None:
351
+ headers["Content-Type"] = "application/json"
352
+ content_type = "application/json" # Update local var for logging
353
+ elif isinstance(data, str):
354
+ # Pass string directly, requests will encode based on Content-Type or default
355
+ request_body_arg = data
356
+ if content_type is None:
357
+ logger.warning(
358
+ "Request data is str, but Content-Type header is not set. Assuming utf-8."
359
+ )
360
+ # Optionally set a default? Or rely on requests default?
361
+ # headers['Content-Type'] = 'text/plain; charset=utf-8' # Example
362
+ elif isinstance(data, bytes):
363
+ # Pass bytes directly
364
+ request_body_arg = data
365
+ if content_type is None:
366
+ logger.warning(
367
+ "Request data is bytes, but Content-Type header is not set."
368
+ )
369
+ else:
370
+ # Should not happen with type hints, but handle defensively
371
+ raise APIError(
372
+ f"Unsupported request data type: {type(data)}. Use Pydantic model, dict, list, str, or bytes."
373
+ )
374
+
375
+ # Log warning if Content-Type seems mismatched with data type (e.g., JSON data without JSON header)
376
+ if content_type != "application/json" and isinstance(
377
+ data, (BaseModel, dict, list)
378
+ ):
379
+ logger.warning(
380
+ f"Request data is {type(data).__name__} but Content-Type is '{content_type}'. Ensure this is intended."
381
+ )
382
+ elif content_type == "application/json" and isinstance(data, (str, bytes)):
383
+ logger.warning(
384
+ f"Request data is {type(data).__name__} but Content-Type is 'application/json'. Ensure data is valid JSON."
385
+ )
386
+
387
+ if self.verbose:
388
+ # Mask Authorization header for logging
389
+ log_headers = {
390
+ k: ("Bearer ..." if k.lower() == "authorization" else v)
391
+ for k, v in headers.items()
392
+ }
393
+ logger.debug(f"API Request:")
394
+ logger.debug(f" Method: {method}")
395
+ logger.debug(f" URL: {url}")
396
+ logger.debug(f" Params: {params}")
397
+ logger.debug(f" Headers: {log_headers}")
398
+ # Log body carefully, decoding bytes if possible for readability
399
+ log_body_display = "None"
400
+ if request_body_arg:
401
+ if isinstance(request_body_arg, bytes):
402
+ try:
403
+ # Try decoding as UTF-8 for logging, fallback to repr
404
+ log_body_display = request_body_arg.decode(
405
+ "utf-8", errors="replace"
406
+ )
407
+ except Exception: # Broad catch for safety
408
+ log_body_display = repr(request_body_arg)
409
+ else: # Should be str or dict/list if not bytes
410
+ log_body_display = repr(request_body_arg)
411
+
412
+ # Truncate long bodies for logging
413
+ if len(log_body_display) > 500:
414
+ log_body_display = log_body_display[:500] + "...(truncated)"
415
+ logger.debug(f" Body: {log_body_display}") # Logging body remains the same
416
+
417
+ # --- Make the Request ---
418
+ try:
419
+ # Filter out None values from params before sending
420
+ filtered_params = (
421
+ {k: v for k, v in params.items() if v is not None} if params else None
422
+ )
423
+
424
+ # Explicitly convert boolean values in params to capitalized strings "True" or "False"
425
+ # This is needed if the API specifically expects these strings instead of standard 'true'/'false'.
426
+ stringified_bool_params = {}
427
+ if filtered_params:
428
+ for k, v in filtered_params.items():
429
+ if isinstance(v, bool):
430
+ # Convert Python bool to lowercase string "true" or "false"
431
+ stringified_bool_params[k] = str(v).lower()
432
+ else:
433
+ stringified_bool_params[k] = v
434
+ # Use the dictionary with stringified booleans for the request
435
+ request_params = stringified_bool_params
436
+ else:
437
+ request_params = None
438
+
439
+ # Using requests.request directly for simplicity, session might be better for performance
440
+ response = None
441
+ trial = 0
442
+ max_trial = 3
443
+ while response is None:
444
+ trial += 1
445
+ try:
446
+ # Enforce rate limit before making the request
447
+ self._enforce_rate_limit()
448
+
449
+ response = requests.request(
450
+ method=method,
451
+ url=url,
452
+ params=request_params, # Use params with stringified booleans
453
+ data=request_body_arg, # Serialized data (bytes or str)
454
+ headers=headers,
455
+ verify=self.verify_ssl,
456
+ timeout=60, # Increased default timeout
457
+ )
458
+ except Exception as e:
459
+ if trial >= max_trial:
460
+ logger.error("Too many retries. Crashing.")
461
+ raise
462
+ if "max retries exceeded" in str(e).lower():
463
+ logger.warning(
464
+ f"Error encounterd during requests. Trial={trial}/{max_trial}. Retrying after a small wait.\nError: {e}"
465
+ )
466
+ time.sleep(trial * 2)
467
+ else:
468
+ raise
469
+
470
+ if self.verbose:
471
+ logger.debug(f"API Response:")
472
+ logger.debug(f" Status Code: {response.status_code}")
473
+ logger.debug(f" Headers: {response.headers}")
474
+
475
+ # --- Handle Response ---
476
+
477
+ # Check for specific auth error first for a more specific exception
478
+ if response.status_code == 401:
479
+ error_msg = f"Authentication failed (401): Check your API Key. URL: {method} {url}"
480
+ logger.error(error_msg)
481
+ # Attempt to get more details from response body
482
+ try:
483
+ details = response.json().get("message", response.text)
484
+ error_msg += f" Details: {details[:200]}..." # Add snippet
485
+ except Exception:
486
+ error_msg += f" Raw Response: {response.text[:200]}..."
487
+ raise AuthenticationError(error_msg)
488
+
489
+ # Check for other client/server errors (4xx/5xx)
490
+ response.raise_for_status() # Raises requests.exceptions.HTTPError for bad responses
491
+
492
+ # Handle successful No Content response (204)
493
+ if response.status_code == 204 or not response.content:
494
+ if self.verbose:
495
+ logger.debug(" Body: None (204 No Content or empty response body)")
496
+ return None
497
+
498
+ # Attempt to parse successful response as JSON
499
+ try:
500
+ result = response.json()
501
+ if self.verbose:
502
+ # Log parsed response body carefully
503
+ log_resp_str = repr(result)
504
+ if len(log_resp_str) > 1000:
505
+ log_resp_str = log_resp_str[:1000] + "...(truncated)"
506
+ logger.debug(f" Body (JSON Parsed): {log_resp_str}")
507
+ # Return the raw parsed JSON (dict/list). Deserialization into
508
+ # specific Pydantic models should happen in the calling wrapper method.
509
+ return result
510
+ except json.JSONDecodeError as e:
511
+ # Handle cases where the response is successful (2xx) but not valid JSON
512
+ logger.error(
513
+ f"API Error: Failed to decode JSON response from {method} {url}. Status: {response.status_code}. Content: {response.text[:500]}..."
514
+ )
515
+ # Raise APIError as the response format is unexpected
516
+ raise APIError(
517
+ message=f"Failed to parse successful API response JSON from {url}: {e}. Response text: {response.text[:200]}...",
518
+ status_code=response.status_code,
519
+ ) from e
520
+
521
+ except requests.exceptions.HTTPError as e:
522
+ # Handle 4xx/5xx errors raised by response.raise_for_status()
523
+ error_status_code = e.response.status_code
524
+ error_body = e.response.text
525
+ error_details = error_body # Default to raw body
526
+
527
+ # Attempt to extract a more meaningful message from the error response body
528
+ try:
529
+ parsed_error = json.loads(error_body)
530
+ if isinstance(parsed_error, dict):
531
+ # Look for common error message keys
532
+ error_details = parsed_error.get(
533
+ "message", parsed_error.get("detail", error_body)
534
+ )
535
+ except json.JSONDecodeError:
536
+ # Not JSON, try parsing as HTML if bs4 is available and looks like HTML
537
+ if BS4_AVAILABLE and error_body.strip().startswith(
538
+ ("<html", "<!DOCTYPE")
539
+ ):
540
+ try:
541
+ soup = BeautifulSoup(error_body, "html.parser")
542
+ # Extract text, remove excessive whitespace
543
+ html_text = " ".join(soup.get_text().split())
544
+ if html_text: # Use parsed text if not empty
545
+ error_details = html_text
546
+ except Exception as parse_err: # Catch potential parsing errors
547
+ logger.warning(
548
+ f"Failed to parse HTML error body with BeautifulSoup: {parse_err}. Falling back to raw text."
549
+ )
550
+ # error_details remains raw body
551
+
552
+ # Log the error
553
+ max_log_len = 500
554
+ log_details = (
555
+ error_details[:max_log_len] + "..."
556
+ if len(error_details) > max_log_len
557
+ else error_details
558
+ )
559
+ logger.error(
560
+ f"API HTTP Error {error_status_code} for {method} {url}. Response: {log_details}"
561
+ )
562
+
563
+ # Raise our custom APIError
564
+ max_exc_len = 500
565
+ truncated_details = (
566
+ error_details[:max_exc_len] + "..."
567
+ if len(error_details) > max_exc_len
568
+ else error_details
569
+ )
570
+ raise APIError(
571
+ message=f"API request failed for {method} {url}: {truncated_details}",
572
+ status_code=error_status_code,
573
+ ) from e
574
+
575
+ except requests.exceptions.Timeout as e:
576
+ logger.error(f"API Error: Request timed out for {method} {url}: {e}")
577
+ raise APIError(message=f"Request timed out for {method} {url}") from e
578
+ except requests.exceptions.ConnectionError as e:
579
+ logger.error(f"API Error: Connection error for {method} {url}: {e}")
580
+ raise APIError(
581
+ message=f"Connection error for {method} {url}: {str(e)}"
582
+ ) from e
583
+ except requests.exceptions.RequestException as e:
584
+ # Catch other potential request-related errors
585
+ logger.error(
586
+ f"API Error: An unexpected request exception occurred for {method} {url}: {e}"
587
+ )
588
+ raise APIError(
589
+ message=f"API request failed for {method} {url}: {str(e)}"
590
+ ) from e
591
+
592
+ @optional_typecheck
593
+ def _enforce_rate_limit(self, min_interval_sec: float = 1.0) -> None:
594
+ """
595
+ Ensures a minimum time interval between consecutive API calls.
596
+
597
+ If the time since the last call is less than `min_interval_sec`, this method
598
+ will sleep for the remaining duration. It then updates the timestamp of the
599
+ last request.
600
+
601
+ Args:
602
+ min_interval_sec: The minimum desired interval between requests in seconds.
603
+ """
604
+ current_time = time.monotonic()
605
+ time_since_last = current_time - self.last_request_time
606
+
607
+ if time_since_last < min_interval_sec:
608
+ sleep_duration = min_interval_sec - time_since_last
609
+ if self.verbose:
610
+ logger.debug(
611
+ f"Rate limit triggered. Sleeping for {sleep_duration:.3f} seconds."
612
+ )
613
+ time.sleep(sleep_duration)
614
+
615
+ # Update last request time *after* potential sleep
616
+ self.last_request_time = time.monotonic()
617
+
618
+ # --- Dynamically Generated API Methods ---
619
+
620
+ @optional_typecheck
621
+ def get_all_bookmarks(
622
+ self,
623
+ archived: Optional[bool] = None,
624
+ favourited: Optional[bool] = None,
625
+ limit: Optional[int] = None,
626
+ cursor: Optional[str] = None,
627
+ include_content: bool = True, # Default from spec
628
+ ) -> Any: # Returns PaginatedBookmarks or raw dict/list
629
+ """
630
+ Get all bookmarks. Corresponds to GET /bookmarks.
631
+
632
+ Args:
633
+ archived: Filter by archived status (optional).
634
+ favourited: Filter by favourited status (optional).
635
+ limit: Maximum number of bookmarks to return (optional).
636
+ cursor: Pagination cursor for the next page (optional).
637
+ include_content: If set to true, bookmark's content will be included (default: True).
638
+
639
+ Returns:
640
+ datatypes.PaginatedBookmarks: Paginated list of bookmarks.
641
+ If response validation is disabled, returns the raw API response (dict/list).
642
+
643
+ Raises:
644
+ APIError: If the API request fails.
645
+ pydantic.ValidationError: If response validation fails (and is not disabled).
646
+ """
647
+ params = {
648
+ "archived": archived,
649
+ "favourited": favourited,
650
+ "limit": limit,
651
+ "cursor": cursor,
652
+ "includeContent": include_content, # Use camelCase as per API spec query param
653
+ }
654
+ response_data = self._call("GET", "bookmarks", params=params)
655
+
656
+ if self.disable_response_validation:
657
+ logger.debug("Skipping response validation as requested.")
658
+ return response_data
659
+ else:
660
+ # Response should match PaginatedBookmarks schema
661
+ return datatypes.PaginatedBookmarks.model_validate(response_data)
662
+
663
+ @optional_typecheck
664
+ def create_a_new_bookmark(
665
+ self,
666
+ type: Literal["link", "text", "asset"],
667
+ # Common optional fields
668
+ title: Optional[str] = None,
669
+ archived: Optional[bool] = None,
670
+ favourited: Optional[bool] = None,
671
+ note: Optional[str] = None,
672
+ summary: Optional[str] = None,
673
+ createdAt: Optional[str] = None, # ISO 8601 format string
674
+ # Link specific
675
+ url: Optional[str] = None,
676
+ precrawledArchiveId: Optional[str] = None,
677
+ # Text specific
678
+ text: Optional[str] = None,
679
+ sourceUrl: Optional[str] = None, # Also used by asset
680
+ # Asset specific
681
+ asset_type: Optional[Literal["image", "pdf"]] = None,
682
+ assetId: Optional[str] = None,
683
+ fileName: Optional[str] = None,
684
+ # size: Optional[int] = None, # Size is not in POST spec
685
+ # content: Optional[str] = None, # Content is not in POST spec
686
+ ) -> Any: # Returns Bookmark or raw dict/list
687
+ """
688
+ Create a new bookmark. Corresponds to POST /bookmarks.
689
+
690
+ Args:
691
+ type: The type of bookmark ('link', 'text', 'asset'). Required.
692
+ title: Optional title for the bookmark (max 1000 chars).
693
+ archived: Optional boolean indicating if the bookmark is archived.
694
+ favourited: Optional boolean indicating if the bookmark is favourited.
695
+ note: Optional note content for the bookmark.
696
+ summary: Optional summary content for the bookmark.
697
+ createdAt: Optional creation timestamp override (ISO 8601 format string).
698
+
699
+ --- Link Type Specific ---
700
+ url: The URL for the link bookmark. Required if type='link'.
701
+ precrawledArchiveId: Optional ID of a pre-crawled archive.
702
+
703
+ --- Text Type Specific ---
704
+ text: The text content for the text bookmark. Required if type='text'.
705
+ sourceUrl: Optional source URL where the text originated.
706
+
707
+ --- Asset Type Specific ---
708
+ asset_type: The type of asset ('image' or 'pdf'). Required if type='asset'.
709
+ assetId: The ID of the uploaded asset. Required if type='asset'.
710
+ fileName: Optional filename for the asset.
711
+ sourceUrl: Optional source URL where the asset originated.
712
+
713
+ Returns:
714
+ datatypes.Bookmark: The created bookmark.
715
+ If response validation is disabled, returns the raw API response (dict/list).
716
+
717
+ Raises:
718
+ ValueError: If required arguments for the specified type are missing.
719
+ APIError: If the API request fails (e.g., bad request).
720
+ pydantic.ValidationError: If response validation fails (and is not disabled).
721
+ """
722
+ # --- Construct the request body ---
723
+ request_body: Dict[str, Any] = {"type": type}
724
+
725
+ # Add common optional fields if provided
726
+ if title is not None:
727
+ request_body["title"] = title
728
+ if archived is not None:
729
+ request_body["archived"] = archived
730
+ if favourited is not None:
731
+ request_body["favourited"] = favourited
732
+ if note is not None:
733
+ request_body["note"] = note
734
+ if summary is not None:
735
+ request_body["summary"] = summary
736
+ if createdAt is not None:
737
+ request_body["createdAt"] = createdAt
738
+
739
+ # Add type-specific fields and perform validation
740
+ if type == "link":
741
+ if url is None:
742
+ raise ValueError("Argument 'url' is required when type is 'link'.")
743
+ request_body["url"] = url
744
+ if precrawledArchiveId is not None:
745
+ request_body["precrawledArchiveId"] = precrawledArchiveId
746
+ elif type == "text":
747
+ if text is None:
748
+ raise ValueError("Argument 'text' is required when type is 'text'.")
749
+ request_body["text"] = text
750
+ if sourceUrl is not None:
751
+ request_body["sourceUrl"] = sourceUrl
752
+ elif type == "asset":
753
+ if asset_type is None:
754
+ raise ValueError(
755
+ "Argument 'asset_type' ('image' or 'pdf') is required when type is 'asset'."
756
+ )
757
+ if assetId is None:
758
+ raise ValueError(
759
+ "Argument 'assetId' is required when type is 'asset'."
760
+ )
761
+ request_body["assetType"] = asset_type
762
+ request_body["assetId"] = assetId
763
+ if fileName is not None:
764
+ request_body["fileName"] = fileName
765
+ if sourceUrl is not None:
766
+ request_body["sourceUrl"] = sourceUrl
767
+ else:
768
+ # Should not happen with Literal type hint, but defensive check
769
+ raise ValueError(f"Invalid bookmark type specified: {type}")
770
+
771
+ # --- Make the API call ---
772
+ response_data = self._call("POST", "bookmarks", data=request_body)
773
+
774
+ if self.disable_response_validation:
775
+ logger.debug("Skipping response validation as requested.")
776
+ return response_data
777
+ else:
778
+ # Response should match Bookmark schema
779
+ return datatypes.Bookmark.model_validate(response_data)
780
+
781
+ @optional_typecheck
782
+ def search_bookmarks(
783
+ self,
784
+ q: str, # Search query is required
785
+ limit: Optional[int] = None,
786
+ cursor: Optional[str] = None,
787
+ include_content: bool = True, # Default from spec
788
+ ) -> Any: # Returns PaginatedBookmarks or raw dict/list
789
+ """
790
+ Search bookmarks. Corresponds to GET /bookmarks/search.
791
+
792
+ Args:
793
+ q: The search query string.
794
+ limit: Maximum number of bookmarks to return (optional).
795
+ cursor: Pagination cursor for the next page (optional).
796
+ include_content: If set to true, bookmark's content will be included (default: True).
797
+
798
+ Returns:
799
+ datatypes.PaginatedBookmarks: Paginated list of bookmarks matching the search query.
800
+ If response validation is disabled, returns the raw API response (dict/list).
801
+
802
+ Raises:
803
+ APIError: If the API request fails.
804
+ pydantic.ValidationError: If response validation fails (and is not disabled).
805
+ """
806
+ params = {
807
+ "q": q,
808
+ "limit": limit,
809
+ "cursor": cursor,
810
+ "includeContent": include_content, # Use camelCase as per API spec query param
811
+ }
812
+ response_data = self._call("GET", "bookmarks/search", params=params)
813
+
814
+ if self.disable_response_validation:
815
+ logger.debug("Skipping response validation as requested.")
816
+ return response_data
817
+ else:
818
+ # Response should match PaginatedBookmarks schema
819
+ return datatypes.PaginatedBookmarks.model_validate(response_data)
820
+
821
+ @optional_typecheck
822
+ def get_a_single_bookmark(
823
+ self, bookmark_id: str, include_content: bool = True # Default from spec
824
+ ) -> Any: # Returns Bookmark or raw dict/list
825
+ """
826
+ Get a single bookmark by its ID. Corresponds to GET /bookmarks/{bookmarkId}.
827
+
828
+ Args:
829
+ bookmark_id: The ID (string) of the bookmark to retrieve.
830
+ include_content: If set to true, bookmark's content will be included (default: True).
831
+
832
+ Returns:
833
+ datatypes.Bookmark: The requested bookmark.
834
+ If response validation is disabled, returns the raw API response (dict/list).
835
+
836
+ Raises:
837
+ APIError: If the API request fails (e.g., 404 bookmark not found).
838
+ pydantic.ValidationError: If response validation fails (and is not disabled).
839
+ """
840
+ endpoint = f"bookmarks/{bookmark_id}"
841
+ params = {
842
+ "includeContent": include_content
843
+ } # Use camelCase as per API spec query param
844
+ response_data = self._call("GET", endpoint, params=params)
845
+
846
+ if self.disable_response_validation:
847
+ logger.debug("Skipping response validation as requested.")
848
+ return response_data
849
+ else:
850
+ # Response should match Bookmark schema
851
+ return datatypes.Bookmark.model_validate(response_data)
852
+
853
+ @optional_typecheck
854
+ def delete_a_bookmark(self, bookmark_id: str) -> None:
855
+ """
856
+ Delete a bookmark by its ID. Corresponds to DELETE /bookmarks/{bookmarkId}.
857
+
858
+ bookmark_id: The ID (string) of the bookmark to delete.
859
+
860
+ Returns:
861
+ None: Returns None upon successful deletion (204 No Content).
862
+
863
+ Raises:
864
+ APIError: If the API request fails (e.g., 404 bookmark not found).
865
+ """
866
+ endpoint = f"bookmarks/{bookmark_id}"
867
+ self._call("DELETE", endpoint) # Expects 204 No Content
868
+ return None # Explicitly return None for 204
869
+
870
+ @optional_typecheck
871
+ def update_a_bookmark(
872
+ self, bookmark_id: str, update_data: dict
873
+ ) -> Any: # Returns dict
874
+ """
875
+ Update a bookmark by its ID. Corresponds to PATCH /bookmarks/{bookmarkId}.
876
+ Allows updating fields like 'archived', 'favourited', 'summary', 'note', 'title', etc.
877
+
878
+ Args:
879
+ bookmark_id: The ID (string) of the bookmark to update.
880
+ update_data: A dictionary containing the fields to update (e.g., `{"archived": True}`).
881
+ See the OpenAPI spec for allowed fields in the request body.
882
+
883
+ Returns:
884
+ dict: A dictionary representing the updated bookmark (partial representation).
885
+ The response typically includes 'id', 'createdAt', 'modifiedAt', 'title', 'archived', 'favourited',
886
+ 'taggingStatus', 'note', 'summary'.
887
+ Validation is not performed on this response type by default.
888
+
889
+ Raises:
890
+ APIError: If the API request fails (e.g., 404 bookmark not found).
891
+ """
892
+ endpoint = f"bookmarks/{bookmark_id}"
893
+ response_data = self._call("PATCH", endpoint, data=update_data)
894
+ # The response schema is a subset of Bookmark, return as dict as specified in spec
895
+ # No Pydantic validation applied here as the spec defines a partial response (dict)
896
+ return response_data
897
+
898
+ @optional_typecheck
899
+ def summarize_a_bookmark(self, bookmark_id: str) -> Any: # Returns dict
900
+ """
901
+ Summarize a bookmark by its ID. Corresponds to POST /bookmarks/{bookmarkId}/summarize.
902
+ This triggers the summarization process and returns the updated bookmark record (partially).
903
+
904
+ bookmark_id: The ID (string) of the bookmark to summarize.
905
+
906
+ Returns:
907
+ dict: A dictionary representing the updated bookmark with the summary (partial representation).
908
+ Similar structure to the response of `update_a_bookmark`.
909
+ Validation is not performed on this response type by default.
910
+
911
+ Raises:
912
+ APIError: If the API request fails (e.g., 404 bookmark not found, summarization failure).
913
+ """
914
+ endpoint = f"bookmarks/{bookmark_id}/summarize"
915
+ response_data = self._call("POST", endpoint)
916
+ # The response schema is a subset of Bookmark, return as dict as specified in spec
917
+ # No Pydantic validation applied here as the spec defines a partial response (dict)
918
+ return response_data
919
+
920
+ @optional_typecheck
921
+ def attach_tags_to_a_bookmark(
922
+ self, bookmark_id: str, tags_data: dict
923
+ ) -> Any: # Returns dict
924
+ """
925
+ Attach one or more tags to a bookmark. Corresponds to POST /bookmarks/{bookmarkId}/tags.
926
+
927
+ bookmark_id: The ID (string) of the bookmark.
928
+ tags_data: Dictionary specifying the tags to attach. Must contain a "tags" key
929
+ which is a list of objects, each having *either* "tagId" (string) *or* "tagName" (string).
930
+ Example: `{"tags": [{"tagId": "existing_tag_id"}, {"tagName": "new_or_existing_tag_name"}]}`
931
+
932
+ Returns:
933
+ dict: A dictionary containing the list of attached tag IDs under the key "attached".
934
+ Example: `{"attached": ["tag_id_1", "tag_id_2"]}`
935
+ Validation is not performed on this response type by default.
936
+
937
+ Raises:
938
+ APIError: If the API request fails (e.g., 404 bookmark not found).
939
+ """
940
+ endpoint = f"bookmarks/{bookmark_id}/tags"
941
+ response_data = self._call("POST", endpoint, data=tags_data)
942
+ # Response schema is {"attached": [TagId]}, return as dict
943
+ # No Pydantic validation applied here as the spec defines a simple dict response
944
+ return response_data
945
+
946
+ @optional_typecheck
947
+ def detach_tags_from_a_bookmark(
948
+ self, bookmark_id: str, tags_data: dict
949
+ ) -> Any: # Returns dict
950
+ """
951
+ Detach one or more tags from a bookmark. Corresponds to DELETE /bookmarks/{bookmarkId}/tags.
952
+
953
+ bookmark_id: The ID (string) of the bookmark.
954
+ tags_data: Dictionary specifying the tags to detach. Must contain a "tags" key
955
+ which is a list of objects, each having *either* "tagId" (string) *or* "tagName" (string).
956
+ Example: `{"tags": [{"tagId": "tag_id_to_remove"}, {"tagName": "tag_name_to_remove"}]}`
957
+
958
+ Returns:
959
+ dict: A dictionary containing the list of detached tag IDs under the key "detached".
960
+ Example: `{"detached": ["tag_id_1", "tag_id_2"]}`
961
+ Validation is not performed on this response type by default.
962
+
963
+ Raises:
964
+ APIError: If the API request fails (e.g., 404 bookmark not found).
965
+ """
966
+ endpoint = f"bookmarks/{bookmark_id}/tags"
967
+ response_data = self._call("DELETE", endpoint, data=tags_data)
968
+ # Response schema is {"detached": [TagId]}, return as dict
969
+ # No Pydantic validation applied here as the spec defines a simple dict response
970
+ return response_data
971
+
972
+ @optional_typecheck
973
+ def get_highlights_of_a_bookmark(
974
+ self, bookmark_id: str
975
+ ) -> Any: # Returns List[Highlight] or raw dict/list
976
+ """
977
+ Get all highlights associated with a specific bookmark. Corresponds to GET /bookmarks/{bookmarkId}/highlights.
978
+
979
+ bookmark_id: The ID (string) of the bookmark.
980
+
981
+ Returns:
982
+ List[datatypes.Highlight]: A list of highlight objects associated with the bookmark.
983
+ If response validation is disabled, returns the raw API response (dict/list).
984
+
985
+ Raises:
986
+ APIError: If the API request fails (e.g., 404 bookmark not found).
987
+ pydantic.ValidationError: If response validation fails (and is not disabled).
988
+ """
989
+ endpoint = f"bookmarks/{bookmark_id}/highlights"
990
+ response_data = self._call("GET", endpoint)
991
+
992
+ if self.disable_response_validation:
993
+ logger.debug("Skipping response validation as requested.")
994
+ # Return raw data, which might be {"highlights": [...]} or something else
995
+ return response_data
996
+ else:
997
+ # Response schema is {"highlights": [Highlight]}, extract the list and validate
998
+ if (
999
+ isinstance(response_data, dict)
1000
+ and "highlights" in response_data
1001
+ and isinstance(response_data["highlights"], list)
1002
+ ):
1003
+ try:
1004
+ return [
1005
+ datatypes.Highlight.model_validate(h)
1006
+ for h in response_data["highlights"]
1007
+ ]
1008
+ except (
1009
+ Exception
1010
+ ) as e: # Catch validation errors during list comprehension
1011
+ logger.error(f"Validation failed for one or more highlights: {e}")
1012
+ raise # Re-raise the validation error
1013
+ else:
1014
+ # Raise error if format is unexpected and validation is enabled
1015
+ raise APIError(
1016
+ f"Unexpected response format for get_highlights_of_a_bookmark when validation is enabled: {response_data}"
1017
+ )
1018
+
1019
+ @optional_typecheck
1020
+ def attach_asset(
1021
+ self, bookmark_id: str, asset_data: dict
1022
+ ) -> Any: # Returns Asset or raw dict/list
1023
+ """
1024
+ Attach a new asset to a bookmark. Corresponds to POST /bookmarks/{bookmarkId}/assets.
1025
+
1026
+ bookmark_id: The ID (string) of the bookmark.
1027
+ asset_data: Dictionary specifying the asset to attach. Must contain "id" (string) and "assetType" (string enum).
1028
+ Example: `{"id": "asset_id_string", "assetType": "screenshot"}`
1029
+ See `datatypes.AssetType1` enum for possible asset types.
1030
+
1031
+ Returns:
1032
+ datatypes.Asset: The attached asset object.
1033
+ If response validation is disabled, returns the raw API response (dict/list).
1034
+
1035
+ Raises:
1036
+ APIError: If the API request fails (e.g., 404 bookmark not found).
1037
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1038
+ """
1039
+ endpoint = f"bookmarks/{bookmark_id}/assets"
1040
+ response_data = self._call("POST", endpoint, data=asset_data)
1041
+
1042
+ if self.disable_response_validation:
1043
+ logger.debug("Skipping response validation as requested.")
1044
+ return response_data
1045
+ else:
1046
+ # Response should match Asset schema
1047
+ return datatypes.Asset.model_validate(response_data)
1048
+
1049
+ @optional_typecheck
1050
+ def replace_asset(
1051
+ self, bookmark_id: str, asset_id: str, new_asset_data: dict
1052
+ ) -> None:
1053
+ """
1054
+ Replace an existing asset associated with a bookmark with a new one.
1055
+ Corresponds to PUT /bookmarks/{bookmarkId}/assets/{assetId}.
1056
+
1057
+ bookmark_id: The ID (string) of the bookmark.
1058
+ asset_id: The ID (string) of the asset to be replaced.
1059
+ new_asset_data: Dictionary specifying the new asset ID. Must contain "assetId" (string).
1060
+ Example: `{"assetId": "new_asset_id_string"}`
1061
+
1062
+ Returns:
1063
+ None: Returns None upon successful replacement (204 No Content).
1064
+
1065
+ Raises:
1066
+ APIError: If the API request fails (e.g., 404 bookmark or asset not found).
1067
+ """
1068
+ endpoint = f"bookmarks/{bookmark_id}/assets/{asset_id}"
1069
+ self._call("PUT", endpoint, data=new_asset_data) # Expects 204 No Content
1070
+ return None # Explicitly return None for 204
1071
+
1072
+ @optional_typecheck
1073
+ def detach_asset(self, bookmark_id: str, asset_id: str) -> None:
1074
+ """
1075
+ Detach an asset from a bookmark. Corresponds to DELETE /bookmarks/{bookmarkId}/assets/{assetId}.
1076
+
1077
+ bookmark_id: The ID (string) of the bookmark.
1078
+ asset_id: The ID (string) of the asset to detach.
1079
+
1080
+ Returns:
1081
+ None: Returns None upon successful detachment (204 No Content).
1082
+
1083
+ Raises:
1084
+ APIError: If the API request fails (e.g., 404 bookmark or asset not found).
1085
+ """
1086
+ endpoint = f"bookmarks/{bookmark_id}/assets/{asset_id}"
1087
+ self._call("DELETE", endpoint) # Expects 204 No Content
1088
+ return None # Explicitly return None for 204
1089
+
1090
+ @optional_typecheck
1091
+ def get_all_lists(self) -> Any: # Returns List[ListModel] or raw dict/list
1092
+ """
1093
+ Get all lists for the current user. Corresponds to GET /lists.
1094
+
1095
+ List[datatypes.ListModel]: A list of list objects.
1096
+ If response validation is disabled, returns the raw API response (dict/list).
1097
+
1098
+ Raises:
1099
+ APIError: If the API request fails.
1100
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1101
+ """
1102
+ response_data = self._call("GET", "lists")
1103
+
1104
+ if self.disable_response_validation:
1105
+ logger.debug("Skipping response validation as requested.")
1106
+ # Return raw data, which might be {"lists": [...]} or something else
1107
+ return response_data
1108
+ else:
1109
+ # Response schema is {"lists": [ListModel]}, extract the list and validate
1110
+ if (
1111
+ isinstance(response_data, dict)
1112
+ and "lists" in response_data
1113
+ and isinstance(response_data["lists"], list)
1114
+ ):
1115
+ try:
1116
+ return [
1117
+ datatypes.ListModel.model_validate(lst)
1118
+ for lst in response_data["lists"]
1119
+ ]
1120
+ except (
1121
+ Exception
1122
+ ) as e: # Catch validation errors during list comprehension
1123
+ logger.error(f"Validation failed for one or more lists: {e}")
1124
+ raise # Re-raise the validation error
1125
+ else:
1126
+ # Raise error if format is unexpected and validation is enabled
1127
+ raise APIError(
1128
+ f"Unexpected response format for get_all_lists when validation is enabled: {response_data}"
1129
+ )
1130
+
1131
+ @optional_typecheck
1132
+ def create_a_new_list(
1133
+ self, list_data: dict
1134
+ ) -> Any: # Returns ListModel or raw dict/list
1135
+ """
1136
+ Create a new list (manual or smart). Corresponds to POST /lists.
1137
+
1138
+ list_data: Dictionary containing the data for the new list. Requires "name" (string) and "icon" (string).
1139
+ Optional fields include "description", "parentId", "type" ('manual' or 'smart'), "query".
1140
+ See the OpenAPI spec for details. Example: `{"name": "My List", "icon": "📚"}`
1141
+
1142
+ Returns:
1143
+ datatypes.ListModel: The created list object.
1144
+ If response validation is disabled, returns the raw API response (dict/list).
1145
+
1146
+ Raises:
1147
+ APIError: If the API request fails (e.g., bad request, invalid data).
1148
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1149
+ """
1150
+ response_data = self._call("POST", "lists", data=list_data)
1151
+
1152
+ if self.disable_response_validation:
1153
+ logger.debug("Skipping response validation as requested.")
1154
+ return response_data
1155
+ else:
1156
+ # Response should match ListModel schema
1157
+ return datatypes.ListModel.model_validate(response_data)
1158
+
1159
+ @optional_typecheck
1160
+ def get_a_single_list(
1161
+ self, list_id: str
1162
+ ) -> Any: # Returns ListModel or raw dict/list
1163
+ """
1164
+ Get a single list by its ID. Corresponds to GET /lists/{listId}.
1165
+
1166
+ list_id: The ID (string) of the list to retrieve.
1167
+
1168
+ Returns:
1169
+ datatypes.ListModel: The requested list object.
1170
+ If response validation is disabled, returns the raw API response (dict/list).
1171
+
1172
+ Raises:
1173
+ APIError: If the API request fails (e.g., 404 list not found).
1174
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1175
+ """
1176
+ endpoint = f"lists/{list_id}"
1177
+ response_data = self._call("GET", endpoint)
1178
+
1179
+ if self.disable_response_validation:
1180
+ logger.debug("Skipping response validation as requested.")
1181
+ return response_data
1182
+ else:
1183
+ # Response should match ListModel schema
1184
+ return datatypes.ListModel.model_validate(response_data)
1185
+
1186
+ @optional_typecheck
1187
+ def delete_a_list(self, list_id: str) -> None:
1188
+ """
1189
+ Delete a list by its ID. Corresponds to DELETE /lists/{listId}.
1190
+
1191
+ list_id: The ID (string) of the list to delete.
1192
+
1193
+ Returns:
1194
+ None: Returns None upon successful deletion (204 No Content).
1195
+
1196
+ Raises:
1197
+ APIError: If the API request fails (e.g., 404 list not found).
1198
+ """
1199
+ endpoint = f"lists/{list_id}"
1200
+ self._call("DELETE", endpoint) # Expects 204 No Content
1201
+ return None # Explicitly return None for 204
1202
+
1203
+ @optional_typecheck
1204
+ def update_a_list(
1205
+ self, list_id: str, update_data: dict
1206
+ ) -> Any: # Returns ListModel or raw dict/list
1207
+ """
1208
+ Update a list by its ID. Corresponds to PATCH /lists/{listId}.
1209
+ Allows updating fields like "name", "description", "icon", "parentId", "query".
1210
+
1211
+ list_id: The ID (string) of the list to update.
1212
+ update_data: A dictionary containing the fields to update (e.g., `{"name": "new name"}`).
1213
+ See the OpenAPI spec for allowed fields.
1214
+
1215
+ Returns:
1216
+ datatypes.ListModel: The updated list object.
1217
+ If response validation is disabled, returns the raw API response (dict/list).
1218
+
1219
+ Raises:
1220
+ APIError: If the API request fails (e.g., 404 list not found).
1221
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1222
+ """
1223
+ endpoint = f"lists/{list_id}"
1224
+ response_data = self._call("PATCH", endpoint, data=update_data)
1225
+
1226
+ if self.disable_response_validation:
1227
+ logger.debug("Skipping response validation as requested.")
1228
+ return response_data
1229
+ else:
1230
+ # Response should match ListModel schema
1231
+ return datatypes.ListModel.model_validate(response_data)
1232
+
1233
+ @optional_typecheck
1234
+ def get_a_bookmarks_in_a_list(
1235
+ self,
1236
+ list_id: str,
1237
+ limit: Optional[int] = None,
1238
+ cursor: Optional[str] = None,
1239
+ include_content: bool = True, # Default from spec
1240
+ ) -> Any: # Returns PaginatedBookmarks or raw dict/list
1241
+ """
1242
+ Get the bookmarks contained within a specific list. Corresponds to GET /lists/{listId}/bookmarks.
1243
+
1244
+ list_id: The ID (string) of the list.
1245
+ limit: Maximum number of bookmarks to return (optional).
1246
+ cursor: Pagination cursor for the next page (optional).
1247
+ include_content: If set to true, bookmark's content will be included (default: True).
1248
+
1249
+ Returns:
1250
+ datatypes.PaginatedBookmarks: Paginated list of bookmarks in the specified list.
1251
+ If response validation is disabled, returns the raw API response (dict/list).
1252
+
1253
+ Raises:
1254
+ APIError: If the API request fails (e.g., 404 list not found).
1255
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1256
+ """
1257
+ endpoint = f"lists/{list_id}/bookmarks"
1258
+ params = {
1259
+ "limit": limit,
1260
+ "cursor": cursor,
1261
+ "includeContent": include_content, # Use camelCase as per API spec query param
1262
+ }
1263
+ response_data = self._call("GET", endpoint, params=params)
1264
+
1265
+ if self.disable_response_validation:
1266
+ logger.debug("Skipping response validation as requested.")
1267
+ return response_data
1268
+ else:
1269
+ # Response should match PaginatedBookmarks schema
1270
+ return datatypes.PaginatedBookmarks.model_validate(response_data)
1271
+
1272
+ @optional_typecheck
1273
+ def add_a_bookmark_to_a_list(self, list_id: str, bookmark_id: str) -> None:
1274
+ """
1275
+ Add a bookmark to a specific list. Corresponds to PUT /lists/{listId}/bookmarks/{bookmarkId}.
1276
+
1277
+ list_id: The ID (string) of the list.
1278
+ bookmark_id: The ID (string) of the bookmark to add.
1279
+
1280
+ Returns:
1281
+ None: Returns None upon successful addition (204 No Content).
1282
+
1283
+ Raises:
1284
+ APIError: If the API request fails (e.g., 404 list or bookmark not found, 400 bookmark already in list).
1285
+ """
1286
+ endpoint = f"lists/{list_id}/bookmarks/{bookmark_id}"
1287
+ self._call("PUT", endpoint) # Expects 204 No Content
1288
+ return None # Explicitly return None for 204
1289
+
1290
+ @optional_typecheck
1291
+ def remove_a_bookmark_from_a_list(self, list_id: str, bookmark_id: str) -> None:
1292
+ """
1293
+ Remove a bookmark from a specific list. Corresponds to DELETE /lists/{listId}/bookmarks/{bookmarkId}.
1294
+
1295
+ list_id: The ID (string) of the list.
1296
+ bookmark_id: The ID (string) of the bookmark to remove.
1297
+
1298
+ Returns:
1299
+ None: Returns None upon successful removal (204 No Content).
1300
+
1301
+ Raises:
1302
+ APIError: If the API request fails (e.g., 404 list or bookmark not found, 400 bookmark not in list).
1303
+ """
1304
+ endpoint = f"lists/{list_id}/bookmarks/{bookmark_id}"
1305
+ self._call("DELETE", endpoint) # Expects 204 No Content
1306
+ return None # Explicitly return None for 204
1307
+
1308
+ @optional_typecheck
1309
+ def get_all_tags(self) -> Any: # Returns List[Tag1] or raw dict/list
1310
+ """
1311
+ Get all tags for the current user. Corresponds to GET /tags.
1312
+
1313
+ List[datatypes.Tag1]: A list of tag objects, including bookmark counts.
1314
+ If response validation is disabled, returns the raw API response (dict/list).
1315
+
1316
+ Raises:
1317
+ APIError: If the API request fails.
1318
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1319
+ """
1320
+ response_data = self._call("GET", "tags")
1321
+
1322
+ if self.disable_response_validation:
1323
+ logger.debug("Skipping response validation as requested.")
1324
+ # Return raw data, which might be {"tags": [...]} or something else
1325
+ return response_data
1326
+ else:
1327
+ # Response schema is {"tags": [Tag1]}, extract the list and validate
1328
+ if (
1329
+ isinstance(response_data, dict)
1330
+ and "tags" in response_data
1331
+ and isinstance(response_data["tags"], list)
1332
+ ):
1333
+ try:
1334
+ return [
1335
+ datatypes.Tag1.model_validate(tag)
1336
+ for tag in response_data["tags"]
1337
+ ]
1338
+ except (
1339
+ Exception
1340
+ ) as e: # Catch validation errors during list comprehension
1341
+ logger.error(f"Validation failed for one or more tags: {e}")
1342
+ raise # Re-raise the validation error
1343
+ else:
1344
+ # Raise error if format is unexpected and validation is enabled
1345
+ raise APIError(
1346
+ f"Unexpected response format for get_all_tags when validation is enabled: {response_data}"
1347
+ )
1348
+
1349
+ @optional_typecheck
1350
+ def get_a_single_tag(self, tag_id: str) -> Any: # Returns Tag1 or raw dict/list
1351
+ """
1352
+ Get a single tag by its ID. Corresponds to GET /tags/{tagId}.
1353
+
1354
+ tag_id: The ID (string) of the tag to retrieve.
1355
+
1356
+ Returns:
1357
+ datatypes.Tag1: The requested tag object.
1358
+ If response validation is disabled, returns the raw API response (dict/list).
1359
+
1360
+ Raises:
1361
+ APIError: If the API request fails (e.g., 404 tag not found).
1362
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1363
+ """
1364
+ endpoint = f"tags/{tag_id}"
1365
+ response_data = self._call("GET", endpoint)
1366
+
1367
+ if self.disable_response_validation:
1368
+ logger.debug("Skipping response validation as requested.")
1369
+ return response_data
1370
+ else:
1371
+ # Response should match Tag1 schema
1372
+ return datatypes.Tag1.model_validate(response_data)
1373
+
1374
+ @optional_typecheck
1375
+ def delete_a_tag(self, tag_id: str) -> None:
1376
+ """
1377
+ Delete a tag by its ID. Corresponds to DELETE /tags/{tagId}.
1378
+
1379
+ tag_id: The ID (string) of the tag to delete.
1380
+
1381
+ Returns:
1382
+ None: Returns None upon successful deletion (204 No Content).
1383
+
1384
+ Raises:
1385
+ APIError: If the API request fails (e.g., 404 tag not found).
1386
+ """
1387
+ endpoint = f"tags/{tag_id}"
1388
+ self._call("DELETE", endpoint) # Expects 204 No Content
1389
+ return None # Explicitly return None for 204
1390
+
1391
+ @optional_typecheck
1392
+ def update_a_tag(
1393
+ self, tag_id: str, update_data: dict
1394
+ ) -> Any: # Returns Tag1 or raw dict/list
1395
+ """
1396
+ Update a tag by its ID. Currently only supports updating the "name".
1397
+ Corresponds to PATCH /tags/{tagId}.
1398
+
1399
+ tag_id: The ID (string) of the tag to update.
1400
+ update_data: A dictionary containing the fields to update. Must include "name" (string).
1401
+ Example: `{"name": "new tag name"}`
1402
+
1403
+ Returns:
1404
+ datatypes.Tag1: The updated tag object.
1405
+ If response validation is disabled, returns the raw API response (dict/list).
1406
+
1407
+ Raises:
1408
+ APIError: If the API request fails (e.g., 404 tag not found).
1409
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1410
+ """
1411
+ endpoint = f"tags/{tag_id}"
1412
+ response_data = self._call("PATCH", endpoint, data=update_data)
1413
+
1414
+ if self.disable_response_validation:
1415
+ logger.debug("Skipping response validation as requested.")
1416
+ return response_data
1417
+ else:
1418
+ # Response should match Tag1 schema
1419
+ return datatypes.Tag1.model_validate(response_data)
1420
+
1421
+ @optional_typecheck
1422
+ def get_a_bookmarks_with_the_tag(
1423
+ self,
1424
+ tag_id: str,
1425
+ limit: Optional[int] = None,
1426
+ cursor: Optional[str] = None,
1427
+ include_content: bool = True, # Default from spec
1428
+ ) -> Any: # Returns PaginatedBookmarks or raw dict/list
1429
+ """
1430
+ Get the bookmarks associated with a specific tag. Corresponds to GET /tags/{tagId}/bookmarks.
1431
+
1432
+ tag_id: The ID (string) of the tag.
1433
+ limit: Maximum number of bookmarks to return (optional).
1434
+ cursor: Pagination cursor for the next page (optional).
1435
+ include_content: If set to true, bookmark's content will be included (default: True).
1436
+
1437
+ Returns:
1438
+ datatypes.PaginatedBookmarks: Paginated list of bookmarks associated with the specified tag.
1439
+ If response validation is disabled, returns the raw API response (dict/list).
1440
+
1441
+ Raises:
1442
+ APIError: If the API request fails (e.g., 404 tag not found).
1443
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1444
+ """
1445
+ endpoint = f"tags/{tag_id}/bookmarks"
1446
+ params = {
1447
+ "limit": limit,
1448
+ "cursor": cursor,
1449
+ "includeContent": include_content, # Use camelCase as per API spec query param
1450
+ }
1451
+ response_data = self._call("GET", endpoint, params=params)
1452
+
1453
+ if self.disable_response_validation:
1454
+ logger.debug("Skipping response validation as requested.")
1455
+ return response_data
1456
+ else:
1457
+ # Response should match PaginatedBookmarks schema
1458
+ return datatypes.PaginatedBookmarks.model_validate(response_data)
1459
+
1460
+ @optional_typecheck
1461
+ def get_all_highlights(
1462
+ self, limit: Optional[int] = None, cursor: Optional[str] = None
1463
+ ) -> Any: # Returns PaginatedHighlights or raw dict/list
1464
+ """
1465
+ Get all highlights for the current user. Corresponds to GET /highlights.
1466
+
1467
+ Args:
1468
+ limit: Maximum number of highlights to return (optional).
1469
+ cursor: Pagination cursor for the next page (optional).
1470
+
1471
+ Returns:
1472
+ datatypes.PaginatedHighlights: Paginated list of highlights.
1473
+ If response validation is disabled, returns the raw API response (dict/list).
1474
+
1475
+ Raises:
1476
+ APIError: If the API request fails.
1477
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1478
+ """
1479
+ params = {"limit": limit, "cursor": cursor}
1480
+ response_data = self._call("GET", "highlights", params=params)
1481
+
1482
+ if self.disable_response_validation:
1483
+ logger.debug("Skipping response validation as requested.")
1484
+ return response_data
1485
+ else:
1486
+ # Response should match PaginatedHighlights schema
1487
+ return datatypes.PaginatedHighlights.model_validate(response_data)
1488
+
1489
+ @optional_typecheck
1490
+ def create_a_new_highlight(
1491
+ self, highlight_data: dict
1492
+ ) -> Any: # Returns Highlight or raw dict/list
1493
+ """
1494
+ Create a new highlight on a bookmark. Corresponds to POST /highlights.
1495
+
1496
+ highlight_data: Dictionary containing the data for the new highlight. Requires "bookmarkId" (string),
1497
+ "startOffset" (number), "endOffset" (number). Optional fields include "color", "text", "note".
1498
+ See the OpenAPI spec for details. Example: `{"bookmarkId": "...", "startOffset": 10, "endOffset": 25}`
1499
+
1500
+ Returns:
1501
+ datatypes.Highlight: The created highlight object.
1502
+ If response validation is disabled, returns the raw API response (dict/list).
1503
+
1504
+ Raises:
1505
+ APIError: If the API request fails (e.g., 400 bad request, 404 bookmark not found).
1506
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1507
+ """
1508
+ response_data = self._call("POST", "highlights", data=highlight_data)
1509
+
1510
+ if self.disable_response_validation:
1511
+ logger.debug("Skipping response validation as requested.")
1512
+ return response_data
1513
+ else:
1514
+ # Response should match Highlight schema
1515
+ return datatypes.Highlight.model_validate(response_data)
1516
+
1517
+ @optional_typecheck
1518
+ def get_a_single_highlight(
1519
+ self, highlight_id: str
1520
+ ) -> Any: # Returns Highlight or raw dict/list
1521
+ """
1522
+ Get a single highlight by its ID. Corresponds to GET /highlights/{highlightId}.
1523
+
1524
+ highlight_id: The ID (string) of the highlight to retrieve.
1525
+
1526
+ Returns:
1527
+ datatypes.Highlight: The requested highlight object.
1528
+ If response validation is disabled, returns the raw API response (dict/list).
1529
+
1530
+ Raises:
1531
+ APIError: If the API request fails (e.g., 404 highlight not found).
1532
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1533
+ """
1534
+ endpoint = f"highlights/{highlight_id}"
1535
+ response_data = self._call("GET", endpoint)
1536
+
1537
+ if self.disable_response_validation:
1538
+ logger.debug("Skipping response validation as requested.")
1539
+ return response_data
1540
+ else:
1541
+ # Response should match Highlight schema
1542
+ return datatypes.Highlight.model_validate(response_data)
1543
+
1544
+ @optional_typecheck
1545
+ def delete_a_highlight(
1546
+ self, highlight_id: str
1547
+ ) -> Any: # Returns Highlight or raw dict/list
1548
+ """
1549
+ Delete a highlight by its ID. Corresponds to DELETE /highlights/{highlightId}.
1550
+ Note: Unlike most DELETE endpoints, this returns the deleted highlight object on success (status 200).
1551
+
1552
+ highlight_id: The ID (string) of the highlight to delete.
1553
+
1554
+ Returns:
1555
+ datatypes.Highlight: The deleted highlight object.
1556
+ If response validation is disabled, returns the raw API response (dict/list).
1557
+
1558
+ Raises:
1559
+ APIError: If the API request fails (e.g., 404 highlight not found).
1560
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1561
+ """
1562
+ endpoint = f"highlights/{highlight_id}"
1563
+ response_data = self._call("DELETE", endpoint) # Expects 200 OK with body
1564
+
1565
+ if self.disable_response_validation:
1566
+ logger.debug("Skipping response validation as requested.")
1567
+ return response_data
1568
+ else:
1569
+ # Response should match Highlight schema
1570
+ return datatypes.Highlight.model_validate(response_data)
1571
+
1572
+ @optional_typecheck
1573
+ def update_a_highlight(
1574
+ self, highlight_id: str, update_data: dict
1575
+ ) -> Any: # Returns Highlight or raw dict/list
1576
+ """
1577
+ Update a highlight by its ID. Currently only supports updating the "color".
1578
+ Corresponds to PATCH /highlights/{highlightId}.
1579
+
1580
+ highlight_id: The ID (string) of the highlight to update.
1581
+ update_data: A dictionary containing the fields to update. Must include "color" (string enum).
1582
+ See `datatypes.Color` enum. Example: `{"color": "red"}`
1583
+
1584
+ Returns:
1585
+ datatypes.Highlight: The updated highlight object.
1586
+ If response validation is disabled, returns the raw API response (dict/list).
1587
+
1588
+ Raises:
1589
+ APIError: If the API request fails (e.g., 404 highlight not found).
1590
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1591
+ """
1592
+ endpoint = f"highlights/{highlight_id}"
1593
+ response_data = self._call("PATCH", endpoint, data=update_data)
1594
+
1595
+ if self.disable_response_validation:
1596
+ logger.debug("Skipping response validation as requested.")
1597
+ return response_data
1598
+ else:
1599
+ # Response should match Highlight schema
1600
+ return datatypes.Highlight.model_validate(response_data)
1601
+
1602
+ @optional_typecheck
1603
+ def get_current_user_info(self) -> Any: # Returns dict
1604
+ """
1605
+ Get information about the current authenticated user. Corresponds to GET /users/me.
1606
+
1607
+ dict: A dictionary containing user information ('id', 'name', 'email').
1608
+ Validation is not performed on this response type by default.
1609
+
1610
+ Raises:
1611
+ APIError: If the API request fails (e.g., authentication error).
1612
+ """
1613
+ response_data = self._call("GET", "users/me")
1614
+ # No Pydantic validation applied here as the spec defines a simple dict response
1615
+ return response_data
1616
+
1617
+ @optional_typecheck
1618
+ def get_current_user_stats(self) -> Any: # Returns dict
1619
+ """
1620
+ Get statistics about the current authenticated user's data. Corresponds to GET /users/me/stats.
1621
+
1622
+ dict: A dictionary containing user statistics ('numBookmarks', 'numFavorites', 'numArchived', etc.).
1623
+ Validation is not performed on this response type by default.
1624
+
1625
+ Raises:
1626
+ APIError: If the API request fails (e.g., authentication error).
1627
+ """
1628
+ response_data = self._call("GET", "users/me/stats")
1629
+ # No Pydantic validation applied here as the spec defines a simple dict response
1630
+ return response_data