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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ # Import API class and errors directly from the module
2
+ from .karakeep_api import KarakeepAPI, APIError, AuthenticationError
3
+
4
+ # Import the datatypes module so users can do `from karakeep_python_api.datatypes import ...`
5
+ from . import datatypes
6
+
7
+ # Define the package version
8
+ # This is the single source of truth, read by setup.py and updated by bumpver.
9
+ __version__ = KarakeepAPI.VERSION
10
+
11
+ __all__ = [
12
+ "KarakeepAPI",
13
+ "APIError",
14
+ "AuthenticationError",
15
+ "datatypes", # Expose the datatypes module
16
+ "__version__",
17
+ ]
18
+
19
+ # Models are available via `from karakeep_python_api.datatypes import ...`
@@ -0,0 +1,509 @@
1
+ import inspect
2
+ import json
3
+ import sys
4
+ import os
5
+ import functools
6
+ import click
7
+ import traceback # Moved import to top
8
+ from typing import Any, List, Dict, Optional, Callable, Union, get_origin, get_args, Literal
9
+ from pydantic import BaseModel, ValidationError
10
+ from loguru import logger # Import logger
11
+
12
+ # Attempt relative imports for package execution
13
+ try:
14
+ # Import API class and errors directly from the module
15
+ from .karakeep_api import KarakeepAPI, APIError, AuthenticationError
16
+
17
+ # Models are not directly used here, API methods handle data types
18
+ except ImportError:
19
+ # Fallback for direct script execution (e.g., python -m karakeep_python_api ...)
20
+ # Import API class and errors directly from the module
21
+ from karakeep_api import KarakeepAPI, APIError, AuthenticationError
22
+
23
+
24
+ # --- Serialization Helper ---
25
+ def serialize_output(data: Any) -> Any:
26
+ """
27
+ Recursively serialize data for JSON output, handling Pydantic models,
28
+ dataclasses, lists, and dicts.
29
+ """
30
+ if isinstance(data, BaseModel):
31
+ return data.model_dump(
32
+ mode="json"
33
+ ) # Use Pydantic's built-in JSON serialization
34
+ elif isinstance(data, list):
35
+ return [serialize_output(item) for item in data]
36
+ # Removed dataclass handling as API uses Pydantic models primarily
37
+ elif isinstance(data, dict):
38
+ # Serialize dictionary values
39
+ return {k: serialize_output(v) for k, v in data.items()}
40
+ # Add handling for other types like datetime if needed, though Pydantic's
41
+ # model_dump(mode='json') often handles them.
42
+ # Basic types (str, int, float, bool, None) are returned as is.
43
+ return data
44
+
45
+
46
+ # --- Click CLI Setup ---
47
+
48
+ # Shared options for the API client
49
+ shared_options = [
50
+ click.option(
51
+ "--base-url",
52
+ envvar="KARAKEEP_PYTHON_API_BASE_URL",
53
+ help="Full Karakeep API base URL, including /api/v1/ (e.g., https://instance.com/api/v1/).",
54
+ ),
55
+ click.option(
56
+ "--api-key",
57
+ envvar="KARAKEEP_PYTHON_API_KEY",
58
+ help="Karakeep API Key (required, uses env var if not provided).",
59
+ required=False,
60
+ ), # Made not required here, checked in context
61
+ click.option(
62
+ "--verify-ssl/--no-verify-ssl",
63
+ default=True,
64
+ envvar="KARAKEEP_PYTHON_API_VERIFY_SSL",
65
+ help="Verify SSL certificates.",
66
+ ),
67
+ click.option(
68
+ "--verbose",
69
+ "-v",
70
+ is_flag=True,
71
+ default=False,
72
+ envvar="KARAKEEP_PYTHON_API_VERBOSE",
73
+ help="Enable verbose logging.",
74
+ ),
75
+ click.option(
76
+ "--disable-response-validation",
77
+ is_flag=True,
78
+ default=False,
79
+ envvar="KARAKEEP_PYTHON_API_DISABLE_RESPONSE_VALIDATION",
80
+ help="Disable Pydantic validation of API responses (returns raw data).",
81
+ ),
82
+ ]
83
+
84
+
85
+ def add_options(options):
86
+ """Decorator to add a list of click options to a command."""
87
+
88
+ def _add_options(func):
89
+ for option in reversed(options):
90
+ func = option(func)
91
+ return func
92
+
93
+ return _add_options
94
+
95
+
96
+ # --- Callback for --dump-openapi-specification ---
97
+ def print_openapi_spec(ctx, param, value):
98
+ """Callback function for the --dump-openapi-specification option."""
99
+ if not value or ctx.resilient_parsing:
100
+ # Exit if the flag is not set, or if Click is doing resilient parsing (e.g., for completion)
101
+ return
102
+ try:
103
+ package_dir = os.path.dirname(__file__)
104
+ spec_path = os.path.join(package_dir, "openapi_reference.json")
105
+ if not os.path.exists(spec_path):
106
+ click.echo(
107
+ f"Error: Specification file not found at expected location: {spec_path}",
108
+ err=True,
109
+ )
110
+ ctx.exit(1) # Use ctx.exit
111
+ with open(spec_path, "r") as f:
112
+ click.echo(f.read()) # Use click.echo
113
+ except Exception as e:
114
+ click.echo(f"Error reading or printing specification file: {e}", err=True)
115
+ ctx.exit(1) # Exit with error code if reading failed
116
+ # Exit successfully *after* the try/except block if no error occurred
117
+ ctx.exit(0)
118
+
119
+
120
+ @click.group(context_settings=dict(help_option_names=["-h", "--help"]))
121
+ @click.option(
122
+ "--dump-openapi-specification",
123
+ is_flag=True,
124
+ callback=print_openapi_spec,
125
+ expose_value=False, # Don't pass the value to the main cli function
126
+ is_eager=True, # Process this option before others
127
+ help="Dump the OpenAPI specification JSON to stdout and exit.",
128
+ )
129
+ @add_options(shared_options) # Apply shared options to the group
130
+ @click.pass_context
131
+ def cli(ctx, base_url, api_key, verify_ssl, verbose, disable_response_validation):
132
+ """
133
+ Karakeep Python API Command Line Interface.
134
+
135
+ Dynamically generates commands based on the OpenAPI specification.
136
+ Requires KARAKEEP_PYTHON_API_KEY environment variable or --api-key option.
137
+ """
138
+ # Ensure the context object exists
139
+ ctx.ensure_object(dict)
140
+
141
+ # --- Strict Check for API Key and Base URL ---
142
+ # Check for API key (must be provided via arg or env)
143
+ resolved_api_key = api_key or os.environ.get("KARAKEEP_PYTHON_API_KEY")
144
+ if not resolved_api_key:
145
+ raise click.UsageError(
146
+ "API Key is required. Provide --api-key option or set KARAKEEP_PYTHON_API_KEY environment variable."
147
+ )
148
+
149
+ # Check for Base URL (must be provided via arg or env)
150
+ resolved_base_url = base_url or os.environ.get("KARAKEEP_PYTHON_API_BASE_URL")
151
+ if not resolved_base_url:
152
+ raise click.UsageError(
153
+ "API Base URL is required. Provide --base-url option or set KARAKEEP_PYTHON_API_BASE_URL environment variable. "
154
+ "The URL must include the API path, e.g., 'https://your-instance.com/api/v1/'."
155
+ )
156
+
157
+ # Store common API parameters in the context for commands to use
158
+ ctx.obj["BASE_URL"] = resolved_base_url # Store the resolved URL
159
+ ctx.obj["API_KEY"] = resolved_api_key # Store the resolved key
160
+ ctx.obj["VERIFY_SSL"] = verify_ssl
161
+ ctx.obj["VERBOSE"] = verbose
162
+ ctx.obj["DISABLE_RESPONSE_VALIDATION"] = (
163
+ disable_response_validation # Store the flag
164
+ )
165
+
166
+ # --- Configure Logger ---
167
+ log_level = "DEBUG" if verbose else "INFO"
168
+ logger.remove() # Remove default handler
169
+ logger.add(sys.stderr, level=log_level)
170
+ logger.debug("Logger configured for level: {}", log_level)
171
+ logger.debug("CLI context initialized.")
172
+
173
+
174
+ def create_click_command(
175
+ api_method_name: str, api_method: Callable
176
+ ) -> Optional[click.Command]:
177
+ """
178
+ Dynamically creates a Click command for a given API method instance,
179
+ inspecting its signature for arguments. Returns None if creation fails.
180
+ """
181
+ # Get the signature from the bound method (which copied it from the original function)
182
+ try:
183
+ sig = inspect.signature(api_method)
184
+ # Exclude 'self' parameter from the signature when creating CLI options
185
+ params = [p for p in sig.parameters.values() if p.name != "self"]
186
+ except (ValueError, TypeError) as e:
187
+ logger.warning(f"Could not get signature for method '{api_method_name}': {e}")
188
+ return None
189
+
190
+ # Define the command function template using a closure
191
+ def command_func_factory(method_name, signature):
192
+ @click.pass_context
193
+ def command_func(ctx, **kwargs):
194
+ """Dynamically generated command function wrapper."""
195
+ # Retrieve API parameters from context, ensuring API key is present now
196
+ base_url = ctx.obj["BASE_URL"]
197
+ api_key = ctx.obj["API_KEY"]
198
+ verify_ssl = ctx.obj["VERIFY_SSL"]
199
+ verbose = ctx.obj["VERBOSE"]
200
+ disable_validation = ctx.obj["DISABLE_RESPONSE_VALIDATION"] # Retrieve flag
201
+
202
+ if not api_key:
203
+ click.echo(
204
+ "Error: API Key is required via --api-key or KARAKEEP_PYTHON_API_KEY environment variable.",
205
+ err=True,
206
+ )
207
+ ctx.exit(1)
208
+
209
+ try:
210
+ # Initialize API client within the command context
211
+ # Method generation already happened during inspection phase or initial load
212
+ api = KarakeepAPI(
213
+ api_key=api_key,
214
+ base_url=base_url,
215
+ verify_ssl=verify_ssl,
216
+ verbose=verbose,
217
+ disable_response_validation=disable_validation, # Pass flag to constructor
218
+ )
219
+ # Get the actual bound method from the initialized API instance
220
+ instance_method = getattr(
221
+ api, method_name
222
+ ) # Use the captured method_name
223
+
224
+ # Prepare arguments for the API call from Click's kwargs
225
+ call_args = {}
226
+ sig_params = signature.parameters # Use captured signature
227
+
228
+ # Process Click kwargs into API call arguments
229
+ # Convert kebab-case keys from Click back to snake_case for Python call
230
+ call_args = {
231
+ k.replace("-", "_"): v for k, v in kwargs.items() if v is not None
232
+ }
233
+
234
+ # Remove arguments that are not part of the method signature
235
+ # (e.g., if extra options were somehow passed)
236
+ valid_arg_names = set(signature.parameters.keys())
237
+ call_args = {
238
+ k: v for k, v in call_args.items() if k in valid_arg_names
239
+ }
240
+
241
+ # Note: No special '--data' handling needed anymore for create_a_new_bookmark
242
+ # Type conversion for basic types (str, int, bool) is handled by Click.
243
+ # List/Dict parameters still expect JSON strings if used elsewhere.
244
+
245
+ # Call the API method
246
+ try:
247
+ logger.debug(
248
+ f"Calling API method '{method_name}' with args: {call_args}"
249
+ )
250
+ result = instance_method(**call_args)
251
+ except TypeError as call_error:
252
+ logger.error(
253
+ f"Error calling API method '{method_name}': {call_error}"
254
+ )
255
+ logger.error(f"Provided arguments: {call_args}")
256
+ logger.error(f"Expected signature: {signature}")
257
+ # Add traceback in verbose mode
258
+ if verbose:
259
+ logger.debug(traceback.format_exc())
260
+ ctx.exit(1)
261
+
262
+ # Serialize and print the result
263
+ if result is not None:
264
+ output_data = serialize_output(result)
265
+ click.echo(
266
+ json.dumps(output_data, indent=2)
267
+ ) # Keep this for stdout result
268
+ else:
269
+ # Handle None result (e.g., 204 No Content) gracefully
270
+ # Verbose check is implicitly handled by logger level
271
+ logger.debug("Operation successful (No content returned).")
272
+
273
+ except (
274
+ APIError,
275
+ AuthenticationError,
276
+ ValueError,
277
+ ValidationError,
278
+ TypeError,
279
+ ) as e:
280
+ logger.error(f"Error: {e}")
281
+ # Provide more detail for TypeErrors during binding/call
282
+ if isinstance(e, TypeError):
283
+ logger.error(f"Error: {e}")
284
+ # Provide more detail for TypeErrors during binding/call
285
+ if isinstance(e, TypeError) and verbose:
286
+ logger.debug(traceback.format_exc()) # Use top-level import
287
+ sys.exit(1)
288
+ except Exception as e:
289
+ logger.error(f"An unexpected error occurred: {e}")
290
+ if verbose:
291
+ logger.debug(traceback.format_exc()) # Use top-level import
292
+ sys.exit(1)
293
+
294
+ # Set the name of the inner function for help display purposes
295
+ command_func.__name__ = method_name
296
+ return command_func
297
+
298
+ # Create the actual command function instance using the factory
299
+ command_func = command_func_factory(api_method_name, sig)
300
+
301
+ # --- Add Click options/arguments based on the captured method signature ---
302
+ click_params = []
303
+ # Use the docstring from the original method (captured by functools.update_wrapper)
304
+ docstring = api_method.__doc__ or f"Execute the {api_method_name} API operation."
305
+ docstring_lines = docstring.split("\n")
306
+ help_text = docstring_lines[0].strip() # First line as short help
307
+ full_help = docstring # Full docstring as help
308
+
309
+ # Extract parameter descriptions from the Args section of the docstring
310
+ param_descriptions = {}
311
+ in_args_section = False
312
+ args_section_lines = []
313
+ for line in docstring_lines:
314
+ stripped_line = line.strip() # Corrected indentation
315
+ if stripped_line == "Args:": # Corrected indentation
316
+ in_args_section = True # Corrected indentation
317
+ elif (
318
+ stripped_line == "Returns:" or stripped_line == "Raises:"
319
+ ): # Corrected indentation
320
+ in_args_section = False # Stop capturing when Returns/Raises section starts # Corrected indentation
321
+ elif in_args_section and stripped_line: # Corrected indentation
322
+ args_section_lines.append(stripped_line) # Corrected indentation
323
+ # Try parsing the parameter name and description
324
+ parts = stripped_line.split(":", 1) # Corrected indentation
325
+ if len(parts) == 2: # Corrected indentation
326
+ # Extract name, assuming format "name (type): description"
327
+ name_part = parts[0].split(" ")[0] # Corrected indentation
328
+ # Clean potential trailing parenthesis from type hint parsing
329
+ name_part = name_part.rstrip(")") # Corrected indentation
330
+ param_descriptions[name_part] = parts[
331
+ 1
332
+ ].strip() # Corrected indentation
333
+
334
+ # Add parameters from signature to Click command
335
+ for param in params: # Use the filtered list from signature inspection
336
+ param_name_cli = param.name.replace("_", "-") # Use kebab-case for CLI options
337
+ is_required_in_sig = param.default is inspect.Parameter.empty
338
+ default_value = param.default if not is_required_in_sig else None
339
+ param_type = click.STRING # Default to string for CLI
340
+
341
+ # Basic type mapping for Click
342
+ annotation = param.annotation
343
+ origin = getattr(annotation, "__origin__", None)
344
+ args = getattr(annotation, "__args__", [])
345
+
346
+ # Determine Click type and if it's a flag
347
+ is_flag = False
348
+ click_type = click.STRING
349
+ if annotation is int:
350
+ click_type = click.INT
351
+ elif annotation is float:
352
+ click_type = click.FLOAT
353
+ elif annotation is bool:
354
+ click_type = click.BOOL
355
+ # Boolean options are flags if they don't have a default or default is False
356
+ is_flag = is_required_in_sig or default_value is False
357
+ # Handle Optional[T] - makes the option not required unless T is bool
358
+ elif origin is Union and type(None) in args and len(args) == 2:
359
+ non_none_type = args[0] if args[1] is type(None) else args[1]
360
+ if non_none_type is int:
361
+ click_type = click.INT
362
+ elif non_none_type is float:
363
+ click_type = click.FLOAT
364
+ elif non_none_type is bool:
365
+ click_type = click.BOOL
366
+ # Optional bools are typically flags like --enable-feature/--disable-feature
367
+ # For simplicity, treat as a standard option unless explicitly designed as toggle
368
+ is_flag = (
369
+ False # Treat Optional[bool] as --option/--no-option by default
370
+ )
371
+ # Keep click_type as STRING for Optional[List/Dict/str/Any]
372
+ is_required_in_sig = False # Optional means not required
373
+ default_value = None # Default for Optional is None
374
+
375
+ # Handle List[T] or Dict[K, V] - expect JSON string
376
+ elif origin in (list, dict, List, Dict) or annotation in (list, dict):
377
+ click_type = click.STRING # Expect JSON string
378
+ # Handle Literal[...] for choices
379
+ elif origin is Literal:
380
+ choices = get_args(annotation)
381
+ # Ensure all choices are strings for click.Choice
382
+ if all(isinstance(c, str) for c in choices):
383
+ click_type = click.Choice(choices, case_sensitive=False)
384
+ else:
385
+ logger.warning(
386
+ f"Parameter '{param.name}' is Literal but contains non-string types. Treating as STRING."
387
+ )
388
+ click_type = click.STRING # Fallback
389
+
390
+ # Determine option name(s) and help text
391
+ option_names = [f"--{param_name_cli}"]
392
+ # Add /--no- option for boolean flags that are not required and default to True
393
+ if (
394
+ is_flag
395
+ and annotation is bool
396
+ and not is_required_in_sig
397
+ and default_value is True
398
+ ):
399
+ option_names.append(f"--no-{param_name_cli}")
400
+
401
+ param_help = param_descriptions.get(param.name, f"Parameter '{param.name}'.")
402
+ if click_type is click.STRING and (
403
+ origin in (list, dict) or annotation in (list, dict)
404
+ ):
405
+ param_help += " (Provide as JSON string)"
406
+ elif isinstance(click_type, click.Choice):
407
+ param_help += f" (Choices: {', '.join(click_type.choices)})"
408
+
409
+ # Standard parameter handling (no special '--data' mapping anymore)
410
+ click_required = is_required_in_sig and default_value is None and not is_flag
411
+
412
+ # Add the Click Option
413
+ click_params.append(
414
+ click.Option(
415
+ option_names,
416
+ type=click_type,
417
+ required=click_required,
418
+ default=default_value if not is_flag else None,
419
+ help=param_help,
420
+ is_flag=(is_flag if len(option_names) == 1 else False),
421
+ show_default=not is_flag and default_value is not None, # Show default unless it's a flag or None
422
+ # Click derives the Python identifier (e.g., 'bookmark_id') from the first long option name
423
+ )
424
+ )
425
+
426
+ # Create the Click command
427
+ try:
428
+ dynamic_command = click.Command(
429
+ name=api_method_name.replace("_", "-"), # Use kebab-case for command names
430
+ callback=command_func,
431
+ params=click_params,
432
+ help=full_help,
433
+ short_help=help_text,
434
+ )
435
+ return dynamic_command
436
+ except Exception as e:
437
+ logger.warning(f"Failed to create click command for '{api_method_name}': {e}")
438
+ return None
439
+
440
+
441
+ # --- Dynamically Add Commands to CLI Group ---
442
+ def add_commands_to_cli(cli_group):
443
+ """
444
+ Inspects the KarakeepAPI class *statically* to find public methods
445
+ and adds them as Click commands. Does NOT require API keys or URL for inspection.
446
+ """
447
+ logger.info("Statically inspecting KarakeepAPI class and generating commands...")
448
+
449
+ try:
450
+ added_count = 0
451
+ skipped_count = 0
452
+ # Inspect the KarakeepAPI class directly, not an instance
453
+ for name, member in inspect.getmembers(KarakeepAPI):
454
+ # Check if it's a public function/method defined in the class
455
+ if (
456
+ not name.startswith("_")
457
+ and inspect.isfunction(
458
+ member
459
+ ) # Check if it's a function (method in class def)
460
+ # Add further checks if needed, e.g., based on naming convention or decorators
461
+ ):
462
+ try:
463
+ # Attempt to create a command for the method
464
+ # We pass the function object directly. The command_func will later
465
+ # get the bound method from the API instance created at runtime.
466
+ command = create_click_command(name, member)
467
+ if command:
468
+ cli_group.add_command(command)
469
+ added_count += 1
470
+ else:
471
+ logger.warning(f"Skipped command generation for method: {name}")
472
+ skipped_count += 1
473
+ except Exception as cmd_gen_e:
474
+ logger.warning(
475
+ f"Failed to create command for method '{name}': {cmd_gen_e}"
476
+ )
477
+ skipped_count += 1
478
+
479
+ if added_count == 0:
480
+ logger.warning(
481
+ "No API commands were dynamically added. Check KarakeepAPI class definition and logs."
482
+ )
483
+ else:
484
+ logger.info(f"Added {added_count} API commands. Skipped {skipped_count}.")
485
+
486
+ except Exception as e:
487
+ # Handle errors during static inspection or command creation
488
+ logger.error(f"Unexpected error during dynamic command setup: {e}")
489
+ # Determine verbosity from environment for traceback logging during setup
490
+ verbose_setup = os.environ.get("KARAKEEP_PYTHON_API_VERBOSE", "").lower() in (
491
+ "true",
492
+ "1",
493
+ "yes",
494
+ )
495
+ if verbose_setup:
496
+ logger.debug(traceback.format_exc()) # Use top-level import
497
+ # Raise an exception to halt execution if setup fails
498
+ error_message = f"Error: Unexpected error during dynamic command setup: {e}"
499
+ raise click.ClickException(error_message)
500
+
501
+
502
+ # Add commands when the script is loaded by calling the function
503
+ add_commands_to_cli(cli)
504
+
505
+ # Main entry point for the script
506
+ if __name__ == "__main__":
507
+ # Normal Click execution starts here. The --dump-openapi-specification
508
+ # is now handled by its callback function defined above.
509
+ cli(obj={}) # Pass initial empty object for context