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.
- karakeep_python_api/__init__.py +19 -0
- karakeep_python_api/__main__.py +509 -0
- karakeep_python_api/datatypes.py +199 -0
- karakeep_python_api/karakeep_api.py +1630 -0
- karakeep_python_api/openapi_reference.json +2901 -0
- karakeep_python_api-0.1.0.dist-info/METADATA +256 -0
- karakeep_python_api-0.1.0.dist-info/RECORD +11 -0
- karakeep_python_api-0.1.0.dist-info/WHEEL +5 -0
- karakeep_python_api-0.1.0.dist-info/entry_points.txt +2 -0
- karakeep_python_api-0.1.0.dist-info/licenses/LICENSE +674 -0
- karakeep_python_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|