sv-cli 0.3.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.
- sv_cli/__init__.py +3 -0
- sv_cli/adapters.py +323 -0
- sv_cli/api_client.py +82 -0
- sv_cli/commands/__init__.py +0 -0
- sv_cli/commands/auth.py +4 -0
- sv_cli/commands/better_keywords.py +4 -0
- sv_cli/commands/call.py +4 -0
- sv_cli/commands/config.py +4 -0
- sv_cli/commands/content_quality.py +5 -0
- sv_cli/commands/content_transformer.py +4 -0
- sv_cli/commands/core_analysis.py +4 -0
- sv_cli/commands/definitions.py +4 -0
- sv_cli/commands/geo_audit.py +4 -0
- sv_cli/commands/insight_igniter.py +4 -0
- sv_cli/commands/marketplace_services.py +5 -0
- sv_cli/commands/options.py +4 -0
- sv_cli/commands/preliminary_audit.py +4 -0
- sv_cli/commands/ranklens.py +4 -0
- sv_cli/commands/seo_image.py +4 -0
- sv_cli/commands/seo_mapping.py +4 -0
- sv_cli/commands/seogpt.py +4 -0
- sv_cli/commands/seogpt2.py +4 -0
- sv_cli/commands/seogpt_compare.py +4 -0
- sv_cli/commands/top_competitors.py +5 -0
- sv_cli/commands/topical_authority.py +4 -0
- sv_cli/config.py +216 -0
- sv_cli/definitions.py +283 -0
- sv_cli/errors.py +55 -0
- sv_cli/executor.py +188 -0
- sv_cli/formatter.py +227 -0
- sv_cli/main.py +905 -0
- sv_cli/renderers/__init__.py +0 -0
- sv_cli/renderers/csv_renderer.py +9 -0
- sv_cli/renderers/json_renderer.py +10 -0
- sv_cli/renderers/markdown_renderer.py +9 -0
- sv_cli/renderers/table_renderer.py +9 -0
- sv_cli/renderers/text_renderer.py +9 -0
- sv_cli/resolver.py +523 -0
- sv_cli/schemas/__init__.py +0 -0
- sv_cli/schemas/api_response.py +12 -0
- sv_cli/schemas/config.py +13 -0
- sv_cli/schemas/tool_definition.py +17 -0
- sv_cli/tasks.py +168 -0
- sv_cli/utils.py +204 -0
- sv_cli-0.3.0.dist-info/METADATA +338 -0
- sv_cli-0.3.0.dist-info/RECORD +49 -0
- sv_cli-0.3.0.dist-info/WHEEL +4 -0
- sv_cli-0.3.0.dist-info/entry_points.txt +2 -0
- sv_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
sv_cli/executor.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Generic tool execution layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from .adapters import get_adapter
|
|
13
|
+
from .api_client import APIClient
|
|
14
|
+
from .config import resolve_api_key
|
|
15
|
+
from .definitions import DefinitionsManager
|
|
16
|
+
from .errors import ConfigError, InvalidInputError
|
|
17
|
+
from .formatter import print_output
|
|
18
|
+
from .resolver import extract_option_sets, resolve_api_field, resolve_enum_value
|
|
19
|
+
from .tasks import extract_task_id, save_task, wait_for_task
|
|
20
|
+
from .utils import coerce_mapping_values, mask_mapping, maybe_read_file_value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RuntimeOptions:
|
|
25
|
+
profile: str | None = None
|
|
26
|
+
api_key: str | None = None
|
|
27
|
+
base_url: str | None = None
|
|
28
|
+
output_format: str = "pretty"
|
|
29
|
+
output: str | None = None
|
|
30
|
+
quiet: bool = False
|
|
31
|
+
verbose: bool = False
|
|
32
|
+
debug: bool = False
|
|
33
|
+
strict: bool = False
|
|
34
|
+
no_fuzzy: bool = False
|
|
35
|
+
non_interactive: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class WaitOptions:
|
|
40
|
+
wait: bool = False
|
|
41
|
+
timeout: int = 600
|
|
42
|
+
poll_interval: int = 5
|
|
43
|
+
no_progress: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_json_payload(
|
|
47
|
+
*, json_payload: str | None = None, file_path: str | None = None, stdin_payload: str | None = None
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
supplied = [json_payload is not None, file_path is not None, stdin_payload is not None]
|
|
50
|
+
if sum(bool(item) for item in supplied) != 1:
|
|
51
|
+
raise InvalidInputError("Provide exactly one of --json, --file, or --stdin for raw call payloads.")
|
|
52
|
+
if json_payload is not None:
|
|
53
|
+
source = json_payload
|
|
54
|
+
elif file_path is not None:
|
|
55
|
+
source = Path(file_path).expanduser().read_text(encoding="utf-8")
|
|
56
|
+
else:
|
|
57
|
+
source = stdin_payload or ""
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(source)
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
raise InvalidInputError(f"Invalid JSON payload: {exc}") from exc
|
|
62
|
+
if not isinstance(payload, dict):
|
|
63
|
+
raise InvalidInputError("Raw call payload must be a JSON object.")
|
|
64
|
+
return payload
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_payload(
|
|
68
|
+
*,
|
|
69
|
+
tool: str,
|
|
70
|
+
action: str | None,
|
|
71
|
+
params: dict[str, Any],
|
|
72
|
+
definition: Any,
|
|
73
|
+
strict: bool,
|
|
74
|
+
fuzzy: bool,
|
|
75
|
+
non_interactive: bool,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
payload: dict[str, Any] = {}
|
|
78
|
+
if action and action != "raw":
|
|
79
|
+
payload["action"] = normalize_action(action)
|
|
80
|
+
|
|
81
|
+
params = coerce_mapping_values({key: value for key, value in params.items() if value is not None})
|
|
82
|
+
option_sets = extract_option_sets(definition)
|
|
83
|
+
|
|
84
|
+
for cli_field, value in params.items():
|
|
85
|
+
if cli_field in {"json", "file", "stdin"}:
|
|
86
|
+
continue
|
|
87
|
+
if cli_field == "keywords" and isinstance(value, str):
|
|
88
|
+
value = maybe_read_file_value(value)
|
|
89
|
+
if cli_field == "outline" and isinstance(value, str):
|
|
90
|
+
value = maybe_read_file_value(value)
|
|
91
|
+
|
|
92
|
+
api_field, candidates = resolve_api_field(tool, cli_field, definition)
|
|
93
|
+
if candidates is None:
|
|
94
|
+
candidates = option_sets.get(api_field)
|
|
95
|
+
if candidates:
|
|
96
|
+
value = resolve_enum_value(
|
|
97
|
+
api_field,
|
|
98
|
+
value,
|
|
99
|
+
candidates,
|
|
100
|
+
strict=strict,
|
|
101
|
+
fuzzy=fuzzy,
|
|
102
|
+
non_interactive=non_interactive,
|
|
103
|
+
)
|
|
104
|
+
payload[api_field] = value
|
|
105
|
+
return payload
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalize_action(action: str) -> str:
|
|
109
|
+
parts = action.split("-")
|
|
110
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def execute_tool(
|
|
114
|
+
*,
|
|
115
|
+
tool_name: str,
|
|
116
|
+
action: str | None,
|
|
117
|
+
params: dict[str, Any],
|
|
118
|
+
runtime: RuntimeOptions,
|
|
119
|
+
wait_options: WaitOptions | None = None,
|
|
120
|
+
raw_payload: dict[str, Any] | None = None,
|
|
121
|
+
method: str = "POST",
|
|
122
|
+
console: Console | None = None,
|
|
123
|
+
) -> Any:
|
|
124
|
+
console = console or Console()
|
|
125
|
+
definitions = DefinitionsManager(runtime.base_url)
|
|
126
|
+
entry = definitions.get_tool(tool_name)
|
|
127
|
+
tool = entry["tool"]
|
|
128
|
+
endpoint = entry.get("endpoint")
|
|
129
|
+
if not endpoint:
|
|
130
|
+
raise ConfigError(f'Tool "{tool}" does not have an endpoint in the API root.')
|
|
131
|
+
definition = entry.get("definition") or {}
|
|
132
|
+
api_key = resolve_api_key(
|
|
133
|
+
cli_api_key=runtime.api_key,
|
|
134
|
+
profile=runtime.profile,
|
|
135
|
+
allow_prompt=not runtime.non_interactive,
|
|
136
|
+
non_interactive=runtime.non_interactive,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if raw_payload is not None:
|
|
140
|
+
payload = dict(raw_payload)
|
|
141
|
+
else:
|
|
142
|
+
payload = build_payload(
|
|
143
|
+
tool=tool,
|
|
144
|
+
action=action,
|
|
145
|
+
params=params,
|
|
146
|
+
definition=definition,
|
|
147
|
+
strict=runtime.strict,
|
|
148
|
+
fuzzy=not runtime.no_fuzzy,
|
|
149
|
+
non_interactive=runtime.non_interactive,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
client = APIClient(debug=runtime.debug, console=Console(stderr=True))
|
|
153
|
+
response = client.request_tool(endpoint=str(endpoint), payload=payload, api_key=api_key, method=method)
|
|
154
|
+
data = response.data
|
|
155
|
+
|
|
156
|
+
task_id = extract_task_id(data)
|
|
157
|
+
if task_id:
|
|
158
|
+
save_task(task_id, tool, str(endpoint))
|
|
159
|
+
|
|
160
|
+
if wait_options and wait_options.wait:
|
|
161
|
+
if not task_id:
|
|
162
|
+
if runtime.verbose or runtime.debug:
|
|
163
|
+
Console(stderr=True).print("[yellow]--wait was set, but no task ID was found in the response.[/yellow]")
|
|
164
|
+
else:
|
|
165
|
+
data = wait_for_task(
|
|
166
|
+
task_id=task_id,
|
|
167
|
+
tool=tool,
|
|
168
|
+
api_key=api_key,
|
|
169
|
+
definitions=definitions,
|
|
170
|
+
client=client,
|
|
171
|
+
timeout_seconds=wait_options.timeout,
|
|
172
|
+
poll_interval=wait_options.poll_interval,
|
|
173
|
+
no_progress=wait_options.no_progress or runtime.quiet,
|
|
174
|
+
console=Console(stderr=True),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if runtime.debug:
|
|
178
|
+
Console(stderr=True).print("[dim]Resolved payload:[/dim]")
|
|
179
|
+
Console(stderr=True).print_json(json.dumps(mask_mapping(payload), ensure_ascii=False))
|
|
180
|
+
|
|
181
|
+
if not runtime.quiet:
|
|
182
|
+
print_output(console, data, runtime.output_format, runtime.output)
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def adapter_default_action(tool: str) -> str:
|
|
187
|
+
adapter = get_adapter(tool)
|
|
188
|
+
return adapter.default_action if adapter else "run"
|
sv_cli/formatter.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Output formatters for human and machine use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from .errors import UnsupportedFeatureError
|
|
17
|
+
from .utils import ensure_parent, mask_mapping
|
|
18
|
+
|
|
19
|
+
SUPPORTED_FORMATS = {"pretty", "json", "table", "csv", "markdown", "text"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_output(data: Any, fmt: str = "pretty") -> str | None:
|
|
23
|
+
fmt = fmt.lower()
|
|
24
|
+
if fmt not in SUPPORTED_FORMATS:
|
|
25
|
+
raise UnsupportedFeatureError(
|
|
26
|
+
f'Unsupported output format "{fmt}". Use one of: {", ".join(sorted(SUPPORTED_FORMATS))}'
|
|
27
|
+
)
|
|
28
|
+
safe_data = mask_mapping(data)
|
|
29
|
+
if fmt == "json":
|
|
30
|
+
return json.dumps(safe_data, indent=2, ensure_ascii=False)
|
|
31
|
+
if fmt == "csv":
|
|
32
|
+
return to_csv(safe_data)
|
|
33
|
+
if fmt == "markdown":
|
|
34
|
+
return to_markdown(safe_data)
|
|
35
|
+
if fmt == "text":
|
|
36
|
+
return to_text(safe_data)
|
|
37
|
+
if fmt == "table":
|
|
38
|
+
return to_table_text(safe_data)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def print_output(console: Console, data: Any, fmt: str = "pretty", output: str | None = None) -> None:
|
|
43
|
+
rendered = render_output(data, fmt)
|
|
44
|
+
if output:
|
|
45
|
+
path = Path(output).expanduser()
|
|
46
|
+
ensure_parent(path)
|
|
47
|
+
if rendered is None:
|
|
48
|
+
rendered = to_text(mask_mapping(data))
|
|
49
|
+
path.write_text(rendered + ("" if rendered.endswith("\n") else "\n"), encoding="utf-8")
|
|
50
|
+
return
|
|
51
|
+
if rendered is not None:
|
|
52
|
+
if fmt == "json":
|
|
53
|
+
# Bypass Rich — it word-wraps long lines, inserting literal newlines
|
|
54
|
+
# inside JSON string values and producing unparseable output.
|
|
55
|
+
sys.stdout.write(rendered + "\n")
|
|
56
|
+
else:
|
|
57
|
+
console.print(rendered)
|
|
58
|
+
return
|
|
59
|
+
print_pretty(console, mask_mapping(data))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def print_pretty(console: Console, data: Any) -> None:
|
|
63
|
+
if isinstance(data, dict):
|
|
64
|
+
primary_text = extract_primary_text(data)
|
|
65
|
+
rows = find_tabular_data(data)
|
|
66
|
+
if primary_text and not rows:
|
|
67
|
+
console.print(primary_text)
|
|
68
|
+
return
|
|
69
|
+
if rows:
|
|
70
|
+
print_table(console, rows)
|
|
71
|
+
extra = {k: v for k, v in data.items() if v is not rows and not isinstance(v, list)}
|
|
72
|
+
if extra:
|
|
73
|
+
console.print_json(json.dumps(extra, ensure_ascii=False))
|
|
74
|
+
return
|
|
75
|
+
console.print_json(json.dumps(data, ensure_ascii=False))
|
|
76
|
+
return
|
|
77
|
+
if isinstance(data, list):
|
|
78
|
+
if data and all(isinstance(item, dict) for item in data):
|
|
79
|
+
print_table(console, data)
|
|
80
|
+
else:
|
|
81
|
+
console.print_json(json.dumps(data, ensure_ascii=False))
|
|
82
|
+
return
|
|
83
|
+
console.print(str(data))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def extract_primary_text(data: dict[str, Any]) -> str | None:
|
|
87
|
+
for key in ("text", "content", "result", "output", "answer", "message"):
|
|
88
|
+
value = data.get(key)
|
|
89
|
+
if isinstance(value, str):
|
|
90
|
+
return value
|
|
91
|
+
nested = data.get("data")
|
|
92
|
+
if isinstance(nested, str):
|
|
93
|
+
return nested
|
|
94
|
+
if isinstance(nested, dict):
|
|
95
|
+
return extract_primary_text(nested)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_tabular_data(data: Any) -> list[dict[str, Any]] | None:
|
|
100
|
+
if isinstance(data, list) and all(isinstance(item, dict) for item in data):
|
|
101
|
+
return data
|
|
102
|
+
if isinstance(data, dict):
|
|
103
|
+
for key in ("keywords", "results", "items", "data", "rows", "records"):
|
|
104
|
+
value = data.get(key)
|
|
105
|
+
if isinstance(value, list) and all(isinstance(item, dict) for item in value):
|
|
106
|
+
return value
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_csv(data: Any) -> str:
|
|
111
|
+
rows = find_tabular_data(data)
|
|
112
|
+
if rows is None:
|
|
113
|
+
raise UnsupportedFeatureError("CSV output requires a list of records in the API response.")
|
|
114
|
+
if not rows:
|
|
115
|
+
return ""
|
|
116
|
+
fieldnames: list[str] = []
|
|
117
|
+
for row in rows:
|
|
118
|
+
for key in row:
|
|
119
|
+
if key not in fieldnames:
|
|
120
|
+
fieldnames.append(key)
|
|
121
|
+
buffer = io.StringIO()
|
|
122
|
+
writer = csv.DictWriter(buffer, fieldnames=fieldnames, extrasaction="ignore")
|
|
123
|
+
writer.writeheader()
|
|
124
|
+
for row in rows:
|
|
125
|
+
writer.writerow({key: serialize_cell(row.get(key)) for key in fieldnames})
|
|
126
|
+
return buffer.getvalue().rstrip("\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def to_markdown(data: Any) -> str:
|
|
130
|
+
rows = find_tabular_data(data)
|
|
131
|
+
if rows:
|
|
132
|
+
return rows_to_markdown(rows)
|
|
133
|
+
if isinstance(data, dict):
|
|
134
|
+
primary = extract_primary_text(data)
|
|
135
|
+
if primary:
|
|
136
|
+
return primary
|
|
137
|
+
lines = []
|
|
138
|
+
for key, value in data.items():
|
|
139
|
+
lines.append(f"## {key}\n")
|
|
140
|
+
lines.append(value if isinstance(value, str) else f"```json\n{json.dumps(value, indent=2, ensure_ascii=False)}\n```")
|
|
141
|
+
return "\n\n".join(lines)
|
|
142
|
+
if isinstance(data, list):
|
|
143
|
+
return "\n".join(f"- {serialize_cell(item)}" for item in data)
|
|
144
|
+
return str(data)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def rows_to_markdown(rows: list[dict[str, Any]]) -> str:
|
|
148
|
+
if not rows:
|
|
149
|
+
return ""
|
|
150
|
+
fieldnames: list[str] = []
|
|
151
|
+
for row in rows:
|
|
152
|
+
for key in row:
|
|
153
|
+
if key not in fieldnames:
|
|
154
|
+
fieldnames.append(key)
|
|
155
|
+
header = "| " + " | ".join(fieldnames) + " |"
|
|
156
|
+
divider = "| " + " | ".join("---" for _ in fieldnames) + " |"
|
|
157
|
+
body = [
|
|
158
|
+
"| " + " | ".join(escape_markdown_table(serialize_cell(row.get(key))) for key in fieldnames) + " |"
|
|
159
|
+
for row in rows
|
|
160
|
+
]
|
|
161
|
+
return "\n".join([header, divider, *body])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def to_text(data: Any) -> str:
|
|
165
|
+
if isinstance(data, str):
|
|
166
|
+
return data
|
|
167
|
+
if isinstance(data, dict):
|
|
168
|
+
primary = extract_primary_text(data)
|
|
169
|
+
if primary:
|
|
170
|
+
return primary
|
|
171
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def to_table_text(data: Any) -> str:
|
|
175
|
+
rows = find_tabular_data(data)
|
|
176
|
+
if rows is None:
|
|
177
|
+
raise UnsupportedFeatureError("Table output requires a list of records in the API response.")
|
|
178
|
+
if not rows:
|
|
179
|
+
return "(no results)"
|
|
180
|
+
# Plain text table for file output and non-rich paths.
|
|
181
|
+
fieldnames: list[str] = []
|
|
182
|
+
for row in rows:
|
|
183
|
+
for key in row:
|
|
184
|
+
if key not in fieldnames:
|
|
185
|
+
fieldnames.append(key)
|
|
186
|
+
widths = {
|
|
187
|
+
key: max(len(str(key)), *(len(serialize_cell(row.get(key))) for row in rows)) for key in fieldnames
|
|
188
|
+
}
|
|
189
|
+
lines = []
|
|
190
|
+
lines.append(" ".join(str(key).ljust(widths[key]) for key in fieldnames))
|
|
191
|
+
lines.append(" ".join("-" * widths[key] for key in fieldnames))
|
|
192
|
+
for row in rows:
|
|
193
|
+
lines.append(" ".join(serialize_cell(row.get(key)).ljust(widths[key]) for key in fieldnames))
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def print_table(console: Console, rows: list[dict[str, Any]]) -> None:
|
|
198
|
+
if not rows:
|
|
199
|
+
console.print("No rows returned.")
|
|
200
|
+
return
|
|
201
|
+
fieldnames: list[str] = []
|
|
202
|
+
for row in rows:
|
|
203
|
+
for key in row:
|
|
204
|
+
if key not in fieldnames:
|
|
205
|
+
fieldnames.append(str(key))
|
|
206
|
+
table = Table(show_header=True, header_style="bold")
|
|
207
|
+
for key in fieldnames:
|
|
208
|
+
table.add_column(str(key))
|
|
209
|
+
for row in rows:
|
|
210
|
+
table.add_row(*(serialize_cell(row.get(key)) for key in fieldnames))
|
|
211
|
+
console.print(table)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def print_markdown(console: Console, text: str) -> None:
|
|
215
|
+
console.print(Markdown(text))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def serialize_cell(value: Any) -> str:
|
|
219
|
+
if value is None:
|
|
220
|
+
return ""
|
|
221
|
+
if isinstance(value, (dict, list)):
|
|
222
|
+
return json.dumps(value, ensure_ascii=False)
|
|
223
|
+
return str(value)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def escape_markdown_table(value: str) -> str:
|
|
227
|
+
return value.replace("|", "\\|").replace("\n", "<br>")
|