dataspring-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.
- cli/__init__.py +15 -0
- cli/_skills/dataspring-author/SKILL.md +401 -0
- cli/_skills/dataspring-consume/SKILL.md +712 -0
- cli/_skills/dataspring-correct/SKILL.md +124 -0
- cli/auth.py +375 -0
- cli/bundled_manifest.py +36 -0
- cli/contract.py +1138 -0
- cli/generated.py +1297 -0
- cli/main.py +3232 -0
- cli/output.py +266 -0
- cli/runtime.py +201 -0
- cli/skills_commands.py +247 -0
- cli/skilltree.py +350 -0
- cli/upgrade.py +66 -0
- cli/version.py +123 -0
- dataspring_cli-0.3.0.dist-info/METADATA +202 -0
- dataspring_cli-0.3.0.dist-info/RECORD +20 -0
- dataspring_cli-0.3.0.dist-info/WHEEL +4 -0
- dataspring_cli-0.3.0.dist-info/entry_points.txt +2 -0
- settings.py +78 -0
cli/output.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""CLI Output Formatters - Rich tables, JSON, CSV output.
|
|
2
|
+
|
|
3
|
+
Provides consistent output formatting across all CLI commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Shared console instance
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
OutputFormat = Literal["table", "json", "yaml", "csv"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_table(
|
|
23
|
+
data: list[dict],
|
|
24
|
+
columns: list[str] | None = None,
|
|
25
|
+
title: str | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""Print data as a Rich table.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: List of row dictionaries
|
|
31
|
+
columns: Column names (defaults to keys from first row)
|
|
32
|
+
title: Optional table title
|
|
33
|
+
"""
|
|
34
|
+
if not data:
|
|
35
|
+
console.print("[dim]No data to display[/]")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if columns is None:
|
|
39
|
+
columns = list(data[0].keys())
|
|
40
|
+
|
|
41
|
+
table = Table(title=title, show_header=True, header_style="bold")
|
|
42
|
+
|
|
43
|
+
for col in columns:
|
|
44
|
+
# Don't wrap ID columns so users can copy-paste full IDs
|
|
45
|
+
if col.lower() == "id":
|
|
46
|
+
table.add_column(col, no_wrap=True, overflow="fold")
|
|
47
|
+
else:
|
|
48
|
+
table.add_column(col)
|
|
49
|
+
|
|
50
|
+
for row in data:
|
|
51
|
+
table.add_row(*[_format_cell(row.get(col)) for col in columns])
|
|
52
|
+
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_cell(value: Any) -> str:
|
|
57
|
+
"""Format a cell value for display."""
|
|
58
|
+
if value is None:
|
|
59
|
+
return ""
|
|
60
|
+
if isinstance(value, bool):
|
|
61
|
+
return "Yes" if value else "No"
|
|
62
|
+
if isinstance(value, float):
|
|
63
|
+
# Format floats nicely
|
|
64
|
+
if value == int(value):
|
|
65
|
+
return str(int(value))
|
|
66
|
+
return f"{value:,.2f}"
|
|
67
|
+
return str(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_json(data: Any):
|
|
71
|
+
"""Print data as formatted JSON.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
data: Any JSON-serializable data
|
|
75
|
+
"""
|
|
76
|
+
console.print_json(json.dumps(data, indent=2, default=str))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_yaml(data: Any):
|
|
80
|
+
"""Print data as YAML.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
data: Any data to format as YAML
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
import yaml
|
|
87
|
+
|
|
88
|
+
console.print(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
89
|
+
except ImportError:
|
|
90
|
+
# Fall back to JSON if PyYAML not installed
|
|
91
|
+
console.print("[yellow]PyYAML not installed, showing as JSON:[/]")
|
|
92
|
+
print_json(data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def print_csv(data: list[dict], columns: list[str] | None = None):
|
|
96
|
+
"""Print data as CSV.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
data: List of row dictionaries
|
|
100
|
+
columns: Column names (defaults to keys from first row)
|
|
101
|
+
"""
|
|
102
|
+
if not data:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if columns is None:
|
|
106
|
+
columns = list(data[0].keys())
|
|
107
|
+
|
|
108
|
+
output = io.StringIO()
|
|
109
|
+
writer = csv.DictWriter(output, fieldnames=columns, extrasaction="ignore")
|
|
110
|
+
writer.writeheader()
|
|
111
|
+
writer.writerows(data)
|
|
112
|
+
|
|
113
|
+
console.print(output.getvalue(), highlight=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_output(
|
|
117
|
+
data: Any,
|
|
118
|
+
format: OutputFormat = "table",
|
|
119
|
+
columns: list[str] | None = None,
|
|
120
|
+
title: str | None = None,
|
|
121
|
+
):
|
|
122
|
+
"""Format and print data according to output format.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
data: Data to output (list of dicts for table/csv, any for json/yaml)
|
|
126
|
+
format: Output format
|
|
127
|
+
columns: Column names for table/csv output
|
|
128
|
+
title: Title for table output
|
|
129
|
+
"""
|
|
130
|
+
if format == "table":
|
|
131
|
+
if isinstance(data, list):
|
|
132
|
+
print_table(data, columns=columns, title=title)
|
|
133
|
+
else:
|
|
134
|
+
# Single item - show as key-value pairs
|
|
135
|
+
print_key_value(data)
|
|
136
|
+
elif format == "json":
|
|
137
|
+
print_json(data)
|
|
138
|
+
elif format == "yaml":
|
|
139
|
+
print_yaml(data)
|
|
140
|
+
elif format == "csv":
|
|
141
|
+
if isinstance(data, list):
|
|
142
|
+
print_csv(data, columns=columns)
|
|
143
|
+
else:
|
|
144
|
+
console.print("[red]CSV format requires list data[/]")
|
|
145
|
+
else:
|
|
146
|
+
console.print(f"[red]Unknown format: {format}[/]")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def print_key_value(data: dict, title: str | None = None):
|
|
150
|
+
"""Print a single object as key-value pairs.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
data: Dictionary to display
|
|
154
|
+
title: Optional panel title
|
|
155
|
+
"""
|
|
156
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
157
|
+
table.add_column(style="bold cyan")
|
|
158
|
+
table.add_column()
|
|
159
|
+
|
|
160
|
+
for key, value in data.items():
|
|
161
|
+
table.add_row(key, _format_cell(value))
|
|
162
|
+
|
|
163
|
+
if title:
|
|
164
|
+
console.print(Panel(table, title=title))
|
|
165
|
+
else:
|
|
166
|
+
console.print(table)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def print_success(message: str):
|
|
170
|
+
"""Print a success message.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
message: Success message
|
|
174
|
+
"""
|
|
175
|
+
console.print(f"[green]✓[/] {message}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def print_error(message: str, hint: str | None = None):
|
|
179
|
+
"""Print an error message.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
message: Error message
|
|
183
|
+
hint: Optional hint for resolving the error
|
|
184
|
+
"""
|
|
185
|
+
console.print(f"[red]✗ Error:[/] {message}")
|
|
186
|
+
if hint:
|
|
187
|
+
console.print(f"[dim] Hint: {hint}[/]")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def print_warning(message: str):
|
|
191
|
+
"""Print a warning message.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
message: Warning message
|
|
195
|
+
"""
|
|
196
|
+
console.print(f"[yellow]⚠[/] {message}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def print_info(message: str):
|
|
200
|
+
"""Print an info message.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
message: Info message
|
|
204
|
+
"""
|
|
205
|
+
console.print(f"[blue]ℹ[/] {message}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def print_user_context(email: str, org_id: str, role: str, org_name: str | None = None):
|
|
209
|
+
"""Print user context in a nice format.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
email: User email
|
|
213
|
+
org_id: Organization ID
|
|
214
|
+
role: User's role
|
|
215
|
+
org_name: Optional organization name
|
|
216
|
+
"""
|
|
217
|
+
org_display = org_name if org_name else org_id
|
|
218
|
+
|
|
219
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
220
|
+
table.add_column(style="bold")
|
|
221
|
+
table.add_column()
|
|
222
|
+
|
|
223
|
+
table.add_row("Email", email)
|
|
224
|
+
table.add_row("Organization", org_display)
|
|
225
|
+
table.add_row("Role", _format_role(role))
|
|
226
|
+
|
|
227
|
+
console.print(Panel(table, title="[bold]Current User[/]"))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _format_role(role: str) -> str:
|
|
231
|
+
"""Format role with color."""
|
|
232
|
+
colors = {
|
|
233
|
+
"owner": "magenta",
|
|
234
|
+
"admin": "red",
|
|
235
|
+
"member": "blue",
|
|
236
|
+
"viewer": "dim",
|
|
237
|
+
}
|
|
238
|
+
color = colors.get(role, "white")
|
|
239
|
+
return f"[{color}]{role}[/{color}]"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def print_report_hint(command: str, error_message: str):
|
|
243
|
+
"""Print a hint about submitting an error report.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
command: The command that failed
|
|
247
|
+
error_message: The error that occurred
|
|
248
|
+
"""
|
|
249
|
+
safe_error = error_message.replace('"', '\\"')
|
|
250
|
+
safe_command = command.replace('"', '\\"')
|
|
251
|
+
console.print(
|
|
252
|
+
f'\n[dim]If this seems wrong, you can report it:[/]\n'
|
|
253
|
+
f'[dim] dataspring report "{safe_command}" "{safe_error}" --expected "..." --context "..."[/]'
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def print_visualization_suggestion(widget_type: str, rationale: str):
|
|
258
|
+
"""Print a visualization suggestion.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
widget_type: Suggested widget type
|
|
262
|
+
rationale: Reason for the suggestion
|
|
263
|
+
"""
|
|
264
|
+
console.print()
|
|
265
|
+
console.print(f"[bold]Suggested visualization:[/] {widget_type}")
|
|
266
|
+
console.print(f"[dim]{rationale}[/]")
|
cli/runtime.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""How a parsed command becomes one ``POST /api/dispatch/<key>`` call.
|
|
2
|
+
|
|
3
|
+
The generated commands in ``cli/generated.py`` and the hand-written aliases
|
|
4
|
+
in ``cli/main.py`` both end here, so there is one place that
|
|
5
|
+
|
|
6
|
+
* decodes ``--<field>-json`` values (JSON text, or ``@path`` to a YAML/JSON
|
|
7
|
+
file) and ``--from-file`` (the whole body; flags override its keys);
|
|
8
|
+
* keeps only the flags the user gave, so the server's defaults apply to the
|
|
9
|
+
rest and the body says nothing the user did not;
|
|
10
|
+
* wraps an edit family's fields as ``{"action": {"action": <verb>, ...}}``;
|
|
11
|
+
* validates the body against the contract model the server will validate
|
|
12
|
+
it against (``cli/contract.py``), so a wrong flag is a message here, not
|
|
13
|
+
a 400 in a terminal;
|
|
14
|
+
* prints the manifest skew hint once per process when the server has
|
|
15
|
+
operations or fields this CLI does not know (``cli/version.py``), and
|
|
16
|
+
still runs what it can;
|
|
17
|
+
* writes the answer's ``content`` or ``data_base64`` to ``--output``, or
|
|
18
|
+
prints the JSON answer.
|
|
19
|
+
|
|
20
|
+
The HTTP client, the auth manager and the error formatting stay in
|
|
21
|
+
``cli/main.py`` and are looked up there at call time, which is what lets
|
|
22
|
+
the tests stand in a fake server and a fake login for every command.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import typer
|
|
33
|
+
import yaml
|
|
34
|
+
from pydantic import BaseModel, ValidationError
|
|
35
|
+
|
|
36
|
+
from cli.output import print_error, print_success
|
|
37
|
+
|
|
38
|
+
EXIT_USAGE = 2
|
|
39
|
+
|
|
40
|
+
#: Seconds a generated command waits for the server; renders and imports are
|
|
41
|
+
#: the slow ones, and a query has its own byte and time caps on the server.
|
|
42
|
+
DEFAULT_TIMEOUT = 180.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Values
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_document(path: Path) -> Any:
|
|
51
|
+
"""A YAML or JSON file, by suffix (YAML reads JSON too)."""
|
|
52
|
+
if not path.exists():
|
|
53
|
+
print_error(f"File not found: {path}")
|
|
54
|
+
raise typer.Exit(EXIT_USAGE)
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
try:
|
|
57
|
+
if path.suffix.lower() == ".json":
|
|
58
|
+
return json.loads(text)
|
|
59
|
+
return yaml.safe_load(text)
|
|
60
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
61
|
+
print_error(f"{path} is not valid {'JSON' if path.suffix.lower() == '.json' else 'YAML'}: {e}")
|
|
62
|
+
raise typer.Exit(EXIT_USAGE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def J(raw: str | None) -> Any: # noqa: N802 - short on purpose: it wraps every JSON flag in the generated file
|
|
66
|
+
"""Decode a ``--<field>-json`` flag: JSON text, or ``@path`` to a YAML/JSON file."""
|
|
67
|
+
if raw is None:
|
|
68
|
+
return None
|
|
69
|
+
if raw.startswith("@"):
|
|
70
|
+
return load_document(Path(raw[1:]))
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(raw)
|
|
73
|
+
except json.JSONDecodeError as e:
|
|
74
|
+
print_error(f"Not JSON: {raw!r} ({e})", hint="Pass JSON text, or @path to a .json/.yaml file")
|
|
75
|
+
raise typer.Exit(EXIT_USAGE)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# The body
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_body(verb: str | None, fields: dict[str, Any], from_file: Path | None = None) -> dict:
|
|
84
|
+
"""The wire body: the file's keys under the flags given (``None`` is "not
|
|
85
|
+
given"), wrapped as an action for an edit family."""
|
|
86
|
+
base: dict = {}
|
|
87
|
+
if from_file is not None:
|
|
88
|
+
loaded = load_document(from_file)
|
|
89
|
+
if not isinstance(loaded, dict):
|
|
90
|
+
print_error(f"{from_file} must hold a mapping of fields")
|
|
91
|
+
raise typer.Exit(EXIT_USAGE)
|
|
92
|
+
base = dict(loaded)
|
|
93
|
+
if verb is not None and isinstance(base.get("action"), str):
|
|
94
|
+
base.pop("action") # the verb is the subcommand
|
|
95
|
+
merged = {**base, **{k: v for k, v in fields.items() if v is not None}}
|
|
96
|
+
if verb is None:
|
|
97
|
+
return merged
|
|
98
|
+
return {"action": {"action": verb, **merged}}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate(model: type[BaseModel], body: dict, verb: str | None = None) -> None:
|
|
102
|
+
"""Refuse locally what the server would refuse with a 400, in the same words."""
|
|
103
|
+
try:
|
|
104
|
+
model.model_validate(body)
|
|
105
|
+
except ValidationError as e:
|
|
106
|
+
for err in e.errors(include_url=False):
|
|
107
|
+
loc = ".".join(str(p) for p in err["loc"] if p not in ("action", verb))
|
|
108
|
+
print_error(f"{loc}: {err['msg']}" if loc else err["msg"])
|
|
109
|
+
raise typer.Exit(EXIT_USAGE)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# The call
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def call_tool(
|
|
118
|
+
key: str,
|
|
119
|
+
verb: str | None,
|
|
120
|
+
fields: dict[str, Any],
|
|
121
|
+
*,
|
|
122
|
+
model: type[BaseModel] | None = None,
|
|
123
|
+
from_file: Path | None = None,
|
|
124
|
+
token: str | None = None,
|
|
125
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
126
|
+
) -> dict:
|
|
127
|
+
"""Build, validate and send one dispatch call; answers the handler's result.
|
|
128
|
+
|
|
129
|
+
Raises what ``cli.main.api_dispatch`` raises (``DispatchToolError`` for a
|
|
130
|
+
handler's ``{"error": ...}``, ``httpx`` errors for the transport) so a
|
|
131
|
+
caller formats failures its own way; ``typer.Exit`` for a local refusal.
|
|
132
|
+
"""
|
|
133
|
+
from cli import main as cli # at call time: the tests patch cli.main's auth and HTTP functions
|
|
134
|
+
from cli.auth import AuthenticationError
|
|
135
|
+
from cli.version import skew_hint
|
|
136
|
+
|
|
137
|
+
if token is None:
|
|
138
|
+
try:
|
|
139
|
+
token = cli.auth.get_access_token()
|
|
140
|
+
except AuthenticationError as e:
|
|
141
|
+
print_error(str(e), hint="Run 'dataspring login' to authenticate")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
if model is None:
|
|
144
|
+
from cli.generated import MODELS
|
|
145
|
+
|
|
146
|
+
model = MODELS[key]
|
|
147
|
+
body = build_body(verb, fields, from_file)
|
|
148
|
+
validate(model, body, verb)
|
|
149
|
+
hint = skew_hint(cli.get_api_base(), token)
|
|
150
|
+
if hint:
|
|
151
|
+
typer.echo(hint, err=True)
|
|
152
|
+
return cli.api_dispatch(key, body, token, timeout=timeout)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_tool(
|
|
156
|
+
key: str,
|
|
157
|
+
model: type[BaseModel],
|
|
158
|
+
verb: str | None,
|
|
159
|
+
fields: dict[str, Any],
|
|
160
|
+
*,
|
|
161
|
+
from_file: Path | None = None,
|
|
162
|
+
output: Path | None = None,
|
|
163
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""What every generated command does: ``call_tool`` and then print or write the answer."""
|
|
166
|
+
from cli import main as cli
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
result = call_tool(key, verb, fields, model=model, from_file=from_file, timeout=timeout)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
cli.fail(e)
|
|
172
|
+
emit(result, output)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def emit(result: Any, output: Path | None) -> None:
|
|
176
|
+
"""The JSON answer on stdout, or its document / rendered file at ``output``."""
|
|
177
|
+
if output is None:
|
|
178
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
179
|
+
return
|
|
180
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
if isinstance(result, dict) and result.get("data_base64"):
|
|
182
|
+
data = base64.b64decode(result["data_base64"])
|
|
183
|
+
output.write_bytes(data)
|
|
184
|
+
what = "file"
|
|
185
|
+
elif isinstance(result, dict) and isinstance(result.get("content"), str):
|
|
186
|
+
data = result["content"].encode("utf-8")
|
|
187
|
+
output.write_bytes(data)
|
|
188
|
+
what = "document"
|
|
189
|
+
else:
|
|
190
|
+
data = json.dumps(result, indent=2, default=str).encode("utf-8")
|
|
191
|
+
output.write_bytes(data)
|
|
192
|
+
what = "answer"
|
|
193
|
+
print_success(f"Wrote the {what} to {output} ({_size(len(data))})")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _size(size: int) -> str:
|
|
197
|
+
if size >= 1024 * 1024:
|
|
198
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
199
|
+
if size >= 1024:
|
|
200
|
+
return f"{size / 1024:.1f} KB"
|
|
201
|
+
return f"{size} bytes"
|