karakeep-python-api 0.1.6__tar.gz → 0.1.7__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-0.1.6/karakeep_python_api.egg-info → karakeep_python_api-0.1.7}/PKG-INFO +8 -2
  2. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/README.md +7 -1
  3. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api/__main__.py +113 -9
  4. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api/datatypes.py +10 -10
  5. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api/karakeep_api.py +22 -8
  6. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api/openapi_reference.json +1 -1
  7. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7/karakeep_python_api.egg-info}/PKG-INFO +8 -2
  8. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/setup.py +1 -1
  9. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/tests/test_karakeep_api.py +58 -0
  10. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/LICENSE +0 -0
  11. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/MANIFEST.in +0 -0
  12. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api/__init__.py +0 -0
  13. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api.egg-info/SOURCES.txt +0 -0
  14. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api.egg-info/dependency_links.txt +0 -0
  15. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api.egg-info/entry_points.txt +0 -0
  16. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api.egg-info/requires.txt +0 -0
  17. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/karakeep_python_api.egg-info/top_level.txt +0 -0
  18. {karakeep_python_api-0.1.6 → karakeep_python_api-0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karakeep_python_api
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -57,7 +57,7 @@ A community-developed Python client for the [Karakeep](https://karakeep.app/) AP
57
57
 
58
58
  ## Overview
59
59
 
60
- This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API.
60
+ This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API. The author also developed [freshrss_to_karakeep](https://github.com/thiswillbeyourgithub/freshrss_to_karakeep), a Python script that periodically sends FreshRSS "favourite" articles to Karakeep (a bookmarking and read-it-later app, see [Karakeep on GitHub](https://github.com/karakeep-app/karakeep)).
61
61
 
62
62
  The development process involved:
63
63
 
@@ -238,6 +238,12 @@ except Exception as e:
238
238
 
239
239
  ```
240
240
 
241
+ ### Usage Examples
242
+
243
+ Examples of the API being used can be found in the [`./examples`](./examples) folder. Don't hesitate to submit yours!
244
+
245
+ - **Omnivore Archiving Status Updater**: A script to fix the archive status of bookmarks imported from Omnivore into Karakeep. See [`examples/omnivore_archiving_status_updater/README.md`](./examples/omnivore_archiving_status_updater/README.md) for details.
246
+
241
247
  ## Development
242
248
 
243
249
  1. Clone the repository.
@@ -22,7 +22,7 @@ A community-developed Python client for the [Karakeep](https://karakeep.app/) AP
22
22
 
23
23
  ## Overview
24
24
 
25
- This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API.
25
+ This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API. The author also developed [freshrss_to_karakeep](https://github.com/thiswillbeyourgithub/freshrss_to_karakeep), a Python script that periodically sends FreshRSS "favourite" articles to Karakeep (a bookmarking and read-it-later app, see [Karakeep on GitHub](https://github.com/karakeep-app/karakeep)).
26
26
 
27
27
  The development process involved:
28
28
 
@@ -203,6 +203,12 @@ except Exception as e:
203
203
 
204
204
  ```
205
205
 
206
+ ### Usage Examples
207
+
208
+ Examples of the API being used can be found in the [`./examples`](./examples) folder. Don't hesitate to submit yours!
209
+
210
+ - **Omnivore Archiving Status Updater**: A script to fix the archive status of bookmarks imported from Omnivore into Karakeep. See [`examples/omnivore_archiving_status_updater/README.md`](./examples/omnivore_archiving_status_updater/README.md) for details.
211
+
206
212
  ## Development
207
213
 
208
214
  1. Clone the repository.
@@ -286,10 +286,97 @@ def create_click_command(
286
286
 
287
287
  # Call the API method
288
288
  try:
289
- logger.debug(
290
- f"Calling API method '{method_name}' with args: {call_args}"
291
- )
292
- result = instance_method(**call_args)
289
+ if method_name == "get_all_bookmarks":
290
+ logger.debug(f"Special CLI pagination handling for '{method_name}'.")
291
+ cli_total_limit = call_args.pop('limit', None)
292
+ # Other relevant params for get_all_bookmarks
293
+ archived_filter = call_args.get('archived')
294
+ favourited_filter = call_args.get('favourited')
295
+ include_content_cli = call_args.get('include_content', True)
296
+
297
+ call_args.pop('cursor', None) # Ignore CLI cursor
298
+
299
+ all_bookmarks_data = []
300
+ current_page_api_cursor = None
301
+ fetched_count = 0
302
+ API_INTERNAL_PAGE_SIZE = 50 # Define a page size for API calls
303
+
304
+ while True:
305
+ api_call_limit = API_INTERNAL_PAGE_SIZE
306
+ if cli_total_limit is not None:
307
+ remaining_needed = cli_total_limit - fetched_count
308
+ if remaining_needed <= 0:
309
+ break # Reached or exceeded CLI total limit
310
+ api_call_limit = min(API_INTERNAL_PAGE_SIZE, remaining_needed)
311
+
312
+ if api_call_limit <= 0 and cli_total_limit is not None : # Avoid asking for 0 or negative items unless fetching all
313
+ break
314
+
315
+
316
+ logger.debug(f"Fetching page for '{method_name}' with cursor: {current_page_api_cursor}, api_limit: {api_call_limit}")
317
+
318
+ page_call_args = {
319
+ 'archived': archived_filter,
320
+ 'favourited': favourited_filter,
321
+ 'limit': api_call_limit,
322
+ 'cursor': current_page_api_cursor,
323
+ 'include_content': include_content_cli,
324
+ }
325
+ page_call_args_filtered = {k: v for k, v in page_call_args.items() if v is not None}
326
+
327
+ try:
328
+ page_result_obj = instance_method(**page_call_args_filtered)
329
+ except TypeError as call_error_page:
330
+ logger.error(f"Error calling API method '{method_name}' (paginated): {call_error_page}")
331
+ logger.error(f"Provided arguments for page: {page_call_args_filtered}")
332
+ if verbose: logger.debug(traceback.format_exc())
333
+ ctx.exit(1)
334
+
335
+ bookmarks_on_this_page = []
336
+ next_api_cursor = None
337
+
338
+ # Convert Pydantic model to dict using model_dump if available
339
+ if hasattr(page_result_obj, 'model_dump'):
340
+ result_dict = page_result_obj.model_dump()
341
+ elif isinstance(page_result_obj, dict):
342
+ result_dict = page_result_obj
343
+ else:
344
+ logger.warning(f"Unexpected result type: {type(page_result_obj)}")
345
+ result_dict = {}
346
+
347
+ # Extract data and cursor from the dict
348
+ bookmarks_on_this_page = result_dict.get("bookmarks", [])
349
+ next_api_cursor = result_dict.get("nextCursor")
350
+
351
+ logger.debug(f"Extracted {len(bookmarks_on_this_page)} bookmarks and cursor: {next_api_cursor}")
352
+
353
+ if not isinstance(bookmarks_on_this_page, list):
354
+ logger.warning(f"Expected a list of bookmarks, got {type(bookmarks_on_this_page)}. Stopping pagination.")
355
+ break
356
+
357
+ all_bookmarks_data.extend(bookmarks_on_this_page)
358
+ fetched_count += len(bookmarks_on_this_page)
359
+ logger.debug(f"Fetched {len(bookmarks_on_this_page)} bookmarks this page. Total fetched: {fetched_count}.")
360
+
361
+ current_page_api_cursor = next_api_cursor
362
+ if not current_page_api_cursor:
363
+ logger.debug("No nextCursor from API, pagination complete.")
364
+ break
365
+ if cli_total_limit is not None and fetched_count >= cli_total_limit:
366
+ logger.debug(f"CLI total limit of {cli_total_limit} reached or exceeded.")
367
+ break
368
+ if not bookmarks_on_this_page and api_call_limit > 0:
369
+ logger.debug("API returned an empty list of bookmarks while a positive limit was set, assuming end of data.")
370
+ break
371
+
372
+ result = all_bookmarks_data # This will be a list of Bookmark models or dicts
373
+ else:
374
+ # Original behavior for other commands
375
+ logger.debug(
376
+ f"Calling API method '{method_name}' with args: {call_args}"
377
+ )
378
+ result = instance_method(**call_args)
379
+
293
380
  except TypeError as call_error:
294
381
  logger.error(
295
382
  f"Error calling API method '{method_name}': {call_error}"
@@ -460,16 +547,33 @@ def create_click_command(
460
547
 
461
548
  click_required = is_required_in_sig and default_value is None and not is_flag
462
549
 
550
+ # Make copies of properties that might be modified for specific commands/params
551
+ current_param_help = param_help
552
+ current_click_required = click_required
553
+ current_default_value = default_value
554
+ current_is_flag = is_flag # Though is_flag interpretation might change help/required
555
+
556
+ # Special handling for 'get_all_bookmarks' command parameters
557
+ if api_method_name == "get_all_bookmarks":
558
+ if param.name == "cursor":
559
+ current_param_help = "[Ignored by CLI for get-all-bookmarks] " + param_help
560
+ current_click_required = False # Cursor is handled by CLI, not required from user
561
+ current_default_value = None # Explicitly set default to None for ignored param
562
+ elif param.name == "limit":
563
+ current_param_help = "Total maximum number of bookmarks to fetch across pages for get-all-bookmarks. If omitted, all are fetched."
564
+ # For 'limit', required status and default remain as derived from its Optional[int] type hint
565
+ # current_click_required and current_default_value will be correctly False and None respectively.
566
+
463
567
  # Add the Click Option
464
568
  click_params.append(
465
569
  click.Option(
466
570
  option_names,
467
571
  type=click_type,
468
- required=click_required,
469
- default=default_value if not is_flag else None,
470
- help=param_help,
471
- is_flag=(is_flag if len(option_names) == 1 else False),
472
- show_default=not is_flag and default_value is not None, # Show default unless it's a flag or None
572
+ required=current_click_required,
573
+ default=current_default_value if not current_is_flag else None,
574
+ help=current_param_help,
575
+ is_flag=(current_is_flag if len(option_names) == 1 else False),
576
+ show_default=not current_is_flag and current_default_value is not None,
473
577
  # Click derives the Python identifier (e.g., 'bookmark_id') from the first long option name
474
578
  )
475
579
  )
@@ -30,13 +30,13 @@ class HighlightId(RootModel[str]):
30
30
  root: str = Field(..., example="ieidlxygmwj87oxz5hxttoc8")
31
31
 
32
32
 
33
- class TaggingStatus(Enum):
33
+ class TaggingStatus(str, Enum):
34
34
  success = "success"
35
35
  failure = "failure"
36
36
  pending = "pending"
37
37
 
38
38
 
39
- class AttachedBy(Enum):
39
+ class AttachedBy(str, Enum):
40
40
  ai = "ai"
41
41
  human = "human"
42
42
 
@@ -47,7 +47,7 @@ class Tag(BaseModel):
47
47
  attachedBy: AttachedBy
48
48
 
49
49
 
50
- class Type(Enum):
50
+ class Type(str, Enum):
51
51
  link = "link"
52
52
 
53
53
 
@@ -71,7 +71,7 @@ class Content(BaseModel):
71
71
  dateModified: Optional[str] = None
72
72
 
73
73
 
74
- class Type1(Enum):
74
+ class Type1(str, Enum):
75
75
  text = "text"
76
76
 
77
77
 
@@ -81,11 +81,11 @@ class Content1(BaseModel):
81
81
  sourceUrl: Optional[str] = None
82
82
 
83
83
 
84
- class Type2(Enum):
84
+ class Type2(str, Enum):
85
85
  asset = "asset"
86
86
 
87
87
 
88
- class AssetType(Enum):
88
+ class AssetType(str, Enum):
89
89
  image = "image"
90
90
  pdf = "pdf"
91
91
 
@@ -100,7 +100,7 @@ class Content2(BaseModel):
100
100
  content: Optional[str] = None
101
101
 
102
102
 
103
- class Type3(Enum):
103
+ class Type3(str, Enum):
104
104
  unknown = "unknown"
105
105
 
106
106
 
@@ -108,7 +108,7 @@ class Content3(BaseModel):
108
108
  type: Type3
109
109
 
110
110
 
111
- class AssetType1(Enum):
111
+ class AssetType1(str, Enum):
112
112
  screenshot = "screenshot"
113
113
  assetScreenshot = "assetScreenshot"
114
114
  bannerImage = "bannerImage"
@@ -148,7 +148,7 @@ class Cursor(RootModel[str]):
148
148
  root: str
149
149
 
150
150
 
151
- class Color(Enum):
151
+ class Color(str, Enum):
152
152
  yellow = "yellow"
153
153
  red = "red"
154
154
  green = "green"
@@ -167,7 +167,7 @@ class Highlight(BaseModel):
167
167
  createdAt: str
168
168
 
169
169
 
170
- class Type4(Enum):
170
+ class Type4(str, Enum):
171
171
  manual = "manual"
172
172
  smart = "smart"
173
173
 
@@ -85,7 +85,7 @@ class KarakeepAPI:
85
85
  """
86
86
 
87
87
  # Version reflects the client library version, updated by bumpver
88
- VERSION: str = "0.1.6"
88
+ VERSION: str = "0.1.7"
89
89
 
90
90
  def __init__(
91
91
  self,
@@ -578,9 +578,10 @@ class KarakeepAPI:
578
578
  ) from e
579
579
 
580
580
  @optional_typecheck
581
- def _enforce_rate_limit(self, min_interval_sec: float = 2.0) -> None:
581
+ def _enforce_rate_limit(self, min_interval_sec: float = 0.5) -> None:
582
582
  """
583
583
  Ensures a minimum time interval between consecutive API calls.
584
+ Otherwise karakeep tends to error out.
584
585
 
585
586
  If the time since the last call is less than `min_interval_sec`, this method
586
587
  will sleep for the remaining duration. It then updates the timestamp of the
@@ -610,23 +611,36 @@ class KarakeepAPI:
610
611
  self,
611
612
  archived: Optional[bool] = None,
612
613
  favourited: Optional[bool] = None,
613
- limit: Optional[int] = None,
614
+ limit: Optional[int] = None, # This is the per-page limit for the API
614
615
  cursor: Optional[str] = None,
615
616
  include_content: bool = True, # Default from spec
616
617
  ) -> Any: # Returns PaginatedBookmarks or raw dict/list
617
618
  """
618
- Get all bookmarks. Corresponds to GET /bookmarks.
619
+ Get bookmarks, one page at a time. Corresponds to GET /bookmarks.
620
+
621
+ This method fetches a single page of bookmarks.
622
+ The 'limit' parameter controls the number of items per page for this API call.
623
+ The 'cursor' parameter is used for pagination to get the next page.
624
+
625
+ CLI Usage Notes:
626
+ - When the 'get-all-bookmarks' command is used via the CLI:
627
+ - The `--cursor` CLI option is ignored; pagination is handled automatically.
628
+ - The `--limit` CLI option specifies the *total* number of bookmarks to fetch
629
+ across multiple pages. If omitted, all bookmarks are fetched.
630
+ - The CLI internally calls this API method multiple times to achieve this.
619
631
 
620
632
  Args:
621
633
  archived: Filter by archived status (optional).
622
634
  favourited: Filter by favourited status (optional).
623
- limit: Maximum number of bookmarks to return (optional).
624
- cursor: Pagination cursor for the next page (optional).
635
+ limit: Maximum number of bookmarks to return *per page* in this API call (optional).
636
+ When used from the CLI's 'get-all-bookmarks' command, this translates to an
637
+ internal per-page fetching limit, while the CLI's `--limit` controls the total.
638
+ cursor: Pagination cursor for fetching the next page (optional).
625
639
  include_content: If set to true, bookmark's content will be included (default: True).
626
640
 
627
641
  Returns:
628
- datatypes.PaginatedBookmarks: Paginated list of bookmarks.
629
- If response validation is disabled, returns the raw API response (dict/list).
642
+ datatypes.PaginatedBookmarks: Paginated list of bookmarks for the current page.
643
+ If response validation is disabled, returns the raw API response (dict/list) for the current page.
630
644
 
631
645
  Raises:
632
646
  APIError: If the API request fails.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karakeep_python_api
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -57,7 +57,7 @@ A community-developed Python client for the [Karakeep](https://karakeep.app/) AP
57
57
 
58
58
  ## Overview
59
59
 
60
- This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API.
60
+ This library provides a Python interface (both a class and a command-line tool) to interact with a Karakeep instance's API. The author also developed [freshrss_to_karakeep](https://github.com/thiswillbeyourgithub/freshrss_to_karakeep), a Python script that periodically sends FreshRSS "favourite" articles to Karakeep (a bookmarking and read-it-later app, see [Karakeep on GitHub](https://github.com/karakeep-app/karakeep)).
61
61
 
62
62
  The development process involved:
63
63
 
@@ -238,6 +238,12 @@ except Exception as e:
238
238
 
239
239
  ```
240
240
 
241
+ ### Usage Examples
242
+
243
+ Examples of the API being used can be found in the [`./examples`](./examples) folder. Don't hesitate to submit yours!
244
+
245
+ - **Omnivore Archiving Status Updater**: A script to fix the archive status of bookmarks imported from Omnivore into Karakeep. See [`examples/omnivore_archiving_status_updater/README.md`](./examples/omnivore_archiving_status_updater/README.md) for details.
246
+
241
247
  ## Development
242
248
 
243
249
  1. Clone the repository.
@@ -7,7 +7,7 @@ with open("README.md", "r") as readme:
7
7
 
8
8
  setup(
9
9
  name="karakeep_python_api",
10
- version="0.1.6",
10
+ version="0.1.7",
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",
@@ -600,6 +600,64 @@ def test_tag_lifecycle_on_bookmark(karakeep_client: KarakeepAPI, managed_bookmar
600
600
 
601
601
  # --- Test User Info/Stats Endpoints ---
602
602
 
603
+ def test_cli_get_bookmarks_count_with_jq(karakeep_client: KarakeepAPI):
604
+ """Test that CLI get-all-bookmarks with --limit returns the expected number of items."""
605
+ # Skip test if jq is not installed
606
+ try:
607
+ subprocess.run(["jq", "--version"], check=True, capture_output=True)
608
+ except (subprocess.CalledProcessError, FileNotFoundError):
609
+ pytest.skip("jq is not installed. This test requires jq for JSON processing.")
610
+
611
+ # Define the limit we want to test
612
+ test_limit = 200
613
+
614
+ try:
615
+ logger.info(f"\nRunning CLI command: get-all-bookmarks --limit={test_limit}")
616
+ # Use a two-command pipe: Run the CLI command and pipe to jq to count array length
617
+ cmd = f"python -m karakeep_python_api --verbose get-all-bookmarks --limit={test_limit} | jq 'length'"
618
+
619
+ # Execute the piped command
620
+ result = subprocess.run(
621
+ cmd,
622
+ shell=True,
623
+ check=True,
624
+ capture_output=True,
625
+ text=True,
626
+ )
627
+
628
+ # Parse the output (should be just a number)
629
+ try:
630
+ actual_count = int(result.stdout.strip())
631
+ logger.info(f"✓ Command returned {actual_count} bookmarks")
632
+
633
+ # Check if we got exactly the requested number or fewer (if there aren't enough bookmarks)
634
+ assert actual_count <= test_limit, f"Expected at most {test_limit} bookmarks, got {actual_count}"
635
+
636
+ # Check if we got any bookmarks at all (to ensure the test is meaningful)
637
+ # This could fail if the account has no bookmarks
638
+ assert actual_count > 0, "Expected at least some bookmarks to be returned"
639
+
640
+ # If the account has enough bookmarks, we should get exactly the limit
641
+ # But we can't assert this because we don't know how many bookmarks exist
642
+ if actual_count < test_limit:
643
+ logger.info(f"Note: Only {actual_count} bookmarks were returned, which is less than the requested limit of {test_limit}. This is acceptable if the account doesn't have {test_limit} bookmarks.")
644
+ else:
645
+ logger.info(f"✓ Command returned exactly the requested limit of {test_limit} bookmarks")
646
+
647
+ except ValueError:
648
+ logger.error(f"Failed to parse jq output as integer: '{result.stdout}'")
649
+ pytest.fail(f"jq output is not a valid integer: '{result.stdout}'")
650
+
651
+ except subprocess.CalledProcessError as e:
652
+ logger.error(f"Command failed with exit code {e.returncode}")
653
+ logger.error(f"Stdout: {e.stdout}")
654
+ logger.error(f"Stderr: {e.stderr}")
655
+ pytest.fail(f"CLI command failed: {e}")
656
+ except Exception as e:
657
+ logger.error(f"An unexpected error occurred: {e}")
658
+ pytest.fail(f"Unexpected error: {e}")
659
+
660
+
603
661
  def test_get_current_user_stats(karakeep_client: KarakeepAPI):
604
662
  """Test retrieving statistics for the current user."""
605
663
  try: