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.
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: dataspring-correct
3
+ description: How to handle a correction — where the fact belongs, what this user may change, and what needs a dbt change instead. Use when a user disputes a number, says a metric means something else, tells you a caveat, asks what you computed, or asks you to remember a query.
4
+ ---
5
+
6
+ # Correcting DataSpring
7
+
8
+ A correction is a fact arriving late. Find its home, write it where future
9
+ readers see it, and be exact about what changed.
10
+
11
+ ## 1. First explain, never guess
12
+
13
+ Before agreeing anything is wrong, call `explain_query`; with no
14
+ `params` it explains this session's last query. Quote back the metric it
15
+ resolved, the filters applied (standing preferences included), the effective
16
+ window, and briefly the SQL. Often the number is right and the disagreement
17
+ is about which metric was meant — ambiguity, not a correction. Never
18
+ apologise for a number you have not re-read.
19
+
20
+ If that query returned an EMPTY or null result, try the other query shape —
21
+ grouped instead of filtered, or vice versa — before reporting that there is
22
+ no data. Explaining a null is not an answer.
23
+
24
+ ## 2. Classify the fact
25
+
26
+ | Type of fact | Goes in... |
27
+ |---|---|
28
+ | One metric's meaning, computation, unit, source rollup | That metric's description |
29
+ | One metric's known caveat (bug warning, temporal discontinuity, methodology change) | That metric's description |
30
+ | One metric's standing filter rule ("for B2B, pair with...") | That metric's description |
31
+ | One dimension's allowed values, derivation, "use this not raw" steering | That dimension's description, via `semantic_model_edit` |
32
+ | One source's freshness, format quirks, "field X is 100% NULL" | That source's description |
33
+ | Cross-cutting derivation ("B2B is derived in event_segment, never raw event_type") | `business_context` |
34
+ | Org-level system inventory (what's connected, what's sunset) | `business_context` |
35
+ | Org-level coverage gap ("we don't sync system X") | `business_context` |
36
+ | Region, dbt layout, project-level operational pointers | `business_context` |
37
+ | Findings, analysis results, history | NOT in the context layer at all |
38
+ | Per-metric definition logic | NOT in context — lives in the dbt model SQL |
39
+ | The user's own standing intent ("always exclude Direct for me") | A personal preference, via `update_user_preferences` |
40
+ | A question and the query that correctly answers it, confirmed by the user | `verified_query_edit` record, scope `user` |
41
+
42
+ A verified query is scope `org` only on an admin's say-so. When torn between
43
+ an entity description and `business_context`, choose the entity.
44
+
45
+ ## 3. Offer only what this user may change
46
+
47
+ Read the WHAT YOU MAY CHANGE block — this user's targets, nothing else. An
48
+ admin gets the systemic fix; a member or viewer a personal preference,
49
+ verified query or quick metric, which fixes their experience immediately.
50
+ Both are complete answers: never route a user elsewhere, never offer what
51
+ ends in a permission error. §7 is the same split for a coverage gap.
52
+
53
+ ## 4. Ownership and ceremony
54
+
55
+ **DataSpring owns TEXT.** Descriptions, labels and `business_context` write
56
+ immediately, no confirmation, even on dbt-imported entities. Always report
57
+ what changed AND how to undo it:
58
+
59
+ > Learned: `total_revenue` is gross of VAT.
60
+ > Live now, and survives manifest uploads. Undo with "undo that".
61
+
62
+ **dbt owns COMPUTATION.** On an IMPORTED metric or model, never promise to
63
+ change how a number is computed. The update returns `proposal: true` with an
64
+ `impact` block and a `dbt_yaml` snippet — nothing was written. Show the
65
+ dashboards affected and the before/after sample:
66
+
67
+ > Changing `total_revenue` to exclude VAT moves 4 dashboards and takes last
68
+ > month from 123k to 94k.
69
+
70
+ Then present the YAML verbatim and say what applies it: commit it to dbt —
71
+ it lands on the next manifest upload. Never offer to make the change or ask
72
+ whether to "proceed"; you cannot apply it, and asking implies you can. You
73
+ CAN offer: the text edit, a new metric, a quick metric.
74
+
75
+ For a NATIVE metric, `requires_confirmation` comes back with the impact on
76
+ the first call; show it and stop. Only the user's NEXT message confirms — a
77
+ validation error, a retry prompt or your own reasoning is not the user, and
78
+ a `confirmed` sent in the same turn is refused. Probe with
79
+ `metric_edit` action `impact` and the verdict comes back with it:
80
+ imported gets the dbt YAML, native the confirmation requirement. Never a
81
+ menu of options with no verdict.
82
+
83
+ ## 5. Metrics that are known to be wrong
84
+
85
+ When the real fix needs dbt work, do not leave consumers unwarned — write
86
+ the caveat into the description NOW, house style:
87
+
88
+ > KNOWN ISSUE: measured to the first resolution timestamp only, so tickets
89
+ > that were reopened and resolved again are undercounted — not yet fixed.
90
+
91
+ Pair it with the dbt YAML proposal for the fix, same turn.
92
+
93
+ ## 6. Undo
94
+
95
+ "undo that" is `learned_edit` undo with no `learning_id` — it reverts
96
+ the caller's most recent reversible entry; name what was restored. Its list
97
+ action shows what changed and by whom. A proposal cannot be undone — nothing
98
+ was written; say that, not "done".
99
+
100
+ ## 7. Coverage gaps
101
+
102
+ The declaration is unconditional. Whatever the role, open with the verbatim
103
+ SEMANTIC LAYER GAP block, then give the number as an ephemeral computation
104
+ from rows you fetched: name the metrics it came from, and say it is
105
+ not a defined metric. Only the DURABLE offer differs.
106
+
107
+ - **Admin / owner** — a real definition, this turn and unasked.
108
+ Ask `metric_edit` for a `preview`, then quote the `dbt_yaml` in full. A preview writes nothing, so needs no permission; only create needs
109
+ a yes — a native, additive entity that moves no existing number.
110
+ - **Member / viewer** — a quick metric via `quick_metric_edit` for a
111
+ recurring need (query it with the `qm:` prefix); an ephemeral computation
112
+ alone for a one-off. Never an org-scoped offer.
113
+
114
+ Bad, for an admin: a business heading, an inline ratio, an offer to save a
115
+ quick metric — or to propose a definition, with none shown. Good: the
116
+ declaration, the labelled ratio, the delegated preview's dbt YAML in full,
117
+ then "create it?". Raw SQL is never an option on any path.
118
+
119
+ ## 8. Do not
120
+
121
+ - Do not describe a queue, review step or approval state; none exist.
122
+ - Do not record a verified query on your own judgement — only when confirmed.
123
+ - Do not store numbers, results or findings in a description, preference or
124
+ memory — they go stale tomorrow; facts do not.
cli/auth.py ADDED
@@ -0,0 +1,375 @@
1
+ """CLI Authentication - Google OAuth flow and token management.
2
+
3
+ Handles:
4
+ - Interactive OAuth login via browser
5
+ - Token storage and refresh
6
+ - User context retrieval from ``GET /api/me``
7
+
8
+ This module is the only place the CLI touches Google directly, and only for
9
+ the OAuth login flow. Who the user is - and which org they are in - is the
10
+ server's answer to ``/api/me``; nothing here opens Firestore.
11
+ """
12
+
13
+ import json
14
+ import secrets
15
+ import webbrowser
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from http.server import HTTPServer, BaseHTTPRequestHandler
19
+ from pathlib import Path
20
+ from threading import Thread
21
+ from typing import Any
22
+ from urllib.parse import parse_qs, urlparse
23
+
24
+ import httpx
25
+ from google.auth.transport.requests import Request
26
+ from google.oauth2.credentials import Credentials
27
+ from google_auth_oauthlib.flow import Flow
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class UserContext:
32
+ """The signed-in user as ``GET /api/me`` reports them.
33
+
34
+ Mirrors the fields of ``services.auth.UserContext`` that the CLI shows,
35
+ without importing the service layer: the CLI wheel ships without it.
36
+ """
37
+
38
+ uid: str
39
+ email: str
40
+ org_id: str
41
+ org_name: str
42
+ role: str
43
+
44
+
45
+ class AuthenticationError(Exception):
46
+ """Raised when authentication fails or user is not logged in."""
47
+
48
+ pass
49
+
50
+
51
+ class CLIAuthManager:
52
+ """Manages OAuth tokens for CLI sessions.
53
+
54
+ Handles the complete OAuth flow:
55
+ 1. Start local callback server
56
+ 2. Open browser to Google consent screen
57
+ 3. Receive callback with auth code
58
+ 4. Exchange for tokens
59
+ 5. Store refresh token locally
60
+ 6. Resolve the user's org context from the API (``/api/me``)
61
+ """
62
+
63
+ CONFIG_DIR = Path.home() / ".dataspring"
64
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
65
+ CONFIG_FILE = CONFIG_DIR / "config.json"
66
+
67
+ # OAuth configuration - Desktop app client for CLI login flow
68
+ # Created in Google Cloud Console > APIs & Services > Credentials
69
+ # Application type: Desktop app, Name: DataSpring CLI
70
+ CLI_OAUTH_CLIENT_ID = "910366018047-ekplje5p344nbq60o8vqk88378turj52.apps.googleusercontent.com"
71
+ CLI_OAUTH_CLIENT_SECRET = "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"
72
+
73
+ SCOPES = [
74
+ "openid",
75
+ "https://www.googleapis.com/auth/userinfo.email",
76
+ "https://www.googleapis.com/auth/userinfo.profile",
77
+ ]
78
+
79
+ def __init__(self):
80
+ """Initialize the auth manager."""
81
+ self._ensure_config_dir()
82
+
83
+ def _ensure_config_dir(self):
84
+ """Create config directory if it doesn't exist."""
85
+ self.CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
86
+
87
+ def _get_oauth_config(self) -> dict[str, Any]:
88
+ """Get OAuth client configuration for desktop app flow."""
89
+ return {
90
+ "installed": {
91
+ "client_id": self.CLI_OAUTH_CLIENT_ID,
92
+ "client_secret": self.CLI_OAUTH_CLIENT_SECRET,
93
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
94
+ "token_uri": "https://oauth2.googleapis.com/token",
95
+ "redirect_uris": ["http://localhost"],
96
+ }
97
+ }
98
+
99
+ def _save_credentials(self, credentials: Credentials):
100
+ """Save credentials to file with restricted permissions."""
101
+ data = {
102
+ "token": credentials.token,
103
+ "refresh_token": credentials.refresh_token,
104
+ "token_uri": credentials.token_uri,
105
+ "client_id": credentials.client_id,
106
+ "client_secret": credentials.client_secret,
107
+ "scopes": list(credentials.scopes) if credentials.scopes else None,
108
+ "expiry": credentials.expiry.isoformat() if credentials.expiry else None,
109
+ }
110
+
111
+ self.CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
112
+ self.CREDENTIALS_FILE.chmod(0o600)
113
+
114
+ def _load_credentials(self) -> Credentials | None:
115
+ """Load credentials from file."""
116
+ if not self.CREDENTIALS_FILE.exists():
117
+ return None
118
+
119
+ try:
120
+ data = json.loads(self.CREDENTIALS_FILE.read_text())
121
+ expiry = None
122
+ if data.get("expiry"):
123
+ expiry = datetime.fromisoformat(data["expiry"])
124
+
125
+ return Credentials(
126
+ token=data.get("token"),
127
+ refresh_token=data.get("refresh_token"),
128
+ token_uri=data.get("token_uri"),
129
+ client_id=data.get("client_id"),
130
+ client_secret=data.get("client_secret"),
131
+ scopes=data.get("scopes"),
132
+ expiry=expiry,
133
+ )
134
+ except (json.JSONDecodeError, KeyError):
135
+ return None
136
+
137
+ def _refresh_if_needed(self, credentials: Credentials) -> Credentials:
138
+ """Refresh credentials if expired."""
139
+ if credentials.expired and credentials.refresh_token:
140
+ credentials.refresh(Request())
141
+ self._save_credentials(credentials)
142
+ return credentials
143
+
144
+ async def login(self) -> UserContext:
145
+ """Interactive OAuth login flow.
146
+
147
+ Opens browser for Google consent, then looks up user context.
148
+
149
+ Returns:
150
+ UserContext with user info and org context
151
+
152
+ Raises:
153
+ AuthenticationError: If login fails
154
+ """
155
+ config = self._get_oauth_config()
156
+
157
+ # Create flow with redirect to localhost
158
+ flow = Flow.from_client_config(
159
+ config,
160
+ scopes=self.SCOPES,
161
+ redirect_uri="http://localhost:8085/callback",
162
+ )
163
+
164
+ # Generate authorization URL with state for CSRF protection
165
+ state = secrets.token_urlsafe(32)
166
+ auth_url, _ = flow.authorization_url(
167
+ access_type="offline",
168
+ include_granted_scopes="true",
169
+ prompt="consent",
170
+ state=state,
171
+ )
172
+
173
+ # Start local callback server
174
+ auth_code = None
175
+ received_state = None
176
+
177
+ class CallbackHandler(BaseHTTPRequestHandler):
178
+ def do_GET(self):
179
+ nonlocal auth_code, received_state
180
+ parsed = urlparse(self.path)
181
+
182
+ if parsed.path == "/callback":
183
+ params = parse_qs(parsed.query)
184
+ auth_code = params.get("code", [None])[0]
185
+ received_state = params.get("state", [None])[0]
186
+
187
+ self.send_response(200)
188
+ self.send_header("Content-type", "text/html")
189
+ self.end_headers()
190
+ self.wfile.write(
191
+ b"<html><body><h1>Login successful!</h1>"
192
+ b"<p>You can close this window and return to the terminal.</p>"
193
+ b"</body></html>"
194
+ )
195
+ else:
196
+ self.send_response(404)
197
+ self.end_headers()
198
+
199
+ def log_message(self, format, *args):
200
+ pass # Suppress server logs
201
+
202
+ server = HTTPServer(("localhost", 8085), CallbackHandler)
203
+
204
+ # Run server in background thread
205
+ server_thread = Thread(target=lambda: server.handle_request())
206
+ server_thread.start()
207
+
208
+ # Open browser
209
+ print("Opening browser for login...")
210
+ webbrowser.open(auth_url)
211
+
212
+ # Wait for callback
213
+ server_thread.join(timeout=120)
214
+
215
+ if not auth_code:
216
+ raise AuthenticationError("Login timeout or failed to receive auth code")
217
+
218
+ if received_state != state:
219
+ raise AuthenticationError("Invalid state parameter - possible CSRF attack")
220
+
221
+ # Exchange code for tokens
222
+ flow.fetch_token(code=auth_code)
223
+ credentials = flow.credentials
224
+
225
+ # Save credentials
226
+ self._save_credentials(credentials)
227
+
228
+ # Get user context
229
+ return await self._get_user_context(credentials)
230
+
231
+ async def _get_user_context(self, credentials: Credentials) -> UserContext:
232
+ """Get user context from email in token."""
233
+ # Get user email from Google userinfo
234
+ async with httpx.AsyncClient() as client:
235
+ resp = await client.get(
236
+ "https://www.googleapis.com/oauth2/v3/userinfo",
237
+ headers={"Authorization": f"Bearer {credentials.token}"},
238
+ )
239
+ if resp.status_code != 200:
240
+ raise AuthenticationError(f"Failed to get user info: {resp.text}")
241
+ user_info = resp.json()
242
+
243
+ email = user_info.get("email")
244
+ if not email:
245
+ raise AuthenticationError("Email not found in OAuth response")
246
+
247
+ # Save email to config for future use
248
+ self._save_config({"email": email})
249
+
250
+ # Resolve full user context via backend API — no local Firestore/ADC required
251
+ from settings import get_settings
252
+ api_base = get_settings().mcp_server_base_url # e.g. https://dataspring.app/api
253
+
254
+ async with httpx.AsyncClient(timeout=15.0) as api_client:
255
+ me_resp = await api_client.get(
256
+ f"{api_base}/me",
257
+ headers={"Authorization": f"Bearer {credentials.token}"},
258
+ )
259
+ if me_resp.status_code == 401:
260
+ raise AuthenticationError(
261
+ "Authentication failed — token rejected by server"
262
+ )
263
+ if me_resp.status_code == 403:
264
+ detail = me_resp.json().get("detail", "Access denied")
265
+ raise AuthenticationError(detail)
266
+ if me_resp.status_code != 200:
267
+ raise AuthenticationError(
268
+ f"Failed to resolve user context (HTTP {me_resp.status_code})"
269
+ )
270
+ data = me_resp.json()
271
+
272
+ return UserContext(
273
+ uid=data["uid"],
274
+ email=data["email"] or email,
275
+ org_id=data["org_id"],
276
+ org_name=data.get("org_name") or data["org_id"],
277
+ role=data["org_role"],
278
+ )
279
+
280
+ def _save_config(self, data: dict):
281
+ """Save config data (non-sensitive)."""
282
+ existing = {}
283
+ if self.CONFIG_FILE.exists():
284
+ try:
285
+ existing = json.loads(self.CONFIG_FILE.read_text())
286
+ except json.JSONDecodeError:
287
+ pass
288
+ existing.update(data)
289
+ self.CONFIG_FILE.write_text(json.dumps(existing, indent=2))
290
+
291
+ def _load_config(self) -> dict:
292
+ """Load config data."""
293
+ if not self.CONFIG_FILE.exists():
294
+ return {}
295
+ try:
296
+ return json.loads(self.CONFIG_FILE.read_text())
297
+ except json.JSONDecodeError:
298
+ return {}
299
+
300
+ async def get_user(self) -> UserContext:
301
+ """Get current user, refreshing token if needed.
302
+
303
+ Returns:
304
+ UserContext with user info and org context
305
+
306
+ Raises:
307
+ AuthenticationError: If not logged in or token refresh fails
308
+ """
309
+ credentials = self._load_credentials()
310
+ if not credentials:
311
+ raise AuthenticationError("Not logged in. Run 'dataspring login' first.")
312
+
313
+ if not credentials.valid:
314
+ if credentials.refresh_token:
315
+ try:
316
+ credentials = self._refresh_if_needed(credentials)
317
+ except Exception as e:
318
+ raise AuthenticationError(f"Failed to refresh token: {e}")
319
+ else:
320
+ raise AuthenticationError(
321
+ "Token expired and no refresh token. Run 'dataspring login' again."
322
+ )
323
+
324
+ return await self._get_user_context(credentials)
325
+
326
+ async def logout(self):
327
+ """Clear stored credentials and config."""
328
+ if self.CREDENTIALS_FILE.exists():
329
+ self.CREDENTIALS_FILE.unlink()
330
+ if self.CONFIG_FILE.exists():
331
+ self.CONFIG_FILE.unlink()
332
+
333
+ def is_logged_in(self) -> bool:
334
+ """Check if user has valid credentials."""
335
+ credentials = self._load_credentials()
336
+ if not credentials:
337
+ return False
338
+ if credentials.expired and not credentials.refresh_token:
339
+ return False
340
+ return True
341
+
342
+ def get_stored_email(self) -> str | None:
343
+ """Get stored email from config (doesn't validate token)."""
344
+ config = self._load_config()
345
+ return config.get("email")
346
+
347
+ def get_access_token(self) -> str:
348
+ """Get a valid access token, refreshing if needed.
349
+
350
+ Returns:
351
+ Valid access token string
352
+
353
+ Raises:
354
+ AuthenticationError: If not logged in or token refresh fails
355
+ """
356
+ credentials = self._load_credentials()
357
+ if not credentials:
358
+ raise AuthenticationError("Not logged in. Run 'dataspring login' first.")
359
+
360
+ if not credentials.valid:
361
+ if credentials.refresh_token:
362
+ try:
363
+ credentials = self._refresh_if_needed(credentials)
364
+ except Exception as e:
365
+ raise AuthenticationError(f"Failed to refresh token: {e}")
366
+ else:
367
+ raise AuthenticationError(
368
+ "Token expired and no refresh token. Run 'dataspring login' again."
369
+ )
370
+
371
+ return credentials.token
372
+
373
+
374
+ # Global auth manager instance
375
+ auth_manager = CLIAuthManager()
@@ -0,0 +1,36 @@
1
+ """GENERATED by `python -m services.skill_tables`; do not edit.
2
+
3
+ The hash of the dispatch manifest this CLI was built against, the one
4
+ ``GET /api/dispatch`` answers. ``dataspring --version`` compares it with
5
+ the server's to say whether the server has operations this CLI does not
6
+ know. tests/test_skills_surface.py asserts it is current.
7
+ """
8
+
9
+ MANIFEST_HASH = "76c44516f0729b5b4c1d4b8f17ebd7a3ffbd63b94dae6308c740a4af62db1811"
10
+ TOOL_KEYS = (
11
+ "dashboard_edit",
12
+ "widget_edit",
13
+ "page_edit",
14
+ "section_edit",
15
+ "business_context_edit",
16
+ "semantic_model_edit",
17
+ "metric_edit",
18
+ "quick_metric_edit",
19
+ "report_edit",
20
+ "learned_edit",
21
+ "verified_query_edit",
22
+ "warehouse_edit",
23
+ "query_metrics",
24
+ "explain_query",
25
+ "update_context",
26
+ "export_data",
27
+ "import_manifest",
28
+ "render_dashboard",
29
+ "render_widget",
30
+ "switch_organization",
31
+ "submit_error_report",
32
+ "secret_edit",
33
+ "run_sql",
34
+ "datacore_edit",
35
+ "datacore_run",
36
+ )