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.
- snowglobe/__init__.py +6 -0
- snowglobe/__main__.py +3 -0
- snowglobe/cli/__init__.py +0 -0
- snowglobe/cli/access.py +197 -0
- snowglobe/cli/app.py +148 -0
- snowglobe/cli/context.py +48 -0
- snowglobe/cli/cost.py +291 -0
- snowglobe/cli/debug.py +265 -0
- snowglobe/cli/diff.py +34 -0
- snowglobe/cli/optimizer.py +91 -0
- snowglobe/cli/prompts.py +161 -0
- snowglobe/cli/report.py +91 -0
- snowglobe/cli/shell.py +1437 -0
- snowglobe/cli/shell_completer.py +128 -0
- snowglobe/collectors/access.py +882 -0
- snowglobe/collectors/query_history.py +46 -0
- snowglobe/collectors/query_profile.py +101 -0
- snowglobe/config/loader.py +42 -0
- snowglobe/core/access_service.py +721 -0
- snowglobe/core/cost_service.py +929 -0
- snowglobe/core/optimizer.py +92 -0
- snowglobe/core/query_service.py +48 -0
- snowglobe/core/report_service.py +110 -0
- snowglobe/core/risk_service.py +358 -0
- snowglobe/engines/access/__init__.py +0 -0
- snowglobe/engines/access/explainer.py +113 -0
- snowglobe/engines/access/resolver.py +199 -0
- snowglobe/engines/ai/cortex_optimizer.py +69 -0
- snowglobe/engines/optimizer/query_optimizer.py +326 -0
- snowglobe/graphs/__init__.py +0 -0
- snowglobe/graphs/role_graph.py +140 -0
- snowglobe/graphs/user_graph.py +64 -0
- snowglobe/models/__init__.py +0 -0
- snowglobe/models/access.py +65 -0
- snowglobe/models/access_path.py +15 -0
- snowglobe/models/object_ref.py +11 -0
- snowglobe/models/object_type.py +50 -0
- snowglobe/models/optimizer.py +15 -0
- snowglobe/models/privilege.py +78 -0
- snowglobe/models/query.py +59 -0
- snowglobe/output/__init__.py +0 -0
- snowglobe/output/cli.py +413 -0
- snowglobe/queries/__init__.py +0 -0
- snowglobe/queries/query_history.py +37 -0
- snowglobe/snowflake/connection.py +75 -0
- snowglobe/state/db.py +559 -0
- snowglobe/state/state.py +60 -0
- snowglobe/templates/report.md.j2 +55 -0
- snowglobe/tests/access_tests.py +5 -0
- snowglobe/tui/__init__.py +1 -0
- snowglobe/tui/__main__.py +3 -0
- snowglobe/tui/app.py +299 -0
- snowglobe/tui/screens/__init__.py +0 -0
- snowglobe/tui/screens/access.py +627 -0
- snowglobe/tui/screens/cost.py +831 -0
- snowglobe/tui/screens/home.py +222 -0
- snowglobe/tui/screens/refresh.py +222 -0
- snowglobe/tui/screens/reports.py +252 -0
- snowglobe/tui/screens/risk.py +417 -0
- snowglobe/tui/screens/tune.py +254 -0
- snowglobe/tui/widgets/__init__.py +0 -0
- snowglobe/tui/widgets/access_paths.py +63 -0
- snowglobe/tui/widgets/cache_badge.py +28 -0
- snowglobe/tui/widgets/header.py +21 -0
- snowglobe/tui/widgets/nav.py +32 -0
- snowglobe_cli-0.1.0.dist-info/METADATA +368 -0
- snowglobe_cli-0.1.0.dist-info/RECORD +71 -0
- snowglobe_cli-0.1.0.dist-info/WHEEL +5 -0
- snowglobe_cli-0.1.0.dist-info/entry_points.txt +2 -0
- snowglobe_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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)
|