karakeep-python-api 1.0.0__py3-none-any.whl → 1.2.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.
@@ -59,9 +59,9 @@ def serialize_output(data: Any) -> Any:
59
59
  # Shared options for the API client
60
60
  shared_options = [
61
61
  click.option(
62
- "--base-url",
63
- envvar="KARAKEEP_PYTHON_API_BASE_URL",
64
- help="Full Karakeep API base URL, including /api/v1/ (e.g., https://instance.com/api/v1/).",
62
+ "--api-endpoint",
63
+ envvar="KARAKEEP_PYTHON_API_ENDPOINT",
64
+ help="Full Karakeep API endpoint URL, including /api/v1/ (e.g., https://instance.com/api/v1/).",
65
65
  ),
66
66
  click.option(
67
67
  "--api-key",
@@ -151,7 +151,7 @@ def print_openapi_spec(ctx, param, value):
151
151
  @click.pass_context
152
152
  def cli(
153
153
  ctx,
154
- base_url,
154
+ api_endpoint,
155
155
  api_key,
156
156
  verify_ssl,
157
157
  verbose,
@@ -167,7 +167,7 @@ def cli(
167
167
  # Ensure the context object exists
168
168
  ctx.ensure_object(dict)
169
169
 
170
- # --- Strict Check for API Key and Base URL ---
170
+ # --- Strict Check for API Key and Endpoint ---
171
171
  # Check for API key (must be provided via arg or env)
172
172
  resolved_api_key = api_key or os.environ.get("KARAKEEP_PYTHON_API_KEY")
173
173
  if not resolved_api_key:
@@ -175,16 +175,18 @@ def cli(
175
175
  "API Key is required. Provide --api-key option or set KARAKEEP_PYTHON_API_KEY environment variable."
176
176
  )
177
177
 
178
- # Check for Base URL (must be provided via arg or env)
179
- resolved_base_url = base_url or os.environ.get("KARAKEEP_PYTHON_API_BASE_URL")
180
- if not resolved_base_url:
178
+ # Check for API endpoint (must be provided via arg or env)
179
+ resolved_api_endpoint = api_endpoint or os.environ.get(
180
+ "KARAKEEP_PYTHON_API_ENDPOINT"
181
+ )
182
+ if not resolved_api_endpoint:
181
183
  raise click.UsageError(
182
- "API Base URL is required. Provide --base-url option or set KARAKEEP_PYTHON_API_BASE_URL environment variable. "
184
+ "API endpoint is required. Provide --api-endpoint option or set KARAKEEP_PYTHON_API_ENDPOINT environment variable. "
183
185
  "The URL must include the API path, e.g., 'https://your-instance.com/api/v1/'."
184
186
  )
185
187
 
186
188
  # Store common API parameters in the context for commands to use
187
- ctx.obj["BASE_URL"] = resolved_base_url # Store the resolved URL
189
+ ctx.obj["API_ENDPOINT"] = resolved_api_endpoint # Store the resolved endpoint
188
190
  ctx.obj["API_KEY"] = resolved_api_key # Store the resolved key
189
191
  ctx.obj["VERIFY_SSL"] = verify_ssl
190
192
  ctx.obj["VERBOSE"] = verbose
@@ -231,7 +233,7 @@ def create_click_command(
231
233
  def command_func(ctx, **kwargs):
232
234
  """Dynamically generated command function wrapper."""
233
235
  # Retrieve API parameters from context, ensuring API key is present now
234
- base_url = ctx.obj["BASE_URL"]
236
+ api_endpoint = ctx.obj["API_ENDPOINT"]
235
237
  api_key = ctx.obj["API_KEY"]
236
238
  verify_ssl = ctx.obj["VERIFY_SSL"]
237
239
  verbose = ctx.obj["VERBOSE"]
@@ -250,7 +252,7 @@ def create_click_command(
250
252
  # Method generation already happened during inspection phase or initial load
251
253
  api = KarakeepAPI(
252
254
  api_key=api_key,
253
- base_url=base_url,
255
+ api_endpoint=api_endpoint,
254
256
  verify_ssl=verify_ssl,
255
257
  verbose=verbose,
256
258
  disable_response_validation=disable_validation, # Pass flag to constructor
@@ -78,7 +78,7 @@ class ContentTypeAsset(BaseModel):
78
78
  content: Optional[str] = None
79
79
 
80
80
 
81
- class Asset(BaseModel):
81
+ class BookmarkAsset(BaseModel):
82
82
  id: str
83
83
  assetType: Literal[
84
84
  "screenshot",
@@ -92,6 +92,13 @@ class Asset(BaseModel):
92
92
  ]
93
93
 
94
94
 
95
+ class Asset(BaseModel):
96
+ assetId: str
97
+ contentType: str
98
+ size: float
99
+ fileName: str
100
+
101
+
95
102
  class Bookmark(BaseModel):
96
103
  id: str
97
104
  createdAt: str
@@ -107,7 +114,7 @@ class Bookmark(BaseModel):
107
114
  content: Union[
108
115
  ContentTypeLink, ContentTypeText, ContentTypeAsset, ContentTypeUnknown
109
116
  ]
110
- assets: List[Asset]
117
+ assets: List[BookmarkAsset]
111
118
 
112
119
 
113
120
  class PaginatedBookmarks(BaseModel):
@@ -135,6 +142,7 @@ class ListModel(BaseModel):
135
142
  parentId: Optional[str]
136
143
  type: Optional[Literal["manual", "smart"]] = "manual"
137
144
  query: Optional[str] = None
145
+ public: bool
138
146
 
139
147
 
140
148
  class PaginatedHighlights(BaseModel):
@@ -77,7 +77,7 @@ class KarakeepAPI:
77
77
 
78
78
  Attributes:
79
79
  api_key (str): The API key used for authentication.
80
- api_base_url (str): The base URL of the Karakeep API instance.
80
+ api_endpoint (str): The endpoint of the Karakeep API instance, including /api/v1 (e.g., https://instance.com/api/v1/).
81
81
  openapi_spec (dict): The parsed content of the OpenAPI specification file.
82
82
  verify_ssl (bool): Whether SSL verification is enabled.
83
83
  verbose (bool): Whether verbose logging is enabled.
@@ -85,12 +85,12 @@ class KarakeepAPI:
85
85
  """
86
86
 
87
87
  # Version reflects the client library version, updated by bumpver
88
- VERSION: str = "1.0.0"
88
+ VERSION: str = "1.2.0"
89
89
 
90
90
  def __init__(
91
91
  self,
92
92
  api_key: Optional[str] = None,
93
- base_url: Optional[str] = None,
93
+ api_endpoint: Optional[str] = None,
94
94
  openapi_spec_path: Optional[str] = None, # Allow None, default handled below
95
95
  verify_ssl: bool = True,
96
96
  verbose: bool = False,
@@ -105,8 +105,9 @@ class KarakeepAPI:
105
105
  Args:
106
106
  api_key: Karakeep API key (Bearer token).
107
107
  Defaults to KARAKEEP_PYTHON_API_KEY environment variable if not provided.
108
- base_url: Override the base URL for the API. Must be provided either as an argument
109
- or via the KARAKEEP_PYTHON_API_BASE_URL environment variable.
108
+ api_endpoint: Override the endpoint for the API. Must be provided either as an argument
109
+ or via the KARAKEEP_PYTHON_API_ENDPOINT environment variable.
110
+ Example: 'https://karakeep.domain.com/api/v1/'
110
111
  openapi_spec_path: Path to the OpenAPI JSON specification file.
111
112
  Defaults to 'openapi_reference.json' alongside the package code if not provided.
112
113
  The loaded spec is available via the `openapi_spec` attribute.
@@ -130,47 +131,47 @@ class KarakeepAPI:
130
131
  self.api_key = resolved_api_key
131
132
  logger.debug("API Key loaded successfully.")
132
133
 
133
- # --- Base URL Validation ---
134
- env_base_url = os.environ.get("KARAKEEP_PYTHON_API_BASE_URL")
134
+ # --- Endpoint Validation ---
135
+ env_endpoint = os.environ.get("KARAKEEP_PYTHON_API_ENDPOINT")
135
136
  logger.debug(
136
- f"Checked KARAKEEP_PYTHON_API_BASE_URL environment variable, found: '{env_base_url}'"
137
+ f"Checked KARAKEEP_PYTHON_API_ENDPOINT environment variable, found: '{env_endpoint}'"
137
138
  )
138
- logger.debug(f"Base URL provided as argument: '{base_url}'")
139
+ logger.debug(f"Endpoint provided as argument: '{api_endpoint}'")
139
140
 
140
- if base_url:
141
- self.api_base_url = base_url
142
- logger.info(f"Using provided base URL: {self.api_base_url}")
143
- elif env_base_url:
144
- self.api_base_url = env_base_url
141
+ if api_endpoint:
142
+ self.api_endpoint = api_endpoint
143
+ logger.info(f"Using provided endpoint: {self.api_endpoint}")
144
+ elif env_endpoint:
145
+ self.api_endpoint = env_endpoint
145
146
  logger.info(
146
- f"Using base URL from KARAKEEP_PYTHON_API_BASE_URL: {self.api_base_url}"
147
+ f"Using endpoint from KARAKEEP_PYTHON_API_ENDPOINT: {self.api_endpoint}"
147
148
  )
148
149
  else:
149
- # No base_url from arg or env var - raise error as per requirement
150
+ # No api_endpoint from arg or env var - raise error as per requirement
150
151
  raise ValueError(
151
- "API base URL is required. Provide 'base_url' argument or set KARAKEEP_PYTHON_API_BASE_URL environment variable."
152
+ "API endpoint is required. Provide 'api_endpoint' argument or set KARAKEEP_PYTHON_API_ENDPOINT environment variable."
152
153
  )
153
154
 
154
- # Ensure base URL ends with /v1/
155
- resolved_url = self.api_base_url # Use a temporary variable for checks
156
- if resolved_url.endswith("/v1"):
157
- # Ends with /v1, needs a slash
158
- self.api_base_url = resolved_url + "/"
155
+ # Ensure endpoint ends with /api/v1/
156
+ resolved_url = self.api_endpoint # Use a temporary variable for checks
157
+ if resolved_url.endswith("/api/v1"):
158
+ # Ends with /api/v1, needs a slash
159
+ self.api_endpoint = resolved_url + "/"
159
160
  logger.info(
160
- f"Appended trailing slash to base URL ending in /v1: {self.api_base_url}"
161
+ f"Appended trailing slash to endpoint ending in /api/v1: {self.api_endpoint}"
161
162
  )
162
- elif resolved_url.endswith("/v1/"):
163
+ elif resolved_url.endswith("/api/v1/"):
163
164
  # Already ends correctly, do nothing
164
- logger.debug(f"Base URL already ends with /v1/: {self.api_base_url}")
165
+ logger.debug(f"Endpoint already ends with /api/v1/: {self.api_endpoint}")
165
166
  else:
166
- # Doesn't end with /v1 or /v1/, append /v1/
167
- # First, remove any existing trailing slash to avoid //v1/
167
+ # Doesn't end with /api/v1 or /api/v1/, append /api/v1/
168
+ # First, remove any existing trailing slash to avoid //api/v1/
168
169
  if resolved_url.endswith("/"):
169
170
  resolved_url = resolved_url[:-1]
170
- self.api_base_url = resolved_url + "/v1/"
171
- logger.info(f"Appended /v1/ to base URL: {self.api_base_url}")
171
+ self.api_endpoint = resolved_url + "/api/v1/"
172
+ logger.info(f"Appended /api/v1/ to endpoint: {self.api_endpoint}")
172
173
 
173
- logger.debug(f"Final API Base URL after /v1/ check: {self.api_base_url}")
174
+ logger.debug(f"Final API Endpoint after /api/v1/ check: {self.api_endpoint}")
174
175
 
175
176
  # --- Load and Parse OpenAPI Spec ---
176
177
  if openapi_spec_path is None:
@@ -257,7 +258,7 @@ class KarakeepAPI:
257
258
  # self.verbose is still used for conditional logging within the class methods.
258
259
 
259
260
  logger.debug("KarakeepAPI client initialized.")
260
- logger.debug(f" Base URL: {self.api_base_url}")
261
+ logger.debug(f" Endpoint: {self.api_endpoint}")
261
262
  logger.debug(f" Verify SSL: {self.verify_ssl}")
262
263
  logger.debug(f" Verbose: {self.verbose}")
263
264
  logger.debug(
@@ -294,34 +295,37 @@ class KarakeepAPI:
294
295
  data: Optional[
295
296
  Union[BaseModel, dict, list, str, bytes]
296
297
  ] = None, # More specific type hint
298
+ files: Optional[Dict[str, Any]] = None,
297
299
  extra_headers: Optional[Dict[str, str]] = None,
298
- ) -> Union[Dict[str, Any], List[Any], None]:
300
+ ) -> Union[Dict[str, Any], List[Any], None, bytes]:
299
301
  """
300
302
  Internal method to make an HTTP call to the Karakeep API. Handles authentication,
301
303
  request formatting, response parsing, and error handling.
302
304
 
303
305
  Args:
304
306
  method: HTTP method ('GET', 'POST', 'PUT', 'PATCH', 'DELETE').
305
- endpoint: API endpoint path relative to the base URL (e.g., 'bookmarks' or 'bookmarks/some_id').
307
+ endpoint: API endpoint path relative to the endpoint (e.g., 'bookmarks' or 'bookmarks/some_id').
306
308
  Path parameters (like {bookmarkId}) MUST be substituted *before* calling _call.
307
309
  params: Dictionary of URL query parameters. Values should be primitive types suitable for URLs.
308
310
  data: Request body data. Can be a Pydantic model, dict, list, bytes, or str.
309
311
  - Pydantic models, dicts, and lists will be automatically JSON-encoded
310
312
  with 'Content-Type: application/json' unless overridden in extra_headers.
311
313
  - For bytes or str, ensure 'Content-Type' is set correctly via extra_headers if needed.
314
+ files: Dictionary for file uploads (multipart/form-data). If provided, data parameter is ignored.
312
315
  extra_headers: Additional headers to include or override default headers.
313
316
 
314
317
  Returns:
315
- The parsed JSON response from the API as a dict or list, or None for 204 No Content responses.
316
- The calling wrapper method is responsible for further parsing/validation into specific Pydantic models.
318
+ The parsed JSON response from the API as a dict or list, None for 204 No Content responses,
319
+ or raw bytes for non-JSON responses. The calling wrapper method is responsible for further
320
+ parsing/validation into specific Pydantic models.
317
321
 
318
322
  Raises:
319
323
  AuthenticationError: If authentication fails (401).
320
324
  APIError: For other HTTP errors or request issues.
321
325
  """
322
- # Ensure endpoint doesn't start with / if base_url ends with /
326
+ # Ensure endpoint doesn't start with / if endpoint ends with /
323
327
  safe_endpoint = endpoint.lstrip("/")
324
- url = urljoin(self.api_base_url, safe_endpoint)
328
+ url = urljoin(self.api_endpoint, safe_endpoint)
325
329
 
326
330
  # Default headers
327
331
  headers = {
@@ -345,7 +349,16 @@ class KarakeepAPI:
345
349
  # Determine Content-Type, prioritizing extra_headers
346
350
  content_type = headers.get("Content-Type")
347
351
 
348
- if data is not None:
352
+ # Handle file uploads (multipart/form-data)
353
+ if files is not None:
354
+ # When files are provided, let requests handle Content-Type automatically
355
+ # Don't set Content-Type header for multipart uploads
356
+ if "Content-Type" in headers:
357
+ # Remove Content-Type if it was set, let requests set it for multipart
358
+ headers.pop("Content-Type")
359
+ # Don't process data when files are provided
360
+ request_body_arg = None
361
+ elif data is not None:
349
362
  if isinstance(data, BaseModel):
350
363
  # Serialize Pydantic model to JSON bytes
351
364
  request_body_arg = data.model_dump_json(
@@ -470,6 +483,7 @@ class KarakeepAPI:
470
483
  url=url,
471
484
  params=request_params, # Use params with stringified booleans
472
485
  data=request_body_arg, # Serialized data (bytes or str)
486
+ files=files, # File uploads for multipart/form-data
473
487
  headers=headers,
474
488
  verify=self.verify_ssl,
475
489
  timeout=60, # Increased default timeout
@@ -514,28 +528,42 @@ class KarakeepAPI:
514
528
  logger.debug(" Body: None (204 No Content or empty response body)")
515
529
  return None
516
530
 
517
- # Attempt to parse successful response as JSON
518
- try:
519
- result = response.json()
531
+ # Check if the response is expected to be JSON based on Accept header
532
+ accept_header = headers.get("Accept", "application/json")
533
+ expects_json = "application/json" in accept_header
534
+
535
+ # Attempt to parse successful response as JSON if we expect JSON
536
+ if expects_json:
537
+ try:
538
+ result = response.json()
539
+ if self.verbose:
540
+ # Log parsed response body carefully
541
+ log_resp_str = repr(result)
542
+ if len(log_resp_str) > 1000:
543
+ log_resp_str = log_resp_str[:1000] + "...(truncated)"
544
+ logger.debug(f" Body (JSON Parsed): {log_resp_str}")
545
+ # Return the raw parsed JSON (dict/list). Deserialization into
546
+ # specific Pydantic models should happen in the calling wrapper method.
547
+ return result
548
+ except json.JSONDecodeError as e:
549
+ # Handle cases where the response is successful (2xx) but not valid JSON
550
+ logger.error(
551
+ f"API Error: Failed to decode JSON response from {method} {url}. Status: {response.status_code}. Content: {response.text[:500]}..."
552
+ )
553
+ # Raise APIError as the response format is unexpected
554
+ raise APIError(
555
+ message=f"Failed to parse successful API response JSON from {url}: {e}. Response text: {response.text[:200]}...",
556
+ status_code=response.status_code,
557
+ ) from e
558
+ else:
559
+ # For non-JSON responses (like asset downloads), return raw bytes
520
560
  if self.verbose:
521
- # Log parsed response body carefully
522
- log_resp_str = repr(result)
523
- if len(log_resp_str) > 1000:
524
- log_resp_str = log_resp_str[:1000] + "...(truncated)"
525
- logger.debug(f" Body (JSON Parsed): {log_resp_str}")
526
- # Return the raw parsed JSON (dict/list). Deserialization into
527
- # specific Pydantic models should happen in the calling wrapper method.
528
- return result
529
- except json.JSONDecodeError as e:
530
- # Handle cases where the response is successful (2xx) but not valid JSON
531
- logger.error(
532
- f"API Error: Failed to decode JSON response from {method} {url}. Status: {response.status_code}. Content: {response.text[:500]}..."
533
- )
534
- # Raise APIError as the response format is unexpected
535
- raise APIError(
536
- message=f"Failed to parse successful API response JSON from {url}: {e}. Response text: {response.text[:200]}...",
537
- status_code=response.status_code,
538
- ) from e
561
+ content_type = response.headers.get("Content-Type", "unknown")
562
+ content_length = len(response.content)
563
+ logger.debug(
564
+ f" Body (Binary): {content_length} bytes, Content-Type: {content_type}"
565
+ )
566
+ return response.content
539
567
 
540
568
  except requests.exceptions.HTTPError as e:
541
569
  # Handle 4xx/5xx errors raised by response.raise_for_status()
@@ -1179,7 +1207,7 @@ class KarakeepAPI:
1179
1207
  "precrawledArchive",
1180
1208
  "unknown",
1181
1209
  ],
1182
- ) -> Union[datatypes.Asset, Dict[str, Any], List[Any]]:
1210
+ ) -> Union[datatypes.BookmarkAsset, Dict[str, Any], List[Any]]:
1183
1211
  """
1184
1212
  Attach a new asset to a bookmark. Corresponds to POST /bookmarks/{bookmarkId}/assets.
1185
1213
 
@@ -1190,7 +1218,7 @@ class KarakeepAPI:
1190
1218
  "bannerImage", "fullPageArchive", "video", "bookmarkAsset", "precrawledArchive", "unknown".
1191
1219
 
1192
1220
  Returns:
1193
- datatypes.Asset: The attached asset object.
1221
+ datatypes.BookmarkAsset: The attached asset object.
1194
1222
  If response validation is disabled, returns the raw API response (dict/list).
1195
1223
 
1196
1224
  Raises:
@@ -1207,8 +1235,8 @@ class KarakeepAPI:
1207
1235
  logger.debug("Skipping response validation as requested.")
1208
1236
  return response_data
1209
1237
  else:
1210
- # Response should match Asset schema
1211
- return datatypes.Asset.model_validate(response_data)
1238
+ # Response should match BookmarkAsset schema
1239
+ return datatypes.BookmarkAsset.model_validate(response_data)
1212
1240
 
1213
1241
  @optional_typecheck
1214
1242
  def replace_asset(self, bookmark_id: str, asset_id: str, new_asset_id: str) -> None:
@@ -1306,6 +1334,7 @@ class KarakeepAPI:
1306
1334
  parent_id: Optional[str] = None,
1307
1335
  list_type: Optional[Literal["manual", "smart"]] = "manual",
1308
1336
  query: Optional[str] = None,
1337
+ public: bool = False,
1309
1338
  ) -> Union[datatypes.ListModel, Dict[str, Any], List[Any]]:
1310
1339
  """
1311
1340
  Create a new list (manual or smart). Corresponds to POST /lists.
@@ -1317,6 +1346,7 @@ class KarakeepAPI:
1317
1346
  parent_id: Optional parent list ID for nested lists.
1318
1347
  list_type: The type of list ('manual' or 'smart'). Default is 'manual'.
1319
1348
  query: Optional query string for smart lists (required if list_type is 'smart').
1349
+ public: Whether the list is public (default: False).
1320
1350
 
1321
1351
  Returns:
1322
1352
  datatypes.ListModel: The created list object.
@@ -1336,6 +1366,7 @@ class KarakeepAPI:
1336
1366
  "name": name,
1337
1367
  "icon": icon,
1338
1368
  "type": list_type,
1369
+ "public": public,
1339
1370
  }
1340
1371
 
1341
1372
  # Add optional fields if provided
@@ -1410,10 +1441,11 @@ class KarakeepAPI:
1410
1441
  icon: Optional[str] = None,
1411
1442
  parent_id: Optional[str] = None,
1412
1443
  query: Optional[str] = None,
1444
+ public: Optional[bool] = None,
1413
1445
  ) -> Union[datatypes.ListModel, Dict[str, Any], List[Any]]:
1414
1446
  """
1415
1447
  Update a list by its ID. Corresponds to PATCH /lists/{listId}.
1416
- Allows updating various list fields including name, description, icon, parent relationship, and query.
1448
+ Allows updating various list fields including name, description, icon, parent relationship, query, and public status.
1417
1449
 
1418
1450
  Args:
1419
1451
  list_id: The ID (string) of the list to update.
@@ -1422,6 +1454,7 @@ class KarakeepAPI:
1422
1454
  icon: Optional new icon for the list.
1423
1455
  parent_id: Optional new parent list ID (can be None to remove parent relationship).
1424
1456
  query: Optional new query string for smart lists (minimum 1 character).
1457
+ public: Optional new public status for the list.
1425
1458
 
1426
1459
  Returns:
1427
1460
  datatypes.ListModel: The updated list object.
@@ -1444,6 +1477,8 @@ class KarakeepAPI:
1444
1477
  update_data["parentId"] = parent_id
1445
1478
  if query is not None:
1446
1479
  update_data["query"] = query
1480
+ if public is not None:
1481
+ update_data["public"] = public
1447
1482
 
1448
1483
  # Ensure at least one field is being updated
1449
1484
  if not update_data:
@@ -1935,3 +1970,125 @@ class KarakeepAPI:
1935
1970
  response_data = self._call("GET", "users/me/stats")
1936
1971
  # No Pydantic validation applied here as the spec defines a simple dict response
1937
1972
  return response_data
1973
+
1974
+ @optional_typecheck
1975
+ def upload_a_new_asset(
1976
+ self, file: str
1977
+ ) -> Union[datatypes.Asset, Dict[str, Any], List[Any]]:
1978
+ """
1979
+ Upload a new asset file. Corresponds to POST /assets.
1980
+
1981
+ Args:
1982
+ file: Path to the file to upload.
1983
+
1984
+ Returns:
1985
+ datatypes.Asset: Details about the uploaded asset (assetId, contentType, size, fileName).
1986
+ If response validation is disabled, returns the raw API response (dict/list).
1987
+
1988
+ Raises:
1989
+ FileNotFoundError: If the specified file does not exist.
1990
+ APIError: If the API request fails (e.g., unsupported file type, file too large).
1991
+ pydantic.ValidationError: If response validation fails (and is not disabled).
1992
+ """
1993
+ import os
1994
+ import mimetypes
1995
+
1996
+ # Validate file path exists
1997
+ if not os.path.isfile(file):
1998
+ raise FileNotFoundError(f"File not found: {file}")
1999
+
2000
+ # Get filename from path
2001
+ file_name = os.path.basename(file)
2002
+
2003
+ # Detect MIME type
2004
+ mime_type, _ = mimetypes.guess_type(file)
2005
+ if mime_type is None:
2006
+ mime_type = "application/octet-stream"
2007
+
2008
+ if self.verbose:
2009
+ logger.debug(
2010
+ f"Uploading asset: {file} (filename: {file_name}, type: {mime_type})"
2011
+ )
2012
+
2013
+ # Prepare file for upload
2014
+ try:
2015
+ with open(file, "rb") as f:
2016
+ file_content = f.read()
2017
+ # Note: The 'file' key must match the OpenAPI spec parameter name
2018
+ files = {"file": (file_name, file_content, mime_type)}
2019
+ response_data = self._call("POST", "assets", files=files)
2020
+ except IOError as e:
2021
+ raise APIError(f"Failed to read file {file}: {e}") from e
2022
+
2023
+ if self.disable_response_validation:
2024
+ logger.debug("Skipping response validation as requested.")
2025
+ return response_data
2026
+ else:
2027
+ # Response should match Asset schema
2028
+ return datatypes.Asset.model_validate(response_data)
2029
+
2030
+ @optional_typecheck
2031
+ def get_a_single_asset(self, asset_id: str) -> bytes:
2032
+ """
2033
+ Get the raw content of an asset by its ID. Corresponds to GET /assets/{assetId}.
2034
+
2035
+ Args:
2036
+ asset_id: The ID (string) of the asset to retrieve.
2037
+
2038
+ Returns:
2039
+ bytes: The raw asset content. The Content-Type is determined by the asset type.
2040
+ Use response headers to determine the actual content type if needed.
2041
+
2042
+ Raises:
2043
+ APIError: If the API request fails (e.g., 404 asset not found).
2044
+ ValueError: If asset_id is empty or invalid.
2045
+
2046
+ Note:
2047
+ This method always returns raw bytes regardless of the disable_response_validation setting,
2048
+ as the response is binary content rather than JSON.
2049
+ """
2050
+ # Validate asset_id
2051
+ if not asset_id or not asset_id.strip():
2052
+ raise ValueError("asset_id cannot be empty")
2053
+
2054
+ asset_id = asset_id.strip()
2055
+
2056
+ # Validate asset_id format (basic check for reasonable ID format)
2057
+ if len(asset_id) < 5: # Assuming asset IDs are at least 5 characters
2058
+ raise ValueError(f"asset_id appears to be invalid: {asset_id}")
2059
+
2060
+ endpoint = f"assets/{asset_id}"
2061
+
2062
+ # Override the Accept header to get raw content instead of JSON
2063
+ # This is crucial for the assets endpoint to return binary data
2064
+ extra_headers = {"Accept": "*/*"}
2065
+
2066
+ if self.verbose:
2067
+ logger.debug(f"Retrieving asset: {asset_id}")
2068
+
2069
+ response_data = self._call("GET", endpoint, extra_headers=extra_headers)
2070
+
2071
+ # The _call method should return bytes for non-JSON responses when Accept is not application/json
2072
+ if isinstance(response_data, bytes):
2073
+ if self.verbose:
2074
+ logger.debug(f"Retrieved asset {asset_id}: {len(response_data)} bytes")
2075
+ return response_data
2076
+ elif response_data is None:
2077
+ # Handle empty response (valid for some assets like empty files)
2078
+ if self.verbose:
2079
+ logger.debug(f"Retrieved empty asset {asset_id}")
2080
+ return b""
2081
+ else:
2082
+ # This shouldn't happen with the updated _call method, but handle gracefully
2083
+ error_msg = f"Expected bytes from asset endpoint for asset {asset_id}, got {type(response_data).__name__}"
2084
+ if isinstance(response_data, (dict, list)):
2085
+ # If we got JSON, it might be an error response that wasn't caught
2086
+ error_detail = (
2087
+ str(response_data)[:200] + "..."
2088
+ if len(str(response_data)) > 200
2089
+ else str(response_data)
2090
+ )
2091
+ error_msg += f". Response content: {error_detail}"
2092
+
2093
+ logger.error(error_msg)
2094
+ raise APIError(error_msg)
@@ -25,10 +25,6 @@
25
25
  }
26
26
  },
27
27
  "schemas": {
28
- "AssetId": {
29
- "type": "string",
30
- "example": "ieidlxygmwj87oxz5hxttoc8"
31
- },
32
28
  "BookmarkId": {
33
29
  "type": "string",
34
30
  "example": "ieidlxygmwj87oxz5hxttoc8"
@@ -45,6 +41,10 @@
45
41
  "type": "string",
46
42
  "example": "ieidlxygmwj87oxz5hxttoc8"
47
43
  },
44
+ "AssetId": {
45
+ "type": "string",
46
+ "example": "ieidlxygmwj87oxz5hxttoc8"
47
+ },
48
48
  "Bookmark": {
49
49
  "type": "object",
50
50
  "properties": {
@@ -426,13 +426,17 @@
426
426
  "query": {
427
427
  "type": "string",
428
428
  "nullable": true
429
+ },
430
+ "public": {
431
+ "type": "boolean"
429
432
  }
430
433
  },
431
434
  "required": [
432
435
  "id",
433
436
  "name",
434
437
  "icon",
435
- "parentId"
438
+ "parentId",
439
+ "public"
436
440
  ]
437
441
  },
438
442
  "Tag": {
@@ -484,17 +488,33 @@
484
488
  "highlights",
485
489
  "nextCursor"
486
490
  ]
487
- }
488
- },
489
- "parameters": {
490
- "AssetId": {
491
- "schema": {
492
- "$ref": "#/components/schemas/AssetId"
491
+ },
492
+ "Asset": {
493
+ "type": "object",
494
+ "properties": {
495
+ "assetId": {
496
+ "type": "string"
497
+ },
498
+ "contentType": {
499
+ "type": "string"
500
+ },
501
+ "size": {
502
+ "type": "number"
503
+ },
504
+ "fileName": {
505
+ "type": "string"
506
+ }
493
507
  },
494
- "required": true,
495
- "name": "assetId",
496
- "in": "path"
508
+ "required": [
509
+ "assetId",
510
+ "contentType",
511
+ "size",
512
+ "fileName"
513
+ ]
497
514
  },
515
+ "File to be uploaded": {}
516
+ },
517
+ "parameters": {
498
518
  "BookmarkId": {
499
519
  "schema": {
500
520
  "$ref": "#/components/schemas/BookmarkId"
@@ -526,6 +546,14 @@
526
546
  "required": true,
527
547
  "name": "highlightId",
528
548
  "in": "path"
549
+ },
550
+ "AssetId": {
551
+ "schema": {
552
+ "$ref": "#/components/schemas/AssetId"
553
+ },
554
+ "required": true,
555
+ "name": "assetId",
556
+ "in": "path"
529
557
  }
530
558
  }
531
559
  },
@@ -1982,6 +2010,9 @@
1982
2010
  "query": {
1983
2011
  "type": "string",
1984
2012
  "minLength": 1
2013
+ },
2014
+ "public": {
2015
+ "type": "boolean"
1985
2016
  }
1986
2017
  }
1987
2018
  }
@@ -3024,6 +3055,74 @@
3024
3055
  }
3025
3056
  }
3026
3057
  }
3058
+ },
3059
+ "/assets": {
3060
+ "post": {
3061
+ "description": "Upload a new asset",
3062
+ "summary": "Upload a new asset",
3063
+ "tags": [
3064
+ "Assets"
3065
+ ],
3066
+ "security": [
3067
+ {
3068
+ "bearerAuth": []
3069
+ }
3070
+ ],
3071
+ "requestBody": {
3072
+ "description": "The data to create the asset with.",
3073
+ "content": {
3074
+ "multipart/form-data": {
3075
+ "schema": {
3076
+ "type": "object",
3077
+ "properties": {
3078
+ "file": {
3079
+ "$ref": "#/components/schemas/File to be uploaded"
3080
+ }
3081
+ },
3082
+ "required": [
3083
+ "file"
3084
+ ]
3085
+ }
3086
+ }
3087
+ }
3088
+ },
3089
+ "responses": {
3090
+ "200": {
3091
+ "description": "Details about the created asset",
3092
+ "content": {
3093
+ "application/json": {
3094
+ "schema": {
3095
+ "$ref": "#/components/schemas/Asset"
3096
+ }
3097
+ }
3098
+ }
3099
+ }
3100
+ }
3101
+ }
3102
+ },
3103
+ "/assets/{assetId}": {
3104
+ "get": {
3105
+ "description": "Get asset by its id",
3106
+ "summary": "Get a single asset",
3107
+ "tags": [
3108
+ "Assets"
3109
+ ],
3110
+ "security": [
3111
+ {
3112
+ "bearerAuth": []
3113
+ }
3114
+ ],
3115
+ "parameters": [
3116
+ {
3117
+ "$ref": "#/components/parameters/AssetId"
3118
+ }
3119
+ ],
3120
+ "responses": {
3121
+ "200": {
3122
+ "description": "Asset content. Content type is determined by the asset type."
3123
+ }
3124
+ }
3125
+ }
3027
3126
  }
3028
3127
  }
3029
3128
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karakeep_python_api
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: Community python client for the Karakeep API.
5
5
  Home-page: https://github.com/thiswillbeyourgithub/karakeep_python_api/
6
6
  License: GPLv3
@@ -81,7 +81,7 @@ Methods or CLI commands marked with ❌ should be used with caution as their beh
81
81
  | Method Name | Pytest | CLI | Remarks |
82
82
  | -------------------------------- | :----: | :--: | -------------------------------------------- |
83
83
  | `get_all_bookmarks` | ✅ | ✅ | Tested with pagination. |
84
- | `create_a_new_bookmark` | ✅ | ❌ | Pytest for `type="link"` via fixture. CLI not directly tested. |
84
+ | `create_a_new_bookmark` | ✅ | ❌ | Pytest for `type="link"` via fixture and `type="asset"` via PDF test. CLI not directly tested. |
85
85
  | `search_bookmarks` | ✅ | ✅ | Seems to be nondeterministic and fails if using more than 3 words |
86
86
  | `get_a_single_bookmark` | ✅ | ❌ | |
87
87
  | `delete_a_bookmark` | ✅ | ❌ | |
@@ -112,6 +112,8 @@ Methods or CLI commands marked with ❌ should be used with caution as their beh
112
112
  | `get_a_single_highlight` | ❌ | ❌ | |
113
113
  | `delete_a_highlight` | ❌ | ❌ | Works from the CLI; not yet added to Pytest. |
114
114
  | `update_a_highlight` | ❌ | ❌ | |
115
+ | `upload_a_new_asset` | ✅ | ❌ | Tested in PDF asset lifecycle test. |
116
+ | `get_a_single_asset` | ✅ | ❌ | Tested in PDF asset lifecycle test. |
115
117
  | `get_current_user_info` | ✅ | ❌ | Pytest: Tested indirectly during client init. CLI not directly tested. |
116
118
  | `get_current_user_stats` | ✅ | ✅ | |
117
119
 
@@ -137,7 +139,7 @@ This package can be used as a Python library or as a command-line interface (CLI
137
139
 
138
140
  The client can be configured using the following environment variables:
139
141
 
140
- * `KARAKEEP_PYTHON_API_BASE_URL`: **Required**. The full base URL of your Karakeep API, including the `/api/v1/` path (e.g., `https://your-karakeep.example.com/api/v1/` or `https://try.karakeep.app/api/v1/`).
142
+ * `KARAKEEP_PYTHON_API_ENDPOINT`: **Required**. The full URL of your Karakeep API, including the `/api/v1/` path (e.g., `https://karakeep.domain.com/api/v1/` or `https://try.karakeep.app/api/v1/`).
141
143
  * `KARAKEEP_PYTHON_API_KEY`: **Required**. Your Karakeep API key (Bearer token).
142
144
  * `KARAKEEP_PYTHON_API_VERIFY_SSL`: Set to `false` to disable SSL certificate verification (default: `true`).
143
145
  * `KARAKEEP_PYTHON_API_VERBOSE`: Set to `true` to enable verbose debug logging for the client and CLI (default: `false`).
@@ -146,7 +148,7 @@ The client can be configured using the following environment variables:
146
148
 
147
149
  ### Command Line Interface (CLI)
148
150
 
149
- The CLI dynamically generates commands based on the API methods. You need to provide your API key and base URL either via environment variables (recommended) or command-line options.
151
+ The CLI dynamically generates commands based on the API methods. You need to provide your API key and endpoint either via environment variables (recommended) or command-line options.
150
152
 
151
153
  **Basic Structure:**
152
154
 
@@ -171,8 +173,8 @@ python -m karakeep_python_api get-all-bookmarks --help
171
173
  python -m karakeep_python_api get-all-tags
172
174
 
173
175
  # Get the first page of bookmarks with a limit, overriding env vars if needed
174
- # Note: Ensure the base URL includes the /api/v1/ path
175
- python -m karakeep_python_api --base-url https://my.karakeep.com/api/v1/ --api-key YOUR_API_KEY get-all-bookmarks --limit 10
176
+ # Note: The /api/v1/ path will be automatically appended if not present
177
+ python -m karakeep_python_api --base-url https://karakeep.domain.com/api/v1/ --api-key YOUR_API_KEY get-all-bookmarks --limit 10
176
178
 
177
179
  # Get all lists and pipe the JSON output to jq to extract the first list
178
180
  python -m karakeep_python_api get-all-lists | jq '.[0]'
@@ -196,14 +198,14 @@ import os
196
198
  from karakeep_python_api import KarakeepAPI, APIError, AuthenticationError, datatypes
197
199
 
198
200
  # Ensure required environment variables are set
199
- # Example: os.environ["KARAKEEP_PYTHON_API_BASE_URL"] = "https://your-karakeep.example.com/api/v1/"
201
+ # Example: os.environ["KARAKEEP_PYTHON_API_ENDPOINT"] = "https://karakeep.domain.com/api/v1/"
200
202
  # Example: os.environ["KARAKEEP_PYTHON_API_KEY"] = "your_secret_api_key"
201
203
 
202
204
  try:
203
205
  # Initialize the client (reads from env vars by default)
204
206
  client = KarakeepAPI(
205
207
  # Optionally override env vars:
206
- # base_url="https://another.karakeep.com",
208
+ # api_endpoint="https://karakeep.domain.com/api/v1/",
207
209
  # api_key="another_key",
208
210
  # verbose=True,
209
211
  # disable_response_validation=False
@@ -233,7 +235,7 @@ except AuthenticationError as e:
233
235
  except APIError as e:
234
236
  print(f"An API error occurred: {e}")
235
237
  except ValueError as e:
236
- # Handles missing API key/base URL during initialization
238
+ # Handles missing API key/endpoint during initialization
237
239
  print(f"Configuration error: {e}")
238
240
  except Exception as e:
239
241
  print(f"An unexpected error occurred: {e}")
@@ -242,15 +244,19 @@ except Exception as e:
242
244
 
243
245
  ## Community Scripts
244
246
 
245
- Examples of the API being used can be found in the [`./examples`](./examples) folder. Don't hesitate to submit yours!
247
+ Community Scripts are a bunch of scripts made to solve specific issues. They are made by the community so don't hesitate to submit yours or open an issue if you have a bug. They also serve as example of how to use the API.
246
248
 
247
- | Example Script | Description | Documentation |
248
- |----------------|-------------|---------------|
249
- | **Add Time-to-Read Tags** | Automatically adds time-to-read tags (`0-5m`, `5-10m`, etc.) to bookmarks based on content length analysis. Includes systemd service and timer files for automated periodic execution. | [`README.md`](./examples/add_time_to_read_tag/README.md) |
250
- | **List to Tag Converter** | Converts a Karakeep list into tags by adding a specified tag to all bookmarks within that list. | [`README.md`](./examples/list_to_tag/README.md) |
251
- | **Omnivore Highlights Importer** | Imports highlights from Omnivore export data to Karakeep, with intelligent position detection and bookmark matching. Supports dry-run mode for testing. | [`README.md`](./examples/omnivore_highlights_importer/README.md) |
252
- | **Omnivore Archiving Status Updater** | Fixes the archived status of bookmarks imported from Omnivore by reading export data and updating Karakeep accordingly. | [`README.md`](./examples/omnivore_archiving_status_updater/README.md) |
253
- | **Pocket Archiving Status Updater** | Fixes the archived status of bookmarks imported from Pocket by reading export data and updating Karakeep accordingly. | [`README.md`](./examples/pocket_archiving_status_updater/README.md) |
249
+ They can be found in the [./community_scripts](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts) folder. Don't hesitate to submit yours, the contribution guidelines are in the community_scripts directory README.md file.
250
+
251
+ | Community Script | Description | Documentation |
252
+ |----------------|--------------------------------------------------------------------------------------------------------------|---------------|
253
+ | **Karakeep-Time-Tagger** | Automatically adds time-to-read tags (`0-5m`, `5-10m`, etc.) to bookmarks based on content length analysis. Includes systemd service and timer files for automated periodic execution. | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/karakeep-time-tagger) |
254
+ | **Karakeep-List-To-Tag** | Converts a Karakeep list into tags by adding a specified tag to all bookmarks within that list. | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/karakeep-list-to-tag) |
255
+ | **Omnivore2Karakeep-Highlights** | Imports highlights from Omnivore export data to Karakeep, with intelligent position detection and bookmark matching. Supports dry-run mode for testing. | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/omnivore2karakeep-highlights) |
256
+ | **Omnivore2Karakeep-Archived** | (Should not be needed anymore) Fixes the archived status of bookmarks imported from Omnivore by reading export data and updating Karakeep accordingly. | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/omnivore2karakeep-archived) |
257
+ | **pocket2karakeep-archived** by [@youenchene](https://github.com/youenchene) | (Should not be needed anymore) Fixes the archived status of bookmarks imported from Pocket by reading export data and updating Karakeep accordingly. | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/pocket2karakeep-archived) |
258
+ | **Karakeep-Archive-Before-Date** by [@youenchene](https://github.com/youenchene) | Allow you to archive all not archived post before a given date | [`Link`](https://github.com/thiswillbeyourgithub/karakeep_python_api/tree/main/community_scripts/karakeep-archive-before-date) |
259
+ | **Freshrss-To-Karakeep** | Syncs some links from Freshrss to Karakeep | [`Link`](https://github.com/thiswillbeyourgithub/freshrss_to_karakeep) |
254
260
 
255
261
  ## Development
256
262
 
@@ -261,7 +267,7 @@ Examples of the API being used can be found in the [`./examples`](./examples) fo
261
267
  ```bash
262
268
  uv pip install -e ".[dev]"
263
269
  ```
264
- 4. Set the required environment variables (`KARAKEEP_PYTHON_API_BASE_URL`, `KARAKEEP_PYTHON_API_KEY`) for running tests against a live instance.
270
+ 4. Set the required environment variables (`KARAKEEP_PYTHON_API_ENDPOINT`, `KARAKEEP_PYTHON_API_KEY`) for running tests against a live instance.
265
271
  5. Run tests:
266
272
 
267
273
  ```bash
@@ -0,0 +1,11 @@
1
+ karakeep_python_api/__init__.py,sha256=qk3MeIIvnCNAyYzJrv8dMKVXvA4ejLU3mhB3xFzdLDY,606
2
+ karakeep_python_api/__main__.py,sha256=X1GXwRvAAw8qFmMm2MKdmPNYkmlmEUUQalJX9kWxrS0,32402
3
+ karakeep_python_api/datatypes.py,sha256=VHUuSTifTnVnxYOA9TUGvGK4GiZLcXgQA8AqBQ6Kqgs,3529
4
+ karakeep_python_api/karakeep_api.py,sha256=wB2lTB0uHeiXr7sN9yikp5FkiRI1Dw4IDRRCOanf7HM,91802
5
+ karakeep_python_api/openapi_reference.json,sha256=J1dyFeXgcV6boh851xU3J3YRTz_5ixrGQjtxkJEj1RU,82686
6
+ karakeep_python_api-1.2.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
7
+ karakeep_python_api-1.2.0.dist-info/METADATA,sha256=VfqCwjsuJGhvTu5jTMtmJobLaVAVB5-NObs9URUqKus,15970
8
+ karakeep_python_api-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ karakeep_python_api-1.2.0.dist-info/entry_points.txt,sha256=n0leQp_IX2NivoWuUcaR9ZHwAvl_6yt-NcHWuCJyxNo,62
10
+ karakeep_python_api-1.2.0.dist-info/top_level.txt,sha256=X3VKqh9YAbPQp144db0Ko2C5Q2y6-nwpPgtnuCiSDmU,20
11
+ karakeep_python_api-1.2.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- karakeep_python_api/__init__.py,sha256=qk3MeIIvnCNAyYzJrv8dMKVXvA4ejLU3mhB3xFzdLDY,606
2
- karakeep_python_api/__main__.py,sha256=UfL6R-S3rJB7PH3iqTicNq0cvtumnrG_FVyKK36bqdE,32327
3
- karakeep_python_api/datatypes.py,sha256=CScuMq5oT4YwvGH8Af_Oo60xGmC622d74f4WPUw7l8Q,3398
4
- karakeep_python_api/karakeep_api.py,sha256=Fpyc3A4Gph3czirIDztsvzbtnegmkDLmiJNDCdgTt04,84749
5
- karakeep_python_api/openapi_reference.json,sha256=Ku1cO_N4QPLQc7sK8L7FRt2T68QyPCwTGA7RK-046iU,80379
6
- karakeep_python_api-1.0.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
7
- karakeep_python_api-1.0.0.dist-info/METADATA,sha256=joWnEpuTHNzpSr4U4EJ-Xb48YK8_YU-rrYHW9HWGsAg,14026
8
- karakeep_python_api-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
- karakeep_python_api-1.0.0.dist-info/entry_points.txt,sha256=n0leQp_IX2NivoWuUcaR9ZHwAvl_6yt-NcHWuCJyxNo,62
10
- karakeep_python_api-1.0.0.dist-info/top_level.txt,sha256=X3VKqh9YAbPQp144db0Ko2C5Q2y6-nwpPgtnuCiSDmU,20
11
- karakeep_python_api-1.0.0.dist-info/RECORD,,