snowglobe-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. snowglobe/__init__.py +6 -0
  2. snowglobe/__main__.py +3 -0
  3. snowglobe/cli/__init__.py +0 -0
  4. snowglobe/cli/access.py +197 -0
  5. snowglobe/cli/app.py +148 -0
  6. snowglobe/cli/context.py +48 -0
  7. snowglobe/cli/cost.py +291 -0
  8. snowglobe/cli/debug.py +265 -0
  9. snowglobe/cli/diff.py +34 -0
  10. snowglobe/cli/optimizer.py +91 -0
  11. snowglobe/cli/prompts.py +161 -0
  12. snowglobe/cli/report.py +91 -0
  13. snowglobe/cli/shell.py +1437 -0
  14. snowglobe/cli/shell_completer.py +128 -0
  15. snowglobe/collectors/access.py +882 -0
  16. snowglobe/collectors/query_history.py +46 -0
  17. snowglobe/collectors/query_profile.py +101 -0
  18. snowglobe/config/loader.py +42 -0
  19. snowglobe/core/access_service.py +721 -0
  20. snowglobe/core/cost_service.py +929 -0
  21. snowglobe/core/optimizer.py +92 -0
  22. snowglobe/core/query_service.py +48 -0
  23. snowglobe/core/report_service.py +110 -0
  24. snowglobe/core/risk_service.py +358 -0
  25. snowglobe/engines/access/__init__.py +0 -0
  26. snowglobe/engines/access/explainer.py +113 -0
  27. snowglobe/engines/access/resolver.py +199 -0
  28. snowglobe/engines/ai/cortex_optimizer.py +69 -0
  29. snowglobe/engines/optimizer/query_optimizer.py +326 -0
  30. snowglobe/graphs/__init__.py +0 -0
  31. snowglobe/graphs/role_graph.py +140 -0
  32. snowglobe/graphs/user_graph.py +64 -0
  33. snowglobe/models/__init__.py +0 -0
  34. snowglobe/models/access.py +65 -0
  35. snowglobe/models/access_path.py +15 -0
  36. snowglobe/models/object_ref.py +11 -0
  37. snowglobe/models/object_type.py +50 -0
  38. snowglobe/models/optimizer.py +15 -0
  39. snowglobe/models/privilege.py +78 -0
  40. snowglobe/models/query.py +59 -0
  41. snowglobe/output/__init__.py +0 -0
  42. snowglobe/output/cli.py +413 -0
  43. snowglobe/queries/__init__.py +0 -0
  44. snowglobe/queries/query_history.py +37 -0
  45. snowglobe/snowflake/connection.py +75 -0
  46. snowglobe/state/db.py +559 -0
  47. snowglobe/state/state.py +60 -0
  48. snowglobe/templates/report.md.j2 +55 -0
  49. snowglobe/tests/access_tests.py +5 -0
  50. snowglobe/tui/__init__.py +1 -0
  51. snowglobe/tui/__main__.py +3 -0
  52. snowglobe/tui/app.py +299 -0
  53. snowglobe/tui/screens/__init__.py +0 -0
  54. snowglobe/tui/screens/access.py +627 -0
  55. snowglobe/tui/screens/cost.py +831 -0
  56. snowglobe/tui/screens/home.py +222 -0
  57. snowglobe/tui/screens/refresh.py +222 -0
  58. snowglobe/tui/screens/reports.py +252 -0
  59. snowglobe/tui/screens/risk.py +417 -0
  60. snowglobe/tui/screens/tune.py +254 -0
  61. snowglobe/tui/widgets/__init__.py +0 -0
  62. snowglobe/tui/widgets/access_paths.py +63 -0
  63. snowglobe/tui/widgets/cache_badge.py +28 -0
  64. snowglobe/tui/widgets/header.py +21 -0
  65. snowglobe/tui/widgets/nav.py +32 -0
  66. snowglobe_cli-0.1.0.dist-info/METADATA +368 -0
  67. snowglobe_cli-0.1.0.dist-info/RECORD +71 -0
  68. snowglobe_cli-0.1.0.dist-info/WHEEL +5 -0
  69. snowglobe_cli-0.1.0.dist-info/entry_points.txt +2 -0
  70. snowglobe_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  71. snowglobe_cli-0.1.0.dist-info/top_level.txt +1 -0
snowglobe/cli/cost.py ADDED
@@ -0,0 +1,291 @@
1
+ import typer
2
+ import pandas as pd
3
+ from snowglobe.core.cost_service import CostService
4
+ from snowglobe.output import cli
5
+
6
+ cost_app = typer.Typer(
7
+ help="Inspect Snowflake costs — compute, storage, AI, budgets, and more",
8
+ no_args_is_help=True,
9
+ )
10
+
11
+
12
+ def _get_cost_service(ctx: typer.Context) -> CostService:
13
+ return CostService(ctx.obj)
14
+
15
+
16
+ def _export_csv(df, csv_path: str | None) -> bool:
17
+ if csv_path:
18
+ df.to_csv(csv_path, index=False)
19
+ typer.secho(f"Exported to: {csv_path}", fg=typer.colors.GREEN)
20
+ return True
21
+ return False
22
+
23
+
24
+ def _print_note(note: str | None) -> None:
25
+ if note:
26
+ typer.secho(f"⚠ {note}", fg=typer.colors.YELLOW)
27
+
28
+
29
+ @cost_app.command()
30
+ def summary(
31
+ ctx: typer.Context,
32
+ days: int = typer.Option(30, help="Number of days to analyze"),
33
+ csv: str = typer.Option(None, help="Export to CSV file path"),
34
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
35
+ ):
36
+ """Account cost summary by service type."""
37
+ cost_service = _get_cost_service(ctx)
38
+ df, cache_age = cost_service.get_account_summary(days, refresh=refresh)
39
+ if df.empty:
40
+ typer.echo("No cost data found.")
41
+ return
42
+ if _export_csv(df, csv):
43
+ return
44
+ total = df["CREDITS"].sum()
45
+ typer.secho(f"\nTotal: {total:,.2f} credits ({days} days)", fg=typer.colors.GREEN, bold=True)
46
+ typer.echo("")
47
+ for _, row in df.iterrows():
48
+ bar_len = int(row["PCT"] / 2)
49
+ bar = "█" * bar_len
50
+ typer.echo(f" {row['SERVICE_TYPE']:<40} {row['CREDITS']:>10,.2f} {row['PCT']:>5.1f}% {bar}")
51
+ typer.echo("")
52
+
53
+
54
+ @cost_app.command()
55
+ def warehouses(
56
+ ctx: typer.Context,
57
+ days: int = typer.Option(30, help="Number of days to analyze"),
58
+ csv: str = typer.Option(None, help="Export to CSV file path"),
59
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
60
+ ):
61
+ """Cost breakdown per warehouse."""
62
+ cost_service = _get_cost_service(ctx)
63
+ df, _ = cost_service.get_warehouse_breakdown(days, refresh=refresh)
64
+ if df.empty:
65
+ typer.echo("No warehouse data found.")
66
+ return
67
+ if _export_csv(df, csv):
68
+ return
69
+ cli.print_table(df, title=f"Warehouse Costs ({days} days)")
70
+
71
+
72
+ @cost_app.command()
73
+ def users(
74
+ ctx: typer.Context,
75
+ days: int = typer.Option(7, help="Number of days to analyze (max 7 recommended)"),
76
+ csv: str = typer.Option(None, help="Export to CSV file path"),
77
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
78
+ ):
79
+ """Complete cost per user — warehouse + AI services."""
80
+ cost_service = _get_cost_service(ctx)
81
+ df, _, note = cost_service.get_user_breakdown(min(days, 7), refresh=refresh)
82
+ if df.empty:
83
+ typer.echo("No user data found.")
84
+ return
85
+ if _export_csv(df, csv):
86
+ return
87
+ _print_note(note)
88
+ cli.print_table(df, title=f"User Cost Attribution ({days} days)")
89
+
90
+
91
+ @cost_app.command()
92
+ def ai(
93
+ ctx: typer.Context,
94
+ days: int = typer.Option(30, help="Number of days to analyze"),
95
+ csv: str = typer.Option(None, help="Export to CSV file path"),
96
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
97
+ ):
98
+ """AI/ML token costs by service type."""
99
+ cost_service = _get_cost_service(ctx)
100
+ df, _, note = cost_service.get_ai_costs(days, refresh=refresh)
101
+ if df.empty:
102
+ typer.echo("No AI usage found.")
103
+ return
104
+ if _export_csv(df, csv):
105
+ return
106
+ _print_note(note)
107
+ total = df["TOTAL_CREDITS"].astype(float).sum()
108
+ typer.secho(f"\nTotal AI credits: {total:,.2f} ({days} days)", fg=typer.colors.GREEN, bold=True)
109
+ typer.echo("")
110
+ for _, row in df.iterrows():
111
+ bar_len = int(row["PCT"] / 2)
112
+ bar = "█" * bar_len
113
+ typer.echo(f" {row['SERVICE']:<30} {float(row['TOTAL_CREDITS']):>10,.2f} {row['PCT']:>5.1f}% {bar}")
114
+ typer.echo("")
115
+
116
+
117
+ @cost_app.command(name="ai-users")
118
+ def ai_users(
119
+ ctx: typer.Context,
120
+ days: int = typer.Option(30, help="Number of days to analyze"),
121
+ csv: str = typer.Option(None, help="Export to CSV file path"),
122
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
123
+ ):
124
+ """AI/ML token costs per user with service breakdown."""
125
+ cost_service = _get_cost_service(ctx)
126
+ df, _, note = cost_service.get_ai_costs_by_user(days, refresh=refresh)
127
+ if df.empty:
128
+ typer.echo("No AI usage found.")
129
+ return
130
+ if _export_csv(df, csv):
131
+ return
132
+ _print_note(note)
133
+ cli.print_table(df, title=f"AI Token Costs by User ({days} days)")
134
+
135
+
136
+ @cost_app.command()
137
+ def services(
138
+ ctx: typer.Context,
139
+ days: int = typer.Option(30, help="Number of days to analyze"),
140
+ csv: str = typer.Option(None, help="Export to CSV file path"),
141
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
142
+ ):
143
+ """Non-warehouse service costs — pipes, tasks, SPCS, clustering."""
144
+ cost_service = _get_cost_service(ctx)
145
+ df, _ = cost_service.get_service_breakdown(days, refresh=refresh)
146
+ if df.empty:
147
+ typer.echo("No service cost data found.")
148
+ return
149
+ if _export_csv(df, csv):
150
+ return
151
+ cli.print_table(df, title=f"Service Resource Costs ({days} days)")
152
+
153
+
154
+ @cost_app.command()
155
+ def queries(
156
+ ctx: typer.Context,
157
+ days: int = typer.Option(7, help="Number of days of query history"),
158
+ limit: int = typer.Option(10, help="Number of queries to return"),
159
+ sort_by: str = typer.Option("credits", help="Sort by: credits or bytes"),
160
+ csv: str = typer.Option(None, help="Export to CSV file path"),
161
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
162
+ ):
163
+ """Top expensive individual queries by attributed credits."""
164
+ cost_service = _get_cost_service(ctx)
165
+ df, _, note = cost_service.get_top_queries(days, limit=limit, sort_by=sort_by, refresh=refresh)
166
+ if df.empty:
167
+ typer.echo("No query data found.")
168
+ return
169
+ if _export_csv(df, csv):
170
+ return
171
+ _print_note(note)
172
+ cli.print_table(df, title=f"Top Expensive Queries ({days} days)")
173
+
174
+
175
+ @cost_app.command()
176
+ def trend(
177
+ ctx: typer.Context,
178
+ days: int = typer.Option(30, help="Number of days for trend analysis"),
179
+ csv: str = typer.Option(None, help="Export to CSV file path"),
180
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
181
+ ):
182
+ """Daily spend trend with day-over-day delta and 7-day rolling average."""
183
+ cost_service = _get_cost_service(ctx)
184
+ df, cache_age = cost_service.get_daily_trend(days, refresh=refresh)
185
+ if df.empty:
186
+ typer.echo("No trend data found.")
187
+ return
188
+ if _export_csv(df, csv):
189
+ return
190
+
191
+ total = df["CREDITS"].sum()
192
+ avg_daily = df["CREDITS"].mean()
193
+ typer.secho(f"\nTotal: {total:,.2f} credits | Avg daily: {avg_daily:,.2f}", fg=typer.colors.GREEN, bold=True)
194
+ typer.echo("")
195
+ typer.echo(f" {'DATE':<12} {'CREDITS':>10} {'DELTA %':>9} {'7D AVG':>10} {'TREND'}")
196
+ typer.echo(f" {'─' * 12} {'─' * 10} {'─' * 9} {'─' * 10} {'─' * 20}")
197
+ max_credits = df["CREDITS"].max() if not df.empty else 1
198
+ for _, row in df.iterrows():
199
+ bar_len = int((row["CREDITS"] / max_credits) * 20) if max_credits > 0 else 0
200
+ bar = "▓" * bar_len
201
+ delta_str = f"{row['DELTA_PCT']:+.1f}%" if pd.notna(row["DELTA_PCT"]) else " —"
202
+ avg_str = f"{row['ROLLING_7D_AVG']:,.2f}" if pd.notna(row["ROLLING_7D_AVG"]) else "—"
203
+ typer.echo(f" {str(row['DATE']):<12} {row['CREDITS']:>10,.2f} {delta_str:>9} {avg_str:>10} {bar}")
204
+ typer.echo("")
205
+
206
+
207
+ @cost_app.command()
208
+ def storage(
209
+ ctx: typer.Context,
210
+ days: int = typer.Option(30, help="Number of days to average storage over"),
211
+ csv: str = typer.Option(None, help="Export to CSV file path"),
212
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
213
+ ):
214
+ """Per-database storage breakdown with estimated monthly cost."""
215
+ cost_service = _get_cost_service(ctx)
216
+ df, _ = cost_service.get_storage_usage(days, refresh=refresh)
217
+ if df.empty:
218
+ typer.echo("No storage data found.")
219
+ return
220
+ if _export_csv(df, csv):
221
+ return
222
+
223
+ total_tb = df["TOTAL_TB"].sum()
224
+ total_cost = df["EST_MONTHLY_COST"].sum()
225
+ rate = cost_service.get_storage_rate()
226
+ typer.secho(f"\nTotal storage: {total_tb:,.4f} TB | Est. monthly: ${total_cost:,.2f}", fg=typer.colors.GREEN, bold=True)
227
+ rate_source = "contracted rate" if rate != 23.0 else "on-demand default"
228
+ typer.echo(f"(Estimated at ${rate:.2f}/TB/month — {rate_source})")
229
+ typer.echo("")
230
+ display_df = df[["DATABASE_NAME", "TOTAL_TB", "EST_MONTHLY_COST"]].copy()
231
+ display_df = display_df[display_df["TOTAL_TB"] > 0]
232
+ if not display_df.empty:
233
+ cli.print_table(display_df, title=f"Storage by Database ({days}-day avg)")
234
+
235
+
236
+ @cost_app.command()
237
+ def budget(
238
+ ctx: typer.Context,
239
+ csv: str = typer.Option(None, help="Export to CSV file path"),
240
+ ):
241
+ """Snowflake-native budget status and spending history."""
242
+ cost_service = _get_cost_service(ctx)
243
+ df, error = cost_service.get_budget_status()
244
+ if error:
245
+ typer.secho(error, fg=typer.colors.YELLOW)
246
+ return
247
+ if df.empty:
248
+ typer.echo("No budget spending history found.")
249
+ return
250
+ if _export_csv(df, csv):
251
+ return
252
+ cli.print_table(df, title="Budget Spending History")
253
+
254
+
255
+ @cost_app.command()
256
+ def replication(
257
+ ctx: typer.Context,
258
+ days: int = typer.Option(30, help="Number of days to analyze"),
259
+ csv: str = typer.Option(None, help="Export to CSV file path"),
260
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
261
+ ):
262
+ """Replication costs by replication group."""
263
+ cost_service = _get_cost_service(ctx)
264
+ df, _ = cost_service.get_replication_costs(days, refresh=refresh)
265
+ if df.empty:
266
+ typer.echo("No replication cost data found.")
267
+ return
268
+ if _export_csv(df, csv):
269
+ return
270
+ cli.print_table(df, title=f"Replication Costs ({days} days)")
271
+
272
+
273
+ @cost_app.command(name="mv")
274
+ def materialized_views(
275
+ ctx: typer.Context,
276
+ days: int = typer.Option(30, help="Number of days to analyze"),
277
+ csv: str = typer.Option(None, help="Export to CSV file path"),
278
+ refresh: bool = typer.Option(False, help="Force fresh query to Snowflake"),
279
+ ):
280
+ """Materialized view refresh costs."""
281
+ cost_service = _get_cost_service(ctx)
282
+ df, _ = cost_service.get_materialized_view_costs(days, refresh=refresh)
283
+ if df.empty:
284
+ typer.echo("No materialized view cost data found.")
285
+ return
286
+ if _export_csv(df, csv):
287
+ return
288
+ total = df["CREDITS"].sum() if "CREDITS" in df.columns else 0
289
+ typer.secho(f"\nTotal MV refresh credits: {total:,.2f}", fg=typer.colors.GREEN, bold=True)
290
+ typer.echo("")
291
+ cli.print_table(df, title=f"Materialized View Costs ({days} days)")
snowglobe/cli/debug.py ADDED
@@ -0,0 +1,265 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Callable, Optional, Protocol
4
+
5
+ debug_app = typer.Typer(
6
+ help="Diagnose configuration and connectivity issues",
7
+ no_args_is_help=False,
8
+ )
9
+
10
+
11
+ class DiagnosticsReporter(Protocol):
12
+ """
13
+ Sink for diagnostics output. Each frontend (CLI, TUI, test) supplies one.
14
+
15
+ Levels:
16
+ header — top banner / separator
17
+ step — "[n/total] label"
18
+ ok — success line for the current check
19
+ fail — failure line + optional hint
20
+ info — supplementary detail or remediation tip
21
+ done — final summary line at the end of a successful run
22
+ """
23
+ def header(self, msg: str) -> None: ...
24
+ def step(self, n: int, total: int, label: str) -> None: ...
25
+ def ok(self, msg: str) -> None: ...
26
+ def fail(self, msg: str, hint: str = "") -> None: ...
27
+ def info(self, msg: str) -> None: ...
28
+ def done(self, msg: str) -> None: ...
29
+
30
+
31
+ class TyperReporter:
32
+ """Default reporter — renders coloured output via typer."""
33
+
34
+ def header(self, msg: str) -> None:
35
+ typer.echo(f"\n{msg}")
36
+ typer.echo("=" * 40)
37
+
38
+ def step(self, n: int, total: int, label: str) -> None:
39
+ typer.echo(f"\n[{n}/{total}] {label}")
40
+
41
+ def ok(self, msg: str) -> None:
42
+ typer.secho(f" ✓ {msg}", fg=typer.colors.GREEN)
43
+
44
+ def fail(self, msg: str, hint: str = "") -> None:
45
+ typer.secho(f" ✗ {msg}", fg=typer.colors.RED)
46
+ if hint:
47
+ typer.secho(f" → {hint}", fg=typer.colors.YELLOW)
48
+
49
+ def info(self, msg: str) -> None:
50
+ typer.secho(f" {msg}", dim=True)
51
+
52
+ def done(self, msg: str) -> None:
53
+ typer.echo("\n" + "=" * 40)
54
+ typer.secho(msg, fg=typer.colors.GREEN, bold=True)
55
+ typer.echo("")
56
+
57
+
58
+ class CallableReporter:
59
+ """
60
+ Reporter that delegates every line to a single callback `(level, msg)`.
61
+
62
+ Use from the TUI to route output into a RichLog or any other sink:
63
+
64
+ reporter = CallableReporter(lambda level, msg: my_log.write(level, msg))
65
+ run_diagnostics(reporter=reporter)
66
+ """
67
+
68
+ def __init__(self, write: Callable[[str, str], None]):
69
+ self._write = write
70
+
71
+ def header(self, msg: str) -> None:
72
+ self._write("header", msg)
73
+
74
+ def step(self, n: int, total: int, label: str) -> None:
75
+ self._write("step", f"[{n}/{total}] {label}")
76
+
77
+ def ok(self, msg: str) -> None:
78
+ self._write("ok", msg)
79
+
80
+ def fail(self, msg: str, hint: str = "") -> None:
81
+ self._write("fail", msg)
82
+ if hint:
83
+ self._write("info", f"→ {hint}")
84
+
85
+ def info(self, msg: str) -> None:
86
+ self._write("info", msg)
87
+
88
+ def done(self, msg: str) -> None:
89
+ self._write("done", msg)
90
+
91
+
92
+ def run_diagnostics(
93
+ profile_name: str = "default",
94
+ verbose: bool = False,
95
+ reporter: Optional[DiagnosticsReporter] = None,
96
+ ) -> bool:
97
+ """
98
+ Run all diagnostic checks and report results via the supplied reporter.
99
+
100
+ Returns True if every check passed, False otherwise. Does not raise on
101
+ a failed check — callers decide how to react (CLI exits, TUI just shows
102
+ the log).
103
+ """
104
+ r = reporter or TyperReporter()
105
+ TOTAL = 8
106
+
107
+ r.header("Snowglobe Connection Diagnostics")
108
+
109
+ # --- Step 1: Config file exists ---
110
+ r.step(1, TOTAL, "Config file")
111
+ from snowglobe.config.loader import SnowglobeConfig
112
+ config_path = SnowglobeConfig.CONFIG_PATH
113
+
114
+ if not config_path.exists():
115
+ r.fail(f"Config not found at {config_path}")
116
+ r.info(f"Create {config_path} with your Snowflake profiles.")
117
+ r.info("See: snowglobe/config.yml for an example.")
118
+ return False
119
+ r.ok(f"Found {config_path}")
120
+
121
+ # --- Step 2: Valid YAML ---
122
+ r.step(2, TOTAL, "YAML parsing")
123
+ try:
124
+ import yaml
125
+ with open(config_path, "r") as f:
126
+ raw = yaml.safe_load(f)
127
+ if not isinstance(raw, dict):
128
+ r.fail("Config file is not a YAML mapping")
129
+ return False
130
+ r.ok(f"Valid YAML with {len(raw)} profile(s): {', '.join(raw.keys())}")
131
+ except yaml.YAMLError as e:
132
+ r.fail(f"YAML parse error: {e}")
133
+ return False
134
+
135
+ # --- Step 3: Profile exists ---
136
+ r.step(3, TOTAL, f"Profile '{profile_name}'")
137
+ try:
138
+ config = SnowglobeConfig()
139
+ profile = config.get_profile(profile_name)
140
+ r.ok(f"Profile '{profile_name}' loaded")
141
+ except Exception as e:
142
+ r.fail(f"Profile not found: {e}")
143
+ try:
144
+ r.info(f"Available profiles: {', '.join(config.list_profiles())}")
145
+ except Exception:
146
+ pass
147
+ return False
148
+
149
+ # --- Step 4: Required fields ---
150
+ r.step(4, TOTAL, "Required fields")
151
+ required = ["account", "user"]
152
+ missing = [f for f in required if not profile.get(f)]
153
+ has_auth = bool(profile.get("password") or profile.get("private_key_path"))
154
+
155
+ if missing:
156
+ r.fail(f"Missing required fields: {', '.join(missing)}")
157
+ return False
158
+ if not has_auth:
159
+ r.fail("No auth method — need 'password' or 'private_key_path'")
160
+ return False
161
+ r.ok(f"account={profile['account']}, user={profile['user']}")
162
+
163
+ # --- Step 5: Auth credentials resolve ---
164
+ r.step(5, TOTAL, "Auth credentials")
165
+ auth_method = "key_pair" if profile.get("private_key_path") else "password"
166
+
167
+ if auth_method == "key_pair":
168
+ key_path = Path(profile["private_key_path"]).expanduser()
169
+ if not key_path.exists():
170
+ r.fail(f"Key file not found: {key_path}")
171
+ r.info("Check that private_key_path points to an existing .pem file")
172
+ return False
173
+ r.ok(f"Key pair auth — key file exists at {key_path}")
174
+
175
+ try:
176
+ from cryptography.hazmat.primitives import serialization
177
+ pwd = profile.get("private_key_pwd")
178
+ with key_path.open("rb") as f:
179
+ serialization.load_pem_private_key(
180
+ f.read(),
181
+ password=pwd.encode() if pwd else None,
182
+ )
183
+ r.ok("Key file is a valid PEM private key")
184
+ except Exception as e:
185
+ r.fail(f"Key file parse error: {e}")
186
+ r.info("Ensure the key is in PEM format and the passphrase (if any) is correct")
187
+ return False
188
+ else:
189
+ password = profile.get("password", "")
190
+ if password.startswith("$") or not password:
191
+ r.fail(f"Password appears unresolved: '{password}'")
192
+ r.info("Check that the environment variable is set")
193
+ return False
194
+ r.ok("Password auth — credentials present")
195
+
196
+ # --- Step 6: Snowflake connectivity ---
197
+ r.step(6, TOTAL, "Snowflake connection")
198
+ try:
199
+ from snowglobe.snowflake.connection import SnowflakeReadOnly
200
+ sf = SnowflakeReadOnly(
201
+ account=profile["account"],
202
+ user=profile["user"],
203
+ role=profile.get("role"),
204
+ warehouse=profile.get("warehouse"),
205
+ password=profile.get("password"),
206
+ private_key_path=profile.get("private_key_path"),
207
+ private_key_pwd=profile.get("private_key_pwd"),
208
+ )
209
+ with sf:
210
+ r.ok("Connected to Snowflake successfully")
211
+
212
+ # --- Step 7: Role ---
213
+ r.step(7, TOTAL, "Role")
214
+ result = sf.query("SELECT CURRENT_ROLE() AS role")
215
+ current_role = result[0]["ROLE"] if result else None
216
+ if current_role:
217
+ r.ok(f"Active role: {current_role}")
218
+ else:
219
+ r.fail("Could not determine current role")
220
+
221
+ # --- Step 8: Warehouse ---
222
+ r.step(8, TOTAL, "Warehouse")
223
+ result = sf.query("SELECT CURRENT_WAREHOUSE() AS wh")
224
+ current_wh = result[0]["WH"] if result else None
225
+ if current_wh:
226
+ r.ok(f"Active warehouse: {current_wh}")
227
+ else:
228
+ r.fail("No warehouse active", hint="Set 'warehouse' in your profile or run USE WAREHOUSE")
229
+
230
+ except Exception as e:
231
+ r.fail(f"Connection failed: {e}")
232
+ error_str = str(e).lower()
233
+ if "incorrect username or password" in error_str:
234
+ r.info("Check your username and password in the profile")
235
+ elif "account" in error_str:
236
+ r.info(f"Verify account identifier: {profile['account']}")
237
+ r.info("Format should be: <orgname>-<accountname> or <locator>.<region>.<cloud>")
238
+ elif "private key" in error_str:
239
+ r.info("Key pair auth failed — check key file and passphrase")
240
+ elif "timeout" in error_str or "could not connect" in error_str:
241
+ r.info("Network issue — check firewall, VPN, or proxy settings")
242
+ else:
243
+ r.info("See Snowflake documentation for connection troubleshooting")
244
+ return False
245
+
246
+ r.done("All checks passed.")
247
+ return True
248
+
249
+
250
+ @debug_app.callback(invoke_without_command=True)
251
+ def debug(
252
+ ctx: typer.Context,
253
+ profile_name: Optional[str] = typer.Option(None, "--profile", help="Profile to test (overrides global --profile)"),
254
+ ):
255
+ """
256
+ Run connection diagnostics.
257
+
258
+ Checks config file, credentials, and Snowflake connectivity
259
+ step by step, reporting exactly where things fail.
260
+ """
261
+ context = ctx.obj
262
+ name = profile_name or (context.profile_name if context else "default")
263
+ verbose = context.verbose if context else False
264
+ if not run_diagnostics(profile_name=name, verbose=verbose):
265
+ raise typer.Exit(1)
snowglobe/cli/diff.py ADDED
@@ -0,0 +1,34 @@
1
+ import typer
2
+
3
+ diff_app = typer.Typer(
4
+ help="Compare Snowflake state over time",
5
+ no_args_is_help=True,
6
+ )
7
+
8
+
9
+ @diff_app.command()
10
+ def access(
11
+ ctx: typer.Context,
12
+ days: int = typer.Option(
13
+ None, "--days",
14
+ help="Compare against N days ago. Default: since the last refresh.",
15
+ ),
16
+ output: str = typer.Option(
17
+ "text", "--output",
18
+ help="Output format: text, json",
19
+ ),
20
+ ):
21
+ """
22
+ Show access changes (grants, role edges, user assignments) since the
23
+ last refresh, or in the last `--days` days.
24
+ """
25
+ from snowglobe.core.access_service import AccessService
26
+ from snowglobe.output import cli
27
+
28
+ service = AccessService(ctx.obj)
29
+ result = service.detect_drift(days=days)
30
+
31
+ if output == "json":
32
+ cli.format_json(result)
33
+ else:
34
+ typer.echo(cli.format_drift_text(result))
@@ -0,0 +1,91 @@
1
+ import typer
2
+ from snowglobe.core.optimizer import QueryOptimizerService
3
+ from snowglobe.core.query_service import QueryService
4
+ from snowglobe.output import cli
5
+
6
+ opt_app = typer.Typer(
7
+ help="Query optimizer — analyze and suggest improvements for Snowflake queries",
8
+ no_args_is_help=True,
9
+ )
10
+
11
+
12
+ @opt_app.command()
13
+ def query(
14
+ ctx: typer.Context,
15
+ query_id: str = typer.Option(..., help="Snowflake query ID to analyze"),
16
+ no_ai: bool = typer.Option(False, "--no-ai", help="Skip AI analysis"),
17
+ model: str = typer.Option("claude-haiku-4-5", help="Cortex AI model for suggestions"),
18
+ ):
19
+ """
20
+ Analyze a query and provide optimization suggestions.
21
+ Shows Snowflake-native insights, operator analysis, and optionally AI suggestions.
22
+ """
23
+ context = ctx.obj
24
+ optimizer_service = QueryOptimizerService(context)
25
+ optimizer_service.collect_query_profile(query_id)
26
+ optimizer_service.analyze_query()
27
+
28
+ # 1. Snowflake-native insights (primary)
29
+ insights = optimizer_service.collect_insights()
30
+ if insights:
31
+ typer.echo(cli.format_query_insights(query_id, insights))
32
+
33
+ # 2. Local rule-based suggestions
34
+ opt_suggestions = optimizer_service.suggestions()
35
+ typer.echo(cli.format_optimizer_suggestions(query_id, opt_suggestions.suggestions))
36
+
37
+ # 3. Operator tree + scoring
38
+ tree = optimizer_service.build_operator_tree()
39
+ scores = optimizer_service.score()
40
+ cli.print_operator_tree(tree, scores)
41
+
42
+ # 4. Cost attribution
43
+ opt_cost_attribution = optimizer_service.cost_attribution()
44
+ typer.echo(cli.format_cost_attribution(opt_cost_attribution))
45
+
46
+ # 5. Expensive operators
47
+ opt_exp = optimizer_service.expensive_operators()
48
+ typer.echo(cli.format_expensive_operators(opt_exp))
49
+
50
+ # 6. AI suggestion (optional)
51
+ if not no_ai:
52
+ typer.echo("\nGenerating AI suggestions...")
53
+ ai = optimizer_service.ai_suggestion(model=model)
54
+ typer.echo(ai)
55
+
56
+
57
+ @opt_app.command()
58
+ def top_queries(
59
+ ctx: typer.Context,
60
+ days: int = typer.Option(7, help="Number of days of query history"),
61
+ cost_type: str = typer.Option("credits", help="Sort query history by: credits, bytes"),
62
+ limit: int = typer.Option(10, help="Number of top queries to analyze"),
63
+ refresh_state: bool = typer.Option(False, help="Refresh state from Snowflake"),
64
+ analyze: bool = typer.Option(False, help="Run optimizer analysis on each query"),
65
+ ):
66
+ """
67
+ List top expensive queries, optionally with optimization analysis.
68
+ """
69
+ context = ctx.obj
70
+ query_service = QueryService(context, days)
71
+ output = query_service.inspect_query_history(
72
+ refresh_state=refresh_state,
73
+ cost_type=cost_type,
74
+ limit=limit,
75
+ )
76
+ cli.print_table(output, title=f"Most expensive queries by: {cost_type}")
77
+
78
+ if analyze:
79
+ for query_id in output["query_id"]:
80
+ typer.echo(f"\n{'═' * 60}")
81
+ typer.echo(f"Analyzing: {query_id}")
82
+ typer.echo(f"{'═' * 60}")
83
+
84
+ optimizer_service = QueryOptimizerService(context)
85
+ try:
86
+ optimizer_service.collect_query_profile(query_id)
87
+ optimizer_service.analyze_query()
88
+ opt_result = optimizer_service.suggestions()
89
+ typer.echo(cli.format_optimizer_suggestions(query_id, opt_result.suggestions))
90
+ except Exception as e:
91
+ typer.secho(f" Could not analyze: {e}", fg=typer.colors.YELLOW)