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.
- anysite/__init__.py +4 -0
- anysite/__main__.py +6 -0
- anysite/api/__init__.py +21 -0
- anysite/api/client.py +271 -0
- anysite/api/errors.py +137 -0
- anysite/api/schemas.py +333 -0
- anysite/batch/__init__.py +1 -0
- anysite/batch/executor.py +176 -0
- anysite/batch/input.py +160 -0
- anysite/batch/rate_limiter.py +98 -0
- anysite/cli/__init__.py +1 -0
- anysite/cli/config.py +176 -0
- anysite/cli/executor.py +388 -0
- anysite/cli/options.py +249 -0
- anysite/config/__init__.py +11 -0
- anysite/config/paths.py +46 -0
- anysite/config/settings.py +187 -0
- anysite/dataset/__init__.py +37 -0
- anysite/dataset/analyzer.py +268 -0
- anysite/dataset/cli.py +644 -0
- anysite/dataset/collector.py +686 -0
- anysite/dataset/db_loader.py +248 -0
- anysite/dataset/errors.py +30 -0
- anysite/dataset/exporters.py +121 -0
- anysite/dataset/history.py +153 -0
- anysite/dataset/models.py +245 -0
- anysite/dataset/notifications.py +87 -0
- anysite/dataset/scheduler.py +107 -0
- anysite/dataset/storage.py +171 -0
- anysite/dataset/transformer.py +213 -0
- anysite/db/__init__.py +38 -0
- anysite/db/adapters/__init__.py +1 -0
- anysite/db/adapters/base.py +158 -0
- anysite/db/adapters/postgres.py +201 -0
- anysite/db/adapters/sqlite.py +183 -0
- anysite/db/cli.py +687 -0
- anysite/db/config.py +92 -0
- anysite/db/manager.py +166 -0
- anysite/db/operations/__init__.py +1 -0
- anysite/db/operations/insert.py +199 -0
- anysite/db/operations/query.py +43 -0
- anysite/db/schema/__init__.py +1 -0
- anysite/db/schema/inference.py +213 -0
- anysite/db/schema/types.py +71 -0
- anysite/db/utils/__init__.py +1 -0
- anysite/db/utils/sanitize.py +99 -0
- anysite/main.py +498 -0
- anysite/models/__init__.py +1 -0
- anysite/output/__init__.py +11 -0
- anysite/output/console.py +45 -0
- anysite/output/formatters.py +301 -0
- anysite/output/templates.py +76 -0
- anysite/py.typed +0 -0
- anysite/streaming/__init__.py +1 -0
- anysite/streaming/progress.py +121 -0
- anysite/streaming/writer.py +130 -0
- anysite/utils/__init__.py +1 -0
- anysite/utils/fields.py +242 -0
- anysite/utils/retry.py +109 -0
- anysite_cli-0.1.0.dist-info/METADATA +437 -0
- anysite_cli-0.1.0.dist-info/RECORD +64 -0
- anysite_cli-0.1.0.dist-info/WHEEL +4 -0
- anysite_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
anysite/cli/__init__.py
ADDED
|
@@ -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")
|
anysite/cli/executor.py
ADDED
|
@@ -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
|