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/prompts.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"resolve_access_inputs"
|
|
3
|
+
]
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import typer
|
|
7
|
+
from prompt_toolkit import PromptSession
|
|
8
|
+
from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
|
|
9
|
+
from snowglobe.models.privilege import Privilege, privileges_for_object_type
|
|
10
|
+
from snowglobe.models.access import ObjectType
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_interactive() -> bool:
|
|
14
|
+
"""Return True if stdin is a TTY (interactive terminal)."""
|
|
15
|
+
return sys.stdin.isatty()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prompt(label: str, session: PromptSession, items: list[str], strict: bool = False, output: bool = False):
|
|
19
|
+
word = WordCompleter(items, ignore_case=True, sentence=True)
|
|
20
|
+
fuzzy = FuzzyCompleter(word)
|
|
21
|
+
|
|
22
|
+
def get_value(text):
|
|
23
|
+
value = session.prompt(
|
|
24
|
+
label,
|
|
25
|
+
completer=text,
|
|
26
|
+
complete_while_typing=True,
|
|
27
|
+
).strip()
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
value = get_value(fuzzy)
|
|
31
|
+
if not value and strict:
|
|
32
|
+
typer.secho("Field required!", fg=typer.colors.YELLOW)
|
|
33
|
+
value = get_value(fuzzy)
|
|
34
|
+
elif value not in items and strict:
|
|
35
|
+
typer.secho(f"{label} not found!", fg=typer.colors.YELLOW)
|
|
36
|
+
value = get_value(fuzzy)
|
|
37
|
+
|
|
38
|
+
if output:
|
|
39
|
+
typer.secho(f"{label} {value}", fg=typer.colors.GREEN)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_access_inputs(
|
|
44
|
+
*,
|
|
45
|
+
username,
|
|
46
|
+
role,
|
|
47
|
+
object_type,
|
|
48
|
+
object_name,
|
|
49
|
+
privilege,
|
|
50
|
+
user_graph,
|
|
51
|
+
role_graph,
|
|
52
|
+
grants,
|
|
53
|
+
object_index=None,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""
|
|
56
|
+
Resolve access query inputs. If all required args are provided, returns
|
|
57
|
+
immediately (headless-safe). If args are missing and stdin is a TTY,
|
|
58
|
+
prompts interactively. If args are missing and stdin is NOT a TTY,
|
|
59
|
+
raises an error with a clear message.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Inspect type: user or role
|
|
63
|
+
if username and not role:
|
|
64
|
+
inspect_type = "user"
|
|
65
|
+
elif role and not username:
|
|
66
|
+
inspect_type = "role"
|
|
67
|
+
else:
|
|
68
|
+
inspect_type = None
|
|
69
|
+
|
|
70
|
+
# Inputs
|
|
71
|
+
resolved_inputs = {
|
|
72
|
+
"inspect_type": inspect_type,
|
|
73
|
+
"username": username if username else None,
|
|
74
|
+
"role": role if role else None,
|
|
75
|
+
"object_type": object_type if object_type else None,
|
|
76
|
+
"object_name": object_name.upper() if object_name else None,
|
|
77
|
+
"database": object_name.split(".", 1)[0] if object_name else None,
|
|
78
|
+
"privilege": privilege if privilege else None
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Return resolved inputs if all provided (headless-safe path)
|
|
82
|
+
if all([username or role, object_type, object_name, privilege]):
|
|
83
|
+
return resolved_inputs
|
|
84
|
+
|
|
85
|
+
# In headless mode, fail fast with a clear error
|
|
86
|
+
if not is_interactive():
|
|
87
|
+
missing = []
|
|
88
|
+
if not (username or role):
|
|
89
|
+
missing.append("--username or --role")
|
|
90
|
+
if not object_type:
|
|
91
|
+
missing.append("--object-type")
|
|
92
|
+
if not object_name:
|
|
93
|
+
missing.append("--object-name")
|
|
94
|
+
if not privilege:
|
|
95
|
+
missing.append("--privilege")
|
|
96
|
+
typer.secho(
|
|
97
|
+
f"Error: Missing required arguments: {', '.join(missing)}. "
|
|
98
|
+
"All arguments must be provided in non-interactive (headless) mode.",
|
|
99
|
+
fg=typer.colors.RED
|
|
100
|
+
)
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
# Interactive prompts for missing inputs
|
|
104
|
+
session = PromptSession()
|
|
105
|
+
if not inspect_type:
|
|
106
|
+
choice = prompt("Inspect type (User or Role): ", session, ["User", "Role"])
|
|
107
|
+
if choice.lower().startswith("u"):
|
|
108
|
+
inspect_type = "user"
|
|
109
|
+
resolved_inputs["inspect_type"] = "user"
|
|
110
|
+
elif choice.lower().startswith("r"):
|
|
111
|
+
inspect_type = "role"
|
|
112
|
+
resolved_inputs["inspect_type"] = "role"
|
|
113
|
+
else:
|
|
114
|
+
typer.secho("Please choose 'User' or 'Role'. Exiting.", fg=typer.colors.RED)
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
|
|
117
|
+
if inspect_type == "user" and not username:
|
|
118
|
+
items = list(user_graph.assigned_roles.keys())
|
|
119
|
+
username = prompt("User: ", session, items, strict=True)
|
|
120
|
+
resolved_inputs["username"] = username
|
|
121
|
+
|
|
122
|
+
elif inspect_type == "role" and not role:
|
|
123
|
+
items = list(role_graph.roles.keys())
|
|
124
|
+
role = prompt("Role: ", session, items, strict=True)
|
|
125
|
+
resolved_inputs["role"] = role
|
|
126
|
+
|
|
127
|
+
if not object_type:
|
|
128
|
+
items = [ot.value for ot in ObjectType]
|
|
129
|
+
object_type = prompt("Select object type ", session, items, strict=True)
|
|
130
|
+
typer.secho(f"Object type: {object_type}", fg=typer.colors.GREEN)
|
|
131
|
+
resolved_inputs['object_type'] = object_type
|
|
132
|
+
|
|
133
|
+
if not object_name:
|
|
134
|
+
# Use object_index for completions if available, fall back to grants
|
|
135
|
+
if object_index and object_type in object_index:
|
|
136
|
+
items = object_index[object_type]
|
|
137
|
+
elif object_index and object_type.upper() in object_index:
|
|
138
|
+
items = object_index[object_type.upper()]
|
|
139
|
+
else:
|
|
140
|
+
items = sorted(set(g.object.name for g in grants if g.object.object_type.value == object_type))
|
|
141
|
+
object_name = prompt("Object name (FQN): ", session, items, strict=False)
|
|
142
|
+
if not object_name or not object_name.strip():
|
|
143
|
+
typer.secho("Object name is required.", fg=typer.colors.RED)
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
resolved_inputs['object_name'] = object_name.upper()
|
|
146
|
+
resolved_inputs["database"] = object_name.split(".", 1)[0]
|
|
147
|
+
|
|
148
|
+
if not privilege:
|
|
149
|
+
items = _privileges_for_object_type(object_type)
|
|
150
|
+
privilege = prompt("Privilege to inspect : ", session, items, strict=False)
|
|
151
|
+
if not privilege or not privilege.strip():
|
|
152
|
+
typer.secho("Privilege is required.", fg=typer.colors.RED)
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
resolved_inputs['privilege'] = privilege.upper()
|
|
155
|
+
|
|
156
|
+
return resolved_inputs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Privilege suggestions per object type now live in snowglobe.models.privilege
|
|
160
|
+
# (used by both the CLI and the TUI). Imported above as `privileges_for_object_type`.
|
|
161
|
+
_privileges_for_object_type = privileges_for_object_type
|
snowglobe/cli/report.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
report_app = typer.Typer(
|
|
5
|
+
help="Generate summarized reports — cost, AI, storage, queries",
|
|
6
|
+
no_args_is_help=True,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _default_output_path() -> str:
|
|
11
|
+
return f"snowglobe_report_{date.today().isoformat()}.md"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@report_app.command()
|
|
15
|
+
def full(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
days: int = typer.Option(30, help="Number of days to cover"),
|
|
18
|
+
top: int = typer.Option(10, help="Number of top queries to include"),
|
|
19
|
+
output: str = typer.Option(None, "--output", "-o", help="Output file path (default: snowglobe_report_DATE.md)"),
|
|
20
|
+
):
|
|
21
|
+
"""Generate a full report: cost summary, AI costs, storage, top queries."""
|
|
22
|
+
from snowglobe.core.report_service import ReportService
|
|
23
|
+
|
|
24
|
+
output_path = output or _default_output_path()
|
|
25
|
+
context = ctx.obj
|
|
26
|
+
|
|
27
|
+
typer.echo(f"\nGenerating full report ({days} days)...")
|
|
28
|
+
service = ReportService(context)
|
|
29
|
+
_, data = service.generate_and_save(output_path, days=days, top_n=top)
|
|
30
|
+
|
|
31
|
+
# Print terminal summary
|
|
32
|
+
typer.echo(service.terminal_summary(data))
|
|
33
|
+
typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
|
|
34
|
+
typer.echo("")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@report_app.command()
|
|
38
|
+
def cost(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
days: int = typer.Option(30, help="Number of days to cover"),
|
|
41
|
+
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
42
|
+
):
|
|
43
|
+
"""Generate a cost-focused report: summary, AI, storage."""
|
|
44
|
+
from snowglobe.core.report_service import ReportService
|
|
45
|
+
|
|
46
|
+
output_path = output or f"snowglobe_cost_{date.today().isoformat()}.md"
|
|
47
|
+
context = ctx.obj
|
|
48
|
+
|
|
49
|
+
typer.echo(f"\nGenerating cost report ({days} days)...")
|
|
50
|
+
service = ReportService(context)
|
|
51
|
+
data = service.generate_full_report(days=days, top_n=0)
|
|
52
|
+
# Clear queries for cost-only report
|
|
53
|
+
data["top_queries"] = []
|
|
54
|
+
markdown = service.render_markdown(data)
|
|
55
|
+
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
Path(output_path).write_text(markdown)
|
|
58
|
+
|
|
59
|
+
typer.echo(service.terminal_summary(data))
|
|
60
|
+
typer.secho(f" Report saved: {output_path}", fg=typer.colors.GREEN, bold=True)
|
|
61
|
+
typer.echo("")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@report_app.command()
|
|
65
|
+
def queries(
|
|
66
|
+
ctx: typer.Context,
|
|
67
|
+
days: int = typer.Option(7, help="Number of days to cover"),
|
|
68
|
+
top: int = typer.Option(10, help="Number of queries to include"),
|
|
69
|
+
output: str = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
70
|
+
):
|
|
71
|
+
"""Generate a top-queries report with optimization details."""
|
|
72
|
+
from snowglobe.core.cost_service import CostService
|
|
73
|
+
from snowglobe.output import cli
|
|
74
|
+
|
|
75
|
+
context = ctx.obj
|
|
76
|
+
cost_service = CostService(context)
|
|
77
|
+
|
|
78
|
+
typer.echo(f"\nFetching top {top} queries ({days} days)...")
|
|
79
|
+
df, _, _ = cost_service.get_top_queries(days=days, limit=top)
|
|
80
|
+
|
|
81
|
+
if df.empty:
|
|
82
|
+
typer.echo(" No query data found.")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Terminal output
|
|
86
|
+
cli.print_table(df, title=f"Top {top} Expensive Queries ({days} days)")
|
|
87
|
+
|
|
88
|
+
# Save to file if requested
|
|
89
|
+
if output:
|
|
90
|
+
df.to_csv(output, index=False)
|
|
91
|
+
typer.secho(f"\n Exported to: {output}", fg=typer.colors.GREEN)
|