karakeep-python-api 1.1.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (18) hide show
  1. {karakeep_python_api-1.1.0/karakeep_python_api.egg-info → karakeep_python_api-1.2.0}/PKG-INFO +8 -5
  2. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/README.md +7 -4
  3. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api/datatypes.py +9 -2
  4. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api/karakeep_api.py +192 -43
  5. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api/openapi_reference.json +105 -13
  6. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0/karakeep_python_api.egg-info}/PKG-INFO +8 -5
  7. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/setup.py +1 -1
  8. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/tests/test_karakeep_api.py +86 -1
  9. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/LICENSE +0 -0
  10. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/MANIFEST.in +0 -0
  11. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api/__init__.py +0 -0
  12. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api/__main__.py +0 -0
  13. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api.egg-info/SOURCES.txt +0 -0
  14. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api.egg-info/dependency_links.txt +0 -0
  15. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api.egg-info/entry_points.txt +0 -0
  16. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api.egg-info/requires.txt +0 -0
  17. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/karakeep_python_api.egg-info/top_level.txt +0 -0
  18. {karakeep_python_api-1.1.0 → karakeep_python_api-1.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karakeep_python_api
3
- Version: 1.1.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
 
@@ -246,14 +248,15 @@ Community Scripts are a bunch of scripts made to solve specific issues. They are
246
248
 
247
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.
248
250
 
249
- | Community Script | Description | Documentation |
250
- |----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
251
+ | Community Script | Description | Documentation |
252
+ |----------------|--------------------------------------------------------------------------------------------------------------|---------------|
251
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) |
252
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) |
253
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) |
254
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) |
255
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) |
256
- | **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) |
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) |
257
260
 
258
261
  ## Development
259
262
 
@@ -46,7 +46,7 @@ Methods or CLI commands marked with ❌ should be used with caution as their beh
46
46
  | Method Name | Pytest | CLI | Remarks |
47
47
  | -------------------------------- | :----: | :--: | -------------------------------------------- |
48
48
  | `get_all_bookmarks` | ✅ | ✅ | Tested with pagination. |
49
- | `create_a_new_bookmark` | ✅ | ❌ | Pytest for `type="link"` via fixture. CLI not directly tested. |
49
+ | `create_a_new_bookmark` | ✅ | ❌ | Pytest for `type="link"` via fixture and `type="asset"` via PDF test. CLI not directly tested. |
50
50
  | `search_bookmarks` | ✅ | ✅ | Seems to be nondeterministic and fails if using more than 3 words |
51
51
  | `get_a_single_bookmark` | ✅ | ❌ | |
52
52
  | `delete_a_bookmark` | ✅ | ❌ | |
@@ -77,6 +77,8 @@ Methods or CLI commands marked with ❌ should be used with caution as their beh
77
77
  | `get_a_single_highlight` | ❌ | ❌ | |
78
78
  | `delete_a_highlight` | ❌ | ❌ | Works from the CLI; not yet added to Pytest. |
79
79
  | `update_a_highlight` | ❌ | ❌ | |
80
+ | `upload_a_new_asset` | ✅ | ❌ | Tested in PDF asset lifecycle test. |
81
+ | `get_a_single_asset` | ✅ | ❌ | Tested in PDF asset lifecycle test. |
80
82
  | `get_current_user_info` | ✅ | ❌ | Pytest: Tested indirectly during client init. CLI not directly tested. |
81
83
  | `get_current_user_stats` | ✅ | ✅ | |
82
84
 
@@ -211,14 +213,15 @@ Community Scripts are a bunch of scripts made to solve specific issues. They are
211
213
 
212
214
  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.
213
215
 
214
- | Community Script | Description | Documentation |
215
- |----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
216
+ | Community Script | Description | Documentation |
217
+ |----------------|--------------------------------------------------------------------------------------------------------------|---------------|
216
218
  | **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) |
217
219
  | **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) |
218
220
  | **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) |
219
221
  | **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) |
220
222
  | **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) |
221
- | **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) |
223
+ | **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) |
224
+ | **Freshrss-To-Karakeep** | Syncs some links from Freshrss to Karakeep | [`Link`](https://github.com/thiswillbeyourgithub/freshrss_to_karakeep) |
222
225
 
223
226
  ## Development
224
227
 
@@ -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):
@@ -85,7 +85,7 @@ class KarakeepAPI:
85
85
  """
86
86
 
87
87
  # Version reflects the client library version, updated by bumpver
88
- VERSION: str = "1.1.0"
88
+ VERSION: str = "1.2.0"
89
89
 
90
90
  def __init__(
91
91
  self,
@@ -105,7 +105,7 @@ 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
- api_endpoint: Override the base URL for the API. Must be provided either as an argument
108
+ api_endpoint: Override the endpoint for the API. Must be provided either as an argument
109
109
  or via the KARAKEEP_PYTHON_API_ENDPOINT environment variable.
110
110
  Example: 'https://karakeep.domain.com/api/v1/'
111
111
  openapi_spec_path: Path to the OpenAPI JSON specification file.
@@ -131,47 +131,47 @@ class KarakeepAPI:
131
131
  self.api_key = resolved_api_key
132
132
  logger.debug("API Key loaded successfully.")
133
133
 
134
- # --- Base URL Validation ---
134
+ # --- Endpoint Validation ---
135
135
  env_endpoint = os.environ.get("KARAKEEP_PYTHON_API_ENDPOINT")
136
136
  logger.debug(
137
137
  f"Checked KARAKEEP_PYTHON_API_ENDPOINT environment variable, found: '{env_endpoint}'"
138
138
  )
139
- logger.debug(f"Base URL provided as argument: '{api_endpoint}'")
139
+ logger.debug(f"Endpoint provided as argument: '{api_endpoint}'")
140
140
 
141
141
  if api_endpoint:
142
142
  self.api_endpoint = api_endpoint
143
- logger.info(f"Using provided base URL: {self.api_endpoint}")
143
+ logger.info(f"Using provided endpoint: {self.api_endpoint}")
144
144
  elif env_endpoint:
145
145
  self.api_endpoint = env_endpoint
146
146
  logger.info(
147
- f"Using base URL from KARAKEEP_PYTHON_API_ENDPOINT: {self.api_endpoint}"
147
+ f"Using endpoint from KARAKEEP_PYTHON_API_ENDPOINT: {self.api_endpoint}"
148
148
  )
149
149
  else:
150
150
  # No api_endpoint from arg or env var - raise error as per requirement
151
151
  raise ValueError(
152
- "API base URL is required. Provide 'api_endpoint' argument or set KARAKEEP_PYTHON_API_ENDPOINT environment variable."
152
+ "API endpoint is required. Provide 'api_endpoint' argument or set KARAKEEP_PYTHON_API_ENDPOINT environment variable."
153
153
  )
154
154
 
155
- # Ensure base URL ends with /api/v1/
155
+ # Ensure endpoint ends with /api/v1/
156
156
  resolved_url = self.api_endpoint # Use a temporary variable for checks
157
157
  if resolved_url.endswith("/api/v1"):
158
158
  # Ends with /api/v1, needs a slash
159
159
  self.api_endpoint = resolved_url + "/"
160
160
  logger.info(
161
- f"Appended trailing slash to base URL ending in /api/v1: {self.api_endpoint}"
161
+ f"Appended trailing slash to endpoint ending in /api/v1: {self.api_endpoint}"
162
162
  )
163
163
  elif resolved_url.endswith("/api/v1/"):
164
164
  # Already ends correctly, do nothing
165
- logger.debug(f"Base URL already ends with /api/v1/: {self.api_endpoint}")
165
+ logger.debug(f"Endpoint already ends with /api/v1/: {self.api_endpoint}")
166
166
  else:
167
167
  # Doesn't end with /api/v1 or /api/v1/, append /api/v1/
168
168
  # First, remove any existing trailing slash to avoid //api/v1/
169
169
  if resolved_url.endswith("/"):
170
170
  resolved_url = resolved_url[:-1]
171
171
  self.api_endpoint = resolved_url + "/api/v1/"
172
- logger.info(f"Appended /api/v1/ to base URL: {self.api_endpoint}")
172
+ logger.info(f"Appended /api/v1/ to endpoint: {self.api_endpoint}")
173
173
 
174
- logger.debug(f"Final API Base URL after /api/v1/ check: {self.api_endpoint}")
174
+ logger.debug(f"Final API Endpoint after /api/v1/ check: {self.api_endpoint}")
175
175
 
176
176
  # --- Load and Parse OpenAPI Spec ---
177
177
  if openapi_spec_path is None:
@@ -258,7 +258,7 @@ class KarakeepAPI:
258
258
  # self.verbose is still used for conditional logging within the class methods.
259
259
 
260
260
  logger.debug("KarakeepAPI client initialized.")
261
- logger.debug(f" Base URL: {self.api_endpoint}")
261
+ logger.debug(f" Endpoint: {self.api_endpoint}")
262
262
  logger.debug(f" Verify SSL: {self.verify_ssl}")
263
263
  logger.debug(f" Verbose: {self.verbose}")
264
264
  logger.debug(
@@ -295,26 +295,29 @@ class KarakeepAPI:
295
295
  data: Optional[
296
296
  Union[BaseModel, dict, list, str, bytes]
297
297
  ] = None, # More specific type hint
298
+ files: Optional[Dict[str, Any]] = None,
298
299
  extra_headers: Optional[Dict[str, str]] = None,
299
- ) -> Union[Dict[str, Any], List[Any], None]:
300
+ ) -> Union[Dict[str, Any], List[Any], None, bytes]:
300
301
  """
301
302
  Internal method to make an HTTP call to the Karakeep API. Handles authentication,
302
303
  request formatting, response parsing, and error handling.
303
304
 
304
305
  Args:
305
306
  method: HTTP method ('GET', 'POST', 'PUT', 'PATCH', 'DELETE').
306
- 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').
307
308
  Path parameters (like {bookmarkId}) MUST be substituted *before* calling _call.
308
309
  params: Dictionary of URL query parameters. Values should be primitive types suitable for URLs.
309
310
  data: Request body data. Can be a Pydantic model, dict, list, bytes, or str.
310
311
  - Pydantic models, dicts, and lists will be automatically JSON-encoded
311
312
  with 'Content-Type: application/json' unless overridden in extra_headers.
312
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.
313
315
  extra_headers: Additional headers to include or override default headers.
314
316
 
315
317
  Returns:
316
- The parsed JSON response from the API as a dict or list, or None for 204 No Content responses.
317
- 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.
318
321
 
319
322
  Raises:
320
323
  AuthenticationError: If authentication fails (401).
@@ -346,7 +349,16 @@ class KarakeepAPI:
346
349
  # Determine Content-Type, prioritizing extra_headers
347
350
  content_type = headers.get("Content-Type")
348
351
 
349
- 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:
350
362
  if isinstance(data, BaseModel):
351
363
  # Serialize Pydantic model to JSON bytes
352
364
  request_body_arg = data.model_dump_json(
@@ -471,6 +483,7 @@ class KarakeepAPI:
471
483
  url=url,
472
484
  params=request_params, # Use params with stringified booleans
473
485
  data=request_body_arg, # Serialized data (bytes or str)
486
+ files=files, # File uploads for multipart/form-data
474
487
  headers=headers,
475
488
  verify=self.verify_ssl,
476
489
  timeout=60, # Increased default timeout
@@ -515,28 +528,42 @@ class KarakeepAPI:
515
528
  logger.debug(" Body: None (204 No Content or empty response body)")
516
529
  return None
517
530
 
518
- # Attempt to parse successful response as JSON
519
- try:
520
- 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
521
560
  if self.verbose:
522
- # Log parsed response body carefully
523
- log_resp_str = repr(result)
524
- if len(log_resp_str) > 1000:
525
- log_resp_str = log_resp_str[:1000] + "...(truncated)"
526
- logger.debug(f" Body (JSON Parsed): {log_resp_str}")
527
- # Return the raw parsed JSON (dict/list). Deserialization into
528
- # specific Pydantic models should happen in the calling wrapper method.
529
- return result
530
- except json.JSONDecodeError as e:
531
- # Handle cases where the response is successful (2xx) but not valid JSON
532
- logger.error(
533
- f"API Error: Failed to decode JSON response from {method} {url}. Status: {response.status_code}. Content: {response.text[:500]}..."
534
- )
535
- # Raise APIError as the response format is unexpected
536
- raise APIError(
537
- message=f"Failed to parse successful API response JSON from {url}: {e}. Response text: {response.text[:200]}...",
538
- status_code=response.status_code,
539
- ) 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
540
567
 
541
568
  except requests.exceptions.HTTPError as e:
542
569
  # Handle 4xx/5xx errors raised by response.raise_for_status()
@@ -1180,7 +1207,7 @@ class KarakeepAPI:
1180
1207
  "precrawledArchive",
1181
1208
  "unknown",
1182
1209
  ],
1183
- ) -> Union[datatypes.Asset, Dict[str, Any], List[Any]]:
1210
+ ) -> Union[datatypes.BookmarkAsset, Dict[str, Any], List[Any]]:
1184
1211
  """
1185
1212
  Attach a new asset to a bookmark. Corresponds to POST /bookmarks/{bookmarkId}/assets.
1186
1213
 
@@ -1191,7 +1218,7 @@ class KarakeepAPI:
1191
1218
  "bannerImage", "fullPageArchive", "video", "bookmarkAsset", "precrawledArchive", "unknown".
1192
1219
 
1193
1220
  Returns:
1194
- datatypes.Asset: The attached asset object.
1221
+ datatypes.BookmarkAsset: The attached asset object.
1195
1222
  If response validation is disabled, returns the raw API response (dict/list).
1196
1223
 
1197
1224
  Raises:
@@ -1208,8 +1235,8 @@ class KarakeepAPI:
1208
1235
  logger.debug("Skipping response validation as requested.")
1209
1236
  return response_data
1210
1237
  else:
1211
- # Response should match Asset schema
1212
- return datatypes.Asset.model_validate(response_data)
1238
+ # Response should match BookmarkAsset schema
1239
+ return datatypes.BookmarkAsset.model_validate(response_data)
1213
1240
 
1214
1241
  @optional_typecheck
1215
1242
  def replace_asset(self, bookmark_id: str, asset_id: str, new_asset_id: str) -> None:
@@ -1943,3 +1970,125 @@ class KarakeepAPI:
1943
1970
  response_data = self._call("GET", "users/me/stats")
1944
1971
  # No Pydantic validation applied here as the spec defines a simple dict response
1945
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": {
@@ -488,17 +488,33 @@
488
488
  "highlights",
489
489
  "nextCursor"
490
490
  ]
491
- }
492
- },
493
- "parameters": {
494
- "AssetId": {
495
- "schema": {
496
- "$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
+ }
497
507
  },
498
- "required": true,
499
- "name": "assetId",
500
- "in": "path"
508
+ "required": [
509
+ "assetId",
510
+ "contentType",
511
+ "size",
512
+ "fileName"
513
+ ]
501
514
  },
515
+ "File to be uploaded": {}
516
+ },
517
+ "parameters": {
502
518
  "BookmarkId": {
503
519
  "schema": {
504
520
  "$ref": "#/components/schemas/BookmarkId"
@@ -530,6 +546,14 @@
530
546
  "required": true,
531
547
  "name": "highlightId",
532
548
  "in": "path"
549
+ },
550
+ "AssetId": {
551
+ "schema": {
552
+ "$ref": "#/components/schemas/AssetId"
553
+ },
554
+ "required": true,
555
+ "name": "assetId",
556
+ "in": "path"
533
557
  }
534
558
  }
535
559
  },
@@ -3031,6 +3055,74 @@
3031
3055
  }
3032
3056
  }
3033
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
+ }
3034
3126
  }
3035
3127
  }
3036
3128
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karakeep_python_api
3
- Version: 1.1.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
 
@@ -246,14 +248,15 @@ Community Scripts are a bunch of scripts made to solve specific issues. They are
246
248
 
247
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.
248
250
 
249
- | Community Script | Description | Documentation |
250
- |----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
251
+ | Community Script | Description | Documentation |
252
+ |----------------|--------------------------------------------------------------------------------------------------------------|---------------|
251
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) |
252
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) |
253
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) |
254
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) |
255
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) |
256
- | **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) |
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) |
257
260
 
258
261
  ## Development
259
262
 
@@ -7,7 +7,7 @@ with open("README.md", "r") as readme:
7
7
 
8
8
  setup(
9
9
  name="karakeep_python_api",
10
- version="1.1.0",
10
+ version="1.2.0",
11
11
  description="Community python client for the Karakeep API.", # Simplified description
12
12
  long_description=long_description,
13
13
  long_description_content_type="text/markdown",
@@ -237,7 +237,7 @@ def test_openapi_spec_accessible(karakeep_client: KarakeepAPI):
237
237
  # The closest CLI equivalent is dumping the spec file content
238
238
  try:
239
239
  logger.info("\n Running CLI equivalent: --dump-openapi-specification")
240
- # This command doesn't require API key or base URL
240
+ # This command doesn't require API key or endpoint
241
241
  subprocess.run(
242
242
  "python -m karakeep_python_api --dump-openapi-specification",
243
243
  shell=True,
@@ -803,4 +803,89 @@ def test_get_current_user_stats(karakeep_client: KarakeepAPI):
803
803
  pytest.fail(f"An unexpected error occurred running the CLI command: {e}")
804
804
 
805
805
 
806
+ def test_asset_lifecycle_with_pdf(karakeep_client: KarakeepAPI):
807
+ """Test creating a PDF bookmark, verifying its asset, and deleting it."""
808
+ pdf_file_path = "tests/PDF Bookmark Sample.pdf"
809
+ uploaded_asset_id = None
810
+ created_bookmark_id = None
811
+
812
+ # Generate unique title to avoid collisions
813
+ timestamp = int(time.time())
814
+ random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
815
+ bookmark_title = f"Test PDF Bookmark {timestamp}-{random_suffix}"
816
+
817
+ try:
818
+ # 1. Upload the PDF asset
819
+ logger.info(f"\nUploading PDF asset from: {pdf_file_path}")
820
+ uploaded_asset = karakeep_client.upload_a_new_asset(file=pdf_file_path)
821
+ assert isinstance(uploaded_asset, datatypes.Asset)
822
+ assert uploaded_asset.assetId, "Uploaded asset must have an ID"
823
+ assert "pdf" in uploaded_asset.contentType.lower(), "Asset should be PDF type"
824
+ assert (
825
+ uploaded_asset.fileName == "PDF Bookmark Sample.pdf"
826
+ ), "Asset filename should match the original file"
827
+ uploaded_asset_id = uploaded_asset.assetId
828
+ logger.info(f"✓ PDF uploaded with asset ID: {uploaded_asset_id}")
829
+
830
+ # 2. Create a PDF bookmark using the uploaded asset
831
+ logger.info(f"\nCreating PDF bookmark with title: '{bookmark_title}'")
832
+ bookmark = karakeep_client.create_a_new_bookmark(
833
+ type="asset",
834
+ asset_type="pdf",
835
+ assetId=uploaded_asset_id,
836
+ title=bookmark_title,
837
+ fileName="PDF Bookmark Sample.pdf",
838
+ )
839
+ assert isinstance(bookmark, datatypes.Bookmark)
840
+ assert bookmark.id, "Created bookmark must have an ID"
841
+ assert bookmark.title == bookmark_title, "Bookmark title should match"
842
+ created_bookmark_id = bookmark.id
843
+ logger.info(f"✓ PDF bookmark created with ID: {created_bookmark_id}")
844
+
845
+ # 3. Verify the bookmark has the correct asset
846
+ logger.info(f"\nVerifying bookmark {created_bookmark_id} has the PDF asset")
847
+ retrieved_bookmark = karakeep_client.get_a_single_bookmark(
848
+ bookmark_id=created_bookmark_id
849
+ )
850
+ assert isinstance(retrieved_bookmark, datatypes.Bookmark)
851
+ assert (
852
+ len(retrieved_bookmark.assets) > 0
853
+ ), "Bookmark should have at least one asset"
854
+
855
+ # Check that our uploaded asset is among the bookmark's assets
856
+ asset_ids = [asset.id for asset in retrieved_bookmark.assets]
857
+ assert (
858
+ uploaded_asset_id in asset_ids
859
+ ), f"Uploaded asset {uploaded_asset_id} should be attached to bookmark"
860
+ logger.info(f"✓ Verified bookmark contains the PDF asset {uploaded_asset_id}")
861
+
862
+ # 4. Retrieve and verify the asset content
863
+ logger.info(f"\nRetrieving asset content for ID: {uploaded_asset_id}")
864
+ asset_content = karakeep_client.get_a_single_asset(asset_id=uploaded_asset_id)
865
+ assert isinstance(asset_content, bytes), "Asset content should be bytes"
866
+ assert len(asset_content) > 0, "Asset content should not be empty"
867
+ assert asset_content.startswith(b"%PDF"), "PDF should start with PDF header"
868
+ logger.info(f"✓ Retrieved PDF asset content ({len(asset_content)} bytes)")
869
+
870
+ except FileNotFoundError:
871
+ pytest.skip(f"PDF test file not found: {pdf_file_path}")
872
+ except (APIError, AuthenticationError) as e:
873
+ pytest.fail(f"API error during PDF asset test: {e}")
874
+ except Exception as e:
875
+ pytest.fail(f"Unexpected error during PDF asset test: {e}")
876
+ finally:
877
+ # 5. Clean up: Delete the bookmark
878
+ if created_bookmark_id:
879
+ logger.info(f"\nCleaning up: Deleting bookmark {created_bookmark_id}")
880
+ try:
881
+ karakeep_client.delete_a_bookmark(bookmark_id=created_bookmark_id)
882
+ logger.info(f"✓ Successfully deleted bookmark {created_bookmark_id}")
883
+ except Exception as e:
884
+ logger.info(
885
+ f" Error during cleanup - failed to delete bookmark {created_bookmark_id}: {e}"
886
+ )
887
+ else:
888
+ logger.info("\nNo bookmark to clean up")
889
+
890
+
806
891
  # --- End of Tests ---