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.
- sleepybricks/__init__.py +1 -0
- sleepybricks/cli/__init__.py +1 -0
- sleepybricks/cli/commands/__init__.py +49 -0
- sleepybricks/cli/commands/dash_links.py +87 -0
- sleepybricks/cli/commands/pull.py +91 -0
- sleepybricks/cli/commands/secrets.py +164 -0
- sleepybricks/cli/commands/sql.py +134 -0
- sleepybricks/cli/commands/workspace_folder.py +80 -0
- sleepybricks/cli/runtime.py +34 -0
- sleepybricks/core/__init__.py +1 -0
- sleepybricks/core/config.py +88 -0
- sleepybricks/core/credentials.py +103 -0
- sleepybricks/core/logging.py +16 -0
- sleepybricks/core/sleepy_params.py +102 -0
- sleepybricks/core/workspace.py +80 -0
- sleepybricks/main.py +24 -0
- sleepybricks/utils/__init__.py +1 -0
- sleepybricks/utils/formatting.py +26 -0
- sleepybricks-1.0.0.dist-info/METADATA +784 -0
- sleepybricks-1.0.0.dist-info/RECORD +23 -0
- sleepybricks-1.0.0.dist-info/WHEEL +4 -0
- sleepybricks-1.0.0.dist-info/entry_points.txt +3 -0
- sleepybricks-1.0.0.dist-info/licenses/LICENSE +674 -0
sleepybricks/__init__.py
ADDED
|
@@ -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."""
|