anysite-cli 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.

Potentially problematic release.


This version of anysite-cli might be problematic. Click here for more details.

Files changed (64) hide show
  1. anysite/__init__.py +4 -0
  2. anysite/__main__.py +6 -0
  3. anysite/api/__init__.py +21 -0
  4. anysite/api/client.py +271 -0
  5. anysite/api/errors.py +137 -0
  6. anysite/api/schemas.py +333 -0
  7. anysite/batch/__init__.py +1 -0
  8. anysite/batch/executor.py +176 -0
  9. anysite/batch/input.py +160 -0
  10. anysite/batch/rate_limiter.py +98 -0
  11. anysite/cli/__init__.py +1 -0
  12. anysite/cli/config.py +176 -0
  13. anysite/cli/executor.py +388 -0
  14. anysite/cli/options.py +249 -0
  15. anysite/config/__init__.py +11 -0
  16. anysite/config/paths.py +46 -0
  17. anysite/config/settings.py +187 -0
  18. anysite/dataset/__init__.py +37 -0
  19. anysite/dataset/analyzer.py +268 -0
  20. anysite/dataset/cli.py +644 -0
  21. anysite/dataset/collector.py +686 -0
  22. anysite/dataset/db_loader.py +248 -0
  23. anysite/dataset/errors.py +30 -0
  24. anysite/dataset/exporters.py +121 -0
  25. anysite/dataset/history.py +153 -0
  26. anysite/dataset/models.py +245 -0
  27. anysite/dataset/notifications.py +87 -0
  28. anysite/dataset/scheduler.py +107 -0
  29. anysite/dataset/storage.py +171 -0
  30. anysite/dataset/transformer.py +213 -0
  31. anysite/db/__init__.py +38 -0
  32. anysite/db/adapters/__init__.py +1 -0
  33. anysite/db/adapters/base.py +158 -0
  34. anysite/db/adapters/postgres.py +201 -0
  35. anysite/db/adapters/sqlite.py +183 -0
  36. anysite/db/cli.py +687 -0
  37. anysite/db/config.py +92 -0
  38. anysite/db/manager.py +166 -0
  39. anysite/db/operations/__init__.py +1 -0
  40. anysite/db/operations/insert.py +199 -0
  41. anysite/db/operations/query.py +43 -0
  42. anysite/db/schema/__init__.py +1 -0
  43. anysite/db/schema/inference.py +213 -0
  44. anysite/db/schema/types.py +71 -0
  45. anysite/db/utils/__init__.py +1 -0
  46. anysite/db/utils/sanitize.py +99 -0
  47. anysite/main.py +498 -0
  48. anysite/models/__init__.py +1 -0
  49. anysite/output/__init__.py +11 -0
  50. anysite/output/console.py +45 -0
  51. anysite/output/formatters.py +301 -0
  52. anysite/output/templates.py +76 -0
  53. anysite/py.typed +0 -0
  54. anysite/streaming/__init__.py +1 -0
  55. anysite/streaming/progress.py +121 -0
  56. anysite/streaming/writer.py +130 -0
  57. anysite/utils/__init__.py +1 -0
  58. anysite/utils/fields.py +242 -0
  59. anysite/utils/retry.py +109 -0
  60. anysite_cli-0.1.0.dist-info/METADATA +437 -0
  61. anysite_cli-0.1.0.dist-info/RECORD +64 -0
  62. anysite_cli-0.1.0.dist-info/WHEEL +4 -0
  63. anysite_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. anysite_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,98 @@
1
+ """Token bucket rate limiter for controlling request rates."""
2
+
3
+ import asyncio
4
+ import time
5
+
6
+
7
+ class RateLimiter:
8
+ """Async rate limiter using the token bucket algorithm.
9
+
10
+ Usage:
11
+ limiter = RateLimiter("10/s")
12
+ async with limiter:
13
+ await make_request()
14
+ """
15
+
16
+ def __init__(self, rate_string: str) -> None:
17
+ """Initialize rate limiter from a rate string.
18
+
19
+ Args:
20
+ rate_string: Rate limit string (e.g., '10/s', '100/m', '1000/h')
21
+ """
22
+ self.max_tokens, self.interval = self.parse_rate(rate_string)
23
+ self.refill_rate = self.max_tokens / self.interval # tokens per second
24
+ self.tokens = float(self.max_tokens)
25
+ self._last_refill = time.monotonic()
26
+ self._lock = asyncio.Lock()
27
+
28
+ @staticmethod
29
+ def parse_rate(rate_string: str) -> tuple[int, float]:
30
+ """Parse a rate string into (max_tokens, interval_seconds).
31
+
32
+ Args:
33
+ rate_string: Rate string like '10/s', '100/m', '1000/h'
34
+
35
+ Returns:
36
+ Tuple of (max_tokens, interval_in_seconds)
37
+
38
+ Raises:
39
+ ValueError: If rate string is invalid
40
+ """
41
+ rate_string = rate_string.strip()
42
+
43
+ parts = rate_string.split("/")
44
+ if len(parts) != 2:
45
+ raise ValueError(
46
+ f"Invalid rate format: '{rate_string}'. "
47
+ "Expected format: '<number>/<unit>' (e.g., '10/s', '100/m', '1000/h')"
48
+ )
49
+
50
+ try:
51
+ count = int(parts[0])
52
+ except ValueError:
53
+ raise ValueError(f"Invalid rate count: '{parts[0]}'. Must be an integer.") from None
54
+
55
+ unit = parts[1].strip().lower()
56
+ intervals = {"s": 1.0, "m": 60.0, "h": 3600.0}
57
+
58
+ if unit not in intervals:
59
+ raise ValueError(
60
+ f"Invalid rate unit: '{unit}'. Must be 's' (seconds), 'm' (minutes), or 'h' (hours)."
61
+ )
62
+
63
+ return count, intervals[unit]
64
+
65
+ def _refill(self) -> None:
66
+ """Refill tokens based on elapsed time."""
67
+ now = time.monotonic()
68
+ elapsed = now - self._last_refill
69
+ self.tokens = min(
70
+ self.max_tokens,
71
+ self.tokens + elapsed * self.refill_rate,
72
+ )
73
+ self._last_refill = now
74
+
75
+ async def acquire(self) -> None:
76
+ """Wait until a token is available, then consume one.
77
+
78
+ This method will block (async sleep) if no tokens are available.
79
+ """
80
+ async with self._lock:
81
+ self._refill()
82
+
83
+ if self.tokens < 1:
84
+ # Calculate wait time for next token
85
+ wait_time = (1 - self.tokens) / self.refill_rate
86
+ await asyncio.sleep(wait_time)
87
+ self._refill()
88
+
89
+ self.tokens -= 1
90
+
91
+ async def __aenter__(self) -> "RateLimiter":
92
+ """Acquire a token on context entry."""
93
+ await self.acquire()
94
+ return self
95
+
96
+ async def __aexit__(self, *args: object) -> None:
97
+ """No-op on context exit."""
98
+ pass
@@ -0,0 +1 @@
1
+ """CLI command modules."""
anysite/cli/config.py ADDED
@@ -0,0 +1,176 @@
1
+ """Configuration management commands."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from anysite.config import get_config_dir, get_config_path
9
+ from anysite.config.settings import get_config_value, list_config, save_config
10
+ from anysite.output.console import console, print_error, print_success
11
+
12
+ app = typer.Typer(
13
+ help="Manage Anysite CLI configuration",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+
18
+ @app.command("set")
19
+ def config_set(
20
+ key: Annotated[str, typer.Argument(help="Configuration key (e.g., api_key, defaults.format)")],
21
+ value: Annotated[str, typer.Argument(help="Value to set")],
22
+ ) -> None:
23
+ """Set a configuration value.
24
+
25
+ \b
26
+ Examples:
27
+ anysite config set api_key sk-xxxxx
28
+ anysite config set defaults.format table
29
+ anysite config set defaults.count 20
30
+ """
31
+ # Convert value types
32
+ typed_value: str | int | bool = value
33
+ if value.lower() in ("true", "false"):
34
+ typed_value = value.lower() == "true"
35
+ elif value.isdigit():
36
+ typed_value = int(value)
37
+
38
+ try:
39
+ save_config(key, typed_value)
40
+ print_success(f"Set {key} = {typed_value}")
41
+ except Exception as e:
42
+ print_error(f"Failed to save configuration: {e}")
43
+ raise typer.Exit(1) from e
44
+
45
+
46
+ @app.command("get")
47
+ def config_get(
48
+ key: Annotated[str, typer.Argument(help="Configuration key to get")],
49
+ ) -> None:
50
+ """Get a configuration value.
51
+
52
+ \b
53
+ Examples:
54
+ anysite config get api_key
55
+ anysite config get defaults.format
56
+ """
57
+ value = get_config_value(key)
58
+ if value is None:
59
+ print_error(f"Configuration key '{key}' not found")
60
+ raise typer.Exit(1)
61
+
62
+ # Mask API key for security
63
+ if key == "api_key" and isinstance(value, str) and len(value) > 8:
64
+ masked = value[:4] + "*" * (len(value) - 8) + value[-4:]
65
+ console.print(f"{key}: {masked}")
66
+ else:
67
+ console.print(f"{key}: {value}")
68
+
69
+
70
+ @app.command("list")
71
+ def config_list() -> None:
72
+ """List all configuration values.
73
+
74
+ \b
75
+ Example:
76
+ anysite config list
77
+ """
78
+ config = list_config()
79
+
80
+ if not config:
81
+ console.print("[dim]No configuration set. Run 'anysite config init' to set up.[/dim]")
82
+ return
83
+
84
+ table = Table(show_header=True, header_style="bold")
85
+ table.add_column("Key", style="cyan")
86
+ table.add_column("Value")
87
+
88
+ def add_items(data: dict, prefix: str = "") -> None:
89
+ for key, value in data.items():
90
+ full_key = f"{prefix}.{key}" if prefix else key
91
+ if isinstance(value, dict):
92
+ add_items(value, full_key)
93
+ else:
94
+ # Mask API key
95
+ if key == "api_key" and isinstance(value, str) and len(value) > 8:
96
+ value = value[:4] + "*" * (len(value) - 8) + value[-4:]
97
+ table.add_row(full_key, str(value))
98
+
99
+ add_items(config)
100
+ console.print(table)
101
+
102
+
103
+ @app.command("path")
104
+ def config_path() -> None:
105
+ """Show the configuration file path.
106
+
107
+ \b
108
+ Example:
109
+ anysite config path
110
+ """
111
+ path = get_config_path()
112
+ console.print(f"Config directory: {get_config_dir()}")
113
+ console.print(f"Config file: {path}")
114
+ console.print(f"Exists: {path.exists()}")
115
+
116
+
117
+ @app.command("init")
118
+ def config_init(
119
+ api_key: Annotated[
120
+ str | None,
121
+ typer.Option(
122
+ "--api-key",
123
+ "-k",
124
+ help="API key to set",
125
+ prompt="Enter your Anysite API key",
126
+ hide_input=False,
127
+ ),
128
+ ] = None,
129
+ ) -> None:
130
+ """Initialize configuration interactively.
131
+
132
+ \b
133
+ Example:
134
+ anysite config init
135
+ anysite config init --api-key sk-xxxxx
136
+ """
137
+ if api_key:
138
+ save_config("api_key", api_key)
139
+ print_success("Configuration initialized!")
140
+ console.print(f"\nConfig saved to: {get_config_path()}")
141
+ console.print("\nYou can now run commands like:")
142
+ console.print(" [cyan]anysite linkedin user satyanadella[/cyan]")
143
+
144
+
145
+ @app.command("reset")
146
+ def config_reset(
147
+ force: Annotated[
148
+ bool,
149
+ typer.Option(
150
+ "--force",
151
+ "-f",
152
+ help="Skip confirmation",
153
+ ),
154
+ ] = False,
155
+ ) -> None:
156
+ """Reset configuration to defaults.
157
+
158
+ \b
159
+ Example:
160
+ anysite config reset
161
+ anysite config reset --force
162
+ """
163
+ config_path = get_config_path()
164
+
165
+ if not config_path.exists():
166
+ console.print("[dim]No configuration file to reset.[/dim]")
167
+ return
168
+
169
+ if not force:
170
+ confirm = typer.confirm("Are you sure you want to reset all configuration?")
171
+ if not confirm:
172
+ console.print("Aborted.")
173
+ return
174
+
175
+ config_path.unlink()
176
+ print_success("Configuration reset to defaults")
@@ -0,0 +1,388 @@
1
+ """Shared command execution helpers for CLI commands.
2
+
3
+ Provides unified execution logic for search/list commands and
4
+ single-item commands with Phase 2 features (streaming, batch,
5
+ progress, enhanced fields).
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from anysite.api.client import create_client
14
+ from anysite.api.errors import AnysiteError
15
+ from anysite.batch.executor import BatchExecutor, BatchResult
16
+ from anysite.batch.input import InputParser
17
+ from anysite.batch.rate_limiter import RateLimiter
18
+ from anysite.cli.options import ErrorHandling, parse_exclude, parse_fields
19
+ from anysite.output.console import print_error, print_info, print_success
20
+ from anysite.output.formatters import OutputFormat, format_output
21
+ from anysite.output.templates import FilenameTemplate
22
+ from anysite.streaming.progress import ProgressTracker
23
+ from anysite.streaming.writer import StreamingWriter
24
+ from anysite.utils.fields import resolve_fields_preset
25
+
26
+
27
+ def _resolve_fields(
28
+ fields: str | None,
29
+ exclude: str | None,
30
+ fields_preset: str | None,
31
+ ) -> tuple[list[str] | None, list[str] | None]:
32
+ """Resolve field selection from various sources.
33
+
34
+ Returns:
35
+ Tuple of (fields_to_include, fields_to_exclude)
36
+ """
37
+ include = parse_fields(fields)
38
+ excl = parse_exclude(exclude)
39
+
40
+ if fields_preset and not include:
41
+ preset_fields = resolve_fields_preset(fields_preset)
42
+ if preset_fields:
43
+ include = preset_fields
44
+
45
+ return include, excl
46
+
47
+
48
+ def _print_stats(stats: dict[str, Any], batch_result: BatchResult | None = None) -> None:
49
+ """Print execution statistics."""
50
+ lines = ["", "Statistics:"]
51
+ lines.append(f" Total records: {stats.get('total', 0)}")
52
+ lines.append(f" Total time: {stats.get('elapsed_seconds', 0):.1f}s")
53
+ lines.append(f" Records/second: {stats.get('records_per_second', 0):.1f}")
54
+
55
+ if batch_result:
56
+ lines.append(f" Succeeded: {batch_result.succeeded}")
57
+ if batch_result.failed > 0:
58
+ lines.append(f" Failed: {batch_result.failed}")
59
+ if batch_result.skipped > 0:
60
+ lines.append(f" Skipped: {batch_result.skipped}")
61
+
62
+ from anysite.output.console import error_console
63
+ for line in lines:
64
+ error_console.print(f"[dim]{line}[/dim]", style="dim")
65
+
66
+
67
+ async def execute_search_command(
68
+ endpoint: str,
69
+ payload: dict[str, Any],
70
+ *,
71
+ # Phase 1 options
72
+ format: OutputFormat = OutputFormat.JSON,
73
+ fields: str | None = None,
74
+ output: Path | None = None,
75
+ quiet: bool = False,
76
+ # Phase 2: Enhanced fields
77
+ exclude: str | None = None,
78
+ compact: bool = False,
79
+ fields_preset: str | None = None,
80
+ # Phase 2: Streaming
81
+ stream: bool = False,
82
+ # Phase 2: Progress & feedback
83
+ progress: bool | None = None, # noqa: ARG001
84
+ stats: bool = False,
85
+ verbose: bool = False,
86
+ # Phase 2: Output
87
+ append: bool = False,
88
+ ) -> None:
89
+ """Execute a search/list command with Phase 2 features.
90
+
91
+ Handles:
92
+ - Streaming output (--stream)
93
+ - Enhanced field selection (--exclude, --compact, --fields-preset)
94
+ - Progress bars
95
+ - Statistics
96
+ """
97
+ include_fields, excl_fields = _resolve_fields(fields, exclude, fields_preset)
98
+
99
+ async with create_client() as client:
100
+ if verbose:
101
+ print_info(f"Requesting {endpoint} with {payload}")
102
+
103
+ start_time = time.monotonic()
104
+ data = await client.post(endpoint, data=payload)
105
+ elapsed = time.monotonic() - start_time
106
+
107
+ if verbose:
108
+ count = len(data) if isinstance(data, list) else 1
109
+ print_info(f"Received {count} records in {elapsed:.1f}s")
110
+
111
+ # Streaming mode
112
+ if stream and isinstance(data, list):
113
+ writer = StreamingWriter(
114
+ output=output,
115
+ format=OutputFormat.JSONL,
116
+ fields=include_fields,
117
+ exclude=excl_fields,
118
+ compact=compact,
119
+ append=append,
120
+ )
121
+ with writer:
122
+ for record in data:
123
+ writer.write(record)
124
+
125
+ if not quiet and output:
126
+ print_success(f"Streamed {writer.count} records to {output}")
127
+
128
+ else:
129
+ # Standard output
130
+ format_output(
131
+ data,
132
+ format,
133
+ include_fields,
134
+ output,
135
+ quiet,
136
+ exclude=excl_fields,
137
+ compact=compact,
138
+ append=append,
139
+ )
140
+
141
+ # Show stats
142
+ if stats and not quiet:
143
+ total = len(data) if isinstance(data, list) else 1
144
+ stat_data = {
145
+ "total": total,
146
+ "elapsed_seconds": round(elapsed, 2),
147
+ "records_per_second": round(total / elapsed, 1) if elapsed > 0 else 0,
148
+ }
149
+ _print_stats(stat_data)
150
+
151
+
152
+ async def execute_single_command(
153
+ endpoint: str,
154
+ payload: dict[str, Any],
155
+ *,
156
+ # Phase 1 options
157
+ format: OutputFormat = OutputFormat.JSON,
158
+ fields: str | None = None,
159
+ output: Path | None = None,
160
+ quiet: bool = False,
161
+ # Phase 2: Batch input
162
+ from_file: Path | None = None,
163
+ stdin: bool = False,
164
+ parallel: int = 1,
165
+ delay: float = 0.0,
166
+ on_error: ErrorHandling = ErrorHandling.STOP,
167
+ # Phase 2: Enhanced fields
168
+ exclude: str | None = None,
169
+ compact: bool = False,
170
+ fields_preset: str | None = None,
171
+ # Phase 2: Rate limiting
172
+ rate_limit: str | None = None,
173
+ # Phase 2: Progress & feedback
174
+ progress: bool | None = None,
175
+ stats: bool = False,
176
+ verbose: bool = False,
177
+ # Phase 2: Output
178
+ append: bool = False,
179
+ output_dir: Path | None = None,
180
+ filename_template: str = "{id}",
181
+ # Batch-specific
182
+ input_key: str = "user",
183
+ extra_payload: dict[str, Any] | None = None,
184
+ ) -> None:
185
+ """Execute a single-item command with optional batch support.
186
+
187
+ Handles:
188
+ - Batch input from file or stdin (--from-file, --stdin)
189
+ - Parallel execution (--parallel)
190
+ - Rate limiting (--rate-limit)
191
+ - Per-file output (--output-dir)
192
+ - Progress bars
193
+ """
194
+ include_fields, excl_fields = _resolve_fields(fields, exclude, fields_preset)
195
+
196
+ # Check for batch mode
197
+ is_batch = from_file is not None or stdin
198
+
199
+ if not is_batch:
200
+ # Single request (backward compatible path)
201
+ async with create_client() as client:
202
+ if verbose:
203
+ print_info(f"Requesting {endpoint} with {payload}")
204
+
205
+ start_time = time.monotonic()
206
+ data = await client.post(endpoint, data=payload)
207
+ elapsed = time.monotonic() - start_time
208
+
209
+ if verbose:
210
+ print_info(f"Received response in {elapsed:.1f}s")
211
+
212
+ format_output(
213
+ data,
214
+ format,
215
+ include_fields,
216
+ output,
217
+ quiet,
218
+ exclude=excl_fields,
219
+ compact=compact,
220
+ append=append,
221
+ )
222
+
223
+ if stats and not quiet:
224
+ total = len(data) if isinstance(data, list) else 1
225
+ stat_data = {
226
+ "total": total,
227
+ "elapsed_seconds": round(elapsed, 2),
228
+ "records_per_second": round(total / elapsed, 1) if elapsed > 0 else 0,
229
+ }
230
+ _print_stats(stat_data)
231
+
232
+ return
233
+
234
+ # Batch mode
235
+ inputs = InputParser.from_file(from_file) if from_file else InputParser.from_stdin()
236
+
237
+ if not inputs:
238
+ if not quiet:
239
+ print_error("No inputs found")
240
+ return
241
+
242
+ if verbose:
243
+ print_info(f"Processing {len(inputs)} inputs (parallel={parallel})")
244
+
245
+ # Setup rate limiter
246
+ limiter = RateLimiter(rate_limit) if rate_limit else None
247
+
248
+ # Setup progress
249
+ tracker = ProgressTracker(
250
+ total=len(inputs),
251
+ description="Processing...",
252
+ show=progress,
253
+ quiet=quiet,
254
+ )
255
+
256
+ # Create the async fetch function
257
+ async def _fetch_one(inp: str | dict[str, Any]) -> Any:
258
+ # Determine the input value
259
+ if isinstance(inp, dict):
260
+ val = inp.get(input_key, inp.get("value", str(list(inp.values())[0])))
261
+ else:
262
+ val = inp
263
+
264
+ request_payload = {input_key: val}
265
+ if extra_payload:
266
+ request_payload.update(extra_payload)
267
+
268
+ async with create_client() as client:
269
+ return await client.post(endpoint, data=request_payload)
270
+
271
+ # Execute batch
272
+ executor = BatchExecutor(
273
+ func=_fetch_one,
274
+ parallel=parallel,
275
+ delay=delay,
276
+ on_error=on_error,
277
+ rate_limiter=limiter,
278
+ progress_callback=tracker.update,
279
+ )
280
+
281
+ with tracker:
282
+ batch_result = await executor.execute(inputs)
283
+
284
+ # Output results
285
+ if output_dir:
286
+ # Per-file output
287
+ output_dir.mkdir(parents=True, exist_ok=True)
288
+ ext_map = {
289
+ OutputFormat.JSON: ".json",
290
+ OutputFormat.JSONL: ".jsonl",
291
+ OutputFormat.CSV: ".csv",
292
+ OutputFormat.TABLE: ".json",
293
+ }
294
+ template = FilenameTemplate(
295
+ filename_template,
296
+ extension=ext_map.get(format, ".json"),
297
+ )
298
+
299
+ for i, result in enumerate(batch_result.results):
300
+ inp = inputs[i] if i < len(inputs) else ""
301
+ input_val = inp if isinstance(inp, str) else str(list(inp.values())[0]) if isinstance(inp, dict) else str(inp)
302
+ filename = template.resolve(
303
+ record=result,
304
+ index=i,
305
+ input_value=input_val,
306
+ )
307
+ filepath = output_dir / filename
308
+ format_output(
309
+ result,
310
+ format,
311
+ include_fields,
312
+ filepath,
313
+ quiet=True,
314
+ exclude=excl_fields,
315
+ compact=compact,
316
+ )
317
+
318
+ if not quiet:
319
+ print_success(f"Saved {len(batch_result.results)} files to {output_dir}/")
320
+
321
+ else:
322
+ # Collect all results and output together
323
+ all_results = []
324
+ for result in batch_result.results:
325
+ if isinstance(result, list):
326
+ all_results.extend(result)
327
+ elif isinstance(result, dict) and "data" in result:
328
+ data = result["data"]
329
+ if isinstance(data, list):
330
+ all_results.extend(data)
331
+ else:
332
+ all_results.append(data)
333
+ else:
334
+ all_results.append(result)
335
+
336
+ format_output(
337
+ all_results,
338
+ format,
339
+ include_fields,
340
+ output,
341
+ quiet,
342
+ exclude=excl_fields,
343
+ compact=compact,
344
+ append=append,
345
+ )
346
+
347
+ # Show stats
348
+ if stats and not quiet:
349
+ _print_stats(tracker.get_stats(), batch_result)
350
+
351
+
352
+ def run_search_command(
353
+ endpoint: str,
354
+ payload: dict[str, Any],
355
+ **kwargs: Any,
356
+ ) -> None:
357
+ """Sync wrapper for execute_search_command.
358
+
359
+ Catches AnysiteError and exits with proper error message.
360
+ """
361
+ import typer
362
+
363
+ try:
364
+ asyncio.run(execute_search_command(endpoint, payload, **kwargs))
365
+ except AnysiteError as e:
366
+ print_error(str(e))
367
+ raise typer.Exit(1) from None
368
+
369
+
370
+ def run_single_command(
371
+ endpoint: str,
372
+ payload: dict[str, Any],
373
+ **kwargs: Any,
374
+ ) -> None:
375
+ """Sync wrapper for execute_single_command.
376
+
377
+ Catches AnysiteError and exits with proper error message.
378
+ """
379
+ import typer
380
+
381
+ try:
382
+ asyncio.run(execute_single_command(endpoint, payload, **kwargs))
383
+ except AnysiteError as e:
384
+ print_error(str(e))
385
+ raise typer.Exit(1) from None
386
+ except (FileNotFoundError, ValueError) as e:
387
+ print_error(str(e))
388
+ raise typer.Exit(1) from None