sleepybricks 1.0.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.
@@ -0,0 +1 @@
1
+ """sleepybricks: run databricks operations across multiple workspaces."""
@@ -0,0 +1 @@
1
+ """CLI package for sleepybricks."""
@@ -0,0 +1,49 @@
1
+ """Command registration helpers for sleepybricks."""
2
+
3
+ from importlib import import_module
4
+ from pathlib import Path
5
+ from typing import Protocol, cast
6
+
7
+ import typer
8
+
9
+
10
+ class CommandModule(Protocol):
11
+ """Protocol describing a CLI command module."""
12
+
13
+ def register(self, app: typer.Typer) -> None:
14
+ """Register the module's command(s) with the root app."""
15
+
16
+
17
+ def discoverCommandModules() -> list[CommandModule]:
18
+ """Import command modules in this package and return registrable ones.
19
+
20
+ Returns:
21
+ A list of imported command modules that expose a ``register`` function.
22
+ """
23
+
24
+ command_modules: list[CommandModule] = []
25
+ commands_dir = Path(__file__).resolve().parent
26
+
27
+ for module_path in sorted(commands_dir.glob("*.py")):
28
+ if module_path.stem == "__init__":
29
+ continue
30
+
31
+ module = import_module(f"{__name__}.{module_path.stem}")
32
+ if hasattr(module, "register"):
33
+ command_modules.append(cast(CommandModule, module))
34
+
35
+ return command_modules
36
+
37
+
38
+ def registerCommands(app: typer.Typer) -> None:
39
+ """Register all command modules with the provided Typer app.
40
+
41
+ Args:
42
+ app: Root Typer application instance.
43
+
44
+ Returns:
45
+ None.
46
+ """
47
+
48
+ for command_module in discoverCommandModules():
49
+ command_module.register(app)
@@ -0,0 +1,87 @@
1
+ """``dash-links`` command: find dashboard links across workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from sleepybricks.cli.runtime import bootstrap
8
+ from sleepybricks.core.workspace import getClient
9
+ from sleepybricks.utils.formatting import renderTable
10
+
11
+
12
+ def _buildLink(host: str, dashboard_id: str) -> str:
13
+ """Build the published link for a lakeview dashboard.
14
+
15
+ Args:
16
+ host: The workspace host URL.
17
+ dashboard_id: The dashboard id.
18
+
19
+ Returns:
20
+ The published dashboard URL.
21
+ """
22
+
23
+ return f"{host.rstrip('/')}/dashboardsv3/{dashboard_id}/published"
24
+
25
+
26
+ def register(app: typer.Typer) -> None:
27
+ """Register the ``dash-links`` command.
28
+
29
+ Args:
30
+ app: Root Typer application instance.
31
+
32
+ Returns:
33
+ None.
34
+ """
35
+
36
+ @app.command("dash-links")
37
+ def dashLinks(
38
+ dashboard_name: str = typer.Argument(..., help="Case-sensitive dashboard name to match."),
39
+ profile_list: str = typer.Argument(
40
+ ..., help="Comma-separated profiles, e.g. 'dev,stg,us'."
41
+ ),
42
+ ) -> None:
43
+ """Fetch published links for a dashboard across one or more workspaces.
44
+
45
+ Dashboard names are not unique in databricks; the first match in each
46
+ workspace is used and a warning is shown when duplicates exist.
47
+
48
+ Args:
49
+ dashboard_name: Case-sensitive dashboard display name.
50
+ profile_list: Comma-separated list of profiles to search.
51
+
52
+ Returns:
53
+ None.
54
+ """
55
+
56
+ config, profiles = bootstrap(profile_list)
57
+
58
+ rows: list[list[str]] = []
59
+ for profile in profiles:
60
+ label = config.labelFor(profile)
61
+ try:
62
+ client = getClient(profile)
63
+ matches = [
64
+ dashboard
65
+ for dashboard in client.lakeview.list()
66
+ if dashboard.display_name == dashboard_name
67
+ ]
68
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
69
+ rows.append([label, f"⚠️ error: {error}"])
70
+ continue
71
+
72
+ if not matches:
73
+ rows.append([label, "(not found)"])
74
+ continue
75
+
76
+ if len(matches) > 1:
77
+ typer.secho(
78
+ f"⚠️ {label}: found {len(matches)} dashboards named "
79
+ f"'{dashboard_name}'; using the first.",
80
+ fg=typer.colors.YELLOW,
81
+ )
82
+
83
+ first = matches[0]
84
+ rows.append([label, _buildLink(client.config.host, first.dashboard_id)])
85
+
86
+ typer.echo(f"\n✨ {dashboard_name} ✨\n")
87
+ typer.echo(renderTable(rows, headers=["Workspace", "Link"], table_style=config.table_style))
@@ -0,0 +1,91 @@
1
+ """``pull-repo`` command: pull the latest commit for a repo across workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from databricks.sdk import WorkspaceClient
7
+ from databricks.sdk.service.workspace import RepoInfo
8
+
9
+ from sleepybricks.cli.runtime import bootstrap
10
+ from sleepybricks.core.workspace import getClient
11
+ from sleepybricks.utils.formatting import renderTable
12
+
13
+
14
+ def _findRepo(client: WorkspaceClient, repo_name: str) -> tuple[RepoInfo | None, int]:
15
+ """Find a repo by name within a workspace.
16
+
17
+ Args:
18
+ client: The workspace client to query.
19
+ repo_name: The repo name to match against repo paths.
20
+
21
+ Returns:
22
+ A tuple of the first matching repo (or ``None``) and the total match count.
23
+ """
24
+
25
+ matches = [repo for repo in client.repos.list() if repo_name in (repo.path or "")]
26
+ return (matches[0] if matches else None, len(matches))
27
+
28
+
29
+ def register(app: typer.Typer) -> None:
30
+ """Register the ``pull-repo`` command.
31
+
32
+ Args:
33
+ app: Root Typer application instance.
34
+
35
+ Returns:
36
+ None.
37
+ """
38
+
39
+ @app.command("pull-repo")
40
+ def pullRepo(
41
+ repo_name: str = typer.Argument(..., help="Repo name to pull (matched within repo path)."),
42
+ profile_list: str = typer.Argument(
43
+ ..., help="Comma-separated profiles, e.g. 'dev,stg,us'."
44
+ ),
45
+ ) -> None:
46
+ """Pull the latest commit for a repo across one or more workspaces.
47
+
48
+ Args:
49
+ repo_name: The repo name to update.
50
+ profile_list: Comma-separated list of profiles.
51
+
52
+ Returns:
53
+ None.
54
+ """
55
+
56
+ config, profiles = bootstrap(profile_list)
57
+
58
+ rows: list[list[str]] = []
59
+ for profile in profiles:
60
+ label = config.labelFor(profile)
61
+ try:
62
+ client = getClient(profile)
63
+ repo, count = _findRepo(client, repo_name)
64
+ if repo is None:
65
+ rows.append([label, repo_name, "-", "⚠️ not found (is it a git folder?)"])
66
+ continue
67
+ if count > 1:
68
+ typer.secho(
69
+ f"⚠️ {label}: {count} repos match '{repo_name}'; using {repo.path}.",
70
+ fg=typer.colors.YELLOW,
71
+ )
72
+
73
+ if repo.branch:
74
+ client.repos.update(repo.id, branch=repo.branch)
75
+ else:
76
+ client.repos.update(repo.id)
77
+
78
+ updated = client.repos.get(repo.id)
79
+ head = (updated.head_commit_id or "")[:8] or "-"
80
+ rows.append([label, repo.path, updated.branch or "-", f"✅ pulled @ {head}"])
81
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
82
+ rows.append([label, repo_name, "-", f"⚠️ error: {error}"])
83
+
84
+ typer.echo()
85
+ typer.echo(
86
+ renderTable(
87
+ rows,
88
+ headers=["Workspace", "Repo", "Branch", "Status"],
89
+ table_style=config.table_style,
90
+ )
91
+ )
@@ -0,0 +1,164 @@
1
+ """``write-secret`` and ``create-scope`` commands for managing secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from databricks.sdk import WorkspaceClient
7
+
8
+ from sleepybricks.cli.runtime import bootstrap
9
+ from sleepybricks.core.workspace import getClient
10
+ from sleepybricks.utils.formatting import renderTable
11
+
12
+
13
+ def _scopeExists(client: WorkspaceClient, scope_name: str) -> bool:
14
+ """Return whether a secret scope exists in the workspace.
15
+
16
+ Args:
17
+ client: The workspace client to query.
18
+ scope_name: The scope name to check.
19
+
20
+ Returns:
21
+ ``True`` when the scope is present.
22
+ """
23
+
24
+ return any(scope.name == scope_name for scope in client.secrets.list_scopes())
25
+
26
+
27
+ def _parseWriteSecretArgs(parts: list[str]) -> tuple[str, str, str, str]:
28
+ """Parse the flexible ``write-secret`` positional arguments.
29
+
30
+ Supports both the four-arg form (``scope key value profiles``) and the
31
+ dotted three-arg form (``scope.key value profiles``).
32
+
33
+ Args:
34
+ parts: Raw positional arguments.
35
+
36
+ Returns:
37
+ A tuple of (scope, key, value, profile_list).
38
+
39
+ Raises:
40
+ typer.BadParameter: If the arguments don't match a supported form.
41
+ """
42
+
43
+ if len(parts) == 4:
44
+ scope, key, value, profile_list = parts
45
+ return scope, key, value, profile_list
46
+
47
+ if len(parts) == 3:
48
+ scoped_key, value, profile_list = parts
49
+ if "." not in scoped_key:
50
+ raise typer.BadParameter(
51
+ "Three-argument form expects '<scope>.<key>', e.g. 'my_scope.my_key'."
52
+ )
53
+ scope, key = scoped_key.split(".", 1)
54
+ if not scope or not key:
55
+ raise typer.BadParameter("Both scope and key are required in '<scope>.<key>'.")
56
+ return scope, key, value, profile_list
57
+
58
+ raise typer.BadParameter(
59
+ "Usage: write-secret <scope> <key> <value> <profiles> | "
60
+ "write-secret <scope>.<key> <value> <profiles>"
61
+ )
62
+
63
+
64
+ def register(app: typer.Typer) -> None:
65
+ """Register the secret-management commands.
66
+
67
+ Args:
68
+ app: Root Typer application instance.
69
+
70
+ Returns:
71
+ None.
72
+ """
73
+
74
+ @app.command("write-secret")
75
+ def writeSecret(
76
+ parts: list[str] = typer.Argument(
77
+ ...,
78
+ help="<scope> <key> <value> <profiles> OR <scope>.<key> <value> <profiles>",
79
+ ),
80
+ ) -> None:
81
+ """Write a secret into a scope across one or more workspaces.
82
+
83
+ The scope must already exist; if it does not, the relevant
84
+ ``create-scope`` command is suggested.
85
+
86
+ Args:
87
+ parts: Positional arguments in one of the two supported forms.
88
+
89
+ Returns:
90
+ None.
91
+ """
92
+
93
+ scope_name, secret_key, secret_value, profile_list = _parseWriteSecretArgs(parts)
94
+ config, profiles = bootstrap(profile_list)
95
+
96
+ rows: list[list[str]] = []
97
+ for profile in profiles:
98
+ label = config.labelFor(profile)
99
+ try:
100
+ client = getClient(profile)
101
+ if not _scopeExists(client, scope_name):
102
+ rows.append(
103
+ [
104
+ label,
105
+ scope_name,
106
+ secret_key,
107
+ f"⚠️ scope missing — run: sleepybricks create-scope {scope_name} {profile}",
108
+ ]
109
+ )
110
+ continue
111
+ client.secrets.put_secret(scope_name, secret_key, string_value=secret_value)
112
+ rows.append([label, scope_name, secret_key, "✅ written"])
113
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
114
+ rows.append([label, scope_name, secret_key, f"⚠️ error: {error}"])
115
+
116
+ typer.echo()
117
+ typer.echo(
118
+ renderTable(
119
+ rows,
120
+ headers=["Workspace", "Scope", "Key", "Status"],
121
+ table_style=config.table_style,
122
+ )
123
+ )
124
+
125
+ @app.command("create-scope")
126
+ def createScope(
127
+ scope_name: str = typer.Argument(..., help="Name of the secret scope to create."),
128
+ profile_list: str = typer.Argument(
129
+ ..., help="Comma-separated profiles, e.g. 'dev,stg,us'."
130
+ ),
131
+ ) -> None:
132
+ """Create a secret scope across one or more workspaces.
133
+
134
+ Existing scopes are skipped and reported as already present.
135
+
136
+ Args:
137
+ scope_name: The scope name to create.
138
+ profile_list: Comma-separated list of profiles.
139
+
140
+ Returns:
141
+ None.
142
+ """
143
+
144
+ config, profiles = bootstrap(profile_list)
145
+
146
+ rows: list[list[str]] = []
147
+ for profile in profiles:
148
+ label = config.labelFor(profile)
149
+ try:
150
+ client = getClient(profile)
151
+ if _scopeExists(client, scope_name):
152
+ rows.append([label, scope_name, "↩️ already existed"])
153
+ continue
154
+ client.secrets.create_scope(scope_name)
155
+ rows.append([label, scope_name, "✅ created"])
156
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
157
+ rows.append([label, scope_name, f"⚠️ error: {error}"])
158
+
159
+ typer.echo()
160
+ typer.echo(
161
+ renderTable(
162
+ rows, headers=["Workspace", "Scope", "Status"], table_style=config.table_style
163
+ )
164
+ )
@@ -0,0 +1,134 @@
1
+ """``sql`` command: run a SQL statement across workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from databricks.sdk.service.sql import StatementResponse, StatementState
10
+
11
+ from sleepybricks.cli.runtime import bootstrap
12
+ from sleepybricks.core.config import AppConfig
13
+ from sleepybricks.core.workspace import findWarehouseId, getClient, runStatement
14
+ from sleepybricks.utils.formatting import renderTable
15
+
16
+
17
+ def _resolveStatement(sql_file: Optional[Path], sql_string: Optional[str]) -> str:
18
+ """Resolve the SQL text from the mutually-exclusive ``-f``/``-s`` options.
19
+
20
+ Args:
21
+ sql_file: Path to a ``.sql`` file, when provided.
22
+ sql_string: Literal SQL text, when provided.
23
+
24
+ Returns:
25
+ The SQL statement to execute.
26
+
27
+ Raises:
28
+ typer.BadParameter: If neither or both options are provided, or the file
29
+ is invalid.
30
+ """
31
+
32
+ if bool(sql_file) == bool(sql_string):
33
+ raise typer.BadParameter("Provide exactly one of -f/--file or -s/--string.")
34
+
35
+ if sql_string is not None:
36
+ return sql_string
37
+
38
+ assert sql_file is not None
39
+ if sql_file.suffix.lower() != ".sql":
40
+ raise typer.BadParameter(f"SQL file must have a .sql extension: {sql_file}")
41
+ if not sql_file.is_file():
42
+ raise typer.BadParameter(f"SQL file does not exist: {sql_file}")
43
+ return sql_file.read_text(encoding="utf-8")
44
+
45
+
46
+ def _renderResult(response: StatementResponse, config: AppConfig) -> str:
47
+ """Render a statement response as text for a single workspace.
48
+
49
+ Args:
50
+ response: The terminal statement response.
51
+ config: Application configuration (for table style).
52
+
53
+ Returns:
54
+ Rendered output describing the result or failure.
55
+ """
56
+
57
+ state = response.status.state if response.status is not None else None
58
+
59
+ if state == StatementState.SUCCEEDED:
60
+ manifest = response.manifest
61
+ result = response.result
62
+ columns = (
63
+ [column.name for column in manifest.schema.columns]
64
+ if manifest is not None and manifest.schema is not None and manifest.schema.columns
65
+ else []
66
+ )
67
+ data = result.data_array if result is not None and result.data_array else []
68
+ if not data:
69
+ return "✅ succeeded (no rows returned)"
70
+ return renderTable(data, headers=columns, table_style=config.table_style)
71
+
72
+ if response.status is not None and response.status.error is not None:
73
+ return f"⚠️ {state}: {response.status.error.message}"
74
+ return f"⚠️ {state}"
75
+
76
+
77
+ def register(app: typer.Typer) -> None:
78
+ """Register the ``sql`` command.
79
+
80
+ Args:
81
+ app: Root Typer application instance.
82
+
83
+ Returns:
84
+ None.
85
+ """
86
+
87
+ @app.command("sql")
88
+ def sql(
89
+ profile_list: str = typer.Argument(
90
+ ..., help="Comma-separated profiles, e.g. 'dev,stg,us'."
91
+ ),
92
+ sql_file: Optional[Path] = typer.Option(
93
+ None, "-f", "--file", help="Path to a .sql file to execute."
94
+ ),
95
+ sql_string: Optional[str] = typer.Option(
96
+ None, "-s", "--string", help="Literal SQL string to execute."
97
+ ),
98
+ ) -> None:
99
+ """Execute a SQL statement across one or more workspaces.
100
+
101
+ Requires a serverless SQL warehouse (resolved from the configured
102
+ ``serverless_warehouse_name``) in each target workspace.
103
+
104
+ Args:
105
+ profile_list: Comma-separated list of profiles to run against.
106
+ sql_file: Path to a ``.sql`` file (mutually exclusive with ``-s``).
107
+ sql_string: Literal SQL text (mutually exclusive with ``-f``).
108
+
109
+ Returns:
110
+ None.
111
+ """
112
+
113
+ config, profiles = bootstrap(profile_list)
114
+ statement = _resolveStatement(sql_file, sql_string)
115
+
116
+ for profile in profiles:
117
+ label = config.labelFor(profile)
118
+ typer.echo(f"\n─── {label} ───")
119
+ warehouse_name = config.warehouseNameFor(profile)
120
+ try:
121
+ client = getClient(profile)
122
+ warehouse_id = findWarehouseId(client, warehouse_name)
123
+ if warehouse_id is None:
124
+ typer.secho(
125
+ f"⚠️ serverless warehouse '{warehouse_name}' not found in this workspace.",
126
+ fg=typer.colors.YELLOW,
127
+ )
128
+ continue
129
+ response = runStatement(client, statement, warehouse_id)
130
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
131
+ typer.secho(f"⚠️ error: {error}", fg=typer.colors.YELLOW)
132
+ continue
133
+
134
+ typer.echo(_renderResult(response, config))
@@ -0,0 +1,80 @@
1
+ """``create-workspace-folder`` command: make a workspace folder everywhere."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from databricks.sdk.errors import NotFound
7
+
8
+ from sleepybricks.cli.runtime import bootstrap
9
+ from sleepybricks.core.workspace import getClient
10
+ from sleepybricks.utils.formatting import renderTable
11
+
12
+
13
+ def _normalizePath(folder_name: str) -> str:
14
+ """Normalize a folder name into an absolute workspace path.
15
+
16
+ Args:
17
+ folder_name: The folder name or path provided by the user.
18
+
19
+ Returns:
20
+ An absolute workspace path beginning with ``/``.
21
+ """
22
+
23
+ folder_name = folder_name.strip()
24
+ return folder_name if folder_name.startswith("/") else f"/{folder_name}"
25
+
26
+
27
+ def register(app: typer.Typer) -> None:
28
+ """Register the ``create-workspace-folder`` command.
29
+
30
+ Args:
31
+ app: Root Typer application instance.
32
+
33
+ Returns:
34
+ None.
35
+ """
36
+
37
+ @app.command("create-workspace-folder")
38
+ def createWorkspaceFolder(
39
+ folder_name: str = typer.Argument(..., help="Folder path under the workspace root."),
40
+ profile_list: str = typer.Argument(
41
+ ..., help="Comma-separated profiles, e.g. 'dev,stg,us'."
42
+ ),
43
+ ) -> None:
44
+ """Create a workspace folder across one or more workspaces.
45
+
46
+ Existing folders are skipped and reported as already present.
47
+
48
+ Args:
49
+ folder_name: The folder path to create (``/`` prefixed if omitted).
50
+ profile_list: Comma-separated list of profiles.
51
+
52
+ Returns:
53
+ None.
54
+ """
55
+
56
+ config, profiles = bootstrap(profile_list)
57
+ path = _normalizePath(folder_name)
58
+
59
+ rows: list[list[str]] = []
60
+ for profile in profiles:
61
+ label = config.labelFor(profile)
62
+ try:
63
+ client = getClient(profile)
64
+ try:
65
+ client.workspace.get_status(path)
66
+ rows.append([label, path, "↩️ already existed"])
67
+ continue
68
+ except NotFound:
69
+ pass
70
+ client.workspace.mkdirs(path)
71
+ rows.append([label, path, "✅ created"])
72
+ except Exception as error: # noqa: BLE001 - surface any SDK/auth error per profile
73
+ rows.append([label, path, f"⚠️ error: {error}"])
74
+
75
+ typer.echo()
76
+ typer.echo(
77
+ renderTable(
78
+ rows, headers=["Workspace", "Path", "Status"], table_style=config.table_style
79
+ )
80
+ )
@@ -0,0 +1,34 @@
1
+ """Shared runtime helpers for sleepybricks CLI commands.
2
+
3
+ This module lives outside ``cli/commands`` so it is not picked up by the command
4
+ auto-discovery in :mod:`sleepybricks.cli.commands`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from sleepybricks.core.config import AppConfig, getConfig
12
+ from sleepybricks.core.credentials import CredentialError, resolveProfiles
13
+
14
+
15
+ def bootstrap(profile_list: str) -> tuple[AppConfig, list[str]]:
16
+ """Load config and resolve profiles, exiting gracefully on credential errors.
17
+
18
+ Args:
19
+ profile_list: A comma-separated string of profile names.
20
+
21
+ Returns:
22
+ A tuple of the application config and the validated profile names.
23
+
24
+ Raises:
25
+ typer.Exit: With code 1 when credentials cannot be resolved.
26
+ """
27
+
28
+ config = getConfig()
29
+ try:
30
+ profiles = resolveProfiles(profile_list)
31
+ except CredentialError as error:
32
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
33
+ raise typer.Exit(1) from error
34
+ return config, profiles
@@ -0,0 +1 @@
1
+ """Core package for sleepybricks."""