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/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"