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/main.py
ADDED
|
@@ -0,0 +1,3232 @@
|
|
|
1
|
+
"""DataSpring CLI - Main entry point.
|
|
2
|
+
|
|
3
|
+
Two layers, one wire. ``cli/generated.py`` is the dispatch registry as
|
|
4
|
+
commands, generated from the models in ``cli/contract.py`` (decision D19,
|
|
5
|
+
step 4): ``dataspring <key> --<field>`` for a flat tool, ``dataspring <key>
|
|
6
|
+
<verb> --<field>`` for an edit family, every flag a model field, so the
|
|
7
|
+
tree can never lack a field the server has. This module mounts that tree
|
|
8
|
+
and adds the human layer on top: the session commands (login, whoami,
|
|
9
|
+
version, upgrade, skills), the reads over the REST routes (``metrics
|
|
10
|
+
list``, ``dashboards show``, ...), and a few short-named conveniences
|
|
11
|
+
(``query``, ``switch``, ``sql``, ``export``, ``secret set --stdin``,
|
|
12
|
+
``datacore pull|push|check|deploy|run|reset``) that are each a few lines
|
|
13
|
+
over the same ``cli.runtime.call_tool`` the generated commands use, with a
|
|
14
|
+
prompt, a file, stdin or a table where a person wants one. Nothing here
|
|
15
|
+
builds a dispatch body by hand.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Literal, NoReturn, Optional
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from cli.auth import CLIAuthManager, AuthenticationError
|
|
28
|
+
from cli.generated import mount as mount_generated
|
|
29
|
+
from cli.output import (
|
|
30
|
+
console,
|
|
31
|
+
format_output,
|
|
32
|
+
print_error,
|
|
33
|
+
print_info,
|
|
34
|
+
print_report_hint,
|
|
35
|
+
print_success,
|
|
36
|
+
print_warning,
|
|
37
|
+
print_yaml,
|
|
38
|
+
print_user_context,
|
|
39
|
+
print_visualization_suggestion,
|
|
40
|
+
OutputFormat,
|
|
41
|
+
)
|
|
42
|
+
from cli.runtime import call_tool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Initialize auth manager
|
|
46
|
+
auth = CLIAuthManager()
|
|
47
|
+
|
|
48
|
+
# Initialize main app
|
|
49
|
+
app = typer.Typer(
|
|
50
|
+
name="dataspring",
|
|
51
|
+
help="DataSpring CLI - Query metrics and manage dashboards from the terminal.",
|
|
52
|
+
no_args_is_help=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Sub-command groups: the reads and the human conveniences. Writes are the
|
|
56
|
+
# generated tree (`dataspring dashboard_edit create ...`, mounted at the end
|
|
57
|
+
# of this module); a group here that once held create/update/delete keeps
|
|
58
|
+
# only what the generated twin cannot be: a read, a prompt, a file, stdin.
|
|
59
|
+
metrics_app = typer.Typer(help="Metrics: list and show (writes: dataspring metric_edit)")
|
|
60
|
+
dimensions_app = typer.Typer(help="Dimension operations")
|
|
61
|
+
org_app = typer.Typer(help="Organization operations")
|
|
62
|
+
dashboards_app = typer.Typer(help="Dashboards: list, show, update from a file, widgets by id (writes: dashboard_edit, widget_edit)")
|
|
63
|
+
manifest_app = typer.Typer(help="Manifest: status and export (upload: dataspring import_manifest)")
|
|
64
|
+
models_app = typer.Typer(help="Semantic models: list and show (writes: dataspring semantic_model_edit)")
|
|
65
|
+
schedules_app = typer.Typer(help="Scheduled reports: list and show (writes: dataspring report_edit)")
|
|
66
|
+
quick_metrics_app = typer.Typer(help="Quick metrics: list and show (writes: dataspring quick_metric_edit)")
|
|
67
|
+
context_app = typer.Typer(help="Your preferences (update: dataspring update_context)")
|
|
68
|
+
|
|
69
|
+
# Register sub-command groups
|
|
70
|
+
app.add_typer(metrics_app, name="metrics")
|
|
71
|
+
app.add_typer(dimensions_app, name="dimensions")
|
|
72
|
+
app.add_typer(org_app, name="org")
|
|
73
|
+
app.add_typer(dashboards_app, name="dashboards")
|
|
74
|
+
app.add_typer(manifest_app, name="manifest")
|
|
75
|
+
app.add_typer(models_app, name="models")
|
|
76
|
+
app.add_typer(schedules_app, name="schedules")
|
|
77
|
+
app.add_typer(quick_metrics_app, name="quick-metrics")
|
|
78
|
+
app.add_typer(context_app, name="context")
|
|
79
|
+
business_context_app = typer.Typer(help="Org business_context document operations")
|
|
80
|
+
app.add_typer(business_context_app, name="business-context")
|
|
81
|
+
learned_app = typer.Typer(help="The learned trail: what DataSpring learned for this org")
|
|
82
|
+
app.add_typer(learned_app, name="learned")
|
|
83
|
+
warehouses_app = typer.Typer(help="The org's warehouses: external and managed, and which is active")
|
|
84
|
+
app.add_typer(warehouses_app, name="warehouses")
|
|
85
|
+
datacore_app = typer.Typer(help="The datacore workspace: pull, edit locally, push, check, deploy, run")
|
|
86
|
+
app.add_typer(datacore_app, name="datacore")
|
|
87
|
+
from cli.skills_commands import skills_app # noqa: E402
|
|
88
|
+
app.add_typer(skills_app, name="skills")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ============================================================================
|
|
92
|
+
# Helpers
|
|
93
|
+
# ============================================================================
|
|
94
|
+
#
|
|
95
|
+
# Every command talks to the DataSpring API over HTTPS with the user's OAuth
|
|
96
|
+
# access token. Reads use the REST routes; every write, and every operation
|
|
97
|
+
# the server owns end to end (query, explain, export, render, org switch), goes
|
|
98
|
+
# through ``POST /api/dispatch/{tool_key}`` - the same handlers MCP and the
|
|
99
|
+
# inline agent call, so role checks, validation and the learned trail happen
|
|
100
|
+
# once, on the server. The CLI never opens Firestore or a warehouse itself:
|
|
101
|
+
# nothing under backend/cli/ imports `services`, `storage`, `warehouse`,
|
|
102
|
+
# `mf_engine` or google-cloud-firestore (tests/test_cli_thin_client.py pins
|
|
103
|
+
# that). Before this, thirty-eight commands imported the service layer and hit
|
|
104
|
+
# Firestore with the operator's local Google credentials, keyed by an e-mail
|
|
105
|
+
# in a user-editable config file - which bypassed every server role check.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_async(coro):
|
|
109
|
+
"""Run an async function synchronously."""
|
|
110
|
+
return asyncio.run(coro)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def split_commas(values: Optional[list[str]]) -> Optional[list[str]]:
|
|
114
|
+
"""Allow ``-m a,b,c`` as a shorthand for ``-m a -m b -m c``.
|
|
115
|
+
|
|
116
|
+
Typer's ``list[str]`` only splits on repeated flags; bare commas pass
|
|
117
|
+
through as one string and MetricFlow then 500s on the unknown name.
|
|
118
|
+
"""
|
|
119
|
+
if not values:
|
|
120
|
+
return values
|
|
121
|
+
return [item.strip() for entry in values for item in entry.split(",") if item.strip()]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def require_auth():
|
|
125
|
+
"""Get authenticated user or exit with error.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
UserContext for authenticated user
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
typer.Exit: If not authenticated
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
return run_async(auth.get_user())
|
|
135
|
+
except AuthenticationError as e:
|
|
136
|
+
print_error(str(e), hint="Run 'dataspring login' to authenticate")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_api_base() -> str:
|
|
141
|
+
"""Get the backend API base URL."""
|
|
142
|
+
from settings import get_settings
|
|
143
|
+
# mcp_server_base_url is https://dataspring.app/api
|
|
144
|
+
# /query endpoint is at root, not under /api
|
|
145
|
+
base = get_settings().mcp_server_base_url
|
|
146
|
+
# Strip /api suffix to get the host root
|
|
147
|
+
return base.removesuffix("/api")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def api_get(path: str, token: str, timeout: float = 60.0) -> dict:
|
|
151
|
+
"""Make an authenticated GET request to the backend API."""
|
|
152
|
+
import httpx
|
|
153
|
+
|
|
154
|
+
url = f"{get_api_base()}{path}"
|
|
155
|
+
with httpx.Client(timeout=timeout) as client:
|
|
156
|
+
response = client.get(
|
|
157
|
+
url,
|
|
158
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
159
|
+
)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
return response.json()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def api_delete(path: str, token: str, timeout: float = 60.0) -> dict:
|
|
165
|
+
"""Make an authenticated DELETE request to the backend API."""
|
|
166
|
+
import httpx
|
|
167
|
+
|
|
168
|
+
url = f"{get_api_base()}{path}"
|
|
169
|
+
with httpx.Client(timeout=timeout) as client:
|
|
170
|
+
response = client.delete(
|
|
171
|
+
url,
|
|
172
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
173
|
+
)
|
|
174
|
+
response.raise_for_status()
|
|
175
|
+
return response.json()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def api_put(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
|
|
179
|
+
"""Make an authenticated PUT request to the backend API."""
|
|
180
|
+
import httpx
|
|
181
|
+
|
|
182
|
+
url = f"{get_api_base()}{path}"
|
|
183
|
+
with httpx.Client(timeout=timeout) as client:
|
|
184
|
+
response = client.put(
|
|
185
|
+
url,
|
|
186
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
187
|
+
json=body,
|
|
188
|
+
)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
return response.json()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def api_post(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
|
|
194
|
+
"""Make an authenticated POST request to the backend API.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
path: URL path (e.g. "/api/dispatch/query_metrics")
|
|
198
|
+
body: JSON request body
|
|
199
|
+
token: OAuth access token
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Parsed JSON response
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
Exception: On HTTP or connection errors
|
|
206
|
+
"""
|
|
207
|
+
import httpx
|
|
208
|
+
|
|
209
|
+
url = f"{get_api_base()}{path}"
|
|
210
|
+
with httpx.Client(timeout=timeout) as client:
|
|
211
|
+
response = client.post(
|
|
212
|
+
url,
|
|
213
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
214
|
+
json=body,
|
|
215
|
+
)
|
|
216
|
+
response.raise_for_status()
|
|
217
|
+
return response.json()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DispatchToolError(Exception):
|
|
221
|
+
"""A dispatch handler answered ``{"error": ...}`` (with HTTP 200).
|
|
222
|
+
|
|
223
|
+
The handlers report domain failures - "Metric 'x' not found", a role the
|
|
224
|
+
user lacks, an expression that does not parse - in the body rather than as
|
|
225
|
+
a status code, so the wire shape is the same on MCP and here. Raised by
|
|
226
|
+
`api_dispatch` so a command handles it on the same path as an HTTP error.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, payload: dict):
|
|
230
|
+
self.payload = payload
|
|
231
|
+
self.code = payload.get("code")
|
|
232
|
+
self.hint = payload.get("suggestion") or payload.get("hint")
|
|
233
|
+
super().__init__(str(payload.get("error")))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def api_dispatch(tool_key: str, body: dict, token: str, timeout: float = 60.0) -> dict:
|
|
237
|
+
"""``POST /api/dispatch/{tool_key}`` and unwrap the handler's answer.
|
|
238
|
+
|
|
239
|
+
Action families take ``{"action": {"action": "<verb>", ...}}``; flat tools
|
|
240
|
+
take their keyword arguments as the body. A ``{"error": ...}`` answer
|
|
241
|
+
becomes `DispatchToolError`; HTTP 400/403/404 arrive as
|
|
242
|
+
``httpx.HTTPStatusError`` from `api_post`. `format_api_error` turns either
|
|
243
|
+
into a message the user can act on.
|
|
244
|
+
"""
|
|
245
|
+
result = api_post(f"/api/dispatch/{tool_key}", body, token, timeout=timeout)
|
|
246
|
+
if isinstance(result, dict) and result.get("error"):
|
|
247
|
+
raise DispatchToolError(result)
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _response_detail(response) -> str | None:
|
|
252
|
+
"""The server's ``detail`` as one line, whatever shape it came in.
|
|
253
|
+
|
|
254
|
+
FastAPI's own errors carry a string; the dispatch route answers a
|
|
255
|
+
malformed action with pydantic's list of ``{loc, msg}``; ``/query`` used
|
|
256
|
+
to answer ``{"message": ..., "suggestions": [...]}``.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
body = response.json()
|
|
260
|
+
except Exception:
|
|
261
|
+
return None
|
|
262
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
263
|
+
if detail is None:
|
|
264
|
+
return None
|
|
265
|
+
if isinstance(detail, dict):
|
|
266
|
+
return str(detail.get("message") or detail)
|
|
267
|
+
if isinstance(detail, list):
|
|
268
|
+
parts = []
|
|
269
|
+
for err in detail:
|
|
270
|
+
if isinstance(err, dict):
|
|
271
|
+
loc = ".".join(str(p) for p in err.get("loc", []) if p != "body")
|
|
272
|
+
parts.append(f"{loc}: {err.get('msg')}" if loc else str(err.get("msg")))
|
|
273
|
+
else:
|
|
274
|
+
parts.append(str(err))
|
|
275
|
+
return "; ".join(parts)
|
|
276
|
+
return str(detail)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def format_api_error(exc: Exception, role_action: str | None = None) -> tuple[str, str | None]:
|
|
280
|
+
"""Turn an exception from an api_* call into (message, hint).
|
|
281
|
+
|
|
282
|
+
Pulls the server's ``detail`` field out of an HTTPStatusError when present so
|
|
283
|
+
the user sees the actual error rather than a generic httpx repr. The
|
|
284
|
+
``role_action`` hint (e.g. ``"delete metrics"``) is only attached to 403
|
|
285
|
+
responses; on other failures the hint is omitted so we don't gaslight the
|
|
286
|
+
user into thinking it's a permission problem. A `DispatchToolError` carries
|
|
287
|
+
the handler's own message and, when it offered one, its suggestion.
|
|
288
|
+
"""
|
|
289
|
+
import httpx
|
|
290
|
+
|
|
291
|
+
if isinstance(exc, DispatchToolError):
|
|
292
|
+
return str(exc), exc.hint
|
|
293
|
+
|
|
294
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
295
|
+
status = exc.response.status_code
|
|
296
|
+
detail = _response_detail(exc.response)
|
|
297
|
+
message = detail if detail else str(exc)
|
|
298
|
+
if status == 401:
|
|
299
|
+
hint = "Your login was rejected - run 'dataspring login' again."
|
|
300
|
+
elif status == 403:
|
|
301
|
+
hint = (
|
|
302
|
+
f"You need a higher role to {role_action}." if role_action
|
|
303
|
+
else "You don't have permission for this action."
|
|
304
|
+
)
|
|
305
|
+
elif status == 404:
|
|
306
|
+
hint = None # message already says "not found"
|
|
307
|
+
elif status >= 500:
|
|
308
|
+
hint = "Server error — check backend logs or try again."
|
|
309
|
+
else:
|
|
310
|
+
hint = None
|
|
311
|
+
return message, hint
|
|
312
|
+
|
|
313
|
+
if isinstance(exc, httpx.ConnectError):
|
|
314
|
+
return (
|
|
315
|
+
f"Could not connect to {get_api_base()}",
|
|
316
|
+
"Check MCP_SERVER_BASE_URL or your network.",
|
|
317
|
+
)
|
|
318
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
319
|
+
return "Request timed out", "Try again, or narrow the request."
|
|
320
|
+
|
|
321
|
+
return str(exc), None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def fail(
|
|
325
|
+
exc: BaseException, role_action: str | None = None, *, prefix: str | None = None
|
|
326
|
+
) -> NoReturn:
|
|
327
|
+
"""Report a failed API call and exit 1.
|
|
328
|
+
|
|
329
|
+
`typer.Exit` / `typer.Abort` pass through untouched, so a command can keep
|
|
330
|
+
"Cancelled" (exit 0) inside the same ``try`` as its API calls.
|
|
331
|
+
"""
|
|
332
|
+
if isinstance(exc, (typer.Exit, typer.Abort)):
|
|
333
|
+
raise exc
|
|
334
|
+
msg, hint = format_api_error(exc, role_action) # type: ignore[arg-type]
|
|
335
|
+
print_error(f"{prefix}: {msg}" if prefix else msg, hint=hint)
|
|
336
|
+
raise typer.Exit(1)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def load_definition_file(file: Path) -> dict:
|
|
340
|
+
"""Load a metric/model definition from a YAML or JSON file.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
file: Path to the definition file
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Parsed definition dictionary
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
typer.Exit: If file not found or parsing fails
|
|
350
|
+
"""
|
|
351
|
+
if not file.exists():
|
|
352
|
+
print_error(f"File not found: {file}")
|
|
353
|
+
raise typer.Exit(1)
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
content = file.read_text()
|
|
357
|
+
if file.suffix in (".yaml", ".yml"):
|
|
358
|
+
return yaml.safe_load(content)
|
|
359
|
+
return json.loads(content)
|
|
360
|
+
except yaml.YAMLError as e:
|
|
361
|
+
print_error(f"Invalid YAML: {e}")
|
|
362
|
+
raise typer.Exit(1)
|
|
363
|
+
except json.JSONDecodeError as e:
|
|
364
|
+
print_error(f"Invalid JSON: {e}")
|
|
365
|
+
raise typer.Exit(1)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ============================================================================
|
|
369
|
+
# Top-level commands
|
|
370
|
+
# ============================================================================
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _print_version_report() -> None:
|
|
374
|
+
"""The version, and the manifest skew against the server when logged in."""
|
|
375
|
+
from cli.version import version_report
|
|
376
|
+
|
|
377
|
+
token = auth.get_access_token() if auth.is_logged_in() else None
|
|
378
|
+
try:
|
|
379
|
+
lines = version_report(base=get_api_base(), token=token)
|
|
380
|
+
except AuthenticationError:
|
|
381
|
+
lines = version_report(base=get_api_base(), token=None)
|
|
382
|
+
console.print(f"[bold]DataSpring CLI[/] v{lines[0].split(' ', 1)[1]}")
|
|
383
|
+
for line in lines[1:]:
|
|
384
|
+
console.print(line)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _version_option(value: bool) -> None:
|
|
388
|
+
if value:
|
|
389
|
+
_print_version_report()
|
|
390
|
+
raise typer.Exit()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.callback()
|
|
394
|
+
def _root(
|
|
395
|
+
version: Annotated[
|
|
396
|
+
Optional[bool],
|
|
397
|
+
typer.Option("--version", callback=_version_option, is_eager=True, help="Print the version and exit"),
|
|
398
|
+
] = None,
|
|
399
|
+
):
|
|
400
|
+
"""DataSpring CLI - Query metrics and manage dashboards from the terminal."""
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@app.command()
|
|
404
|
+
def version():
|
|
405
|
+
"""Show the CLI version and, when logged in, its skew against the server."""
|
|
406
|
+
_print_version_report()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.command()
|
|
410
|
+
def upgrade(
|
|
411
|
+
check: Annotated[bool, typer.Option("--check", help="Only compare with PyPI's latest; change nothing")] = False,
|
|
412
|
+
):
|
|
413
|
+
"""Upgrade the CLI in place (uv tool, pipx or pip, whichever installed it)."""
|
|
414
|
+
import subprocess
|
|
415
|
+
|
|
416
|
+
from cli import upgrade as up
|
|
417
|
+
|
|
418
|
+
current = up.cli_version()
|
|
419
|
+
console.print(f"installed: {up.DIST} {current}")
|
|
420
|
+
try:
|
|
421
|
+
latest = up.latest_pypi_version()
|
|
422
|
+
except Exception as e:
|
|
423
|
+
print_error(f"Could not read PyPI: {e}")
|
|
424
|
+
raise typer.Exit(1)
|
|
425
|
+
console.print(f"latest on PyPI: {latest}")
|
|
426
|
+
if not up.is_newer(latest, current):
|
|
427
|
+
print_success("Already at the latest version")
|
|
428
|
+
return
|
|
429
|
+
if check:
|
|
430
|
+
console.print("an upgrade is available; run `dataspring upgrade`")
|
|
431
|
+
return
|
|
432
|
+
method = up.detect_install_method()
|
|
433
|
+
command = up.upgrade_command(method)
|
|
434
|
+
console.print(f"upgrading via {method}: {' '.join(command)}")
|
|
435
|
+
result = subprocess.run(command, check=False)
|
|
436
|
+
if result.returncode != 0:
|
|
437
|
+
print_error(f"upgrade command exited {result.returncode}")
|
|
438
|
+
raise typer.Exit(1)
|
|
439
|
+
after = up.installed_version_after_upgrade()
|
|
440
|
+
print_success(f"{up.DIST} {current} -> {after}")
|
|
441
|
+
if after == current:
|
|
442
|
+
print_warning("the version did not change; the installer may have used another environment")
|
|
443
|
+
console.print("run `dataspring skills install --all` to bring the installed skills up to date")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@app.command()
|
|
447
|
+
def login():
|
|
448
|
+
"""Login via Google OAuth.
|
|
449
|
+
|
|
450
|
+
Opens a browser window to authenticate with Google.
|
|
451
|
+
Your credentials are stored locally in ~/.dataspring/credentials.json.
|
|
452
|
+
"""
|
|
453
|
+
if auth.is_logged_in():
|
|
454
|
+
email = auth.get_stored_email()
|
|
455
|
+
print_info(f"Already logged in as {email}")
|
|
456
|
+
print_info("Run 'dataspring logout' first to switch accounts")
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
user = run_async(auth.login())
|
|
461
|
+
print_success(f"Logged in as {user.email}")
|
|
462
|
+
print_info(f"Organization: {user.org_id} ({user.role})")
|
|
463
|
+
except AuthenticationError as e:
|
|
464
|
+
print_error(str(e))
|
|
465
|
+
raise typer.Exit(1)
|
|
466
|
+
except Exception as e:
|
|
467
|
+
print_error(f"Login failed: {e}")
|
|
468
|
+
raise typer.Exit(1)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@app.command()
|
|
472
|
+
def logout():
|
|
473
|
+
"""Logout and clear stored credentials."""
|
|
474
|
+
if not auth.is_logged_in():
|
|
475
|
+
print_info("Not logged in")
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
email = auth.get_stored_email()
|
|
479
|
+
run_async(auth.logout())
|
|
480
|
+
print_success(f"Logged out {email}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def whoami():
|
|
485
|
+
"""Show current user and organization."""
|
|
486
|
+
user = require_auth()
|
|
487
|
+
print_user_context(
|
|
488
|
+
email=user.email,
|
|
489
|
+
org_id=user.org_id,
|
|
490
|
+
role=user.role,
|
|
491
|
+
org_name=user.org_name,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@app.command()
|
|
496
|
+
def report(
|
|
497
|
+
command: Annotated[
|
|
498
|
+
str,
|
|
499
|
+
typer.Argument(help="The CLI command that failed (e.g. 'query', 'dashboards list')"),
|
|
500
|
+
],
|
|
501
|
+
error_message: Annotated[
|
|
502
|
+
str,
|
|
503
|
+
typer.Argument(help="The exact error message you received"),
|
|
504
|
+
],
|
|
505
|
+
expected: Annotated[
|
|
506
|
+
Optional[str],
|
|
507
|
+
typer.Option("--expected", "-e", help="What you expected to happen"),
|
|
508
|
+
] = None,
|
|
509
|
+
context: Annotated[
|
|
510
|
+
Optional[str],
|
|
511
|
+
typer.Option("--context", "-c", help="What you were trying to achieve"),
|
|
512
|
+
] = None,
|
|
513
|
+
):
|
|
514
|
+
"""Submit an error report to help improve DataSpring.
|
|
515
|
+
|
|
516
|
+
Examples:
|
|
517
|
+
dataspring report "query" "Permission denied" --expected "Query should succeed" --context "Querying revenue metric"
|
|
518
|
+
dataspring report "dashboards list" "Connection timeout"
|
|
519
|
+
"""
|
|
520
|
+
token = auth.get_access_token()
|
|
521
|
+
|
|
522
|
+
from importlib.metadata import version as get_version
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
cli_version = get_version("dataspring-cli")
|
|
526
|
+
except Exception:
|
|
527
|
+
cli_version = "unknown"
|
|
528
|
+
|
|
529
|
+
# The handler has no cli_version field; keep the version in the context
|
|
530
|
+
# line so triage still sees which build produced the report.
|
|
531
|
+
context_info = f"{context} (cli {cli_version})" if context else f"cli {cli_version}"
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
result = call_tool(
|
|
535
|
+
"submit_error_report",
|
|
536
|
+
None,
|
|
537
|
+
{
|
|
538
|
+
"command": command,
|
|
539
|
+
"error_message": error_message,
|
|
540
|
+
"expected": expected,
|
|
541
|
+
"context_info": context_info,
|
|
542
|
+
"source": "cli",
|
|
543
|
+
},
|
|
544
|
+
token=token,
|
|
545
|
+
timeout=15.0,
|
|
546
|
+
)
|
|
547
|
+
print_success(f"Error report submitted (ID: {result['id']})")
|
|
548
|
+
print_info("Thank you for the feedback.")
|
|
549
|
+
except Exception as e:
|
|
550
|
+
fail(e, prefix="Failed to submit report")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@app.command()
|
|
554
|
+
def docs(
|
|
555
|
+
full: Annotated[
|
|
556
|
+
bool,
|
|
557
|
+
typer.Option("--full", help="Show full documentation (llms-full.txt)"),
|
|
558
|
+
] = False,
|
|
559
|
+
raw: Annotated[
|
|
560
|
+
bool,
|
|
561
|
+
typer.Option("--raw", help="Output raw markdown without formatting"),
|
|
562
|
+
] = False,
|
|
563
|
+
):
|
|
564
|
+
"""Show DataSpring documentation for LLMs.
|
|
565
|
+
|
|
566
|
+
Fetches documentation from dataspring.app designed for LLM consumption.
|
|
567
|
+
Use this to understand DataSpring's capabilities, API, and CLI commands.
|
|
568
|
+
|
|
569
|
+
Examples:
|
|
570
|
+
dataspring docs # Show navigation/summary
|
|
571
|
+
dataspring docs --full # Show complete documentation
|
|
572
|
+
dataspring docs --full --raw # Raw markdown for piping
|
|
573
|
+
"""
|
|
574
|
+
import httpx
|
|
575
|
+
from rich.markdown import Markdown
|
|
576
|
+
|
|
577
|
+
url = "https://dataspring.app/llms-full.txt" if full else "https://dataspring.app/llms.txt"
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
with httpx.Client(timeout=30.0) as client:
|
|
581
|
+
response = client.get(url)
|
|
582
|
+
response.raise_for_status()
|
|
583
|
+
content = response.text
|
|
584
|
+
|
|
585
|
+
if raw:
|
|
586
|
+
print(content)
|
|
587
|
+
else:
|
|
588
|
+
console.print(Markdown(content))
|
|
589
|
+
|
|
590
|
+
except httpx.HTTPStatusError as e:
|
|
591
|
+
print_error(f"Failed to fetch documentation: HTTP {e.response.status_code}")
|
|
592
|
+
raise typer.Exit(1)
|
|
593
|
+
except httpx.RequestError as e:
|
|
594
|
+
print_error(f"Failed to fetch documentation: {e}")
|
|
595
|
+
print_info("Check your internet connection or try again later")
|
|
596
|
+
raise typer.Exit(1)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _query_body(
|
|
600
|
+
metrics: list[str],
|
|
601
|
+
dimensions: Optional[list[str]] = None,
|
|
602
|
+
grain: Optional[str] = None,
|
|
603
|
+
start_date: Optional[str] = None,
|
|
604
|
+
end_date: Optional[str] = None,
|
|
605
|
+
limit: Optional[int] = None,
|
|
606
|
+
order_by: Optional[str] = None,
|
|
607
|
+
warehouse: Optional[str] = None,
|
|
608
|
+
) -> dict:
|
|
609
|
+
"""The keyword arguments of the ``query_metrics`` / ``export_data`` tools.
|
|
610
|
+
|
|
611
|
+
Only what the user asked for goes on the wire, so the server's defaults
|
|
612
|
+
(and the user's stored query preferences) apply to the rest.
|
|
613
|
+
"""
|
|
614
|
+
body: dict = {"metrics": metrics}
|
|
615
|
+
if dimensions:
|
|
616
|
+
body["dimensions"] = dimensions
|
|
617
|
+
if grain:
|
|
618
|
+
body["grain"] = grain
|
|
619
|
+
if start_date:
|
|
620
|
+
body["start_date"] = start_date
|
|
621
|
+
if end_date:
|
|
622
|
+
body["end_date"] = end_date
|
|
623
|
+
if limit:
|
|
624
|
+
body["limit"] = limit
|
|
625
|
+
if order_by:
|
|
626
|
+
body["order_by"] = order_by
|
|
627
|
+
if warehouse:
|
|
628
|
+
body["warehouse"] = warehouse
|
|
629
|
+
return body
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _print_query_notes(result: dict) -> None:
|
|
633
|
+
"""What the server folded into the query, so a number is never shown bare."""
|
|
634
|
+
for applied in result.get("applied_defaults") or []:
|
|
635
|
+
text = applied.get("echo_text") if isinstance(applied, dict) else None
|
|
636
|
+
print_info(text or f"Applied a saved preference: {applied}")
|
|
637
|
+
for note in result.get("notes") or []:
|
|
638
|
+
console.print(f"[dim]{note}[/]")
|
|
639
|
+
if result.get("hint"):
|
|
640
|
+
print_info(result["hint"])
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@app.command()
|
|
644
|
+
def query(
|
|
645
|
+
metrics: Annotated[
|
|
646
|
+
list[str],
|
|
647
|
+
typer.Option("-m", "--metrics", help="Metric names to query"),
|
|
648
|
+
],
|
|
649
|
+
dimensions: Annotated[
|
|
650
|
+
Optional[list[str]],
|
|
651
|
+
typer.Option("-d", "--dimensions", help="Dimensions to group by (use qualified names, e.g. customer__region)"),
|
|
652
|
+
] = None,
|
|
653
|
+
grain: Annotated[
|
|
654
|
+
Optional[str],
|
|
655
|
+
typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year"),
|
|
656
|
+
] = None,
|
|
657
|
+
start_date: Annotated[
|
|
658
|
+
Optional[str],
|
|
659
|
+
typer.Option("--start", help="Start date (YYYY-MM-DD)"),
|
|
660
|
+
] = None,
|
|
661
|
+
end_date: Annotated[
|
|
662
|
+
Optional[str],
|
|
663
|
+
typer.Option("--end", help="End date (YYYY-MM-DD)"),
|
|
664
|
+
] = None,
|
|
665
|
+
limit: Annotated[
|
|
666
|
+
Optional[int],
|
|
667
|
+
typer.Option("--limit", help="Maximum rows to return"),
|
|
668
|
+
] = None,
|
|
669
|
+
order_by: Annotated[
|
|
670
|
+
Optional[str],
|
|
671
|
+
typer.Option("--order-by", help="Column to sort by (append ' desc' for descending)"),
|
|
672
|
+
] = None,
|
|
673
|
+
suggest_viz: Annotated[
|
|
674
|
+
bool,
|
|
675
|
+
typer.Option("--suggest-viz", help="Suggest a visualization type"),
|
|
676
|
+
] = False,
|
|
677
|
+
warehouse: Annotated[
|
|
678
|
+
Optional[str],
|
|
679
|
+
typer.Option("--warehouse", help="Which of the org's warehouses to query (see `warehouses list`); default: the active one"),
|
|
680
|
+
] = None,
|
|
681
|
+
format: Annotated[
|
|
682
|
+
OutputFormat,
|
|
683
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
684
|
+
] = "table",
|
|
685
|
+
):
|
|
686
|
+
"""Query metrics from your data warehouse.
|
|
687
|
+
|
|
688
|
+
Examples:
|
|
689
|
+
dataspring query -m total_revenue -g month
|
|
690
|
+
dataspring query -m revenue -d region --limit 10
|
|
691
|
+
dataspring query -m revenue -m orders --start 2024-01-01 --end 2024-12-31
|
|
692
|
+
dataspring query -m total_revenue -g month --warehouse managed
|
|
693
|
+
"""
|
|
694
|
+
require_auth()
|
|
695
|
+
token = auth.get_access_token()
|
|
696
|
+
|
|
697
|
+
body = _query_body(
|
|
698
|
+
split_commas(metrics) or [],
|
|
699
|
+
split_commas(dimensions),
|
|
700
|
+
grain,
|
|
701
|
+
start_date,
|
|
702
|
+
end_date,
|
|
703
|
+
limit,
|
|
704
|
+
order_by,
|
|
705
|
+
warehouse,
|
|
706
|
+
)
|
|
707
|
+
if suggest_viz:
|
|
708
|
+
body["suggest_visualization"] = True
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
result = call_tool("query_metrics", None, body, token=token, timeout=120.0)
|
|
712
|
+
except Exception as e:
|
|
713
|
+
if isinstance(e, (typer.Exit, typer.Abort)):
|
|
714
|
+
raise
|
|
715
|
+
msg, hint = format_api_error(e)
|
|
716
|
+
print_error(f"Query failed: {msg}", hint=hint)
|
|
717
|
+
print_report_hint("query", msg)
|
|
718
|
+
raise typer.Exit(1)
|
|
719
|
+
|
|
720
|
+
data = result.get("data", [])
|
|
721
|
+
columns = result.get("columns", [])
|
|
722
|
+
|
|
723
|
+
format_output(
|
|
724
|
+
data,
|
|
725
|
+
format=format,
|
|
726
|
+
columns=columns,
|
|
727
|
+
title=f"Query Results ({len(data)} rows)",
|
|
728
|
+
)
|
|
729
|
+
if format == "table":
|
|
730
|
+
_print_query_notes(result)
|
|
731
|
+
viz = result.get("visualization")
|
|
732
|
+
if suggest_viz and isinstance(viz, dict):
|
|
733
|
+
print_visualization_suggestion(
|
|
734
|
+
viz.get("widget_type", "?"), viz.get("rationale", "")
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@app.command()
|
|
739
|
+
def export(
|
|
740
|
+
output: Annotated[
|
|
741
|
+
Path,
|
|
742
|
+
typer.Option("--output", "-o", help="Output file path (required)"),
|
|
743
|
+
],
|
|
744
|
+
metrics: Annotated[
|
|
745
|
+
Optional[list[str]],
|
|
746
|
+
typer.Option("-m", "--metrics", help="Metric names to export"),
|
|
747
|
+
] = None,
|
|
748
|
+
dimensions: Annotated[
|
|
749
|
+
Optional[list[str]],
|
|
750
|
+
typer.Option("-d", "--dimensions", help="Dimensions to group by"),
|
|
751
|
+
] = None,
|
|
752
|
+
grain: Annotated[
|
|
753
|
+
Optional[str],
|
|
754
|
+
typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year"),
|
|
755
|
+
] = None,
|
|
756
|
+
start_date: Annotated[
|
|
757
|
+
Optional[str],
|
|
758
|
+
typer.Option("--start", help="Start date (YYYY-MM-DD)"),
|
|
759
|
+
] = None,
|
|
760
|
+
end_date: Annotated[
|
|
761
|
+
Optional[str],
|
|
762
|
+
typer.Option("--end", help="End date (YYYY-MM-DD)"),
|
|
763
|
+
] = None,
|
|
764
|
+
dashboard: Annotated[
|
|
765
|
+
Optional[str],
|
|
766
|
+
typer.Option("--dashboard", help="Dashboard ID to export (exports all widget data)"),
|
|
767
|
+
] = None,
|
|
768
|
+
warehouse: Annotated[
|
|
769
|
+
Optional[str],
|
|
770
|
+
typer.Option("--warehouse", help="Which of the org's warehouses to query (metrics export); default: the active one"),
|
|
771
|
+
] = None,
|
|
772
|
+
format: Annotated[
|
|
773
|
+
str,
|
|
774
|
+
typer.Option("--format", "-f", help="Output format: csv or json"),
|
|
775
|
+
] = "csv",
|
|
776
|
+
):
|
|
777
|
+
"""Export query results or dashboard data to CSV or JSON.
|
|
778
|
+
|
|
779
|
+
You can either:
|
|
780
|
+
- Export query results by specifying metrics
|
|
781
|
+
- Export all data from a dashboard by specifying --dashboard
|
|
782
|
+
|
|
783
|
+
Examples:
|
|
784
|
+
dataspring export -o revenue.csv -m total_revenue -g month
|
|
785
|
+
dataspring export -o sales.json -m revenue -m orders -f json
|
|
786
|
+
dataspring export -o dashboard.csv --dashboard abc123
|
|
787
|
+
dataspring export -o report.csv -m revenue -d region --start 2024-01-01
|
|
788
|
+
"""
|
|
789
|
+
require_auth()
|
|
790
|
+
|
|
791
|
+
metrics = split_commas(metrics)
|
|
792
|
+
dimensions = split_commas(dimensions)
|
|
793
|
+
|
|
794
|
+
# Validate format
|
|
795
|
+
format_lower = format.lower()
|
|
796
|
+
if format_lower not in ("csv", "json"):
|
|
797
|
+
print_error(
|
|
798
|
+
f"Invalid format: {format}",
|
|
799
|
+
hint="Use 'csv' or 'json'",
|
|
800
|
+
)
|
|
801
|
+
raise typer.Exit(1)
|
|
802
|
+
|
|
803
|
+
# Ensure output has correct extension
|
|
804
|
+
output_path = output
|
|
805
|
+
expected_ext = f".{format_lower}"
|
|
806
|
+
if output_path.suffix.lower() != expected_ext:
|
|
807
|
+
output_path = output_path.with_suffix(expected_ext)
|
|
808
|
+
print_warning(f"Output file extension adjusted to {output_path.name}")
|
|
809
|
+
|
|
810
|
+
# Must have either metrics or dashboard
|
|
811
|
+
if not metrics and not dashboard:
|
|
812
|
+
print_error(
|
|
813
|
+
"Either --metrics or --dashboard must be provided",
|
|
814
|
+
hint="Use -m metric_name or --dashboard dashboard_id",
|
|
815
|
+
)
|
|
816
|
+
raise typer.Exit(1)
|
|
817
|
+
|
|
818
|
+
token = auth.get_access_token()
|
|
819
|
+
|
|
820
|
+
if dashboard:
|
|
821
|
+
body: dict = {"format": format_lower, "dashboard_id": dashboard}
|
|
822
|
+
print_info(f"Exporting data from dashboard {dashboard}...")
|
|
823
|
+
else:
|
|
824
|
+
body = {
|
|
825
|
+
"format": format_lower,
|
|
826
|
+
**_query_body(metrics or [], dimensions, grain, start_date, end_date, warehouse=warehouse),
|
|
827
|
+
}
|
|
828
|
+
print_info(f"Exporting metrics: {', '.join(metrics or [])}...")
|
|
829
|
+
|
|
830
|
+
try:
|
|
831
|
+
result = call_tool("export_data", None, body, token=token, timeout=120.0)
|
|
832
|
+
except Exception as e:
|
|
833
|
+
if isinstance(e, (typer.Exit, typer.Abort)):
|
|
834
|
+
raise
|
|
835
|
+
msg, hint = format_api_error(e)
|
|
836
|
+
print_error(f"Export failed: {msg}", hint=hint)
|
|
837
|
+
print_report_hint("export", msg)
|
|
838
|
+
raise typer.Exit(1)
|
|
839
|
+
|
|
840
|
+
# The server renders the CSV/JSON; the CLI only writes the bytes it got.
|
|
841
|
+
output_path.write_text(result.get("content") or "", encoding="utf-8")
|
|
842
|
+
|
|
843
|
+
row_count = result.get("row_count", 0)
|
|
844
|
+
columns = result.get("columns") or []
|
|
845
|
+
print_success(f"Exported {row_count} rows to {output_path}")
|
|
846
|
+
if columns:
|
|
847
|
+
print_info(f"Columns: {', '.join(columns)}")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# ============================================================================
|
|
851
|
+
# Metrics sub-commands
|
|
852
|
+
# ============================================================================
|
|
853
|
+
#
|
|
854
|
+
# ``list`` and ``show`` read ``GET /api/semantic-layer/metrics[/{name}]``.
|
|
855
|
+
# Writes are the generated ``dataspring metric_edit create|update|delete|
|
|
856
|
+
# preview|impact`` (``--metric-data-json @metric.yaml``), so the ownership
|
|
857
|
+
# rules (dbt owns computation, DataSpring owns text), the confirmation gate
|
|
858
|
+
# for redefining a native metric and the learned-trail entry all happen on
|
|
859
|
+
# the server and the flags can never lag the model.
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@metrics_app.command("list")
|
|
863
|
+
def metrics_list(
|
|
864
|
+
format: Annotated[
|
|
865
|
+
OutputFormat,
|
|
866
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
867
|
+
] = "table",
|
|
868
|
+
):
|
|
869
|
+
"""List available metrics."""
|
|
870
|
+
require_auth()
|
|
871
|
+
token = auth.get_access_token()
|
|
872
|
+
|
|
873
|
+
try:
|
|
874
|
+
result = api_get("/api/semantic-layer/metrics", token)
|
|
875
|
+
except Exception as e:
|
|
876
|
+
fail(e, role_action="list metrics")
|
|
877
|
+
|
|
878
|
+
format_output(
|
|
879
|
+
result.get("metrics", []),
|
|
880
|
+
format=format,
|
|
881
|
+
columns=["name", "type", "description"],
|
|
882
|
+
title="Available Metrics",
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
@metrics_app.command("show")
|
|
887
|
+
def metrics_show(
|
|
888
|
+
name: Annotated[str, typer.Argument(help="Metric name")],
|
|
889
|
+
format: Annotated[
|
|
890
|
+
OutputFormat,
|
|
891
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
892
|
+
] = "table",
|
|
893
|
+
):
|
|
894
|
+
"""Show details for a specific metric."""
|
|
895
|
+
require_auth()
|
|
896
|
+
token = auth.get_access_token()
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
metric = api_get(f"/api/semantic-layer/metrics/{name}", token)
|
|
900
|
+
except Exception as e:
|
|
901
|
+
fail(e, role_action="view metrics")
|
|
902
|
+
|
|
903
|
+
format_output(metric, format=format, title=f"Metric: {name}")
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# ============================================================================
|
|
907
|
+
# Dimensions sub-commands
|
|
908
|
+
# ============================================================================
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@dimensions_app.command("list")
|
|
912
|
+
def dimensions_list(
|
|
913
|
+
format: Annotated[
|
|
914
|
+
OutputFormat,
|
|
915
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
916
|
+
] = "table",
|
|
917
|
+
):
|
|
918
|
+
"""List available dimensions."""
|
|
919
|
+
user = require_auth()
|
|
920
|
+
token = auth.get_access_token()
|
|
921
|
+
|
|
922
|
+
try:
|
|
923
|
+
result = api_get("/api/semantic-layer/dimensions", token)
|
|
924
|
+
dimensions = result.get("dimensions", [])
|
|
925
|
+
format_output(
|
|
926
|
+
dimensions,
|
|
927
|
+
format=format,
|
|
928
|
+
columns=["name"],
|
|
929
|
+
title="Available Dimensions",
|
|
930
|
+
)
|
|
931
|
+
except Exception as e:
|
|
932
|
+
print_error(str(e))
|
|
933
|
+
raise typer.Exit(1)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# ============================================================================
|
|
937
|
+
# Org sub-commands
|
|
938
|
+
# ============================================================================
|
|
939
|
+
#
|
|
940
|
+
# ``switch`` is the ``switch_organization`` dispatch tool: the server resolves
|
|
941
|
+
# the caller from the bearer token and writes only that user's document.
|
|
942
|
+
# Before this, the CLI called `services.auth.switch_org(email, org_id)` with
|
|
943
|
+
# the e-mail from a user-editable config file, over the operator's own
|
|
944
|
+
# Firestore credentials - anyone with ADC could switch another user's org.
|
|
945
|
+
# ``list`` reads ``GET /api/me/organizations``, the REST twin of the
|
|
946
|
+
# ``dataspring://organizations`` resource; ``/api/me`` carries only the
|
|
947
|
+
# current org.
|
|
948
|
+
|
|
949
|
+
#: The read route for the caller's organizations.
|
|
950
|
+
ORGANIZATIONS_PATH = "/api/me/organizations"
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _orgs_from(result) -> list[dict]:
|
|
954
|
+
"""A bare list or an ``{"organizations": [...]}`` envelope, either way."""
|
|
955
|
+
if isinstance(result, dict):
|
|
956
|
+
return result.get("organizations") or []
|
|
957
|
+
return list(result or [])
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@org_app.command("list")
|
|
961
|
+
def org_list(
|
|
962
|
+
format: Annotated[
|
|
963
|
+
OutputFormat,
|
|
964
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
965
|
+
] = "table",
|
|
966
|
+
):
|
|
967
|
+
"""List organizations you belong to."""
|
|
968
|
+
if not auth.is_logged_in():
|
|
969
|
+
print_error("Not logged in", hint="Run 'dataspring login' first")
|
|
970
|
+
raise typer.Exit(1)
|
|
971
|
+
|
|
972
|
+
token = auth.get_access_token()
|
|
973
|
+
|
|
974
|
+
try:
|
|
975
|
+
orgs = _orgs_from(api_get(ORGANIZATIONS_PATH, token))
|
|
976
|
+
except Exception as e:
|
|
977
|
+
fail(e, role_action="list your organizations")
|
|
978
|
+
|
|
979
|
+
format_output(
|
|
980
|
+
orgs,
|
|
981
|
+
format=format,
|
|
982
|
+
columns=["id", "name", "role", "current"],
|
|
983
|
+
title="Your Organizations",
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
@org_app.command("switch")
|
|
988
|
+
def org_switch(
|
|
989
|
+
org_id: Annotated[str, typer.Argument(help="Organization ID to switch to")],
|
|
990
|
+
):
|
|
991
|
+
"""Switch to a different organization."""
|
|
992
|
+
if not auth.is_logged_in():
|
|
993
|
+
print_error("Not logged in", hint="Run 'dataspring login' first")
|
|
994
|
+
raise typer.Exit(1)
|
|
995
|
+
|
|
996
|
+
token = auth.get_access_token()
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
result = call_tool("switch_organization", None, {"org_id": org_id}, token=token)
|
|
1000
|
+
except Exception as e:
|
|
1001
|
+
fail(e, role_action="switch to this organization")
|
|
1002
|
+
|
|
1003
|
+
print_success(f"Switched to organization: {result.get('org_id', org_id)}")
|
|
1004
|
+
print_info(f"Role: {result.get('role')}")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
# `dataspring switch <org>` is the same command at the root (the D19 note's
|
|
1008
|
+
# name for it); `org switch` stays for the hands that know it.
|
|
1009
|
+
app.command("switch")(org_switch)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
# ============================================================================
|
|
1013
|
+
# Dashboards sub-commands
|
|
1014
|
+
# ============================================================================
|
|
1015
|
+
#
|
|
1016
|
+
# Reads: ``GET /api/dashboards`` and ``GET /api/dashboards/{id}``, which answer
|
|
1017
|
+
# with the stored document as-is. Writes are the generated ``dashboard_edit``,
|
|
1018
|
+
# ``widget_edit``, ``page_edit`` and ``section_edit`` groups and the
|
|
1019
|
+
# ``render_dashboard`` / ``render_widget`` commands (``-o file.pdf`` saves
|
|
1020
|
+
# the render). What stays here is what a person wants and the generated
|
|
1021
|
+
# twin cannot be: ``update`` from a file (its whole-definition path is the
|
|
1022
|
+
# one REST write, ``PUT /api/dashboards/{id}``, which no dispatch action
|
|
1023
|
+
# covers), ``add-widget`` building the widget from flags, and the
|
|
1024
|
+
# ``*-widget`` commands that take a widget id where the action wants
|
|
1025
|
+
# page/section/widget indices.
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _get_dashboard(dashboard_id: str, token: str) -> dict:
|
|
1029
|
+
"""``GET /api/dashboards/{id}`` - 404 when missing or not visible to the user."""
|
|
1030
|
+
return api_get(f"/api/dashboards/{dashboard_id}", token)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _owner_label(owner_uid: str | None, user) -> str:
|
|
1034
|
+
""""you" for the caller; otherwise the uid, shortened.
|
|
1035
|
+
|
|
1036
|
+
Display names lived in Firestore and were read with the operator's own
|
|
1037
|
+
credentials; no read-only route exposes them, so the uid stands in.
|
|
1038
|
+
"""
|
|
1039
|
+
if not owner_uid:
|
|
1040
|
+
return "-"
|
|
1041
|
+
if owner_uid == user.uid:
|
|
1042
|
+
return "you"
|
|
1043
|
+
return owner_uid[:8] + "..."
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _page_widgets(page: dict) -> int:
|
|
1047
|
+
return sum(len(section.get("widgets") or []) for section in page.get("sections") or [])
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _page_index(dashboard: dict, page_id: str) -> int | None:
|
|
1051
|
+
for i, page in enumerate(dashboard.get("pages") or []):
|
|
1052
|
+
if page.get("id") == page_id:
|
|
1053
|
+
return i
|
|
1054
|
+
return None
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _find_widget_location(dashboard: dict, widget_id: str) -> tuple[int, int, int] | None:
|
|
1058
|
+
"""(page_index, section_index, widget_index) of a widget, by id."""
|
|
1059
|
+
for p, page in enumerate(dashboard.get("pages") or []):
|
|
1060
|
+
for s, section in enumerate(page.get("sections") or []):
|
|
1061
|
+
for w, widget in enumerate(section.get("widgets") or []):
|
|
1062
|
+
if widget.get("id") == widget_id:
|
|
1063
|
+
return p, s, w
|
|
1064
|
+
return None
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _dashboard_summary(dashboard: dict) -> dict:
|
|
1068
|
+
return {
|
|
1069
|
+
"id": dashboard.get("id"),
|
|
1070
|
+
"title": dashboard.get("title"),
|
|
1071
|
+
"description": dashboard.get("description") or "-",
|
|
1072
|
+
"visibility": dashboard.get("visibility"),
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _print_dashboard_result(result: dict, format: OutputFormat, summary: dict, title: str) -> None:
|
|
1077
|
+
"""The `_ok(dashboard)` shape: full document as JSON, a summary otherwise."""
|
|
1078
|
+
if format == "json":
|
|
1079
|
+
format_output(result.get("dashboard", result), format=format)
|
|
1080
|
+
else:
|
|
1081
|
+
format_output(summary, format=format, title=title)
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
@dashboards_app.command("list")
|
|
1085
|
+
def dashboards_list(
|
|
1086
|
+
format: Annotated[
|
|
1087
|
+
OutputFormat,
|
|
1088
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1089
|
+
] = "table",
|
|
1090
|
+
):
|
|
1091
|
+
"""List your dashboards (personal and team).
|
|
1092
|
+
|
|
1093
|
+
Shows dashboards you own (personal) and dashboards shared with
|
|
1094
|
+
your organization (team).
|
|
1095
|
+
"""
|
|
1096
|
+
user = require_auth()
|
|
1097
|
+
token = auth.get_access_token()
|
|
1098
|
+
|
|
1099
|
+
try:
|
|
1100
|
+
result = api_get("/api/dashboards", token)
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
fail(e, role_action="list dashboards")
|
|
1103
|
+
|
|
1104
|
+
all_dashboards = []
|
|
1105
|
+
for d in result.get("personal") or []:
|
|
1106
|
+
all_dashboards.append({
|
|
1107
|
+
"id": d.get("id"),
|
|
1108
|
+
"title": d.get("title"),
|
|
1109
|
+
"visibility": d.get("visibility"),
|
|
1110
|
+
"owner": "you",
|
|
1111
|
+
"updated_at": d.get("updated_at"),
|
|
1112
|
+
})
|
|
1113
|
+
for d in result.get("team") or []:
|
|
1114
|
+
all_dashboards.append({
|
|
1115
|
+
"id": d.get("id"),
|
|
1116
|
+
"title": d.get("title"),
|
|
1117
|
+
"visibility": d.get("visibility"),
|
|
1118
|
+
"owner": _owner_label(d.get("owner"), user),
|
|
1119
|
+
"updated_at": d.get("updated_at"),
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
if not all_dashboards:
|
|
1123
|
+
print_info("No dashboards found")
|
|
1124
|
+
print_info("Create one with: dataspring dashboards create 'My Dashboard'")
|
|
1125
|
+
return
|
|
1126
|
+
|
|
1127
|
+
format_output(
|
|
1128
|
+
all_dashboards,
|
|
1129
|
+
format=format,
|
|
1130
|
+
columns=["id", "title", "visibility", "owner", "updated_at"],
|
|
1131
|
+
title="Dashboards",
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@dashboards_app.command("show")
|
|
1136
|
+
def dashboards_show(
|
|
1137
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
|
|
1138
|
+
format: Annotated[
|
|
1139
|
+
OutputFormat,
|
|
1140
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1141
|
+
] = "table",
|
|
1142
|
+
):
|
|
1143
|
+
"""Show details for a specific dashboard.
|
|
1144
|
+
|
|
1145
|
+
Displays dashboard metadata and page/widget structure.
|
|
1146
|
+
"""
|
|
1147
|
+
user = require_auth()
|
|
1148
|
+
token = auth.get_access_token()
|
|
1149
|
+
|
|
1150
|
+
try:
|
|
1151
|
+
dashboard = _get_dashboard(dashboard_id, token)
|
|
1152
|
+
except Exception as e:
|
|
1153
|
+
fail(e, role_action="view this dashboard")
|
|
1154
|
+
|
|
1155
|
+
if format == "json":
|
|
1156
|
+
format_output(dashboard, format=format)
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
pages = dashboard.get("pages") or []
|
|
1160
|
+
summary = {
|
|
1161
|
+
"id": dashboard.get("id"),
|
|
1162
|
+
"title": dashboard.get("title"),
|
|
1163
|
+
"description": dashboard.get("description") or "-",
|
|
1164
|
+
"visibility": dashboard.get("visibility"),
|
|
1165
|
+
"owner": _owner_label(dashboard.get("owner"), user),
|
|
1166
|
+
"created_at": dashboard.get("created_at"),
|
|
1167
|
+
"updated_at": dashboard.get("updated_at"),
|
|
1168
|
+
"pages": len(pages),
|
|
1169
|
+
"widgets": sum(_page_widgets(page) for page in pages),
|
|
1170
|
+
}
|
|
1171
|
+
format_output(summary, format=format, title=f"Dashboard: {dashboard.get('title')}")
|
|
1172
|
+
|
|
1173
|
+
if pages:
|
|
1174
|
+
console.print()
|
|
1175
|
+
pages_data = [
|
|
1176
|
+
{
|
|
1177
|
+
"index": i,
|
|
1178
|
+
"title": page.get("title"),
|
|
1179
|
+
"sections": len(page.get("sections") or []),
|
|
1180
|
+
"widgets": _page_widgets(page),
|
|
1181
|
+
}
|
|
1182
|
+
for i, page in enumerate(pages)
|
|
1183
|
+
]
|
|
1184
|
+
format_output(
|
|
1185
|
+
pages_data,
|
|
1186
|
+
format="table",
|
|
1187
|
+
columns=["index", "title", "sections", "widgets"],
|
|
1188
|
+
title="Pages",
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
_DASHBOARD_METADATA_KEYS = {"title", "description", "visibility"}
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
@dashboards_app.command("update")
|
|
1196
|
+
def dashboards_update(
|
|
1197
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID to update")],
|
|
1198
|
+
file: Annotated[
|
|
1199
|
+
Optional[Path],
|
|
1200
|
+
typer.Argument(help="Path to YAML or JSON file with updates (optional)"),
|
|
1201
|
+
] = None,
|
|
1202
|
+
title: Annotated[
|
|
1203
|
+
Optional[str],
|
|
1204
|
+
typer.Option("--title", "-t", help="New dashboard title"),
|
|
1205
|
+
] = None,
|
|
1206
|
+
description: Annotated[
|
|
1207
|
+
Optional[str],
|
|
1208
|
+
typer.Option("--description", "-d", help="New dashboard description"),
|
|
1209
|
+
] = None,
|
|
1210
|
+
visibility: Annotated[
|
|
1211
|
+
Optional[str],
|
|
1212
|
+
typer.Option(
|
|
1213
|
+
"--visibility",
|
|
1214
|
+
"-v",
|
|
1215
|
+
help="New visibility: 'private' (only you) or 'org' (team)",
|
|
1216
|
+
),
|
|
1217
|
+
] = None,
|
|
1218
|
+
format: Annotated[
|
|
1219
|
+
OutputFormat,
|
|
1220
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1221
|
+
] = "table",
|
|
1222
|
+
):
|
|
1223
|
+
"""Update a dashboard's metadata.
|
|
1224
|
+
|
|
1225
|
+
You can update using command-line options, a file, or both.
|
|
1226
|
+
Command-line options override values from the file.
|
|
1227
|
+
|
|
1228
|
+
Examples:
|
|
1229
|
+
dataspring dashboards update abc123 --title 'New Title'
|
|
1230
|
+
dataspring dashboards update abc123 --description 'Updated description'
|
|
1231
|
+
dataspring dashboards update abc123 --visibility org
|
|
1232
|
+
dataspring dashboards update abc123 dashboard.yaml
|
|
1233
|
+
dataspring dashboards update abc123 updates.yaml --title 'Override Title'
|
|
1234
|
+
"""
|
|
1235
|
+
require_auth()
|
|
1236
|
+
|
|
1237
|
+
import uuid
|
|
1238
|
+
|
|
1239
|
+
def ensure_ids(data: dict) -> dict:
|
|
1240
|
+
"""Ensure all pages, sections, and widgets have IDs."""
|
|
1241
|
+
if "pages" in data:
|
|
1242
|
+
for page in data["pages"]:
|
|
1243
|
+
if not page.get("id"): # Missing, None, or empty string
|
|
1244
|
+
page["id"] = str(uuid.uuid4())
|
|
1245
|
+
for section in page.get("sections", []):
|
|
1246
|
+
# Sections don't have IDs by default, but widgets do
|
|
1247
|
+
for widget in section.get("widgets", []):
|
|
1248
|
+
if not widget.get("id"): # Missing, None, or empty string
|
|
1249
|
+
widget["id"] = str(uuid.uuid4())
|
|
1250
|
+
return data
|
|
1251
|
+
|
|
1252
|
+
# Build updates dict
|
|
1253
|
+
updates = {}
|
|
1254
|
+
|
|
1255
|
+
# Load from file if provided
|
|
1256
|
+
if file:
|
|
1257
|
+
file_updates = load_definition_file(file)
|
|
1258
|
+
# Auto-generate IDs for widgets/pages that don't have them
|
|
1259
|
+
file_updates = ensure_ids(file_updates)
|
|
1260
|
+
updates.update(file_updates)
|
|
1261
|
+
|
|
1262
|
+
# Override with command-line options
|
|
1263
|
+
if title is not None:
|
|
1264
|
+
updates["title"] = title
|
|
1265
|
+
if description is not None:
|
|
1266
|
+
updates["description"] = description
|
|
1267
|
+
if visibility is not None:
|
|
1268
|
+
# Validate visibility
|
|
1269
|
+
if visibility not in ("private", "org"):
|
|
1270
|
+
print_error(
|
|
1271
|
+
f"Invalid visibility: {visibility}",
|
|
1272
|
+
hint="Use 'private' or 'org'",
|
|
1273
|
+
)
|
|
1274
|
+
raise typer.Exit(1)
|
|
1275
|
+
updates["visibility"] = visibility
|
|
1276
|
+
|
|
1277
|
+
# Check that we have something to update
|
|
1278
|
+
if not updates:
|
|
1279
|
+
print_error(
|
|
1280
|
+
"No updates provided",
|
|
1281
|
+
hint="Use --title, --description, --visibility, or provide a file",
|
|
1282
|
+
)
|
|
1283
|
+
raise typer.Exit(1)
|
|
1284
|
+
|
|
1285
|
+
token = auth.get_access_token()
|
|
1286
|
+
|
|
1287
|
+
try:
|
|
1288
|
+
if set(updates) <= _DASHBOARD_METADATA_KEYS:
|
|
1289
|
+
result = call_tool("dashboard_edit", "update", {"id": dashboard_id, **updates}, token=token)
|
|
1290
|
+
updated = result.get("dashboard", {})
|
|
1291
|
+
else:
|
|
1292
|
+
# A whole-definition edit (pages, controls, ...) has no dispatch
|
|
1293
|
+
# action; the REST route validates and saves the document.
|
|
1294
|
+
updated = api_put(f"/api/dashboards/{dashboard_id}", updates, token)
|
|
1295
|
+
except Exception as e:
|
|
1296
|
+
fail(e, role_action="update this dashboard")
|
|
1297
|
+
|
|
1298
|
+
print_success(f"Updated dashboard: {updated.get('title')}")
|
|
1299
|
+
format_output(_dashboard_summary(updated), format=format, title="Updated Dashboard")
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
@dashboards_app.command("add-widget")
|
|
1303
|
+
def add_widget_cmd(
|
|
1304
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
|
|
1305
|
+
widget_type: Annotated[
|
|
1306
|
+
str,
|
|
1307
|
+
typer.Option("--type", "-t", help="Widget type: kpi, line_chart, bar_chart, area_chart, donut, table, heatmap"),
|
|
1308
|
+
],
|
|
1309
|
+
title: Annotated[str, typer.Option("--title", help="Widget title")],
|
|
1310
|
+
metrics: Annotated[
|
|
1311
|
+
list[str],
|
|
1312
|
+
typer.Option("-m", "--metrics", help="Metric names to query"),
|
|
1313
|
+
],
|
|
1314
|
+
dimensions: Annotated[
|
|
1315
|
+
Optional[list[str]],
|
|
1316
|
+
typer.Option("-d", "--dimensions", help="Dimensions to group by"),
|
|
1317
|
+
] = None,
|
|
1318
|
+
grain: Annotated[
|
|
1319
|
+
Optional[str],
|
|
1320
|
+
typer.Option("-g", "--grain", help="Time grain: day, week, month, quarter, year, or $grain for dashboard control"),
|
|
1321
|
+
] = None,
|
|
1322
|
+
page: Annotated[
|
|
1323
|
+
int,
|
|
1324
|
+
typer.Option("--page", help="Page index (0-based)"),
|
|
1325
|
+
] = 0,
|
|
1326
|
+
section: Annotated[
|
|
1327
|
+
int,
|
|
1328
|
+
typer.Option("--section", help="Section index (0-based)"),
|
|
1329
|
+
] = 0,
|
|
1330
|
+
width: Annotated[
|
|
1331
|
+
Optional[int],
|
|
1332
|
+
typer.Option("--width", "-w", help="Widget width (1-10). Defaults: kpi=2, donut=3, heatmap=5, charts/tables=10"),
|
|
1333
|
+
] = None,
|
|
1334
|
+
time_scope: Annotated[
|
|
1335
|
+
Optional[str],
|
|
1336
|
+
typer.Option("--time-scope", help="Date scope: range (full), latest (current period), latest_complete (last complete period)"),
|
|
1337
|
+
] = None,
|
|
1338
|
+
pivot_on: Annotated[
|
|
1339
|
+
Optional[str],
|
|
1340
|
+
typer.Option("--pivot-on", help="Pivot dimension (table only): dimension values become columns"),
|
|
1341
|
+
] = None,
|
|
1342
|
+
pivot_totals: Annotated[
|
|
1343
|
+
bool,
|
|
1344
|
+
typer.Option("--pivot-totals", help="Show row/column totals in pivot table"),
|
|
1345
|
+
] = False,
|
|
1346
|
+
stacked: Annotated[
|
|
1347
|
+
bool,
|
|
1348
|
+
typer.Option("--stacked", help="Stack bars by second dimension (bar_chart only)"),
|
|
1349
|
+
] = False,
|
|
1350
|
+
format: Annotated[
|
|
1351
|
+
OutputFormat,
|
|
1352
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1353
|
+
] = "table",
|
|
1354
|
+
):
|
|
1355
|
+
"""Add a new widget to a dashboard.
|
|
1356
|
+
|
|
1357
|
+
Creates a new widget with the specified configuration and adds it
|
|
1358
|
+
to the specified page and section. Uses a 10-column grid layout.
|
|
1359
|
+
|
|
1360
|
+
Default widths by type (if --width not specified):
|
|
1361
|
+
kpi: 2 columns (5 per row)
|
|
1362
|
+
donut: 3 columns (3 per row)
|
|
1363
|
+
heatmap: 5 columns (2 per row)
|
|
1364
|
+
area_chart, line_chart, bar_chart, table: 10 columns (full width)
|
|
1365
|
+
|
|
1366
|
+
Time scope (for non-time-series widgets):
|
|
1367
|
+
range: Use full dashboard date range (default)
|
|
1368
|
+
latest: Current period only (may be incomplete)
|
|
1369
|
+
latest_complete: Last complete period
|
|
1370
|
+
|
|
1371
|
+
Stacked bar charts (bar_chart only):
|
|
1372
|
+
Use --stacked with 2 dimensions: first for x-axis, second for stack segments.
|
|
1373
|
+
|
|
1374
|
+
Pivot tables (table type only):
|
|
1375
|
+
Use --pivot-on to transform dimension values into columns.
|
|
1376
|
+
The pivot dimension must be included in --dimensions.
|
|
1377
|
+
|
|
1378
|
+
Examples:
|
|
1379
|
+
dataspring dashboards add-widget abc123 --type kpi --title "Revenue" -m total_revenue
|
|
1380
|
+
dataspring dashboards add-widget abc123 -t line_chart --title "Trends" -m revenue -g month
|
|
1381
|
+
dataspring dashboards add-widget abc123 -t kpi --title "Last Month" -m revenue --time-scope latest_complete
|
|
1382
|
+
dataspring dashboards add-widget abc123 -t kpi --title "Custom" -m revenue --width 4
|
|
1383
|
+
dataspring dashboards add-widget abc123 -t bar_chart --title "Spend by Platform" \\
|
|
1384
|
+
-m ad_spend -d metric_time -d platform -g month --stacked
|
|
1385
|
+
dataspring dashboards add-widget abc123 -t table --title "Revenue by Region and Category" \\
|
|
1386
|
+
-m revenue -d region -d category --pivot-on category
|
|
1387
|
+
dataspring dashboards add-widget abc123 -t table --title "Monthly Revenue by Region" \\
|
|
1388
|
+
-m revenue -d region -g month --pivot-on region --pivot-totals
|
|
1389
|
+
"""
|
|
1390
|
+
require_auth()
|
|
1391
|
+
|
|
1392
|
+
metrics = split_commas(metrics) or []
|
|
1393
|
+
dimensions = split_commas(dimensions)
|
|
1394
|
+
|
|
1395
|
+
# Build widget definition
|
|
1396
|
+
widget = {
|
|
1397
|
+
"type": widget_type,
|
|
1398
|
+
"title": title,
|
|
1399
|
+
"query": {
|
|
1400
|
+
"metrics": metrics,
|
|
1401
|
+
},
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if dimensions:
|
|
1405
|
+
widget["query"]["dimensions"] = dimensions
|
|
1406
|
+
if grain:
|
|
1407
|
+
widget["query"]["grain"] = grain
|
|
1408
|
+
if width:
|
|
1409
|
+
widget["width"] = width
|
|
1410
|
+
if time_scope:
|
|
1411
|
+
if time_scope not in ("range", "latest", "latest_complete"):
|
|
1412
|
+
print_error(f"Invalid time_scope: {time_scope}. Must be: range, latest, latest_complete")
|
|
1413
|
+
raise typer.Exit(1)
|
|
1414
|
+
widget["time_scope"] = time_scope
|
|
1415
|
+
|
|
1416
|
+
if pivot_on:
|
|
1417
|
+
if widget_type != "table":
|
|
1418
|
+
print_error("--pivot-on can only be used with table widgets")
|
|
1419
|
+
raise typer.Exit(1)
|
|
1420
|
+
if not dimensions or pivot_on not in dimensions:
|
|
1421
|
+
print_error(f"Pivot dimension '{pivot_on}' must be included in --dimensions")
|
|
1422
|
+
raise typer.Exit(1)
|
|
1423
|
+
widget["pivot"] = {"on": pivot_on, "show_totals": pivot_totals}
|
|
1424
|
+
|
|
1425
|
+
if stacked:
|
|
1426
|
+
if widget_type != "bar_chart":
|
|
1427
|
+
print_error("--stacked can only be used with bar_chart widgets")
|
|
1428
|
+
raise typer.Exit(1)
|
|
1429
|
+
if not dimensions or len(dimensions) < 2:
|
|
1430
|
+
print_error("Stacked bar charts require 2 dimensions: first for x-axis, second for stack segments")
|
|
1431
|
+
raise typer.Exit(1)
|
|
1432
|
+
widget["stacked"] = True
|
|
1433
|
+
|
|
1434
|
+
token = auth.get_access_token()
|
|
1435
|
+
|
|
1436
|
+
try:
|
|
1437
|
+
result = call_tool(
|
|
1438
|
+
"widget_edit",
|
|
1439
|
+
"add",
|
|
1440
|
+
{"dashboard_id": dashboard_id, "widget": widget, "page_index": page, "section_index": section},
|
|
1441
|
+
token=token,
|
|
1442
|
+
)
|
|
1443
|
+
except Exception as e:
|
|
1444
|
+
fail(e, role_action="edit this dashboard")
|
|
1445
|
+
|
|
1446
|
+
dashboard = result.get("dashboard", {})
|
|
1447
|
+
print_success(f"Added widget '{title}' to dashboard")
|
|
1448
|
+
summary = {
|
|
1449
|
+
"dashboard_id": dashboard.get("id"),
|
|
1450
|
+
"widget_title": title,
|
|
1451
|
+
"widget_type": widget_type,
|
|
1452
|
+
"page_index": page,
|
|
1453
|
+
"section_index": section,
|
|
1454
|
+
}
|
|
1455
|
+
_print_dashboard_result(result, format, summary, "Widget Added")
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
@dashboards_app.command("remove-widget")
|
|
1459
|
+
def remove_widget_cmd(
|
|
1460
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
|
|
1461
|
+
widget_id: Annotated[str, typer.Argument(help="Widget ID to remove")],
|
|
1462
|
+
yes: Annotated[
|
|
1463
|
+
bool,
|
|
1464
|
+
typer.Option("--yes", "-y", help="Skip confirmation"),
|
|
1465
|
+
] = False,
|
|
1466
|
+
format: Annotated[
|
|
1467
|
+
OutputFormat,
|
|
1468
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1469
|
+
] = "table",
|
|
1470
|
+
):
|
|
1471
|
+
"""Remove a widget from a dashboard.
|
|
1472
|
+
|
|
1473
|
+
Permanently deletes a widget from the dashboard.
|
|
1474
|
+
|
|
1475
|
+
Examples:
|
|
1476
|
+
dataspring dashboards remove-widget abc123 widget-1
|
|
1477
|
+
dataspring dashboards remove-widget abc123 widget-1 --yes
|
|
1478
|
+
"""
|
|
1479
|
+
require_auth()
|
|
1480
|
+
|
|
1481
|
+
if not yes:
|
|
1482
|
+
confirm = typer.confirm(f"Remove widget '{widget_id}'?")
|
|
1483
|
+
if not confirm:
|
|
1484
|
+
print_info("Cancelled")
|
|
1485
|
+
return
|
|
1486
|
+
|
|
1487
|
+
token = auth.get_access_token()
|
|
1488
|
+
|
|
1489
|
+
try:
|
|
1490
|
+
# Resolve widget ID to page/section/widget indices
|
|
1491
|
+
loc = _find_widget_location(_get_dashboard(dashboard_id, token), widget_id)
|
|
1492
|
+
if loc is None:
|
|
1493
|
+
print_error(f"Widget '{widget_id}' not found in dashboard")
|
|
1494
|
+
raise typer.Exit(1)
|
|
1495
|
+
page_index, section_index, widget_index = loc
|
|
1496
|
+
|
|
1497
|
+
result = call_tool(
|
|
1498
|
+
"widget_edit",
|
|
1499
|
+
"remove",
|
|
1500
|
+
{
|
|
1501
|
+
"dashboard_id": dashboard_id,
|
|
1502
|
+
"page_index": page_index,
|
|
1503
|
+
"section_index": section_index,
|
|
1504
|
+
"widget_index": widget_index,
|
|
1505
|
+
},
|
|
1506
|
+
token=token,
|
|
1507
|
+
)
|
|
1508
|
+
except Exception as e:
|
|
1509
|
+
fail(e, role_action="edit this dashboard")
|
|
1510
|
+
|
|
1511
|
+
dashboard = result.get("dashboard", {})
|
|
1512
|
+
print_success(f"Removed widget '{widget_id}'")
|
|
1513
|
+
summary = {
|
|
1514
|
+
"dashboard_id": dashboard.get("id"),
|
|
1515
|
+
"removed_widget_id": widget_id,
|
|
1516
|
+
}
|
|
1517
|
+
_print_dashboard_result(result, format, summary, "Widget Removed")
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
@dashboards_app.command("update-widget")
|
|
1521
|
+
def update_widget_cmd(
|
|
1522
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
|
|
1523
|
+
widget_id: Annotated[str, typer.Argument(help="Widget ID to update")],
|
|
1524
|
+
title: Annotated[
|
|
1525
|
+
Optional[str],
|
|
1526
|
+
typer.Option("--title", help="New widget title"),
|
|
1527
|
+
] = None,
|
|
1528
|
+
metrics: Annotated[
|
|
1529
|
+
Optional[list[str]],
|
|
1530
|
+
typer.Option("-m", "--metrics", help="New metric names"),
|
|
1531
|
+
] = None,
|
|
1532
|
+
dimensions: Annotated[
|
|
1533
|
+
Optional[list[str]],
|
|
1534
|
+
typer.Option("-d", "--dimensions", help="New dimensions"),
|
|
1535
|
+
] = None,
|
|
1536
|
+
grain: Annotated[
|
|
1537
|
+
Optional[str],
|
|
1538
|
+
typer.Option("-g", "--grain", help="New time grain"),
|
|
1539
|
+
] = None,
|
|
1540
|
+
width: Annotated[
|
|
1541
|
+
Optional[int],
|
|
1542
|
+
typer.Option("--width", "-w", help="New widget width (1-10)"),
|
|
1543
|
+
] = None,
|
|
1544
|
+
format: Annotated[
|
|
1545
|
+
OutputFormat,
|
|
1546
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1547
|
+
] = "table",
|
|
1548
|
+
):
|
|
1549
|
+
"""Update an existing widget.
|
|
1550
|
+
|
|
1551
|
+
Modify the configuration of an existing widget. Only specified
|
|
1552
|
+
fields are updated; others remain unchanged.
|
|
1553
|
+
|
|
1554
|
+
Examples:
|
|
1555
|
+
dataspring dashboards update-widget abc123 widget-1 --title "New Title"
|
|
1556
|
+
dataspring dashboards update-widget abc123 widget-1 -m revenue -m orders
|
|
1557
|
+
dataspring dashboards update-widget abc123 widget-1 --width 6
|
|
1558
|
+
"""
|
|
1559
|
+
require_auth()
|
|
1560
|
+
|
|
1561
|
+
metrics = split_commas(metrics)
|
|
1562
|
+
dimensions = split_commas(dimensions)
|
|
1563
|
+
|
|
1564
|
+
# Build updates dict
|
|
1565
|
+
updates = {}
|
|
1566
|
+
if title:
|
|
1567
|
+
updates["title"] = title
|
|
1568
|
+
if width:
|
|
1569
|
+
updates["width"] = width
|
|
1570
|
+
|
|
1571
|
+
# Build query updates if any query fields provided
|
|
1572
|
+
query_updates = {}
|
|
1573
|
+
if metrics:
|
|
1574
|
+
query_updates["metrics"] = metrics
|
|
1575
|
+
if dimensions:
|
|
1576
|
+
query_updates["dimensions"] = dimensions
|
|
1577
|
+
if grain:
|
|
1578
|
+
query_updates["grain"] = grain
|
|
1579
|
+
|
|
1580
|
+
if query_updates:
|
|
1581
|
+
updates["query"] = query_updates
|
|
1582
|
+
|
|
1583
|
+
if not updates:
|
|
1584
|
+
print_error("No updates specified")
|
|
1585
|
+
raise typer.Exit(1)
|
|
1586
|
+
|
|
1587
|
+
token = auth.get_access_token()
|
|
1588
|
+
|
|
1589
|
+
try:
|
|
1590
|
+
# Resolve widget ID to page/section/widget indices
|
|
1591
|
+
loc = _find_widget_location(_get_dashboard(dashboard_id, token), widget_id)
|
|
1592
|
+
if loc is None:
|
|
1593
|
+
print_error(f"Widget '{widget_id}' not found in dashboard")
|
|
1594
|
+
raise typer.Exit(1)
|
|
1595
|
+
page_index, section_index, widget_index = loc
|
|
1596
|
+
|
|
1597
|
+
result = call_tool(
|
|
1598
|
+
"widget_edit",
|
|
1599
|
+
"update",
|
|
1600
|
+
{
|
|
1601
|
+
"dashboard_id": dashboard_id,
|
|
1602
|
+
"page_index": page_index,
|
|
1603
|
+
"section_index": section_index,
|
|
1604
|
+
"widget_index": widget_index,
|
|
1605
|
+
"updates": updates,
|
|
1606
|
+
},
|
|
1607
|
+
token=token,
|
|
1608
|
+
)
|
|
1609
|
+
except Exception as e:
|
|
1610
|
+
fail(e, role_action="edit this dashboard")
|
|
1611
|
+
|
|
1612
|
+
dashboard = result.get("dashboard", {})
|
|
1613
|
+
print_success(f"Updated widget '{widget_id}'")
|
|
1614
|
+
summary = {
|
|
1615
|
+
"dashboard_id": dashboard.get("id"),
|
|
1616
|
+
"widget_id": widget_id,
|
|
1617
|
+
"updated_fields": list(updates.keys()),
|
|
1618
|
+
}
|
|
1619
|
+
_print_dashboard_result(result, format, summary, "Widget Updated")
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
@dashboards_app.command("reorder-widgets")
|
|
1623
|
+
def reorder_widgets_cmd(
|
|
1624
|
+
dashboard_id: Annotated[str, typer.Argument(help="Dashboard ID")],
|
|
1625
|
+
page_id: Annotated[str, typer.Option("--page", help="Page ID")],
|
|
1626
|
+
section_index: Annotated[int, typer.Option("--section", help="Section index (0-based)")],
|
|
1627
|
+
widget_ids: Annotated[
|
|
1628
|
+
list[str],
|
|
1629
|
+
typer.Argument(help="Widget IDs in desired order"),
|
|
1630
|
+
],
|
|
1631
|
+
format: Annotated[
|
|
1632
|
+
OutputFormat,
|
|
1633
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1634
|
+
] = "table",
|
|
1635
|
+
):
|
|
1636
|
+
"""Reorder widgets within a section.
|
|
1637
|
+
|
|
1638
|
+
Specify the widget IDs in the desired order. All widgets in the
|
|
1639
|
+
section must be included.
|
|
1640
|
+
|
|
1641
|
+
Examples:
|
|
1642
|
+
dataspring dashboards reorder-widgets abc123 --page page-1 --section 0 widget-3 widget-1 widget-2
|
|
1643
|
+
"""
|
|
1644
|
+
require_auth()
|
|
1645
|
+
token = auth.get_access_token()
|
|
1646
|
+
|
|
1647
|
+
try:
|
|
1648
|
+
# The action addresses the page by index; the option takes an id.
|
|
1649
|
+
page_index = _page_index(_get_dashboard(dashboard_id, token), page_id)
|
|
1650
|
+
if page_index is None:
|
|
1651
|
+
print_error(f"Page '{page_id}' not found in dashboard")
|
|
1652
|
+
raise typer.Exit(1)
|
|
1653
|
+
|
|
1654
|
+
result = call_tool(
|
|
1655
|
+
"widget_edit",
|
|
1656
|
+
"reorder",
|
|
1657
|
+
{
|
|
1658
|
+
"dashboard_id": dashboard_id,
|
|
1659
|
+
"page_index": page_index,
|
|
1660
|
+
"section_index": section_index,
|
|
1661
|
+
"widget_ids": widget_ids,
|
|
1662
|
+
},
|
|
1663
|
+
token=token,
|
|
1664
|
+
)
|
|
1665
|
+
except Exception as e:
|
|
1666
|
+
fail(e, role_action="edit this dashboard")
|
|
1667
|
+
|
|
1668
|
+
dashboard = result.get("dashboard", {})
|
|
1669
|
+
print_success(f"Reordered {len(widget_ids)} widgets")
|
|
1670
|
+
summary = {
|
|
1671
|
+
"dashboard_id": dashboard.get("id"),
|
|
1672
|
+
"page_id": page_id,
|
|
1673
|
+
"section_index": section_index,
|
|
1674
|
+
"new_order": widget_ids,
|
|
1675
|
+
}
|
|
1676
|
+
_print_dashboard_result(result, format, summary, "Widgets Reordered")
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
# ============================================================================
|
|
1680
|
+
# Manifest sub-commands
|
|
1681
|
+
# ============================================================================
|
|
1682
|
+
#
|
|
1683
|
+
# ``status`` and ``export`` read ``GET /api/manifest`` and
|
|
1684
|
+
# ``GET /api/semantic-layer/export``; an upload is the generated ``dataspring
|
|
1685
|
+
# import_manifest --manifest-data-json @manifest.yaml``, whose replace
|
|
1686
|
+
# semantics and reference validation live on the server.
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
@manifest_app.command("status")
|
|
1690
|
+
def manifest_status(
|
|
1691
|
+
format: Annotated[
|
|
1692
|
+
OutputFormat,
|
|
1693
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1694
|
+
] = "table",
|
|
1695
|
+
):
|
|
1696
|
+
"""Show manifest status (project name, counts).
|
|
1697
|
+
|
|
1698
|
+
Displays information about the organization's semantic layer manifest
|
|
1699
|
+
including project name, metric count, model count, and dimension count.
|
|
1700
|
+
"""
|
|
1701
|
+
user = require_auth()
|
|
1702
|
+
token = auth.get_access_token()
|
|
1703
|
+
|
|
1704
|
+
try:
|
|
1705
|
+
result = api_get("/api/manifest", token)
|
|
1706
|
+
except Exception as e:
|
|
1707
|
+
fail(e, role_action="view the manifest")
|
|
1708
|
+
|
|
1709
|
+
summary = {
|
|
1710
|
+
"organization": result.get("org_id", user.org_id),
|
|
1711
|
+
"project": result.get("project_name") or "-",
|
|
1712
|
+
"metrics": result.get("metrics", 0),
|
|
1713
|
+
"models": result.get("semantic_models", 0),
|
|
1714
|
+
"dimensions": result.get("dimensions", 0),
|
|
1715
|
+
"last_updated": result.get("uploaded_at") or "-",
|
|
1716
|
+
}
|
|
1717
|
+
format_output(summary, format=format, title="Manifest Status")
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
@manifest_app.command("export")
|
|
1721
|
+
def manifest_export(
|
|
1722
|
+
output: Annotated[
|
|
1723
|
+
str,
|
|
1724
|
+
typer.Option("--output", "-o", help="Output file path (YAML or JSON)"),
|
|
1725
|
+
],
|
|
1726
|
+
):
|
|
1727
|
+
"""Export manifest to a file.
|
|
1728
|
+
|
|
1729
|
+
Exports the organization's semantic layer manifest to a YAML or JSON
|
|
1730
|
+
file. The format is determined by the file extension.
|
|
1731
|
+
|
|
1732
|
+
Examples:
|
|
1733
|
+
dataspring manifest export -o manifest.yaml
|
|
1734
|
+
dataspring manifest export --output backup.json
|
|
1735
|
+
"""
|
|
1736
|
+
require_auth()
|
|
1737
|
+
token = auth.get_access_token()
|
|
1738
|
+
|
|
1739
|
+
# Determine format from extension
|
|
1740
|
+
output_path = Path(output)
|
|
1741
|
+
ext = output_path.suffix.lower()
|
|
1742
|
+
|
|
1743
|
+
if ext not in (".yaml", ".yml", ".json"):
|
|
1744
|
+
print_error(
|
|
1745
|
+
f"Unsupported file format: {ext}",
|
|
1746
|
+
hint="Use .yaml, .yml, or .json",
|
|
1747
|
+
)
|
|
1748
|
+
raise typer.Exit(1)
|
|
1749
|
+
|
|
1750
|
+
try:
|
|
1751
|
+
manifest = api_get("/api/semantic-layer/export", token)
|
|
1752
|
+
except Exception as e:
|
|
1753
|
+
fail(e, role_action="export the manifest")
|
|
1754
|
+
|
|
1755
|
+
# Check if manifest is empty
|
|
1756
|
+
if not manifest.get("metrics") and not manifest.get("semantic_models"):
|
|
1757
|
+
print_warning("Manifest is empty (no metrics or models)")
|
|
1758
|
+
|
|
1759
|
+
# Write to file
|
|
1760
|
+
if ext in (".yaml", ".yml"):
|
|
1761
|
+
with open(output_path, "w") as f:
|
|
1762
|
+
yaml.dump(manifest, f, default_flow_style=False, sort_keys=False)
|
|
1763
|
+
else:
|
|
1764
|
+
with open(output_path, "w") as f:
|
|
1765
|
+
json.dump(manifest, f, indent=2)
|
|
1766
|
+
|
|
1767
|
+
print_success(f"Exported manifest to {output}")
|
|
1768
|
+
print_info(f" Metrics: {len(manifest.get('metrics', []))}")
|
|
1769
|
+
print_info(f" Models: {len(manifest.get('semantic_models', []))}")
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
# ============================================================================
|
|
1773
|
+
# Models sub-commands
|
|
1774
|
+
# ============================================================================
|
|
1775
|
+
#
|
|
1776
|
+
# ``list`` and ``show`` read ``GET /api/semantic-layer/models[/{name}]``;
|
|
1777
|
+
# writes are the generated ``dataspring semantic_model_edit`` (admin/owner,
|
|
1778
|
+
# checked on the server).
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
@models_app.command("list")
|
|
1782
|
+
def models_list(
|
|
1783
|
+
format: Annotated[
|
|
1784
|
+
OutputFormat,
|
|
1785
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1786
|
+
] = "table",
|
|
1787
|
+
):
|
|
1788
|
+
"""List semantic models.
|
|
1789
|
+
|
|
1790
|
+
Shows all semantic models with their name, description,
|
|
1791
|
+
measure count, and dimension count.
|
|
1792
|
+
"""
|
|
1793
|
+
require_auth()
|
|
1794
|
+
token = auth.get_access_token()
|
|
1795
|
+
|
|
1796
|
+
try:
|
|
1797
|
+
result = api_get("/api/semantic-layer/models", token)
|
|
1798
|
+
except Exception as e:
|
|
1799
|
+
fail(e, role_action="list semantic models")
|
|
1800
|
+
|
|
1801
|
+
models = result.get("models", [])
|
|
1802
|
+
if not models:
|
|
1803
|
+
print_info("No semantic models found")
|
|
1804
|
+
return
|
|
1805
|
+
|
|
1806
|
+
model_data = [
|
|
1807
|
+
{
|
|
1808
|
+
"name": model.get("name", "-"),
|
|
1809
|
+
"description": model.get("description", "-") or "-",
|
|
1810
|
+
"measures": len(model.get("measures", [])),
|
|
1811
|
+
"dimensions": len(model.get("dimensions", [])),
|
|
1812
|
+
}
|
|
1813
|
+
for model in models
|
|
1814
|
+
]
|
|
1815
|
+
format_output(
|
|
1816
|
+
model_data,
|
|
1817
|
+
format=format,
|
|
1818
|
+
columns=["name", "description", "measures", "dimensions"],
|
|
1819
|
+
title="Semantic Models",
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
@models_app.command("show")
|
|
1824
|
+
def models_show(
|
|
1825
|
+
name: Annotated[str, typer.Argument(help="Model name")],
|
|
1826
|
+
format: Annotated[
|
|
1827
|
+
OutputFormat,
|
|
1828
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1829
|
+
] = "table",
|
|
1830
|
+
):
|
|
1831
|
+
"""Show details for a semantic model.
|
|
1832
|
+
|
|
1833
|
+
Displays full model definition including all measures and dimensions.
|
|
1834
|
+
"""
|
|
1835
|
+
require_auth()
|
|
1836
|
+
token = auth.get_access_token()
|
|
1837
|
+
|
|
1838
|
+
try:
|
|
1839
|
+
model = api_get(f"/api/semantic-layer/models/{name}", token)
|
|
1840
|
+
except Exception as e:
|
|
1841
|
+
fail(e, role_action="view semantic models")
|
|
1842
|
+
|
|
1843
|
+
if format in ("json", "yaml"):
|
|
1844
|
+
format_output(model, format=format)
|
|
1845
|
+
return
|
|
1846
|
+
|
|
1847
|
+
# Show model summary
|
|
1848
|
+
summary = {
|
|
1849
|
+
"name": model.get("name", "-"),
|
|
1850
|
+
"description": model.get("description", "-") or "-",
|
|
1851
|
+
"model_ref": model.get("model", "-"),
|
|
1852
|
+
"measures": len(model.get("measures", [])),
|
|
1853
|
+
"dimensions": len(model.get("dimensions", [])),
|
|
1854
|
+
}
|
|
1855
|
+
format_output(summary, format=format, title=f"Model: {name}")
|
|
1856
|
+
|
|
1857
|
+
for label, key in (("Measures", "measures"), ("Dimensions", "dimensions")):
|
|
1858
|
+
entries = model.get(key, [])
|
|
1859
|
+
if not entries:
|
|
1860
|
+
continue
|
|
1861
|
+
console.print()
|
|
1862
|
+
format_output(
|
|
1863
|
+
[
|
|
1864
|
+
{
|
|
1865
|
+
"name": entry.get("name", "-"),
|
|
1866
|
+
"type": entry.get("type", "-"),
|
|
1867
|
+
"expr": entry.get("expr", "-"),
|
|
1868
|
+
}
|
|
1869
|
+
for entry in entries
|
|
1870
|
+
],
|
|
1871
|
+
format="table",
|
|
1872
|
+
columns=["name", "type", "expr"],
|
|
1873
|
+
title=label,
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
# ============================================================================
|
|
1878
|
+
# Schedules sub-commands
|
|
1879
|
+
# ============================================================================
|
|
1880
|
+
#
|
|
1881
|
+
# Writes are the generated ``dataspring report_edit``, which answers
|
|
1882
|
+
# "Scheduled reports coming soon" while the feature is off - the server
|
|
1883
|
+
# decides, not a local settings file. ``list`` and ``show`` read
|
|
1884
|
+
# ``GET /api/schedules`` / ``GET /api/schedules/{id}``, the REST twins of
|
|
1885
|
+
# ``dataspring://reports``; no other route exposes them.
|
|
1886
|
+
|
|
1887
|
+
#: The read routes for scheduled reports.
|
|
1888
|
+
SCHEDULES_PATH = "/api/schedules"
|
|
1889
|
+
|
|
1890
|
+
_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def _schedules_from(result) -> list[dict]:
|
|
1894
|
+
"""A bare list or a ``{"schedules": [...]}`` envelope, either way."""
|
|
1895
|
+
if isinstance(result, dict):
|
|
1896
|
+
return result.get("schedules") or []
|
|
1897
|
+
return list(result or [])
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _short_time(value) -> str:
|
|
1901
|
+
"""``YYYY-MM-DD HH:MM`` from an ISO timestamp, "-" when absent."""
|
|
1902
|
+
if not value:
|
|
1903
|
+
return "-"
|
|
1904
|
+
text = str(value)
|
|
1905
|
+
return text[:16].replace("T", " ") if len(text) >= 16 else text
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def _frequency_description(freq: dict) -> str:
|
|
1909
|
+
kind = freq.get("type")
|
|
1910
|
+
time_ = freq.get("time")
|
|
1911
|
+
if kind == "daily":
|
|
1912
|
+
return f"Daily at {time_} UTC"
|
|
1913
|
+
if kind == "weekly":
|
|
1914
|
+
day = freq.get("day_of_week")
|
|
1915
|
+
day_name = _WEEKDAYS[day] if isinstance(day, int) and 0 <= day < 7 else "?"
|
|
1916
|
+
return f"Weekly on {day_name} at {time_} UTC"
|
|
1917
|
+
return f"Monthly on day {freq.get('day_of_month')} at {time_} UTC"
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
@schedules_app.command("list")
|
|
1921
|
+
def schedules_list(
|
|
1922
|
+
format: Annotated[
|
|
1923
|
+
OutputFormat,
|
|
1924
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1925
|
+
] = "table",
|
|
1926
|
+
):
|
|
1927
|
+
"""List all scheduled reports.
|
|
1928
|
+
|
|
1929
|
+
Shows all schedules in your organization with their status
|
|
1930
|
+
and configuration summary.
|
|
1931
|
+
|
|
1932
|
+
Examples:
|
|
1933
|
+
dataspring schedules list
|
|
1934
|
+
dataspring schedules list --format json
|
|
1935
|
+
"""
|
|
1936
|
+
require_auth()
|
|
1937
|
+
token = auth.get_access_token()
|
|
1938
|
+
|
|
1939
|
+
try:
|
|
1940
|
+
schedules = _schedules_from(api_get(SCHEDULES_PATH, token))
|
|
1941
|
+
except Exception as e:
|
|
1942
|
+
fail(e, role_action="list scheduled reports")
|
|
1943
|
+
|
|
1944
|
+
if not schedules:
|
|
1945
|
+
print_info("No scheduled reports found")
|
|
1946
|
+
print_info("Create one with: dataspring schedules create 'My Report' ...")
|
|
1947
|
+
return
|
|
1948
|
+
|
|
1949
|
+
schedules_data = [
|
|
1950
|
+
{
|
|
1951
|
+
"id": s.get("id"),
|
|
1952
|
+
"name": s.get("name"),
|
|
1953
|
+
"frequency": s.get("frequency_type"),
|
|
1954
|
+
"time": s.get("frequency_time"),
|
|
1955
|
+
"type": s.get("report_type"),
|
|
1956
|
+
"enabled": s.get("enabled"),
|
|
1957
|
+
"recipients": s.get("recipients_count"),
|
|
1958
|
+
"last_run": _short_time(s.get("last_run_at")),
|
|
1959
|
+
"status": s.get("last_run_status") or "-",
|
|
1960
|
+
}
|
|
1961
|
+
for s in schedules
|
|
1962
|
+
]
|
|
1963
|
+
format_output(
|
|
1964
|
+
schedules_data,
|
|
1965
|
+
format=format,
|
|
1966
|
+
columns=["id", "name", "frequency", "time", "type", "enabled", "recipients", "last_run", "status"],
|
|
1967
|
+
title="Scheduled Reports",
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
@schedules_app.command("show")
|
|
1972
|
+
def schedules_show(
|
|
1973
|
+
schedule_id: Annotated[str, typer.Argument(help="Schedule ID")],
|
|
1974
|
+
format: Annotated[
|
|
1975
|
+
OutputFormat,
|
|
1976
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
1977
|
+
] = "table",
|
|
1978
|
+
):
|
|
1979
|
+
"""Show details of a scheduled report.
|
|
1980
|
+
|
|
1981
|
+
Displays full schedule configuration including frequency,
|
|
1982
|
+
report type, and recipients.
|
|
1983
|
+
|
|
1984
|
+
Examples:
|
|
1985
|
+
dataspring schedules show abc123
|
|
1986
|
+
dataspring schedules show abc123 --format json
|
|
1987
|
+
"""
|
|
1988
|
+
require_auth()
|
|
1989
|
+
token = auth.get_access_token()
|
|
1990
|
+
|
|
1991
|
+
try:
|
|
1992
|
+
schedule = api_get(f"{SCHEDULES_PATH}/{schedule_id}", token)
|
|
1993
|
+
except Exception as e:
|
|
1994
|
+
fail(e, role_action="view scheduled reports")
|
|
1995
|
+
|
|
1996
|
+
if format == "json":
|
|
1997
|
+
format_output(schedule, format=format)
|
|
1998
|
+
return
|
|
1999
|
+
|
|
2000
|
+
report = schedule.get("report") or {}
|
|
2001
|
+
if report.get("dashboard_id"):
|
|
2002
|
+
report_type = "dashboard"
|
|
2003
|
+
report_details = f"Dashboard: {report.get('dashboard_id')} ({report.get('format')})"
|
|
2004
|
+
else:
|
|
2005
|
+
report_type = "query"
|
|
2006
|
+
metrics_str = ", ".join(report.get("metrics") or [])
|
|
2007
|
+
dims_str = ", ".join(report.get("dimensions") or []) or "-"
|
|
2008
|
+
report_details = f"Metrics: {metrics_str}\nDimensions: {dims_str}\nFormat: {report.get('format')}"
|
|
2009
|
+
|
|
2010
|
+
summary = {
|
|
2011
|
+
"id": schedule.get("id"),
|
|
2012
|
+
"name": schedule.get("name"),
|
|
2013
|
+
"enabled": schedule.get("enabled"),
|
|
2014
|
+
"frequency": _frequency_description(schedule.get("frequency") or {}),
|
|
2015
|
+
"report_type": report_type,
|
|
2016
|
+
"report_details": report_details,
|
|
2017
|
+
"recipients": ", ".join(schedule.get("recipients") or []),
|
|
2018
|
+
"created_by": schedule.get("created_by"),
|
|
2019
|
+
"created_at": _short_time(schedule.get("created_at")),
|
|
2020
|
+
"updated_at": _short_time(schedule.get("updated_at")),
|
|
2021
|
+
"last_run": _short_time(schedule.get("last_run_at")),
|
|
2022
|
+
"last_status": schedule.get("last_run_status") or "-",
|
|
2023
|
+
}
|
|
2024
|
+
format_output(summary, format=format, title=f"Schedule: {schedule.get('name')}")
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
# ============================================================================
|
|
2028
|
+
# Quick Metrics sub-commands
|
|
2029
|
+
# ============================================================================
|
|
2030
|
+
#
|
|
2031
|
+
# Writes are the generated ``dataspring quick_metric_edit``. ``list`` and
|
|
2032
|
+
# ``show`` read ``GET /api/quick-metrics`` / ``GET /api/quick-metrics/{id}`` -
|
|
2033
|
+
# the REST twins of the ``dataspring://quick_metrics`` resources; no other
|
|
2034
|
+
# route exposes them.
|
|
2035
|
+
|
|
2036
|
+
#: The read routes for quick metrics.
|
|
2037
|
+
QUICK_METRICS_PATH = "/api/quick-metrics"
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def _quick_metrics_from(result) -> list[dict]:
|
|
2041
|
+
"""A bare list or a ``{"quick_metrics": [...]}`` envelope, either way."""
|
|
2042
|
+
if isinstance(result, dict):
|
|
2043
|
+
return result.get("quick_metrics") or []
|
|
2044
|
+
return list(result or [])
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _quick_metric_summary(metric: dict) -> dict:
|
|
2048
|
+
return {
|
|
2049
|
+
"id": metric.get("id"),
|
|
2050
|
+
"name": metric.get("name"),
|
|
2051
|
+
"query_name": metric.get("query_name") or f"qm:{metric.get('name')}",
|
|
2052
|
+
"expression": metric.get("expression"),
|
|
2053
|
+
"base_metrics": ", ".join(metric.get("base_metrics") or []),
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
@quick_metrics_app.command("list")
|
|
2058
|
+
def quick_metrics_list(
|
|
2059
|
+
format: Annotated[
|
|
2060
|
+
OutputFormat,
|
|
2061
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
2062
|
+
] = "table",
|
|
2063
|
+
):
|
|
2064
|
+
"""List all quick metrics.
|
|
2065
|
+
|
|
2066
|
+
Quick metrics are user-defined calculated metrics that combine
|
|
2067
|
+
base metrics with arithmetic expressions. Query them using the
|
|
2068
|
+
qm: prefix (e.g., qm:revenue_per_order).
|
|
2069
|
+
|
|
2070
|
+
Examples:
|
|
2071
|
+
dataspring quick-metrics list
|
|
2072
|
+
dataspring quick-metrics list --format json
|
|
2073
|
+
"""
|
|
2074
|
+
require_auth()
|
|
2075
|
+
token = auth.get_access_token()
|
|
2076
|
+
|
|
2077
|
+
try:
|
|
2078
|
+
metrics = _quick_metrics_from(api_get(QUICK_METRICS_PATH, token))
|
|
2079
|
+
except Exception as e:
|
|
2080
|
+
fail(e, role_action="list quick metrics")
|
|
2081
|
+
|
|
2082
|
+
if not metrics:
|
|
2083
|
+
print_info("No quick metrics found")
|
|
2084
|
+
print_info("Create one with: dataspring quick-metrics create revenue_per_order 'total_revenue / order_count'")
|
|
2085
|
+
return
|
|
2086
|
+
|
|
2087
|
+
metrics_data = [
|
|
2088
|
+
{
|
|
2089
|
+
"id": m.get("id"),
|
|
2090
|
+
"name": m.get("name"),
|
|
2091
|
+
"expression": m.get("expression"),
|
|
2092
|
+
"query_name": m.get("query_name") or f"qm:{m.get('name')}",
|
|
2093
|
+
"description": m.get("description") or "-",
|
|
2094
|
+
}
|
|
2095
|
+
for m in metrics
|
|
2096
|
+
]
|
|
2097
|
+
format_output(
|
|
2098
|
+
metrics_data,
|
|
2099
|
+
format=format,
|
|
2100
|
+
columns=["id", "name", "expression", "query_name", "description"],
|
|
2101
|
+
title="Quick Metrics",
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
@quick_metrics_app.command("show")
|
|
2106
|
+
def quick_metrics_show(
|
|
2107
|
+
metric_id: Annotated[str, typer.Argument(help="Quick metric ID")],
|
|
2108
|
+
format: Annotated[
|
|
2109
|
+
OutputFormat,
|
|
2110
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
2111
|
+
] = "table",
|
|
2112
|
+
):
|
|
2113
|
+
"""Show details of a quick metric.
|
|
2114
|
+
|
|
2115
|
+
Displays the full configuration of a quick metric including
|
|
2116
|
+
its expression and the base metrics it references.
|
|
2117
|
+
|
|
2118
|
+
Examples:
|
|
2119
|
+
dataspring quick-metrics show abc123
|
|
2120
|
+
dataspring quick-metrics show abc123 --format json
|
|
2121
|
+
"""
|
|
2122
|
+
require_auth()
|
|
2123
|
+
token = auth.get_access_token()
|
|
2124
|
+
|
|
2125
|
+
try:
|
|
2126
|
+
metric = api_get(f"{QUICK_METRICS_PATH}/{metric_id}", token)
|
|
2127
|
+
except Exception as e:
|
|
2128
|
+
fail(e, role_action="view quick metrics")
|
|
2129
|
+
|
|
2130
|
+
if format == "json":
|
|
2131
|
+
format_output(metric, format=format)
|
|
2132
|
+
return
|
|
2133
|
+
|
|
2134
|
+
summary = {
|
|
2135
|
+
**_quick_metric_summary(metric),
|
|
2136
|
+
"description": metric.get("description") or "-",
|
|
2137
|
+
"created_by": metric.get("created_by"),
|
|
2138
|
+
"created_at": metric.get("created_at"),
|
|
2139
|
+
"updated_at": metric.get("updated_at"),
|
|
2140
|
+
}
|
|
2141
|
+
format_output(summary, format=format, title=f"Quick Metric: {metric.get('name')}")
|
|
2142
|
+
|
|
2143
|
+
# Show usage hint
|
|
2144
|
+
console.print()
|
|
2145
|
+
print_info(f"Query this metric with: dataspring query -m qm:{metric.get('name')} -g month")
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
# ============================================================================
|
|
2149
|
+
# Context sub-commands
|
|
2150
|
+
# ============================================================================
|
|
2151
|
+
#
|
|
2152
|
+
# An update is the generated ``dataspring update_context --updates-json``
|
|
2153
|
+
# (validated and recorded on the learned trail by the server). ``get`` reads
|
|
2154
|
+
# ``GET /api/me/preferences`` - the REST twin of the ``dataspring://context``
|
|
2155
|
+
# MCP resource; no other route exposes a user's stored preferences.
|
|
2156
|
+
|
|
2157
|
+
#: The read route for the caller's own query preferences.
|
|
2158
|
+
PREFERENCES_PATH = "/api/me/preferences"
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def _print_preferences(prefs: dict) -> None:
|
|
2162
|
+
console.print("\n[bold]Preferences[/]")
|
|
2163
|
+
has_prefs = False
|
|
2164
|
+
for key in ("default_currency", "default_grain", "decimal_places", "preferred_chart_type"):
|
|
2165
|
+
value = prefs.get(key)
|
|
2166
|
+
if value is not None:
|
|
2167
|
+
console.print(f" {key}: {value}")
|
|
2168
|
+
has_prefs = True
|
|
2169
|
+
if not has_prefs:
|
|
2170
|
+
console.print(" (none)")
|
|
2171
|
+
|
|
2172
|
+
console.print("\n[bold]Favorite Metrics[/]")
|
|
2173
|
+
favorites = prefs.get("favorite_metrics") or []
|
|
2174
|
+
if favorites:
|
|
2175
|
+
for fav in favorites:
|
|
2176
|
+
console.print(f" - {fav}")
|
|
2177
|
+
else:
|
|
2178
|
+
console.print(" (none)")
|
|
2179
|
+
|
|
2180
|
+
standing = prefs.get("standing_filters") or []
|
|
2181
|
+
substitutions = prefs.get("metric_substitutions") or {}
|
|
2182
|
+
segment = prefs.get("default_segment")
|
|
2183
|
+
if standing or substitutions or segment:
|
|
2184
|
+
console.print("\n[bold]Standing query intent[/]")
|
|
2185
|
+
for item in standing:
|
|
2186
|
+
console.print(f" filter: {item}")
|
|
2187
|
+
for asked, preferred in substitutions.items():
|
|
2188
|
+
console.print(f" {asked} -> {preferred}")
|
|
2189
|
+
if segment:
|
|
2190
|
+
console.print(f" default segment: {segment}")
|
|
2191
|
+
|
|
2192
|
+
console.print()
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
@context_app.command("get")
|
|
2196
|
+
def context_get(
|
|
2197
|
+
format: Annotated[
|
|
2198
|
+
OutputFormat,
|
|
2199
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
2200
|
+
] = "table",
|
|
2201
|
+
):
|
|
2202
|
+
"""Get your accumulated user context.
|
|
2203
|
+
|
|
2204
|
+
Shows preferences and favorite metrics stored across sessions.
|
|
2205
|
+
This context is used by agents to personalize responses.
|
|
2206
|
+
|
|
2207
|
+
Examples:
|
|
2208
|
+
dataspring context get
|
|
2209
|
+
dataspring context get --format json
|
|
2210
|
+
"""
|
|
2211
|
+
require_auth()
|
|
2212
|
+
token = auth.get_access_token()
|
|
2213
|
+
|
|
2214
|
+
try:
|
|
2215
|
+
prefs = api_get(PREFERENCES_PATH, token)
|
|
2216
|
+
except Exception as e:
|
|
2217
|
+
fail(e, role_action="read your context")
|
|
2218
|
+
|
|
2219
|
+
if format == "json":
|
|
2220
|
+
format_output(prefs, format=format)
|
|
2221
|
+
else:
|
|
2222
|
+
_print_preferences(prefs)
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
# ============================================================================
|
|
2226
|
+
# business_context sub-commands (T2.6 — plan task)
|
|
2227
|
+
# ============================================================================
|
|
2228
|
+
#
|
|
2229
|
+
# Reads ``GET /api/context``; writes are the ``business_context_edit``
|
|
2230
|
+
# dispatch family (``set``), whose 2000-byte UTF-8 cap and admin/owner check
|
|
2231
|
+
# live on the server. The CLI surfaces the server's answer verbatim.
|
|
2232
|
+
|
|
2233
|
+
#: UTF-8 byte cap for business_context — locked in plan T1.0; mirrored
|
|
2234
|
+
#: on the server in mcp_dispatch.BUSINESS_CONTEXT_CAP_BYTES. Counted in
|
|
2235
|
+
#: bytes (not chars) so non-ASCII content (Danish ``ø/æ/å``, emoji)
|
|
2236
|
+
#: doesn't smuggle past the cap and then disappear at initialize time.
|
|
2237
|
+
_BUSINESS_CONTEXT_CAP_BYTES = 2000
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
def _read_business_context(token: str) -> str:
|
|
2241
|
+
"""GET /api/context → ``content`` string (or empty)."""
|
|
2242
|
+
return api_get("/api/context", token).get("content", "") or ""
|
|
2243
|
+
|
|
2244
|
+
|
|
2245
|
+
def _write_business_context(token: str, content: str) -> dict:
|
|
2246
|
+
"""``business_context_edit set`` - replaces the document."""
|
|
2247
|
+
return call_tool("business_context_edit", "set", {"content": content}, token=token)
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
@business_context_app.command("get")
|
|
2251
|
+
def business_context_get():
|
|
2252
|
+
"""Print the org's business_context document to stdout."""
|
|
2253
|
+
require_auth()
|
|
2254
|
+
token = auth.get_access_token()
|
|
2255
|
+
|
|
2256
|
+
try:
|
|
2257
|
+
content = _read_business_context(token)
|
|
2258
|
+
except Exception as e:
|
|
2259
|
+
fail(e, role_action="read business_context")
|
|
2260
|
+
|
|
2261
|
+
if not content:
|
|
2262
|
+
print_info("(no business_context set for this org)")
|
|
2263
|
+
return
|
|
2264
|
+
# Plain print so the output is shell-pipeable (e.g. `… get > file`).
|
|
2265
|
+
typer.echo(content)
|
|
2266
|
+
|
|
2267
|
+
|
|
2268
|
+
@business_context_app.command("size")
|
|
2269
|
+
def business_context_size():
|
|
2270
|
+
"""Show the current business_context size against the 2000-byte UTF-8 cap."""
|
|
2271
|
+
require_auth()
|
|
2272
|
+
token = auth.get_access_token()
|
|
2273
|
+
|
|
2274
|
+
try:
|
|
2275
|
+
content = _read_business_context(token)
|
|
2276
|
+
except Exception as e:
|
|
2277
|
+
fail(e, role_action="read business_context")
|
|
2278
|
+
|
|
2279
|
+
n = len(content.encode("utf-8"))
|
|
2280
|
+
cap = _BUSINESS_CONTEXT_CAP_BYTES
|
|
2281
|
+
remaining = max(0, cap - n)
|
|
2282
|
+
console.print(f"{n} / {cap} bytes ({remaining} remaining)")
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
@business_context_app.command("set")
|
|
2286
|
+
def business_context_set():
|
|
2287
|
+
"""Replace the org's business_context. Reads new content from stdin.
|
|
2288
|
+
|
|
2289
|
+
Requires admin or owner role server-side.
|
|
2290
|
+
|
|
2291
|
+
Examples:
|
|
2292
|
+
cat ctx.md | dataspring business-context set
|
|
2293
|
+
echo "Org overview..." | dataspring business-context set
|
|
2294
|
+
"""
|
|
2295
|
+
require_auth()
|
|
2296
|
+
token = auth.get_access_token()
|
|
2297
|
+
|
|
2298
|
+
content = sys.stdin.read()
|
|
2299
|
+
if not content:
|
|
2300
|
+
print_error("No content on stdin", hint="Pipe the document or use `set` interactively")
|
|
2301
|
+
raise typer.Exit(1)
|
|
2302
|
+
|
|
2303
|
+
try:
|
|
2304
|
+
_write_business_context(token, content)
|
|
2305
|
+
except Exception as e:
|
|
2306
|
+
fail(e, role_action="set business_context")
|
|
2307
|
+
|
|
2308
|
+
print_success(
|
|
2309
|
+
f"business_context updated ({len(content.encode('utf-8'))} bytes)"
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
@business_context_app.command("edit")
|
|
2314
|
+
def business_context_edit_cmd():
|
|
2315
|
+
"""Open the current business_context in $EDITOR; save on exit.
|
|
2316
|
+
|
|
2317
|
+
Requires admin or owner role server-side.
|
|
2318
|
+
"""
|
|
2319
|
+
# _cmd suffix avoids shadowing the ``business_context_edit`` MCP
|
|
2320
|
+
# tool name (would mismatch surface naming if both were imported
|
|
2321
|
+
# in the same module — defensive even though they're not yet).
|
|
2322
|
+
import os
|
|
2323
|
+
import subprocess
|
|
2324
|
+
import tempfile
|
|
2325
|
+
|
|
2326
|
+
require_auth()
|
|
2327
|
+
token = auth.get_access_token()
|
|
2328
|
+
|
|
2329
|
+
try:
|
|
2330
|
+
existing = _read_business_context(token)
|
|
2331
|
+
except Exception as e:
|
|
2332
|
+
fail(e, role_action="read business_context")
|
|
2333
|
+
|
|
2334
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
|
|
2335
|
+
with tempfile.NamedTemporaryFile(
|
|
2336
|
+
suffix=".md", mode="w", delete=False, encoding="utf-8"
|
|
2337
|
+
) as tf:
|
|
2338
|
+
tf.write(existing)
|
|
2339
|
+
tmp_path = tf.name
|
|
2340
|
+
|
|
2341
|
+
try:
|
|
2342
|
+
subprocess.call([editor, tmp_path])
|
|
2343
|
+
with open(tmp_path, encoding="utf-8") as f:
|
|
2344
|
+
new_content = f.read()
|
|
2345
|
+
finally:
|
|
2346
|
+
try:
|
|
2347
|
+
os.unlink(tmp_path)
|
|
2348
|
+
except OSError:
|
|
2349
|
+
pass
|
|
2350
|
+
|
|
2351
|
+
if new_content == existing:
|
|
2352
|
+
print_info("No changes")
|
|
2353
|
+
return
|
|
2354
|
+
if not new_content.strip():
|
|
2355
|
+
print_warning("Empty content — aborting save")
|
|
2356
|
+
raise typer.Exit(1)
|
|
2357
|
+
|
|
2358
|
+
try:
|
|
2359
|
+
_write_business_context(token, new_content)
|
|
2360
|
+
except Exception as e:
|
|
2361
|
+
fail(e, role_action="set business_context")
|
|
2362
|
+
|
|
2363
|
+
print_success(
|
|
2364
|
+
f"business_context updated ({len(new_content.encode('utf-8'))} bytes)"
|
|
2365
|
+
)
|
|
2366
|
+
|
|
2367
|
+
|
|
2368
|
+
@business_context_app.command("import")
|
|
2369
|
+
def business_context_import(
|
|
2370
|
+
file: Annotated[
|
|
2371
|
+
Path,
|
|
2372
|
+
typer.Argument(help="Path to the markdown file to import", exists=True),
|
|
2373
|
+
],
|
|
2374
|
+
):
|
|
2375
|
+
"""Replace the org's business_context with the contents of FILE.
|
|
2376
|
+
|
|
2377
|
+
Equivalent to ``cat FILE | dataspring business-context set``.
|
|
2378
|
+
Requires admin or owner role server-side.
|
|
2379
|
+
"""
|
|
2380
|
+
require_auth()
|
|
2381
|
+
token = auth.get_access_token()
|
|
2382
|
+
|
|
2383
|
+
content = file.read_text(encoding="utf-8")
|
|
2384
|
+
|
|
2385
|
+
try:
|
|
2386
|
+
_write_business_context(token, content)
|
|
2387
|
+
except Exception as e:
|
|
2388
|
+
fail(e, role_action="set business_context")
|
|
2389
|
+
|
|
2390
|
+
print_success(
|
|
2391
|
+
f"business_context imported from {file} ({len(content.encode('utf-8'))} bytes)"
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
@business_context_app.command("export")
|
|
2396
|
+
def business_context_export(
|
|
2397
|
+
output: Annotated[
|
|
2398
|
+
Optional[Path],
|
|
2399
|
+
typer.Option("--output", "-o", help="Write to file instead of stdout"),
|
|
2400
|
+
] = None,
|
|
2401
|
+
):
|
|
2402
|
+
"""Write the org's business_context to a file (or stdout if no -o)."""
|
|
2403
|
+
require_auth()
|
|
2404
|
+
token = auth.get_access_token()
|
|
2405
|
+
|
|
2406
|
+
try:
|
|
2407
|
+
content = _read_business_context(token)
|
|
2408
|
+
except Exception as e:
|
|
2409
|
+
fail(e, role_action="read business_context")
|
|
2410
|
+
|
|
2411
|
+
if output is None:
|
|
2412
|
+
typer.echo(content)
|
|
2413
|
+
return
|
|
2414
|
+
output.write_text(content, encoding="utf-8")
|
|
2415
|
+
print_success(
|
|
2416
|
+
f"business_context exported to {output} ({len(content.encode('utf-8'))} bytes)"
|
|
2417
|
+
)
|
|
2418
|
+
|
|
2419
|
+
|
|
2420
|
+
# ============================================================================
|
|
2421
|
+
# learned sub-commands (tick rkn)
|
|
2422
|
+
# ============================================================================
|
|
2423
|
+
#
|
|
2424
|
+
# Reads ``GET /api/learned`` / ``GET /api/learned/{id}``; an undo is the
|
|
2425
|
+
# generated ``dataspring learned_edit undo [--learning-id]``, which enforces
|
|
2426
|
+
# who may revert what: org-scope entries need admin/owner, personal entries
|
|
2427
|
+
# their author.
|
|
2428
|
+
|
|
2429
|
+
|
|
2430
|
+
def _learned_row(entry: dict) -> dict:
|
|
2431
|
+
summary = entry.get("summary", "")
|
|
2432
|
+
if entry.get("kind") == "proposal":
|
|
2433
|
+
summary = f"{summary} (proposed, not applied)"
|
|
2434
|
+
return {
|
|
2435
|
+
"id": entry.get("id"),
|
|
2436
|
+
"when": entry.get("created_at"),
|
|
2437
|
+
"who": entry.get("actor_email") or entry.get("actor_id"),
|
|
2438
|
+
"surface": entry.get("actor_type"),
|
|
2439
|
+
"scope": entry.get("scope"),
|
|
2440
|
+
"summary": summary,
|
|
2441
|
+
"reverted": entry.get("reverted", False),
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
@learned_app.command("list")
|
|
2446
|
+
def learned_list(
|
|
2447
|
+
limit: Annotated[
|
|
2448
|
+
int, typer.Option("--limit", help="Max entries to show")
|
|
2449
|
+
] = 50,
|
|
2450
|
+
include_reverted: Annotated[
|
|
2451
|
+
bool, typer.Option("--include-reverted", help="Include already-reverted entries")
|
|
2452
|
+
] = False,
|
|
2453
|
+
format: Annotated[
|
|
2454
|
+
OutputFormat,
|
|
2455
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
2456
|
+
] = "table",
|
|
2457
|
+
):
|
|
2458
|
+
"""List what DataSpring learned for this org (plus your personal entries).
|
|
2459
|
+
|
|
2460
|
+
Examples:
|
|
2461
|
+
dataspring learned list
|
|
2462
|
+
dataspring learned list --limit 10 --include-reverted
|
|
2463
|
+
dataspring learned list -f json
|
|
2464
|
+
"""
|
|
2465
|
+
require_auth()
|
|
2466
|
+
token = auth.get_access_token()
|
|
2467
|
+
|
|
2468
|
+
try:
|
|
2469
|
+
data = api_get(
|
|
2470
|
+
f"/api/learned?limit={limit}&include_reverted={str(include_reverted).lower()}",
|
|
2471
|
+
token,
|
|
2472
|
+
)
|
|
2473
|
+
except Exception as e:
|
|
2474
|
+
fail(e, role_action="view the learned trail")
|
|
2475
|
+
|
|
2476
|
+
entries = data.get("learnings", [])
|
|
2477
|
+
if not entries:
|
|
2478
|
+
print_info("Nothing learned yet")
|
|
2479
|
+
return
|
|
2480
|
+
|
|
2481
|
+
if format == "json":
|
|
2482
|
+
format_output(entries, format=format)
|
|
2483
|
+
return
|
|
2484
|
+
|
|
2485
|
+
format_output(
|
|
2486
|
+
[_learned_row(entry) for entry in entries],
|
|
2487
|
+
format=format,
|
|
2488
|
+
columns=["id", "when", "who", "surface", "scope", "summary", "reverted"],
|
|
2489
|
+
title="Learned",
|
|
2490
|
+
)
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
@learned_app.command("show")
|
|
2494
|
+
def learned_show(
|
|
2495
|
+
learning_id: Annotated[str, typer.Argument(help="Learning entry ID")],
|
|
2496
|
+
):
|
|
2497
|
+
"""Show one learned entry, with before/after as YAML."""
|
|
2498
|
+
require_auth()
|
|
2499
|
+
token = auth.get_access_token()
|
|
2500
|
+
|
|
2501
|
+
try:
|
|
2502
|
+
entry = api_get(f"/api/learned/{learning_id}", token)
|
|
2503
|
+
except Exception as e:
|
|
2504
|
+
fail(e, role_action="view the learned trail")
|
|
2505
|
+
|
|
2506
|
+
print_yaml(entry)
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
# ============================================================================
|
|
2510
|
+
# Warehouses sub-commands (docs/2026-09-17-datacore-chunk2-plan.md)
|
|
2511
|
+
# ============================================================================
|
|
2512
|
+
#
|
|
2513
|
+
# The read is ``GET /api/warehouses`` (the ``dataspring://warehouses``
|
|
2514
|
+
# payload); the writes are the generated ``dataspring warehouse_edit
|
|
2515
|
+
# activate|add_external|remove``.
|
|
2516
|
+
|
|
2517
|
+
WAREHOUSES_PATH = "/api/warehouses"
|
|
2518
|
+
|
|
2519
|
+
|
|
2520
|
+
def _warehouses_from(result) -> list[dict]:
|
|
2521
|
+
if isinstance(result, dict):
|
|
2522
|
+
return list(result.get("warehouses") or [])
|
|
2523
|
+
return list(result or [])
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
def _warehouse_row(entry: dict) -> dict:
|
|
2527
|
+
connection = entry.get("connection") or {}
|
|
2528
|
+
where = connection.get("path") or (
|
|
2529
|
+
f"{connection.get('project')}.{connection.get('dataset')}"
|
|
2530
|
+
if connection.get("project")
|
|
2531
|
+
else "-"
|
|
2532
|
+
)
|
|
2533
|
+
return {
|
|
2534
|
+
"id": entry.get("id"),
|
|
2535
|
+
"kind": entry.get("kind"),
|
|
2536
|
+
"label": entry.get("label"),
|
|
2537
|
+
"active": "*" if entry.get("active") else "",
|
|
2538
|
+
"models": entry.get("semantic_models", 0),
|
|
2539
|
+
"metrics": entry.get("metrics", 0),
|
|
2540
|
+
"imported": (entry.get("manifest_imported_at") or "-")[:19],
|
|
2541
|
+
"where": where,
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
@warehouses_app.command("list")
|
|
2546
|
+
def warehouses_list(
|
|
2547
|
+
format: Annotated[
|
|
2548
|
+
OutputFormat,
|
|
2549
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
2550
|
+
] = "table",
|
|
2551
|
+
):
|
|
2552
|
+
"""List the org's warehouses: id, kind, label, which is active, manifest counts.
|
|
2553
|
+
|
|
2554
|
+
Examples:
|
|
2555
|
+
dataspring warehouses list
|
|
2556
|
+
dataspring warehouses list -f json
|
|
2557
|
+
"""
|
|
2558
|
+
require_auth()
|
|
2559
|
+
token = auth.get_access_token()
|
|
2560
|
+
|
|
2561
|
+
try:
|
|
2562
|
+
entries = _warehouses_from(api_get(WAREHOUSES_PATH, token))
|
|
2563
|
+
except Exception as e:
|
|
2564
|
+
fail(e, role_action="list warehouses")
|
|
2565
|
+
|
|
2566
|
+
if not entries:
|
|
2567
|
+
print_info("No warehouses configured")
|
|
2568
|
+
return
|
|
2569
|
+
|
|
2570
|
+
if format == "json":
|
|
2571
|
+
format_output(entries, format=format)
|
|
2572
|
+
return
|
|
2573
|
+
|
|
2574
|
+
format_output(
|
|
2575
|
+
[_warehouse_row(entry) for entry in entries],
|
|
2576
|
+
format=format,
|
|
2577
|
+
columns=["id", "kind", "label", "active", "models", "metrics", "imported", "where"],
|
|
2578
|
+
title="Warehouses",
|
|
2579
|
+
)
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
# ============================================================================
|
|
2583
|
+
# Secrets, both directions, and run_sql (datacore chunk 3, step 2)
|
|
2584
|
+
# ============================================================================
|
|
2585
|
+
#
|
|
2586
|
+
# `dataspring secret set <name> --stdin` is the terminal door of D17: the
|
|
2587
|
+
# value is read from stdin here and posted straight to the paste link the
|
|
2588
|
+
# server minted (`POST /api/secret-links/<nonce>`), so it goes terminal to
|
|
2589
|
+
# API and an agent driving the CLI sees only the exit status. `dataspring
|
|
2590
|
+
# airbyte credentials` prints the one-time reveal link (`--stdout` fetches
|
|
2591
|
+
# the value for a terminal user). `dataspring sql` is `run_sql`.
|
|
2592
|
+
|
|
2593
|
+
secret_app = typer.Typer(help="Customer secrets: set a value without it passing through an agent")
|
|
2594
|
+
app.add_typer(secret_app, name="secret")
|
|
2595
|
+
airbyte_app = typer.Typer(help="The tenant's Airbyte destination: its issued credentials")
|
|
2596
|
+
app.add_typer(airbyte_app, name="airbyte")
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def _link_nonce(link: str) -> str:
|
|
2600
|
+
"""The nonce at the end of a `https://dataspring.app/s/<nonce>` link."""
|
|
2601
|
+
return link.rstrip("/").rsplit("/", 1)[-1]
|
|
2602
|
+
|
|
2603
|
+
|
|
2604
|
+
@secret_app.command("set")
|
|
2605
|
+
def secret_set_cmd(
|
|
2606
|
+
name: Annotated[str, typer.Argument(help="The secret: t-<org>-<connection>-<field>")],
|
|
2607
|
+
stdin: Annotated[
|
|
2608
|
+
bool,
|
|
2609
|
+
typer.Option("--stdin", help="Read the value from stdin and post it to the paste link (required)"),
|
|
2610
|
+
] = False,
|
|
2611
|
+
):
|
|
2612
|
+
"""Set or rotate a customer secret from stdin, never from an argument.
|
|
2613
|
+
|
|
2614
|
+
Examples:
|
|
2615
|
+
printf '%s' "$KEY" | dataspring secret set t-acme-kanpla-api_key --stdin
|
|
2616
|
+
dataspring secret set t-acme-kanpla-api_key --stdin < key.txt
|
|
2617
|
+
"""
|
|
2618
|
+
require_auth()
|
|
2619
|
+
token = auth.get_access_token()
|
|
2620
|
+
if not stdin:
|
|
2621
|
+
print_error(
|
|
2622
|
+
"The value is read from stdin only; pass --stdin",
|
|
2623
|
+
hint="printf '%s' \"$VALUE\" | dataspring secret set <name> --stdin. Never put a secret in an argument: it lands in shell history.",
|
|
2624
|
+
)
|
|
2625
|
+
raise typer.Exit(2)
|
|
2626
|
+
value = sys.stdin.read()
|
|
2627
|
+
if value.endswith("\n") and not value.endswith("\n\n"):
|
|
2628
|
+
value = value[:-1] # the one trailing newline a shell adds; a JSON key keeps its shape
|
|
2629
|
+
if not value.strip():
|
|
2630
|
+
print_error("stdin was empty; nothing was set")
|
|
2631
|
+
raise typer.Exit(2)
|
|
2632
|
+
|
|
2633
|
+
try:
|
|
2634
|
+
minted = call_tool("secret_edit", "set", {"name": name}, token=token, timeout=30.0)
|
|
2635
|
+
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}", {"value": value}, token, timeout=60.0)
|
|
2636
|
+
except Exception as e:
|
|
2637
|
+
fail(e, role_action="set secrets", prefix="Failed to set the secret")
|
|
2638
|
+
|
|
2639
|
+
destroyed = result.get("destroyed") or []
|
|
2640
|
+
verb = "created" if result.get("created") else "rotated"
|
|
2641
|
+
print_success(f"Secret {result.get('secret', name)} {verb}: version {result.get('version')}")
|
|
2642
|
+
if destroyed:
|
|
2643
|
+
print_info(f"Previous version(s) destroyed: {', '.join(destroyed)}")
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
@airbyte_app.command("credentials")
|
|
2647
|
+
def airbyte_credentials(
|
|
2648
|
+
stdout: Annotated[
|
|
2649
|
+
bool,
|
|
2650
|
+
typer.Option("--stdout", help="Fetch the key through the link and print it (for a terminal user; it is one-time)"),
|
|
2651
|
+
] = False,
|
|
2652
|
+
):
|
|
2653
|
+
"""A one-time link to the tenant's Airbyte destination key (admin).
|
|
2654
|
+
|
|
2655
|
+
Examples:
|
|
2656
|
+
dataspring airbyte credentials # prints the link to hand to the person entering it in Airbyte
|
|
2657
|
+
dataspring airbyte credentials --stdout # prints the key itself, once
|
|
2658
|
+
"""
|
|
2659
|
+
require_auth()
|
|
2660
|
+
token = auth.get_access_token()
|
|
2661
|
+
try:
|
|
2662
|
+
minted = call_tool("secret_edit", "reveal", {}, token=token, timeout=30.0)
|
|
2663
|
+
except Exception as e:
|
|
2664
|
+
fail(e, role_action="reveal issued credentials", prefix="Failed to mint a reveal link")
|
|
2665
|
+
|
|
2666
|
+
if not stdout:
|
|
2667
|
+
console.print(minted["link"])
|
|
2668
|
+
print_info(f"Shows {minted.get('secret')} once, valid until {minted.get('expires_at')}; never paste it into a chat.")
|
|
2669
|
+
return
|
|
2670
|
+
try:
|
|
2671
|
+
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}/reveal", {}, token, timeout=30.0)
|
|
2672
|
+
except Exception as e:
|
|
2673
|
+
fail(e, prefix="Failed to read the key through the link")
|
|
2674
|
+
sys.stdout.write(result["value"])
|
|
2675
|
+
if not result["value"].endswith("\n"):
|
|
2676
|
+
sys.stdout.write("\n")
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
SqlFormat = Literal["table", "json", "csv", "markdown"]
|
|
2680
|
+
|
|
2681
|
+
|
|
2682
|
+
@app.command("sql")
|
|
2683
|
+
def sql_cmd(
|
|
2684
|
+
statement: Annotated[str, typer.Argument(help="One SQL statement; '-' reads it from stdin")],
|
|
2685
|
+
warehouse: Annotated[
|
|
2686
|
+
Optional[str],
|
|
2687
|
+
typer.Option("--warehouse", help="Which of the org's warehouses to run against (see `warehouses list`); default: the active one"),
|
|
2688
|
+
] = None,
|
|
2689
|
+
max_rows: Annotated[int, typer.Option("--max-rows", help="Rows to return at most (cap 10000)")] = 1000,
|
|
2690
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Estimate the bytes only; run nothing")] = False,
|
|
2691
|
+
format: Annotated[SqlFormat, typer.Option("--format", "-f", help="Output format")] = "table",
|
|
2692
|
+
):
|
|
2693
|
+
"""Run one read-only SQL statement as the org's reader identity (ungoverned).
|
|
2694
|
+
|
|
2695
|
+
Examples:
|
|
2696
|
+
dataspring sql "SELECT venue, SUM(revenue) FROM acme_marts.fct_orders GROUP BY 1"
|
|
2697
|
+
dataspring sql "SELECT COUNT(*) FROM acme_staging.stg_orders" --dry-run
|
|
2698
|
+
dataspring sql - --format csv < query.sql > rows.csv
|
|
2699
|
+
"""
|
|
2700
|
+
require_auth()
|
|
2701
|
+
token = auth.get_access_token()
|
|
2702
|
+
if statement == "-":
|
|
2703
|
+
statement = sys.stdin.read()
|
|
2704
|
+
|
|
2705
|
+
body: dict = {"sql": statement, "max_rows": max_rows}
|
|
2706
|
+
if warehouse:
|
|
2707
|
+
body["warehouse"] = warehouse
|
|
2708
|
+
if dry_run:
|
|
2709
|
+
body["dry_run"] = True
|
|
2710
|
+
if format in ("csv", "markdown"):
|
|
2711
|
+
body["format"] = format
|
|
2712
|
+
|
|
2713
|
+
try:
|
|
2714
|
+
result = call_tool("run_sql", None, body, token=token, timeout=120.0)
|
|
2715
|
+
except Exception as e:
|
|
2716
|
+
if isinstance(e, (typer.Exit, typer.Abort)):
|
|
2717
|
+
raise
|
|
2718
|
+
msg, hint = format_api_error(e, role_action="run SQL")
|
|
2719
|
+
print_error(f"SQL failed: {msg}", hint=hint)
|
|
2720
|
+
raise typer.Exit(1)
|
|
2721
|
+
|
|
2722
|
+
if dry_run:
|
|
2723
|
+
print_success(f"Dry run: {result.get('bytes_processed') or 0:,} bytes would be processed as {result.get('identity')}")
|
|
2724
|
+
return
|
|
2725
|
+
if format in ("csv", "markdown"):
|
|
2726
|
+
sys.stdout.write(result.get("content") or "")
|
|
2727
|
+
return
|
|
2728
|
+
data = result.get("data", [])
|
|
2729
|
+
columns = result.get("columns", [])
|
|
2730
|
+
format_output(data, format=format, columns=columns, title=f"SQL ({len(data)} rows, ungoverned)")
|
|
2731
|
+
if format == "table":
|
|
2732
|
+
bytes_processed = result.get("bytes_processed")
|
|
2733
|
+
console.print(f"[dim]{result.get('note')}[/]")
|
|
2734
|
+
console.print(
|
|
2735
|
+
f"[dim]{bytes_processed:,} bytes processed[/]" if bytes_processed is not None else "",
|
|
2736
|
+
f"[dim]as {result.get('identity')}; job {result.get('job_id')}[/]",
|
|
2737
|
+
)
|
|
2738
|
+
|
|
2739
|
+
|
|
2740
|
+
# ============================================================================
|
|
2741
|
+
# datacore sub-commands (chunk 3): the local-folder door to the datacore
|
|
2742
|
+
# ============================================================================
|
|
2743
|
+
#
|
|
2744
|
+
# `pull` writes the deployed snapshot to a directory; `push` sends every
|
|
2745
|
+
# changed file back as a `datacore_edit` (kind connection, pipeline or model
|
|
2746
|
+
# by path, each with the previous edit's `version` as `expect_version`);
|
|
2747
|
+
# `check` and `deploy` are `datacore_run` actions, as are `run` and `reset`;
|
|
2748
|
+
# `status` reads. Reads are the `/api/datacore/*` routes, the same
|
|
2749
|
+
# payloads as the `dataspring://datacore/*` resources.
|
|
2750
|
+
|
|
2751
|
+
DATACORE_FILES_PATH = "/api/datacore/files"
|
|
2752
|
+
DATACORE_RUNS_PATH = "/api/datacore/runs"
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
def _datacore_family(path: str) -> tuple[str, str] | None:
|
|
2756
|
+
"""Which `datacore_edit` kind writes a workspace path, and the name it takes."""
|
|
2757
|
+
if path.startswith("connections/") and path.count("/") == 1 and path.endswith((".yaml", ".yml")):
|
|
2758
|
+
return "connection", path.split("/", 1)[1].rsplit(".", 1)[0]
|
|
2759
|
+
if path.startswith("pipelines/") and path.count("/") == 1 and path.endswith((".yaml", ".yml")):
|
|
2760
|
+
return "pipeline", path.split("/", 1)[1].rsplit(".", 1)[0]
|
|
2761
|
+
if path.startswith("models/"):
|
|
2762
|
+
return "model", path
|
|
2763
|
+
return None
|
|
2764
|
+
|
|
2765
|
+
|
|
2766
|
+
def _sha256_of(data: bytes) -> str:
|
|
2767
|
+
import hashlib
|
|
2768
|
+
|
|
2769
|
+
return hashlib.sha256(data).hexdigest()
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
def _local_files(directory: Path) -> dict[str, bytes]:
|
|
2773
|
+
out = {}
|
|
2774
|
+
for path in sorted(directory.rglob("*")):
|
|
2775
|
+
if not path.is_file():
|
|
2776
|
+
continue
|
|
2777
|
+
rel = path.relative_to(directory).as_posix()
|
|
2778
|
+
if rel.startswith(".") or "/." in rel or any(part in ("target", "dbt_packages", "logs") for part in rel.split("/")):
|
|
2779
|
+
continue
|
|
2780
|
+
out[rel] = path.read_bytes()
|
|
2781
|
+
return out
|
|
2782
|
+
|
|
2783
|
+
|
|
2784
|
+
@datacore_app.command("pull")
|
|
2785
|
+
def datacore_pull(
|
|
2786
|
+
directory: Annotated[Path, typer.Argument(help="Where to write the workspace")],
|
|
2787
|
+
snapshot: Annotated[
|
|
2788
|
+
str, typer.Option("--snapshot", help="deployed (default) or draft")
|
|
2789
|
+
] = "deployed",
|
|
2790
|
+
):
|
|
2791
|
+
"""Write the deployed snapshot (or the draft) to a directory, file by file.
|
|
2792
|
+
|
|
2793
|
+
Example:
|
|
2794
|
+
dataspring datacore pull ./noon-datacore
|
|
2795
|
+
"""
|
|
2796
|
+
require_auth()
|
|
2797
|
+
token = auth.get_access_token()
|
|
2798
|
+
if snapshot not in ("deployed", "draft"):
|
|
2799
|
+
print_error("--snapshot is deployed or draft")
|
|
2800
|
+
raise typer.Exit(1)
|
|
2801
|
+
try:
|
|
2802
|
+
files = api_get(DATACORE_FILES_PATH, token)
|
|
2803
|
+
except Exception as e:
|
|
2804
|
+
fail(e, role_action="read the datacore")
|
|
2805
|
+
listing = files.get(snapshot)
|
|
2806
|
+
if not listing:
|
|
2807
|
+
print_error(f"the org has no {snapshot} snapshot")
|
|
2808
|
+
raise typer.Exit(1)
|
|
2809
|
+
snapshot_id = listing["id"]
|
|
2810
|
+
paths = [f["path"] for f in listing.get("files") or []]
|
|
2811
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
2812
|
+
written = 0
|
|
2813
|
+
for rel in paths:
|
|
2814
|
+
try:
|
|
2815
|
+
doc = api_get(f"/api/datacore/file/{rel}?snapshot={snapshot_id}", token)
|
|
2816
|
+
except Exception as e:
|
|
2817
|
+
fail(e, prefix=rel)
|
|
2818
|
+
if doc.get("binary"):
|
|
2819
|
+
print_warning(f"skipped binary file {rel}")
|
|
2820
|
+
continue
|
|
2821
|
+
target = directory / rel
|
|
2822
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2823
|
+
target.write_text(doc.get("content") or "", encoding="utf-8")
|
|
2824
|
+
written += 1
|
|
2825
|
+
(directory / ".dataspring-snapshot").write_text(snapshot_id + "\n", encoding="utf-8")
|
|
2826
|
+
print_success(f"pulled snapshot {snapshot_id}: {written} files into {directory}")
|
|
2827
|
+
|
|
2828
|
+
|
|
2829
|
+
@datacore_app.command("push")
|
|
2830
|
+
def datacore_push(
|
|
2831
|
+
directory: Annotated[Path, typer.Argument(help="A pulled (and edited) workspace directory")],
|
|
2832
|
+
prune: Annotated[bool, typer.Option("--prune", help="Also delete remote files absent locally")] = False,
|
|
2833
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show what would change, send nothing")] = False,
|
|
2834
|
+
):
|
|
2835
|
+
"""Upload every changed file as an edit into the draft (connections,
|
|
2836
|
+
pipelines and the dbt project each through their own edit family), then
|
|
2837
|
+
print the new version. Run `dataspring datacore check` next.
|
|
2838
|
+
|
|
2839
|
+
Example:
|
|
2840
|
+
dataspring datacore push ./noon-datacore
|
|
2841
|
+
"""
|
|
2842
|
+
require_auth()
|
|
2843
|
+
token = auth.get_access_token()
|
|
2844
|
+
if not directory.is_dir():
|
|
2845
|
+
print_error(f"{directory} is not a directory")
|
|
2846
|
+
raise typer.Exit(1)
|
|
2847
|
+
try:
|
|
2848
|
+
files = api_get(DATACORE_FILES_PATH, token)
|
|
2849
|
+
except Exception as e:
|
|
2850
|
+
fail(e, role_action="read the datacore")
|
|
2851
|
+
remote = {f["path"]: f for f in ((files.get("draft") or files.get("deployed") or {}).get("files") or [])}
|
|
2852
|
+
version = files.get("version")
|
|
2853
|
+
local = _local_files(directory)
|
|
2854
|
+
|
|
2855
|
+
changes: list[tuple[str, str]] = []
|
|
2856
|
+
for rel, data in local.items():
|
|
2857
|
+
if _datacore_family(rel) is None:
|
|
2858
|
+
continue
|
|
2859
|
+
if rel not in remote or remote[rel].get("sha256") != _sha256_of(data):
|
|
2860
|
+
changes.append(("apply", rel))
|
|
2861
|
+
if prune:
|
|
2862
|
+
for rel in remote:
|
|
2863
|
+
if rel not in local and _datacore_family(rel) is not None:
|
|
2864
|
+
changes.append(("delete", rel))
|
|
2865
|
+
if not changes:
|
|
2866
|
+
print_info(f"nothing to push: the directory matches version {version}")
|
|
2867
|
+
return
|
|
2868
|
+
for verb, rel in changes:
|
|
2869
|
+
typer.echo(f"{verb:6} {rel}")
|
|
2870
|
+
if dry_run:
|
|
2871
|
+
return
|
|
2872
|
+
|
|
2873
|
+
for verb, rel in changes:
|
|
2874
|
+
kind, name = _datacore_family(rel) # type: ignore[misc]
|
|
2875
|
+
try:
|
|
2876
|
+
fields: dict = {"kind": kind, "name": name, "expect_version": version}
|
|
2877
|
+
if verb == "delete":
|
|
2878
|
+
result = call_tool("datacore_edit", "delete", fields, token=token)
|
|
2879
|
+
elif kind == "model":
|
|
2880
|
+
result = call_tool("datacore_edit", "apply", {**fields, "content": local[rel].decode("utf-8")}, token=token)
|
|
2881
|
+
else:
|
|
2882
|
+
document = yaml.safe_load(local[rel].decode("utf-8")) or {}
|
|
2883
|
+
result = call_tool("datacore_edit", "apply", {**fields, "document": document}, token=token)
|
|
2884
|
+
except Exception as e:
|
|
2885
|
+
fail(e, prefix=rel)
|
|
2886
|
+
version = result.get("version", version)
|
|
2887
|
+
print_success(f"pushed {len(changes)} change(s); draft version {version}")
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _print_check_report(run: dict) -> None:
|
|
2891
|
+
report = run.get("check") or {}
|
|
2892
|
+
status = run.get("status")
|
|
2893
|
+
line = f"check {run.get('id')}: {status}"
|
|
2894
|
+
if report.get("snapshot"):
|
|
2895
|
+
line += f" (snapshot {report['snapshot']})"
|
|
2896
|
+
(print_success if status == "succeeded" else print_error)(line)
|
|
2897
|
+
for entry in report.get("pipelines") or []:
|
|
2898
|
+
mark = "ok " if entry.get("ok") else "ERR"
|
|
2899
|
+
typer.echo(f" pipeline {mark} {entry['name']}" + ("" if entry.get("ok") else f": {entry.get('error')}"))
|
|
2900
|
+
for entry in report.get("connections") or []:
|
|
2901
|
+
mark = "ok " if entry.get("ok") else "ERR"
|
|
2902
|
+
typer.echo(f" connection {mark} {entry['name']}" + ("" if entry.get("ok") else f": {entry.get('error')}"))
|
|
2903
|
+
dbt = report.get("dbt") or {}
|
|
2904
|
+
if dbt:
|
|
2905
|
+
typer.echo(
|
|
2906
|
+
f" dbt {dbt.get('status')}: {dbt.get('models', 0)} models, {dbt.get('tests', 0)} tests, "
|
|
2907
|
+
f"{dbt.get('seeds', 0)} seeds" + (f" -> {dbt.get('target')}" if dbt.get("target") else "")
|
|
2908
|
+
)
|
|
2909
|
+
for failure in dbt.get("failures") or []:
|
|
2910
|
+
typer.echo(f" FAIL {failure.get('node')}: {failure.get('message')}")
|
|
2911
|
+
for warning in dbt.get("warnings") or []:
|
|
2912
|
+
typer.echo(f" warn {warning.get('node')}: {warning.get('message')}")
|
|
2913
|
+
if report.get("dry_run"):
|
|
2914
|
+
typer.echo(f" dry run {report.get('dry_run_bytes', 0):,} bytes over {len(report['dry_run'])} models")
|
|
2915
|
+
for error in report.get("errors") or []:
|
|
2916
|
+
typer.echo(f" error {error}")
|
|
2917
|
+
if run.get("error") and not report:
|
|
2918
|
+
typer.echo(f" error {run['error'].get('message')}")
|
|
2919
|
+
|
|
2920
|
+
|
|
2921
|
+
def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float = 1800.0) -> dict:
|
|
2922
|
+
import time
|
|
2923
|
+
|
|
2924
|
+
deadline = time.monotonic() + timeout
|
|
2925
|
+
last = None
|
|
2926
|
+
while True:
|
|
2927
|
+
run = api_get(f"{DATACORE_RUNS_PATH}/{run_id}", token)
|
|
2928
|
+
if run.get("status") != last:
|
|
2929
|
+
last = run.get("status")
|
|
2930
|
+
print_info(f"run {run_id}: {last}")
|
|
2931
|
+
if last in ("succeeded", "failed"):
|
|
2932
|
+
return run
|
|
2933
|
+
if time.monotonic() > deadline:
|
|
2934
|
+
print_error(f"run {run_id} still {last} after {int(timeout)}s")
|
|
2935
|
+
raise typer.Exit(1)
|
|
2936
|
+
time.sleep(interval)
|
|
2937
|
+
|
|
2938
|
+
|
|
2939
|
+
@datacore_app.command("check")
|
|
2940
|
+
def datacore_check_cmd(
|
|
2941
|
+
wait: Annotated[bool, typer.Option("--wait/--no-wait", help="Poll the check run to its end")] = True,
|
|
2942
|
+
):
|
|
2943
|
+
"""Check the draft: schema validation here, then a check run of the
|
|
2944
|
+
tenant's job (compile, dbt build into <org>_dev, tests, dry-run bytes).
|
|
2945
|
+
|
|
2946
|
+
Example:
|
|
2947
|
+
dataspring datacore check
|
|
2948
|
+
"""
|
|
2949
|
+
require_auth()
|
|
2950
|
+
token = auth.get_access_token()
|
|
2951
|
+
try:
|
|
2952
|
+
result = call_tool("datacore_run", "check", {}, token=token)
|
|
2953
|
+
except Exception as e:
|
|
2954
|
+
fail(e, role_action="check the datacore")
|
|
2955
|
+
if not result.get("ok"):
|
|
2956
|
+
print_error(f"check failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
|
|
2957
|
+
for error in result.get("errors") or []:
|
|
2958
|
+
typer.echo(f" {error}")
|
|
2959
|
+
raise typer.Exit(1)
|
|
2960
|
+
run_id = result["run_id"]
|
|
2961
|
+
print_info(f"check run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
|
|
2962
|
+
if not wait:
|
|
2963
|
+
return
|
|
2964
|
+
try:
|
|
2965
|
+
run = _poll_run(run_id, token)
|
|
2966
|
+
except typer.Exit:
|
|
2967
|
+
raise
|
|
2968
|
+
except Exception as e:
|
|
2969
|
+
fail(e, role_action="read the check run")
|
|
2970
|
+
_print_check_report(run)
|
|
2971
|
+
if run.get("status") != "succeeded":
|
|
2972
|
+
raise typer.Exit(1)
|
|
2973
|
+
|
|
2974
|
+
|
|
2975
|
+
@datacore_app.command("deploy")
|
|
2976
|
+
def datacore_deploy_cmd(
|
|
2977
|
+
snapshot: Annotated[Optional[str], typer.Option("--snapshot", help="A previously deployed snapshot id to roll back to")] = None,
|
|
2978
|
+
note: Annotated[Optional[str], typer.Option("--note", help="Why, for the learned trail")] = None,
|
|
2979
|
+
):
|
|
2980
|
+
"""Promote the checked draft to deployed (or roll back to a snapshot).
|
|
2981
|
+
|
|
2982
|
+
Example:
|
|
2983
|
+
dataspring datacore deploy --note "orders mart reads the new partner tables"
|
|
2984
|
+
"""
|
|
2985
|
+
require_auth()
|
|
2986
|
+
token = auth.get_access_token()
|
|
2987
|
+
fields: dict = {}
|
|
2988
|
+
if snapshot:
|
|
2989
|
+
fields["snapshot"] = snapshot
|
|
2990
|
+
if note:
|
|
2991
|
+
fields["note"] = note
|
|
2992
|
+
try:
|
|
2993
|
+
result = call_tool("datacore_run", "deploy", fields, token=token)
|
|
2994
|
+
except Exception as e:
|
|
2995
|
+
fail(e, role_action="deploy the datacore")
|
|
2996
|
+
print_success(f"deployed snapshot {result.get('deployed_snapshot')} (was {result.get('previous_snapshot') or 'none'})")
|
|
2997
|
+
for run in result.get("runs_triggered") or []:
|
|
2998
|
+
typer.echo(f" run {run['run_id']} started for pipeline {run['pipeline']}")
|
|
2999
|
+
for skipped in result.get("runs_skipped") or []:
|
|
3000
|
+
typer.echo(f" pipeline {skipped['pipeline']} not started: run {skipped['active_run']} is active")
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
@datacore_app.command("run")
|
|
3004
|
+
def datacore_run_cmd(
|
|
3005
|
+
pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
|
|
3006
|
+
start: Annotated[Optional[str], typer.Option("--start", help="Window start, YYYY-MM-DD")] = None,
|
|
3007
|
+
end: Annotated[Optional[str], typer.Option("--end", help="Window end, YYYY-MM-DD")] = None,
|
|
3008
|
+
chunk: Annotated[Optional[str], typer.Option("--chunk", help="Backfill chunk: 7d, 2w or 1M")] = None,
|
|
3009
|
+
cursor_from: Annotated[Optional[str], typer.Option("--from", help="Cursor value to re-pull from")] = None,
|
|
3010
|
+
wait: Annotated[bool, typer.Option("--wait", help="Poll the run to its end")] = False,
|
|
3011
|
+
):
|
|
3012
|
+
"""Run a pipeline now, over a window, or as a chunked backfill.
|
|
3013
|
+
|
|
3014
|
+
Examples:
|
|
3015
|
+
dataspring datacore run kanpla
|
|
3016
|
+
dataspring datacore run kanpla --start 2026-01-01 --end 2026-06-30 --chunk 1M
|
|
3017
|
+
"""
|
|
3018
|
+
require_auth()
|
|
3019
|
+
token = auth.get_access_token()
|
|
3020
|
+
if bool(start) != bool(end):
|
|
3021
|
+
print_error("--start and --end go together")
|
|
3022
|
+
raise typer.Exit(1)
|
|
3023
|
+
fields: dict = {"pipeline": pipeline}
|
|
3024
|
+
if start and end:
|
|
3025
|
+
fields["window"] = {"start": start, "end": end}
|
|
3026
|
+
if chunk:
|
|
3027
|
+
fields["backfill_chunk"] = chunk
|
|
3028
|
+
if cursor_from:
|
|
3029
|
+
fields["cursor"] = {"from": cursor_from}
|
|
3030
|
+
try:
|
|
3031
|
+
result = call_tool("datacore_run", "run", fields, token=token)
|
|
3032
|
+
except Exception as e:
|
|
3033
|
+
fail(e, role_action="run a pipeline")
|
|
3034
|
+
if result.get("kind") == "backfill":
|
|
3035
|
+
print_success(f"backfill {result['run_id']} of {pipeline}: {result.get('total')} chunks, first child {result.get('children', [None])[0]}")
|
|
3036
|
+
else:
|
|
3037
|
+
print_success(f"run {result['run_id']} of {pipeline}: {result.get('status')}")
|
|
3038
|
+
if wait:
|
|
3039
|
+
try:
|
|
3040
|
+
run = _poll_run(result["run_id"], token, timeout=6 * 3600)
|
|
3041
|
+
except typer.Exit:
|
|
3042
|
+
raise
|
|
3043
|
+
except Exception as e:
|
|
3044
|
+
fail(e, role_action="read the run")
|
|
3045
|
+
if run.get("status") != "succeeded":
|
|
3046
|
+
print_error(f"run {run['id']} failed: {(run.get('error') or {}).get('message')}")
|
|
3047
|
+
raise typer.Exit(1)
|
|
3048
|
+
|
|
3049
|
+
|
|
3050
|
+
@datacore_app.command("reset")
|
|
3051
|
+
def datacore_reset_cmd(
|
|
3052
|
+
pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
|
|
3053
|
+
approve: Annotated[bool, typer.Option("--approve", help="Required: the reset is irreversible")] = False,
|
|
3054
|
+
):
|
|
3055
|
+
"""Delete a pipeline's dlt state and drop its raw tables (irreversible).
|
|
3056
|
+
|
|
3057
|
+
Example:
|
|
3058
|
+
dataspring datacore reset kanpla --approve
|
|
3059
|
+
"""
|
|
3060
|
+
require_auth()
|
|
3061
|
+
token = auth.get_access_token()
|
|
3062
|
+
if not approve:
|
|
3063
|
+
print_error(f"resetting {pipeline} deletes its dlt state and drops its raw tables; repeat with --approve")
|
|
3064
|
+
raise typer.Exit(1)
|
|
3065
|
+
try:
|
|
3066
|
+
result = call_tool("datacore_run", "reset", {"pipeline": pipeline, "approve": True}, token=token)
|
|
3067
|
+
except Exception as e:
|
|
3068
|
+
fail(e, role_action="reset a pipeline")
|
|
3069
|
+
print_success(f"reset {pipeline}: dropped {', '.join(result.get('tables') or []) or 'no tables'}")
|
|
3070
|
+
|
|
3071
|
+
|
|
3072
|
+
@datacore_app.command("status")
|
|
3073
|
+
def datacore_status(
|
|
3074
|
+
format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format")] = "table",
|
|
3075
|
+
):
|
|
3076
|
+
"""The deployed and draft snapshots, the draft's check, and recent runs.
|
|
3077
|
+
|
|
3078
|
+
Example:
|
|
3079
|
+
dataspring datacore status
|
|
3080
|
+
"""
|
|
3081
|
+
require_auth()
|
|
3082
|
+
token = auth.get_access_token()
|
|
3083
|
+
try:
|
|
3084
|
+
files = api_get(DATACORE_FILES_PATH, token)
|
|
3085
|
+
runs = api_get(DATACORE_RUNS_PATH, token).get("runs") or []
|
|
3086
|
+
except Exception as e:
|
|
3087
|
+
fail(e, role_action="read the datacore")
|
|
3088
|
+
if format == "json":
|
|
3089
|
+
format_output({"files": files, "runs": runs}, format=format)
|
|
3090
|
+
return
|
|
3091
|
+
deployed, draft = files.get("deployed"), files.get("draft")
|
|
3092
|
+
typer.echo(f"deployed {deployed['id'] if deployed else 'none'}" + (f" ({deployed['file_count']} files, {deployed.get('deployed_at') or deployed.get('created_at')})" if deployed else ""))
|
|
3093
|
+
if draft:
|
|
3094
|
+
checks = draft.get("checks") or {}
|
|
3095
|
+
typer.echo(f"draft {draft['id']} ({draft['file_count']} files, check: {checks.get('status') or 'not run'}" + (f", run {checks['run_id']}" if checks.get("run_id") else "") + ")")
|
|
3096
|
+
else:
|
|
3097
|
+
typer.echo("draft none")
|
|
3098
|
+
typer.echo(f"version {files.get('version')} auto_deploy: {'on' if files.get('auto_deploy') else 'off'}")
|
|
3099
|
+
rows = [
|
|
3100
|
+
{
|
|
3101
|
+
"id": r.get("id"),
|
|
3102
|
+
"pipeline": r.get("pipeline"),
|
|
3103
|
+
"trigger": r.get("trigger"),
|
|
3104
|
+
"status": r.get("status") + (f" {r.get('done')}/{r.get('total')}" if r.get("kind") == "backfill" else ""),
|
|
3105
|
+
"window": f"{r['window']['start']}..{r['window']['end']}" if r.get("window") else "",
|
|
3106
|
+
"requested": r.get("requested_at"),
|
|
3107
|
+
"error": ((r.get("error") or {}).get("message") or "")[:60],
|
|
3108
|
+
}
|
|
3109
|
+
for r in runs[:15]
|
|
3110
|
+
]
|
|
3111
|
+
if rows:
|
|
3112
|
+
format_output(rows, format=format, columns=["id", "pipeline", "trigger", "status", "window", "requested", "error"], title="Recent runs")
|
|
3113
|
+
|
|
3114
|
+
|
|
3115
|
+
# ============================================================================
|
|
3116
|
+
# Skill command (T3.5 — plan task)
|
|
3117
|
+
# ============================================================================
|
|
3118
|
+
#
|
|
3119
|
+
# Hits the no-auth /api/v1/skill/* endpoints. Useful even when the user
|
|
3120
|
+
# isn't logged in — the skills are public conventions, not org data.
|
|
3121
|
+
#
|
|
3122
|
+
# Forms:
|
|
3123
|
+
# dataspring skill — list personas
|
|
3124
|
+
# dataspring skill <persona> — frontmatter
|
|
3125
|
+
# dataspring skill <persona> instructions — full body
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
def _skill_url(path: str) -> str:
|
|
3129
|
+
"""Compute the public skill URL from the configured base."""
|
|
3130
|
+
return f"{get_api_base()}{path}"
|
|
3131
|
+
|
|
3132
|
+
|
|
3133
|
+
def _fetch_skill_markdown(path: str) -> str:
|
|
3134
|
+
"""GET text/markdown without auth; surface friendly errors."""
|
|
3135
|
+
import httpx
|
|
3136
|
+
|
|
3137
|
+
url = _skill_url(path)
|
|
3138
|
+
try:
|
|
3139
|
+
response = httpx.get(url, timeout=30.0)
|
|
3140
|
+
response.raise_for_status()
|
|
3141
|
+
return response.text
|
|
3142
|
+
except httpx.HTTPStatusError as e:
|
|
3143
|
+
# Use the server's body verbatim — the 404 includes the list
|
|
3144
|
+
# of available personas, which is the most helpful thing.
|
|
3145
|
+
body = (e.response.text or "").strip()
|
|
3146
|
+
if e.response.status_code == 404 and body:
|
|
3147
|
+
print_error(body)
|
|
3148
|
+
else:
|
|
3149
|
+
print_error(f"Server error {e.response.status_code} fetching {url}")
|
|
3150
|
+
raise typer.Exit(1)
|
|
3151
|
+
except httpx.ConnectError:
|
|
3152
|
+
print_error(
|
|
3153
|
+
f"Could not connect to {url}",
|
|
3154
|
+
hint="Check the API base URL or your network.",
|
|
3155
|
+
)
|
|
3156
|
+
raise typer.Exit(1)
|
|
3157
|
+
except httpx.RequestError as e:
|
|
3158
|
+
print_error(
|
|
3159
|
+
f"Network error fetching skills: {e}",
|
|
3160
|
+
hint="Check the API base URL or your network.",
|
|
3161
|
+
)
|
|
3162
|
+
raise typer.Exit(1)
|
|
3163
|
+
|
|
3164
|
+
|
|
3165
|
+
skill_app = typer.Typer(
|
|
3166
|
+
invoke_without_command=True,
|
|
3167
|
+
help="Fetch published DataSpring skills (no authentication required)",
|
|
3168
|
+
)
|
|
3169
|
+
app.add_typer(skill_app, name="skill")
|
|
3170
|
+
|
|
3171
|
+
|
|
3172
|
+
@skill_app.callback(invoke_without_command=True)
|
|
3173
|
+
def _skill_callback(ctx: typer.Context):
|
|
3174
|
+
"""List all personas when invoked without a subcommand.
|
|
3175
|
+
|
|
3176
|
+
``dataspring skill`` → index. ``dataspring skill <persona>`` and
|
|
3177
|
+
``dataspring skill <persona> instructions`` are explicit subcommands
|
|
3178
|
+
below — keeps Typer in charge of dispatch instead of magic
|
|
3179
|
+
positional strings.
|
|
3180
|
+
"""
|
|
3181
|
+
if ctx.invoked_subcommand is not None:
|
|
3182
|
+
return
|
|
3183
|
+
body = _fetch_skill_markdown("/api/v1/skill")
|
|
3184
|
+
# The server's index includes hints like
|
|
3185
|
+
# Fetch full body: `GET /api/v1/skill/<persona>/instructions`
|
|
3186
|
+
# which is helpful for curl users but irritating for CLI users
|
|
3187
|
+
# ("why are you telling me to curl?"). Rewrite to the CLI form so
|
|
3188
|
+
# the next step the user takes is `dataspring skill instructions ...`.
|
|
3189
|
+
import re
|
|
3190
|
+
|
|
3191
|
+
body = re.sub(
|
|
3192
|
+
r"Fetch full body: `GET /api/v1/skill/([^/`]+)/instructions`",
|
|
3193
|
+
r"Fetch full body: `dataspring skill instructions \1`",
|
|
3194
|
+
body,
|
|
3195
|
+
)
|
|
3196
|
+
typer.echo(body)
|
|
3197
|
+
|
|
3198
|
+
|
|
3199
|
+
@skill_app.command("show")
|
|
3200
|
+
def skill_show(
|
|
3201
|
+
persona: Annotated[
|
|
3202
|
+
str,
|
|
3203
|
+
typer.Argument(help="Skill persona name (e.g. author, consume)"),
|
|
3204
|
+
],
|
|
3205
|
+
):
|
|
3206
|
+
"""Print a persona's frontmatter."""
|
|
3207
|
+
typer.echo(_fetch_skill_markdown(f"/api/v1/skill/{persona}"))
|
|
3208
|
+
|
|
3209
|
+
|
|
3210
|
+
@skill_app.command("instructions")
|
|
3211
|
+
def skill_instructions(
|
|
3212
|
+
persona: Annotated[
|
|
3213
|
+
str,
|
|
3214
|
+
typer.Argument(help="Skill persona name (e.g. author, consume)"),
|
|
3215
|
+
],
|
|
3216
|
+
):
|
|
3217
|
+
"""Print a persona's full body (the SKILL.md content)."""
|
|
3218
|
+
typer.echo(_fetch_skill_markdown(f"/api/v1/skill/{persona}/instructions"))
|
|
3219
|
+
|
|
3220
|
+
|
|
3221
|
+
# ============================================================================
|
|
3222
|
+
# The generated tree: every registry operation, by its key
|
|
3223
|
+
# ============================================================================
|
|
3224
|
+
#
|
|
3225
|
+
# `dataspring <key> --<field>` and `dataspring <key> <verb> --<field>`, from
|
|
3226
|
+
# cli/generated.py. Mounted last so `--help` lists the human layer first.
|
|
3227
|
+
|
|
3228
|
+
mount_generated(app)
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
if __name__ == "__main__":
|
|
3232
|
+
app()
|