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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("snowglobe-cli")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
snowglobe/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from snowglobe.cli.app import app
2
+
3
+ app()
File without changes
@@ -0,0 +1,197 @@
1
+ import typer
2
+ from typing import Optional
3
+ from snowglobe.core.access_service import AccessService
4
+ from snowglobe.cli.prompts import resolve_access_inputs
5
+ from snowglobe.output import cli
6
+
7
+ access_app = typer.Typer(
8
+ help="Inspect Snowflake access and identify roles/users with access and privileges on database objects",
9
+ no_args_is_help=True,
10
+ )
11
+
12
+
13
+ @access_app.command()
14
+ def check(
15
+ ctx: typer.Context,
16
+ ignore_excluded_roles: bool = typer.Option(False, help="Ignore excluded roles"),
17
+ role: Optional[str] = typer.Option(None, help="Role to inspect access for"),
18
+ username: Optional[str] = typer.Option(None, help="Username to inspect access for"),
19
+ object_type: Optional[str] = typer.Option(None, help="Object type (e.g. TABLE)"),
20
+ object_name: Optional[str] = typer.Option(None, help="Object name (e.g. DB.SCHEMA.TABLE)"),
21
+ privilege: Optional[str] = typer.Option(None, help="Privilege to check (e.g. SELECT)"),
22
+ output: str = typer.Option("text", help="Output format: text, json"),
23
+ refresh_state: bool = typer.Option(False, help="Refresh state from Snowflake")
24
+ ):
25
+ """
26
+ Check access for a user or role on a specific object.
27
+
28
+ In interactive mode (TTY), missing arguments will be prompted for
29
+ with fuzzy completion. In headless mode (piped/CI), all arguments
30
+ must be provided or the command will exit with an error.
31
+ """
32
+
33
+ context = ctx.obj
34
+ access_service = AccessService(context)
35
+
36
+ # Load graphs for interactive resolution (needed for completions)
37
+ access_service.setup_state()
38
+ if refresh_state:
39
+ access_service.refresh_state()
40
+ access_service.load_state()
41
+
42
+ # Resolve missing inputs (interactive prompts or headless error)
43
+ resolved = resolve_access_inputs(
44
+ username=username,
45
+ role=role,
46
+ object_type=object_type,
47
+ object_name=object_name,
48
+ privilege=privilege,
49
+ user_graph=access_service.user_graph,
50
+ role_graph=access_service.role_graph,
51
+ grants=[],
52
+ object_index=access_service.object_index,
53
+ )
54
+
55
+ # Run the access check with fully resolved args
56
+ try:
57
+ query_output = access_service.inspect_access(
58
+ username=resolved["username"],
59
+ role=resolved["role"],
60
+ object_type=resolved["object_type"],
61
+ object_name=resolved["object_name"],
62
+ privilege=resolved["privilege"],
63
+ ignore_excluded_roles=ignore_excluded_roles,
64
+ refresh_state=False, # Already refreshed above if needed
65
+ )
66
+ except ValueError as e:
67
+ typer.secho(f"Error: {e}", fg=typer.colors.RED)
68
+ raise typer.Exit(1)
69
+
70
+ # Output
71
+ if output == "text":
72
+ typer.echo(cli.format_access_text(query_output))
73
+ elif output == "json":
74
+ cli.format_json(query_output)
75
+
76
+
77
+ @access_app.command()
78
+ def create(
79
+ ctx: typer.Context,
80
+ role: Optional[str] = typer.Option(None, help="Role to check CREATE privilege for"),
81
+ username: Optional[str] = typer.Option(None, help="Username to check CREATE privilege for"),
82
+ privilege: str = typer.Option("CREATE TABLE", help="CREATE privilege (e.g. 'CREATE TABLE', 'CREATE VIEW')"),
83
+ scope: Optional[str] = typer.Option(None, help="Optional scope: DB or DB.SCHEMA to filter"),
84
+ output: str = typer.Option("text", help="Output format: text, json"),
85
+ ):
86
+ """
87
+ Check CREATE privileges for a user or role.
88
+
89
+ Shows where the role/user can create objects — at account level,
90
+ specific databases, or specific schemas. Optionally filter by a
91
+ specific database or schema scope.
92
+
93
+ Examples:
94
+ snowglobe access create --role SYSADMIN --privilege "CREATE TABLE"
95
+ snowglobe access create --role DEV_DW_DESIGNER --privilege "CREATE TABLE" --scope DEV_REFINED.UNIFIED
96
+ """
97
+ context = ctx.obj
98
+
99
+ if not role and not username:
100
+ typer.secho("Must provide --role or --username.", fg=typer.colors.RED)
101
+ raise typer.Exit(1)
102
+
103
+ access_service = AccessService(context)
104
+ try:
105
+ result = access_service.inspect_create(
106
+ username=username,
107
+ role=role,
108
+ privilege=privilege,
109
+ scope=scope,
110
+ )
111
+ except ValueError as e:
112
+ typer.secho(f"Error: {e}", fg=typer.colors.RED)
113
+ raise typer.Exit(1)
114
+
115
+ if output == "text":
116
+ typer.echo(cli.format_create_text(result))
117
+ elif output == "json":
118
+ cli.format_json(result)
119
+
120
+
121
+ @access_app.command()
122
+ def whoaccess(
123
+ ctx: typer.Context,
124
+ object_type: Optional[str] = typer.Option(None, "--object-type", help="Object type (e.g. TABLE, VIEW, SCHEMA)"),
125
+ object_name: Optional[str] = typer.Option(None, "--object-name", help="Object FQN (e.g. DB.SCHEMA.TABLE)"),
126
+ privilege: Optional[str] = typer.Option(None, "--privilege", help="Filter to a specific privilege (e.g. SELECT)"),
127
+ output: str = typer.Option("text", help="Output format: text, json"),
128
+ ):
129
+ """
130
+ Reverse lookup: who can access this object?
131
+
132
+ Shows all roles and users that have access to the specified object,
133
+ grouped by privilege. Optionally filter to a specific privilege.
134
+
135
+ Examples:
136
+ snowglobe access whoaccess --object-type TABLE --object-name DEV_REFINED.UNIFIED.HUB_EMAIL
137
+ snowglobe access whoaccess --object-type TABLE --object-name DEV_REFINED.UNIFIED.HUB_EMAIL --privilege SELECT
138
+ """
139
+ context = ctx.obj
140
+ access_service = AccessService(context)
141
+
142
+ # Load state for interactive completions
143
+ access_service.setup_state()
144
+ access_service.load_state()
145
+
146
+ # Resolve missing inputs interactively if TTY
147
+ if not object_type or not object_name:
148
+ from snowglobe.cli.prompts import is_interactive
149
+ if not is_interactive():
150
+ missing = []
151
+ if not object_type:
152
+ missing.append("--object-type")
153
+ if not object_name:
154
+ missing.append("--object-name")
155
+ typer.secho(
156
+ f"Error: Missing required arguments: {', '.join(missing)}.",
157
+ fg=typer.colors.RED
158
+ )
159
+ raise typer.Exit(1)
160
+
161
+ from prompt_toolkit import PromptSession
162
+ from prompt_toolkit.completion import WordCompleter, FuzzyCompleter
163
+ from snowglobe.models.access import ObjectType
164
+
165
+ session = PromptSession()
166
+
167
+ if not object_type:
168
+ items = [ot.value for ot in ObjectType]
169
+ word = WordCompleter(items, ignore_case=True)
170
+ fuzzy = FuzzyCompleter(word)
171
+ object_type = session.prompt(
172
+ "Object type: ",
173
+ completer=fuzzy,
174
+ complete_while_typing=True,
175
+ ).strip().upper()
176
+
177
+ if not object_name:
178
+ obj_index = access_service.object_index or {}
179
+ items = obj_index.get(object_type.upper(), [])
180
+ word = WordCompleter(items, ignore_case=True, sentence=True)
181
+ fuzzy = FuzzyCompleter(word)
182
+ object_name = session.prompt(
183
+ "Object name (FQN): ",
184
+ completer=fuzzy,
185
+ complete_while_typing=True,
186
+ ).strip()
187
+
188
+ result = access_service.inspect_reverse(
189
+ object_type=object_type,
190
+ object_name=object_name,
191
+ privilege=privilege,
192
+ )
193
+
194
+ if output == "text":
195
+ typer.echo(cli.format_reverse_text(result))
196
+ elif output == "json":
197
+ cli.format_json(result)
snowglobe/cli/app.py ADDED
@@ -0,0 +1,148 @@
1
+ import typer
2
+ from snowglobe.cli.context import SnowglobeContext
3
+ from snowglobe.cli.access import access_app
4
+ from snowglobe.cli.optimizer import opt_app
5
+ from snowglobe.cli.cost import cost_app
6
+ from snowglobe.cli.diff import diff_app
7
+ from snowglobe.cli.report import report_app
8
+ from snowglobe.cli.debug import debug_app
9
+
10
+ app = typer.Typer(
11
+ help="Snowglobe — Explainable cost and access visibility for Snowflake",
12
+ no_args_is_help=False,
13
+ context_settings={"ignore_unknown_options": True}
14
+ )
15
+
16
+ app.add_typer(access_app, name="access")
17
+ app.add_typer(cost_app, name="cost")
18
+ app.add_typer(diff_app, name="diff")
19
+ app.add_typer(opt_app, name="optimize")
20
+ app.add_typer(report_app, name="report")
21
+ app.add_typer(debug_app, name="debug")
22
+
23
+
24
+ @app.command()
25
+ def refresh(
26
+ ctx: typer.Context,
27
+ full: bool = typer.Option(False, "--full", help="Force full refresh (ignore incremental)")
28
+ ):
29
+ """Refresh cached state from Snowflake. Incremental by default."""
30
+ from snowglobe.core.access_service import AccessService
31
+
32
+ context = ctx.obj
33
+ service = AccessService(context)
34
+ service.setup_state()
35
+
36
+ service.refresh_state(full=full)
37
+
38
+ typer.secho(f" Users: {len(service.user_graph.assigned_roles)}", fg=typer.colors.GREEN)
39
+ typer.secho(f" Roles: {len(service.role_graph.parents)}", fg=typer.colors.GREEN)
40
+ total_objects = sum(len(v) for v in service.object_index.values())
41
+ typer.secho(f" Object index: {total_objects} FQNs", fg=typer.colors.GREEN)
42
+ typer.secho("Done.", fg=typer.colors.GREEN, bold=True)
43
+
44
+
45
+ def _launch_tui(context, *, vim_flag: bool = False, fallback_to_shell: bool = False) -> None:
46
+ """
47
+ Start the Textual TUI.
48
+
49
+ If `fallback_to_shell=True` and Textual isn't installed, drop into the
50
+ REPL shell instead with a one-line notice. Used by the default
51
+ `snowglobe` (no-args) path so users without the TUI extra still get
52
+ something useful. The explicit `snowglobe tui` subcommand passes
53
+ `fallback_to_shell=False` and exits with an error if Textual is missing.
54
+ """
55
+ try:
56
+ from snowglobe.tui.app import SnowglobeApp, VimSnowglobeApp
57
+ except ImportError as e:
58
+ if "textual" in str(e).lower():
59
+ if fallback_to_shell:
60
+ typer.secho(
61
+ "TUI not available (install with: pip install 'snowglobe[tui]'). "
62
+ "Falling back to the REPL shell.",
63
+ fg=typer.colors.YELLOW,
64
+ )
65
+ from snowglobe.cli.shell import start_shell
66
+ start_shell(context)
67
+ return
68
+ typer.secho(
69
+ "TUI requires the 'textual' package. Install with:\n"
70
+ " pip install 'snowglobe[tui]' (or) pip install textual",
71
+ fg=typer.colors.YELLOW,
72
+ )
73
+ raise typer.Exit(1)
74
+ raise
75
+
76
+ # CLI flag wins; otherwise inherit from the active profile's `vim: true`.
77
+ profile_vim = bool((context.profile or {}).get("vim", False)) if context.profile else False
78
+ context.vim_mode = vim_flag or profile_vim
79
+
80
+ app_cls = VimSnowglobeApp if context.vim_mode else SnowglobeApp
81
+ app_cls(context=context).run()
82
+
83
+
84
+ @app.command()
85
+ def tui(
86
+ ctx: typer.Context,
87
+ vim: bool = typer.Option(
88
+ False, "--vim",
89
+ help="Enable vim-style navigation (j/k/h/l/g/G/Ctrl-d/Ctrl-u + Esc blurs inputs).",
90
+ ),
91
+ ):
92
+ """Launch the rich Textual-based TUI (same as running `snowglobe` with no command)."""
93
+ _launch_tui(ctx.obj, vim_flag=vim, fallback_to_shell=False)
94
+
95
+
96
+ @app.command()
97
+ def shell(ctx: typer.Context):
98
+ """Launch the interactive REPL shell (the prompt_toolkit fuzzy-completion REPL)."""
99
+ from snowglobe.cli.shell import start_shell
100
+ start_shell(ctx.obj)
101
+
102
+
103
+ @app.callback(invoke_without_command=True)
104
+ def main(
105
+ ctx: typer.Context,
106
+ profile_name: str = typer.Option(
107
+ "default",
108
+ "--profile",
109
+ help="Snowflake connection profile to use",
110
+ ),
111
+ role: str | None = typer.Option(
112
+ None,
113
+ "--role",
114
+ help="Override Snowflake role",
115
+ ),
116
+ output: str = typer.Option(
117
+ "table",
118
+ "--output",
119
+ help="Output format: table | json",
120
+ ),
121
+ verbose: bool = typer.Option(
122
+ False,
123
+ "--verbose",
124
+ "-v",
125
+ help="Enable verbose output"
126
+ )
127
+ ):
128
+ """
129
+ Inspect and understand Snowflake cost, access, and ownership.
130
+
131
+ Snowglobe is read-only by design.
132
+
133
+ Run without a command to launch the TUI.
134
+ Use `snowglobe shell` for the REPL shell, or any subcommand for headless use.
135
+ """
136
+ context = SnowglobeContext(
137
+ profile_name=profile_name,
138
+ role=role,
139
+ output=output,
140
+ verbose=verbose
141
+ )
142
+ context.load_profile()
143
+ ctx.obj = context
144
+
145
+ # No subcommand → launch the TUI (falling back to the REPL shell
146
+ # if the optional Textual dependency isn't installed).
147
+ if ctx.invoked_subcommand is None:
148
+ _launch_tui(context, vim_flag=False, fallback_to_shell=True)
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional, Dict, Any, List
3
+ from snowglobe.config.loader import SnowglobeConfig
4
+ from snowglobe.snowflake.connection import SnowflakeReadOnly
5
+
6
+
7
+ @dataclass
8
+ class SnowglobeContext:
9
+ profile_name: str = "default"
10
+ profile: Optional[Dict[str, Any]] = None
11
+ role: Optional[str] = None
12
+ output: str = "table"
13
+ verbose: bool = False
14
+ vim_mode: bool = False
15
+
16
+ # Working state (used by shell and interactive prompts)
17
+ target_role: Optional[str] = None
18
+ username: Optional[str] = None
19
+ object_type: Optional[str] = None
20
+ object_name: Optional[str] = None
21
+ privilege: Optional[str] = None
22
+
23
+ # Preloaded graphs (populated by shell or on-demand)
24
+ user_graph: Any = None
25
+ role_graph: Any = None
26
+ object_index: Optional[Dict[str, List[str]]] = None
27
+
28
+ def load_profile(self):
29
+ if self.profile is not None:
30
+ return
31
+ config = SnowglobeConfig()
32
+ self.profile = config.get_profile(self.profile_name)
33
+ if self.role:
34
+ self.profile["role"] = self.role
35
+
36
+ def connect(self) -> SnowflakeReadOnly:
37
+ self.load_profile()
38
+ if not hasattr(self, "_sf"):
39
+ self._sf = SnowflakeReadOnly(
40
+ account=self.profile["account"],
41
+ warehouse=self.profile.get("warehouse"),
42
+ user=self.profile["user"],
43
+ role=self.profile.get("role"),
44
+ password=self.profile.get("password"),
45
+ private_key_path=self.profile.get("private_key_path"),
46
+ private_key_pwd=self.profile.get("private_key_pwd")
47
+ )
48
+ return self._sf