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
@@ -0,0 +1,46 @@
1
+
2
+ from typing import List
3
+ from snowglobe.queries.query_history import get_warehouse_queries
4
+ from snowglobe.models.query import QueryStats
5
+
6
+
7
+ class QueryCollector:
8
+ """
9
+ Collects Snowflake query metadata.
10
+ """
11
+
12
+ def __init__(self, connection, days):
13
+ """
14
+ connection: SnowflakeReadOnly instance
15
+ """
16
+ self.connection = connection
17
+ self.days = days
18
+
19
+ def warehouse_queries(self) -> List[QueryStats]:
20
+ """
21
+ Collect queries against warehouses over X days
22
+ """
23
+ query_history: List[QueryStats] = []
24
+
25
+ with self.connection:
26
+ qh = get_warehouse_queries(days=self.days)
27
+ rows = self.connection.query(qh)
28
+ for q in rows:
29
+ result = QueryStats(
30
+ query_id=q["QUERY_ID"],
31
+ user_name=q["USER_NAME"],
32
+ warehouse_name=q["WAREHOUSE_NAME"],
33
+ warehouse_size=q["WAREHOUSE_SIZE"],
34
+ query_text=q["QUERY_TEXT"],
35
+ query_tag=q["QUERY_TAG"],
36
+ query_type=q["QUERY_TYPE"],
37
+ bytes_scanned=q["BYTES_SCANNED"],
38
+ execution_time_sec=q["EXECUTION_TIME_SEC"],
39
+ start_time=q["START_TIME"],
40
+ warehouse_multiplier=q["WAREHOUSE_MULTIPLIER"],
41
+ estimated_credits=q["ESTIMATED_CREDITS"],
42
+ )
43
+ query_history.append(result)
44
+
45
+ return query_history
46
+
@@ -0,0 +1,101 @@
1
+ import json
2
+ import re
3
+ from typing import List
4
+ from snowglobe.models.query import QueryProfile
5
+
6
+ # Snowflake query IDs are UUIDs in the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
7
+ QUERY_ID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
8
+
9
+
10
+ def _validate_query_id(query_id: str) -> str:
11
+ """Validate query_id matches Snowflake UUID format to prevent injection."""
12
+ if not QUERY_ID_PATTERN.match(query_id):
13
+ raise ValueError(f"Invalid query ID format: '{query_id}'. Expected UUID format.")
14
+ return query_id
15
+
16
+
17
+ class QueryProfileCollector:
18
+
19
+ def __init__(self, connection):
20
+ self.connection = connection
21
+
22
+ def fetch_sql_text(self, query_id: str):
23
+ query_id = _validate_query_id(query_id)
24
+
25
+ with self.connection:
26
+ sql = f"SELECT query_text FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY WHERE query_id = '{query_id}'"
27
+ result = self.connection.query(sql)
28
+
29
+ if not result:
30
+ raise ValueError(f"No query found with id {query_id}")
31
+
32
+ return result[0]["QUERY_TEXT"]
33
+
34
+ def fetch_query_profile(self, query_id: str) -> List[QueryProfile]:
35
+ query_id = _validate_query_id(query_id)
36
+
37
+ with self.connection:
38
+ sql = f"SELECT * FROM TABLE(GET_QUERY_OPERATOR_STATS('{query_id}'))"
39
+ try:
40
+ rows = self.connection.query(sql)
41
+ except Exception as e:
42
+ err = str(e).lower()
43
+ if "profile expired" in err:
44
+ raise ValueError(
45
+ f"Query profile for '{query_id}' has expired. "
46
+ "Snowflake retains profiles for ~14 days. Try a more recent query."
47
+ ) from None
48
+ raise
49
+
50
+ query_profile: List[QueryProfile] = []
51
+
52
+ for q in rows:
53
+ operator_statistics = json.loads(q["OPERATOR_STATISTICS"]) if q["OPERATOR_STATISTICS"] else {}
54
+ execution_time_breakdown = json.loads(q["EXECUTION_TIME_BREAKDOWN"]) if q["EXECUTION_TIME_BREAKDOWN"] else {}
55
+ operator_attributes = json.loads(q["OPERATOR_ATTRIBUTES"]) if q["OPERATOR_ATTRIBUTES"] else {}
56
+ parent_operators = json.loads(q["PARENT_OPERATORS"]) if q["PARENT_OPERATORS"] else []
57
+
58
+ result = QueryProfile(
59
+ query_id=q["QUERY_ID"],
60
+ step_id=q["STEP_ID"],
61
+ operator_id=q["OPERATOR_ID"],
62
+ parent_operators=parent_operators,
63
+ operator_type=q["OPERATOR_TYPE"],
64
+ operator_statistics=operator_statistics,
65
+ execution_time_breakdown=execution_time_breakdown,
66
+ operator_attributes=operator_attributes,
67
+ )
68
+
69
+ query_profile.append(result)
70
+
71
+ return query_profile
72
+
73
+ def fetch_query_insights(self, query_id: str) -> List[dict]:
74
+ """
75
+ Fetch Snowflake-native query insights from ACCOUNT_USAGE.QUERY_INSIGHTS.
76
+ Returns a list of insight dicts. Empty list if no insights or view unavailable.
77
+ Note: latency can be up to 90 minutes after query execution.
78
+ """
79
+ query_id = _validate_query_id(query_id)
80
+
81
+ try:
82
+ with self.connection:
83
+ sql = f"""
84
+ SELECT INSIGHT_TYPE_ID, MESSAGE, SUGGESTIONS, IS_OPPORTUNITY, INSIGHT_TOPIC
85
+ FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_INSIGHTS
86
+ WHERE QUERY_ID = '{query_id}'
87
+ """
88
+ rows = self.connection.query(sql)
89
+
90
+ insights = []
91
+ for row in rows:
92
+ insights.append({
93
+ "type_id": row["INSIGHT_TYPE_ID"],
94
+ "message": row["MESSAGE"],
95
+ "suggestions": row["SUGGESTIONS"],
96
+ "is_opportunity": row["IS_OPPORTUNITY"],
97
+ "topic": row["INSIGHT_TOPIC"],
98
+ })
99
+ return insights
100
+ except Exception:
101
+ return []
@@ -0,0 +1,42 @@
1
+ import os
2
+ import yaml
3
+ from pathlib import Path
4
+ from typing import Dict, Any
5
+
6
+ class ProfileNotFound(Exception):
7
+ pass
8
+
9
+ class SnowglobeConfig:
10
+ CONFIG_PATH = Path.home() / ".snowglobe" / "config.yaml"
11
+
12
+ def __init__(self):
13
+ self.profiles: Dict[str, Dict[str, Any]] = {}
14
+ self.load()
15
+
16
+
17
+ def _expand_env(self, obj: Any) -> Any:
18
+ """Recursively expand environment variables in strings, preserving types."""
19
+ if isinstance(obj, str):
20
+ return os.path.expandvars(obj)
21
+ if isinstance(obj, list):
22
+ return [self._expand_env(x) for x in obj]
23
+ if isinstance(obj, dict):
24
+ return {k: self._expand_env(v) for k, v in obj.items()}
25
+ return obj
26
+
27
+ def load(self):
28
+ if not self.CONFIG_PATH.exists():
29
+ raise FileNotFoundError(f"Config file not found at {self.CONFIG_PATH}")
30
+ with open(self.CONFIG_PATH, "r") as f:
31
+ raw_profiles = yaml.safe_load(f) or {}
32
+
33
+ for profile_name, values in raw_profiles.items():
34
+ self.profiles[profile_name] = self._expand_env(values)
35
+
36
+ def get_profile(self, profile_name: str) -> Dict[str, Any]:
37
+ if profile_name not in self.profiles:
38
+ raise ProfileNotFound(f"Profile '{profile_name}' not found in config")
39
+ return self.profiles[profile_name]
40
+
41
+ def list_profiles(self):
42
+ return list(self.profiles.keys())