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.
Files changed (49) hide show
  1. sv_cli/__init__.py +3 -0
  2. sv_cli/adapters.py +323 -0
  3. sv_cli/api_client.py +82 -0
  4. sv_cli/commands/__init__.py +0 -0
  5. sv_cli/commands/auth.py +4 -0
  6. sv_cli/commands/better_keywords.py +4 -0
  7. sv_cli/commands/call.py +4 -0
  8. sv_cli/commands/config.py +4 -0
  9. sv_cli/commands/content_quality.py +5 -0
  10. sv_cli/commands/content_transformer.py +4 -0
  11. sv_cli/commands/core_analysis.py +4 -0
  12. sv_cli/commands/definitions.py +4 -0
  13. sv_cli/commands/geo_audit.py +4 -0
  14. sv_cli/commands/insight_igniter.py +4 -0
  15. sv_cli/commands/marketplace_services.py +5 -0
  16. sv_cli/commands/options.py +4 -0
  17. sv_cli/commands/preliminary_audit.py +4 -0
  18. sv_cli/commands/ranklens.py +4 -0
  19. sv_cli/commands/seo_image.py +4 -0
  20. sv_cli/commands/seo_mapping.py +4 -0
  21. sv_cli/commands/seogpt.py +4 -0
  22. sv_cli/commands/seogpt2.py +4 -0
  23. sv_cli/commands/seogpt_compare.py +4 -0
  24. sv_cli/commands/top_competitors.py +5 -0
  25. sv_cli/commands/topical_authority.py +4 -0
  26. sv_cli/config.py +216 -0
  27. sv_cli/definitions.py +283 -0
  28. sv_cli/errors.py +55 -0
  29. sv_cli/executor.py +188 -0
  30. sv_cli/formatter.py +227 -0
  31. sv_cli/main.py +905 -0
  32. sv_cli/renderers/__init__.py +0 -0
  33. sv_cli/renderers/csv_renderer.py +9 -0
  34. sv_cli/renderers/json_renderer.py +10 -0
  35. sv_cli/renderers/markdown_renderer.py +9 -0
  36. sv_cli/renderers/table_renderer.py +9 -0
  37. sv_cli/renderers/text_renderer.py +9 -0
  38. sv_cli/resolver.py +523 -0
  39. sv_cli/schemas/__init__.py +0 -0
  40. sv_cli/schemas/api_response.py +12 -0
  41. sv_cli/schemas/config.py +13 -0
  42. sv_cli/schemas/tool_definition.py +17 -0
  43. sv_cli/tasks.py +168 -0
  44. sv_cli/utils.py +204 -0
  45. sv_cli-0.3.0.dist-info/METADATA +338 -0
  46. sv_cli-0.3.0.dist-info/RECORD +49 -0
  47. sv_cli-0.3.0.dist-info/WHEEL +4 -0
  48. sv_cli-0.3.0.dist-info/entry_points.txt +2 -0
  49. 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>")