duckhaven-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.
- dh/__init__.py +20 -0
- dh/_version.py +24 -0
- dh/commands/__init__.py +0 -0
- dh/commands/admin.py +353 -0
- dh/commands/api.py +79 -0
- dh/commands/auth.py +266 -0
- dh/commands/catalog.py +426 -0
- dh/commands/dbt.py +272 -0
- dh/commands/grant.py +129 -0
- dh/commands/health.py +64 -0
- dh/commands/profile.py +80 -0
- dh/commands/query.py +239 -0
- dh/commands/saved.py +213 -0
- dh/commands/session.py +210 -0
- dh/config.py +263 -0
- dh/context.py +166 -0
- dh/errors.py +175 -0
- dh/execute.py +204 -0
- dh/introspect.py +78 -0
- dh/main.py +224 -0
- dh/output/__init__.py +175 -0
- dh/repl.py +158 -0
- dh/resolve.py +146 -0
- dh/rest.py +201 -0
- duckhaven_cli-0.1.0.dist-info/METADATA +69 -0
- duckhaven_cli-0.1.0.dist-info/RECORD +29 -0
- duckhaven_cli-0.1.0.dist-info/WHEEL +4 -0
- duckhaven_cli-0.1.0.dist-info/entry_points.txt +2 -0
- duckhaven_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
dh/commands/auth.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""`dh auth` — signing in, and inspecting the credential in use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
# `datetime.UTC` is 3.11+, and this package supports 3.10. Importing it here
|
|
8
|
+
# broke every test that loads the app, on the one interpreter leg that checks.
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from dh import config as config_mod
|
|
14
|
+
from dh import context
|
|
15
|
+
from dh.config import Profile
|
|
16
|
+
from dh.errors import AuthError, ConfigError, DhError, NotFoundError
|
|
17
|
+
from dh.resolve import describe as describe_settings
|
|
18
|
+
from dh.rest import RestClient
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="auth", help="Sign in, and inspect the credential in use.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("describe")
|
|
24
|
+
def describe(ctx: typer.Context) -> None:
|
|
25
|
+
"""Show which credential is in use and where each setting came from.
|
|
26
|
+
|
|
27
|
+
The question every support thread opens with. A precedence chain is hard to
|
|
28
|
+
reason about by inspection, so the CLI answers it directly rather than leaving
|
|
29
|
+
the reader to guess whether a flag, a variable or a file won.
|
|
30
|
+
|
|
31
|
+
The token is shown as a masked prefix -- enough to tell two apart, never enough
|
|
32
|
+
to use one. Makes no network call.
|
|
33
|
+
"""
|
|
34
|
+
cli = context.of(ctx)
|
|
35
|
+
cfg = config_mod.load()
|
|
36
|
+
# The file path is part of the answer, not a diagnostic about it: "which
|
|
37
|
+
# config am I even reading" is half of what the command is asked.
|
|
38
|
+
rows = [{"setting": "config", "value": str(cfg.path), "source": "-"}]
|
|
39
|
+
rows.extend(describe_settings(cli.settings()))
|
|
40
|
+
cli.emit(rows)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("login")
|
|
44
|
+
def login(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
host: str = typer.Option(None, "--host", help="DuckHaven base URL.", metavar="URL"),
|
|
47
|
+
profile_name: str = typer.Option("default", "--name", help="Profile to write."),
|
|
48
|
+
email: str = typer.Option(None, "--email", help="Skip the email prompt."),
|
|
49
|
+
token: str = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--token",
|
|
52
|
+
help="Store a PAT you were given instead of signing in for one.",
|
|
53
|
+
),
|
|
54
|
+
workspace: str = typer.Option(None, "--workspace", help="Default workspace for the profile."),
|
|
55
|
+
expires_in_days: int = typer.Option(90, "--expires-in-days", min=1, max=365),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Sign in and store a personal access token.
|
|
58
|
+
|
|
59
|
+
Signing in mints the token through `POST /api/me/pats` and stores only that:
|
|
60
|
+
the session cookie lives for three requests and never reaches disk. Token
|
|
61
|
+
*creation* is deliberately cookie-only on the server, because a PAT that could
|
|
62
|
+
mint PATs would outlive its own revocation.
|
|
63
|
+
|
|
64
|
+
An OIDC-only deployment has no password to collect, so pass `--token` with a
|
|
65
|
+
PAT an administrator issued. Unattended callers should not use this command at
|
|
66
|
+
all -- CI authenticates with a service-account token.
|
|
67
|
+
"""
|
|
68
|
+
cli = context.of(ctx)
|
|
69
|
+
settings = cli.settings()
|
|
70
|
+
base = host or settings.get("host") or typer.prompt("DuckHaven host")
|
|
71
|
+
|
|
72
|
+
if token:
|
|
73
|
+
secret, expires_at = token, None
|
|
74
|
+
else:
|
|
75
|
+
secret, expires_at = _mint_token(base, email, expires_in_days)
|
|
76
|
+
|
|
77
|
+
# Verify before writing: a profile holding a token that does not work is worse
|
|
78
|
+
# than no profile, because the next failure looks like a server problem.
|
|
79
|
+
with RestClient(base, secret) as client:
|
|
80
|
+
try:
|
|
81
|
+
me = client.get("me")
|
|
82
|
+
except DhError as exc:
|
|
83
|
+
raise AuthError("login_failed", f"The token was not accepted: {exc.message}") from exc
|
|
84
|
+
|
|
85
|
+
cfg = config_mod.load()
|
|
86
|
+
cfg = config_mod.upsert(
|
|
87
|
+
cfg,
|
|
88
|
+
Profile(
|
|
89
|
+
name=profile_name,
|
|
90
|
+
host=base,
|
|
91
|
+
token=secret,
|
|
92
|
+
workspace=workspace or settings.get("workspace"),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
config_mod.save(cfg)
|
|
96
|
+
cli.note(
|
|
97
|
+
f"Logged in as {me.get('email', 'unknown')}; wrote profile {profile_name!r} to {cfg.path}."
|
|
98
|
+
+ (f" Token expires {expires_at}." if expires_at else "")
|
|
99
|
+
)
|
|
100
|
+
cli.emit(
|
|
101
|
+
{"profile": profile_name, "host": base, "user": me.get("email"), "expires_at": expires_at}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _mint_token(base: str, email: str | None, expires_in_days: int) -> tuple[str, str | None]:
|
|
106
|
+
"""Sign in with a password and exchange the session for a PAT.
|
|
107
|
+
|
|
108
|
+
One client for the whole exchange, because httpx keeps the session cookie on
|
|
109
|
+
the client and it must never be written anywhere else.
|
|
110
|
+
"""
|
|
111
|
+
with RestClient(base) as client:
|
|
112
|
+
methods = client.get("auth/methods")
|
|
113
|
+
if not (methods.get("local") or methods.get("ldap")):
|
|
114
|
+
providers = ", ".join(p.get("id", "?") for p in methods.get("oidc_providers") or [])
|
|
115
|
+
raise AuthError(
|
|
116
|
+
"password_login_unavailable",
|
|
117
|
+
f"This deployment authenticates through {providers or 'an identity provider'} "
|
|
118
|
+
"only, so there is no password to sign in with. Ask an administrator for a "
|
|
119
|
+
"token and run `dh auth login --token dh_pat_...`.",
|
|
120
|
+
)
|
|
121
|
+
address = email or typer.prompt("Email")
|
|
122
|
+
password = typer.prompt("Password", hide_input=True)
|
|
123
|
+
client.post("auth/login", json={"email": address, "password": password})
|
|
124
|
+
try:
|
|
125
|
+
issued = client.post("me/pats", json={"expires_in_days": expires_in_days})
|
|
126
|
+
except NotFoundError as exc:
|
|
127
|
+
# A server predating POST /api/me/pats. Say what to do rather than
|
|
128
|
+
# reporting a bare 404 the reader cannot act on.
|
|
129
|
+
raise AuthError(
|
|
130
|
+
"self_service_unsupported",
|
|
131
|
+
"This DuckHaven is too old to issue your own token. Ask an administrator "
|
|
132
|
+
"for a service-account token and run `dh auth login --token dh_pat_...`.",
|
|
133
|
+
) from exc
|
|
134
|
+
finally:
|
|
135
|
+
# Best effort: the token is already minted, and leaving a session open
|
|
136
|
+
# is worse than a noisy logout.
|
|
137
|
+
try:
|
|
138
|
+
client.post("auth/logout")
|
|
139
|
+
except DhError:
|
|
140
|
+
pass
|
|
141
|
+
return issued["token"], issued.get("expires_at")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#: Warn this far ahead of expiry. A fortnight is long enough to rotate before a
|
|
145
|
+
#: scheduled job breaks, and short enough that the warning still means something.
|
|
146
|
+
_EXPIRY_WARNING = timedelta(days=14)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("status")
|
|
150
|
+
def status(ctx: typer.Context) -> None:
|
|
151
|
+
"""Who the stored credential authenticates as, and how long it has left."""
|
|
152
|
+
cli = context.of(ctx)
|
|
153
|
+
settings = cli.settings()
|
|
154
|
+
with cli.client(settings) as client:
|
|
155
|
+
me = client.get("me")
|
|
156
|
+
version = _optional(client, "version")
|
|
157
|
+
current = _current_token(client)
|
|
158
|
+
expires_at = (current or {}).get("expires_at")
|
|
159
|
+
_warn_if_expiring(cli, expires_at)
|
|
160
|
+
cli.emit(
|
|
161
|
+
{
|
|
162
|
+
"host": settings.get("host"),
|
|
163
|
+
"profile": settings.get("profile"),
|
|
164
|
+
"user": me.get("email"),
|
|
165
|
+
"name": me.get("name"),
|
|
166
|
+
"role": me.get("role"),
|
|
167
|
+
"workspace": settings.get("workspace"),
|
|
168
|
+
"token_expires_at": expires_at,
|
|
169
|
+
"server_version": (version or {}).get("version"),
|
|
170
|
+
"api_version": (version or {}).get("api_version"),
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _current_token(client: RestClient) -> dict | None:
|
|
176
|
+
"""The token this request is authenticating with, if the server can say.
|
|
177
|
+
|
|
178
|
+
Only a hash is stored, so the server marks the caller's own row rather than
|
|
179
|
+
returning anything identifiable. A server predating the route reports
|
|
180
|
+
nothing, which is a missing warning rather than a failure.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
rows = client.get("me/pats") or []
|
|
184
|
+
except DhError:
|
|
185
|
+
return None
|
|
186
|
+
return next((row for row in rows if row.get("current")), None)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _warn_if_expiring(cli, expires_at: str | None) -> None:
|
|
190
|
+
"""Say something before a token expires, not after.
|
|
191
|
+
|
|
192
|
+
The first symptom of an expired token is a 401 in a job nobody was watching,
|
|
193
|
+
which is the failure this whole listing exists to prevent.
|
|
194
|
+
"""
|
|
195
|
+
if not expires_at:
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
moment = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
199
|
+
except ValueError: # pragma: no cover - the server sends ISO-8601
|
|
200
|
+
return
|
|
201
|
+
left = moment - datetime.now(tz=moment.tzinfo or timezone.utc)
|
|
202
|
+
if left <= timedelta(0):
|
|
203
|
+
cli.note("This token has expired. Run `dh auth login` to get a new one.")
|
|
204
|
+
elif left <= _EXPIRY_WARNING:
|
|
205
|
+
# Rounded up, not truncated: `timedelta.days` on 71h59m is 2, so a token
|
|
206
|
+
# with three days left would be reported as two -- and one with hours left
|
|
207
|
+
# as zero, which reads like it is already dead.
|
|
208
|
+
days = math.ceil(left.total_seconds() / 86400)
|
|
209
|
+
cli.note(
|
|
210
|
+
f"This token expires in {days} day{'s' if days != 1 else ''}. "
|
|
211
|
+
"Run `dh auth login` to replace it."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command("tokens")
|
|
216
|
+
def list_tokens(ctx: typer.Context) -> None:
|
|
217
|
+
"""Your own tokens: when each was issued, when it expires, which is in use.
|
|
218
|
+
|
|
219
|
+
Never the secret. A token is shown once, when it is issued; only its hash is
|
|
220
|
+
kept, so a forgotten one is replaced rather than recovered.
|
|
221
|
+
"""
|
|
222
|
+
cli = context.of(ctx)
|
|
223
|
+
with cli.client() as client:
|
|
224
|
+
cli.emit(client.get("me/pats"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command("revoke")
|
|
228
|
+
def revoke_token(ctx: typer.Context, pat_id: str) -> None:
|
|
229
|
+
"""Revoke one of your own tokens, by the id `dh auth tokens` shows.
|
|
230
|
+
|
|
231
|
+
Revoking the token you are currently using works and takes effect at once,
|
|
232
|
+
which is the right move if you think it has leaked.
|
|
233
|
+
"""
|
|
234
|
+
cli = context.of(ctx)
|
|
235
|
+
with cli.client() as client:
|
|
236
|
+
client.delete(f"me/pats/{pat_id}")
|
|
237
|
+
cli.note(f"Revoked token {pat_id}.")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command("logout")
|
|
241
|
+
def logout(ctx: typer.Context, profile_name: str = typer.Option(None, "--name")) -> None:
|
|
242
|
+
"""Forget the stored token, keeping the rest of the profile.
|
|
243
|
+
|
|
244
|
+
Local only: the token stays valid server-side until it expires. To retire
|
|
245
|
+
it as well, run `dh auth revoke <id>` first -- `dh auth tokens` shows the id.
|
|
246
|
+
"""
|
|
247
|
+
cli = context.of(ctx)
|
|
248
|
+
cfg = config_mod.load()
|
|
249
|
+
target = profile_name or cfg.resolved_default()
|
|
250
|
+
profile = cfg.get(target) if target else None
|
|
251
|
+
if profile is None:
|
|
252
|
+
raise ConfigError("no_such_profile", f"No profile named {target!r} in {cfg.path}")
|
|
253
|
+
config_mod.save(config_mod.upsert(cfg, Profile(**{**vars(profile), "token": None})))
|
|
254
|
+
cli.note(f"Cleared the token for profile {target!r}. It remains valid until it expires.")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _optional(client: RestClient, path: str) -> dict | None:
|
|
258
|
+
"""A GET whose absence is a supported answer.
|
|
259
|
+
|
|
260
|
+
`GET /api/version` postdates the oldest servers `dh` supports, and the
|
|
261
|
+
connector treats its 404 the same way.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
return client.get(path)
|
|
265
|
+
except NotFoundError:
|
|
266
|
+
return None
|
dh/commands/catalog.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""`dh workspace`, `dh catalog`, `dh schema` and `dh table`.
|
|
2
|
+
|
|
3
|
+
Every schema and table route names its catalog explicitly -- the workspace-default
|
|
4
|
+
shim was removed from the API -- so a catalog has to be resolved before any of
|
|
5
|
+
these can build a URL. That resolution is the one piece of shared state here, and
|
|
6
|
+
getting it wrong reads the wrong catalog silently, so it is done in one place and
|
|
7
|
+
echoed under `--debug`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from dh import context
|
|
15
|
+
from dh.errors import ConflictError
|
|
16
|
+
from dh.rest import RestClient
|
|
17
|
+
|
|
18
|
+
workspace_app = typer.Typer(name="workspace", help="Workspaces and their members.")
|
|
19
|
+
catalog_app = typer.Typer(name="catalog", help="Catalogs attached to a workspace.")
|
|
20
|
+
schema_app = typer.Typer(name="schema", help="Schemas within a catalog.")
|
|
21
|
+
table_app = typer.Typer(name="table", help="Tables, their metadata and a sample of rows.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_catalog(cli, client: RestClient, settings, override: str | None = None) -> str:
|
|
25
|
+
"""The catalog to address, and where it came from.
|
|
26
|
+
|
|
27
|
+
Order: an explicit value, then the resolved settings, then the workspace's own
|
|
28
|
+
default. The last lookup costs a request, which is why it is only reached when
|
|
29
|
+
nothing nearer answered.
|
|
30
|
+
"""
|
|
31
|
+
if override:
|
|
32
|
+
return override
|
|
33
|
+
from_settings = settings.get("catalog")
|
|
34
|
+
if from_settings:
|
|
35
|
+
cli.debug(f"catalog {from_settings} ({settings.source('catalog')})")
|
|
36
|
+
return from_settings
|
|
37
|
+
workspace = client.get(f"workspaces/{settings.require('workspace')}")
|
|
38
|
+
default = workspace.get("default_catalog")
|
|
39
|
+
if not default:
|
|
40
|
+
raise ConflictError(
|
|
41
|
+
"no_catalog",
|
|
42
|
+
"No catalog given and the workspace has no default. "
|
|
43
|
+
"Set one with --catalog, $DH_CATALOG, or in your profile.",
|
|
44
|
+
)
|
|
45
|
+
cli.debug(f"catalog {default} (workspace default)")
|
|
46
|
+
return default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def split_ref(ref: str, *, want: int) -> list[str]:
|
|
50
|
+
"""Split `schema.table` or `catalog.schema.table` into its parts.
|
|
51
|
+
|
|
52
|
+
The three-part form overrides the resolved catalog, which is how a one-off
|
|
53
|
+
cross-catalog read stays a single command rather than a flag dance.
|
|
54
|
+
"""
|
|
55
|
+
parts = ref.split(".")
|
|
56
|
+
if len(parts) == want:
|
|
57
|
+
return [None, *parts] # type: ignore[list-item]
|
|
58
|
+
if len(parts) == want + 1:
|
|
59
|
+
return parts
|
|
60
|
+
shape = "catalog.schema.table" if want == 2 else "catalog.schema"
|
|
61
|
+
raise ConflictError(
|
|
62
|
+
"bad_reference",
|
|
63
|
+
f"Could not read {ref!r}. Use {'.'.join(['schema', 'table'][:want])} or {shape}.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- Workspaces ------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@workspace_app.command("list")
|
|
71
|
+
def list_workspaces(ctx: typer.Context) -> None:
|
|
72
|
+
"""Workspaces you can see."""
|
|
73
|
+
cli = context.of(ctx)
|
|
74
|
+
with cli.client() as client:
|
|
75
|
+
cli.emit(client.get("workspaces"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@workspace_app.command("get")
|
|
79
|
+
def get_workspace(ctx: typer.Context, workspace: str = typer.Argument(None)) -> None:
|
|
80
|
+
"""One workspace, by slug or id. Defaults to the configured one."""
|
|
81
|
+
cli = context.of(ctx)
|
|
82
|
+
settings = cli.settings()
|
|
83
|
+
with cli.client(settings) as client:
|
|
84
|
+
cli.emit(client.get(f"workspaces/{workspace or settings.require('workspace')}"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@workspace_app.command("create")
|
|
88
|
+
def create_workspace(
|
|
89
|
+
ctx: typer.Context,
|
|
90
|
+
slug: str = typer.Argument(..., help="URL-safe identifier."),
|
|
91
|
+
name: str = typer.Option(None, "--name", help="Display name. Defaults to the slug."),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Create a workspace."""
|
|
94
|
+
cli = context.of(ctx)
|
|
95
|
+
with cli.client() as client:
|
|
96
|
+
cli.emit(client.post("workspaces", json={"slug": slug, "name": name or slug}))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@workspace_app.command("update")
|
|
100
|
+
def update_workspace(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
workspace: str = typer.Argument(None),
|
|
103
|
+
name: str = typer.Option(None, "--name"),
|
|
104
|
+
description: str = typer.Option(None, "--description"),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Rename or re-describe a workspace."""
|
|
107
|
+
cli = context.of(ctx)
|
|
108
|
+
settings = cli.settings()
|
|
109
|
+
body = {k: v for k, v in {"name": name, "description": description}.items() if v is not None}
|
|
110
|
+
if not body:
|
|
111
|
+
raise ConflictError("nothing_to_update", "Give --name or --description.")
|
|
112
|
+
with cli.client(settings) as client:
|
|
113
|
+
target = workspace or settings.require("workspace")
|
|
114
|
+
cli.emit(client.patch(f"workspaces/{target}", json=body))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@workspace_app.command("delete")
|
|
118
|
+
def delete_workspace(ctx: typer.Context, workspace: str, yes: bool = context.YES) -> None:
|
|
119
|
+
"""Delete a workspace."""
|
|
120
|
+
cli = context.of(ctx)
|
|
121
|
+
cli.confirm("Delete workspace", workspace, yes=yes)
|
|
122
|
+
with cli.client() as client:
|
|
123
|
+
client.delete(f"workspaces/{workspace}")
|
|
124
|
+
cli.note(f"Deleted workspace {workspace!r}.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
member_app = typer.Typer(name="member", help="Workspace membership.")
|
|
128
|
+
workspace_app.add_typer(member_app)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@member_app.command("list")
|
|
132
|
+
def list_members(ctx: typer.Context) -> None:
|
|
133
|
+
"""Who belongs to the workspace, and in what role."""
|
|
134
|
+
cli = context.of(ctx)
|
|
135
|
+
settings = cli.settings()
|
|
136
|
+
with cli.client(settings) as client:
|
|
137
|
+
cli.emit(client.get(f"workspaces/{settings.require('workspace')}/members"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@member_app.command("add")
|
|
141
|
+
def add_member(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
user_id: str = typer.Argument(..., help="User id to add."),
|
|
144
|
+
role: str = typer.Option("reader", "--role", help="reader, writer or owner."),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Add a member to the workspace."""
|
|
147
|
+
cli = context.of(ctx)
|
|
148
|
+
settings = cli.settings()
|
|
149
|
+
with cli.client(settings) as client:
|
|
150
|
+
cli.emit(
|
|
151
|
+
client.post(
|
|
152
|
+
f"workspaces/{settings.require('workspace')}/members",
|
|
153
|
+
json={"user_id": user_id, "role": role},
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
agent_app = typer.Typer(name="agent", help="Compute agents you may run queries on.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@agent_app.command("list")
|
|
162
|
+
def list_agents(ctx: typer.Context) -> None:
|
|
163
|
+
"""The agents this caller may run queries on, with their capabilities.
|
|
164
|
+
|
|
165
|
+
Distinct from `dh admin agent list`, which needs `agents:manage` and shows
|
|
166
|
+
every agent in the deployment. This is the list an analyst can actually use,
|
|
167
|
+
and it is where `--agent <name>` resolves a name from.
|
|
168
|
+
"""
|
|
169
|
+
cli = context.of(ctx)
|
|
170
|
+
with cli.client() as client:
|
|
171
|
+
cli.emit(client.get("agents"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- Catalogs --------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@catalog_app.command("list")
|
|
178
|
+
def list_catalogs(
|
|
179
|
+
ctx: typer.Context,
|
|
180
|
+
everywhere: bool = typer.Option(False, "--all", help="Every catalog, not just attached ones."),
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Catalogs attached to the workspace."""
|
|
183
|
+
cli = context.of(ctx)
|
|
184
|
+
settings = cli.settings()
|
|
185
|
+
with cli.client(settings) as client:
|
|
186
|
+
path = "catalogs" if everywhere else f"workspaces/{settings.require('workspace')}/catalogs"
|
|
187
|
+
cli.emit(client.get(path))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@catalog_app.command("create")
|
|
191
|
+
def create_catalog(
|
|
192
|
+
ctx: typer.Context,
|
|
193
|
+
name: str = typer.Argument(..., help="Identifier-safe name."),
|
|
194
|
+
storage_backend: str = typer.Option(None, "--storage-backend", help="Backend id."),
|
|
195
|
+
access_mode: str = typer.Option(None, "--access-mode", help="open or scoped."),
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Create a catalog and attach it to the workspace."""
|
|
198
|
+
cli = context.of(ctx)
|
|
199
|
+
settings = cli.settings()
|
|
200
|
+
body = {"name": name}
|
|
201
|
+
if storage_backend:
|
|
202
|
+
body["storage_backend_id"] = storage_backend
|
|
203
|
+
if access_mode:
|
|
204
|
+
body["access_mode"] = access_mode
|
|
205
|
+
with cli.client(settings) as client:
|
|
206
|
+
cli.emit(client.post(f"workspaces/{settings.require('workspace')}/catalogs", json=body))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@catalog_app.command("attach")
|
|
210
|
+
def attach_catalog(
|
|
211
|
+
ctx: typer.Context,
|
|
212
|
+
catalog: str,
|
|
213
|
+
make_default: bool = typer.Option(
|
|
214
|
+
False, "--default", help="Also make it the workspace's default catalog."
|
|
215
|
+
),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Attach an existing catalog to the workspace."""
|
|
218
|
+
cli = context.of(ctx)
|
|
219
|
+
settings = cli.settings()
|
|
220
|
+
body = {"make_default": True} if make_default else None
|
|
221
|
+
with cli.client(settings) as client:
|
|
222
|
+
cli.emit(
|
|
223
|
+
client.put(f"workspaces/{settings.require('workspace')}/catalogs/{catalog}", json=body)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@catalog_app.command("detach")
|
|
228
|
+
def detach_catalog(ctx: typer.Context, catalog: str, yes: bool = context.YES) -> None:
|
|
229
|
+
"""Detach a catalog from the workspace. The catalog itself survives."""
|
|
230
|
+
cli = context.of(ctx)
|
|
231
|
+
cli.confirm("Detach catalog", catalog, yes=yes)
|
|
232
|
+
settings = cli.settings()
|
|
233
|
+
with cli.client(settings) as client:
|
|
234
|
+
client.delete(f"workspaces/{settings.require('workspace')}/catalogs/{catalog}")
|
|
235
|
+
cli.note(f"Detached {catalog!r}.")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@catalog_app.command("drop")
|
|
239
|
+
def drop_catalog(ctx: typer.Context, catalog_id: str, yes: bool = context.YES) -> None:
|
|
240
|
+
"""Drop a catalog outright, by id. Destructive."""
|
|
241
|
+
cli = context.of(ctx)
|
|
242
|
+
cli.confirm("Drop catalog", catalog_id, yes=yes)
|
|
243
|
+
with cli.client() as client:
|
|
244
|
+
client.delete(f"catalogs/{catalog_id}")
|
|
245
|
+
cli.note(f"Dropped catalog {catalog_id}.")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@catalog_app.command("refresh-stats")
|
|
249
|
+
def refresh_stats(ctx: typer.Context, catalog: str = typer.Option(None, "--catalog")) -> None:
|
|
250
|
+
"""Recompute table statistics across the catalog."""
|
|
251
|
+
cli = context.of(ctx)
|
|
252
|
+
settings = cli.settings()
|
|
253
|
+
with cli.client(settings) as client:
|
|
254
|
+
target = resolve_catalog(cli, client, settings, catalog)
|
|
255
|
+
cli.emit(
|
|
256
|
+
client.post(
|
|
257
|
+
f"workspaces/{settings.require('workspace')}/catalogs/{target}/refresh-stats"
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- Schemas ---------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _schema_base(cli, client, settings, catalog: str | None) -> str:
|
|
266
|
+
target = resolve_catalog(cli, client, settings, catalog)
|
|
267
|
+
return f"workspaces/{settings.require('workspace')}/catalogs/{target}/schemas"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@schema_app.command("list")
|
|
271
|
+
def list_schemas(ctx: typer.Context, catalog: str = typer.Option(None, "--catalog")) -> None:
|
|
272
|
+
"""Schemas in the catalog."""
|
|
273
|
+
cli = context.of(ctx)
|
|
274
|
+
settings = cli.settings()
|
|
275
|
+
with cli.client(settings) as client:
|
|
276
|
+
cli.emit(client.get(_schema_base(cli, client, settings, catalog)))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@schema_app.command("create")
|
|
280
|
+
def create_schema(
|
|
281
|
+
ctx: typer.Context, name: str, catalog: str = typer.Option(None, "--catalog")
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Create a schema."""
|
|
284
|
+
cli = context.of(ctx)
|
|
285
|
+
settings = cli.settings()
|
|
286
|
+
with cli.client(settings) as client:
|
|
287
|
+
base = _schema_base(cli, client, settings, catalog)
|
|
288
|
+
cli.emit(client.post(base, json={"name": name}))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@schema_app.command("drop")
|
|
292
|
+
def drop_schema(
|
|
293
|
+
ctx: typer.Context,
|
|
294
|
+
name: str,
|
|
295
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
296
|
+
yes: bool = context.YES,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Drop a schema."""
|
|
299
|
+
cli = context.of(ctx)
|
|
300
|
+
cli.confirm("Drop schema", name, yes=yes)
|
|
301
|
+
settings = cli.settings()
|
|
302
|
+
with cli.client(settings) as client:
|
|
303
|
+
client.delete(f"{_schema_base(cli, client, settings, catalog)}/{name}")
|
|
304
|
+
cli.note(f"Dropped schema {name!r}.")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- Tables ----------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _table_base(cli, client, settings, ref: str, catalog: str | None, *, want: int = 2) -> str:
|
|
311
|
+
"""The URL prefix for a `schema.table` (or `schema`) reference."""
|
|
312
|
+
parts = split_ref(ref, want=want)
|
|
313
|
+
explicit, rest = parts[0], parts[1:]
|
|
314
|
+
target = resolve_catalog(cli, client, settings, explicit or catalog)
|
|
315
|
+
base = f"workspaces/{settings.require('workspace')}/catalogs/{target}/schemas/{rest[0]}/tables"
|
|
316
|
+
return f"{base}/{rest[1]}" if want == 2 else base
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@table_app.command("list")
|
|
320
|
+
def list_tables(
|
|
321
|
+
ctx: typer.Context,
|
|
322
|
+
schema: str = typer.Argument(..., help="schema, or catalog.schema."),
|
|
323
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Tables in a schema."""
|
|
326
|
+
cli = context.of(ctx)
|
|
327
|
+
settings = cli.settings()
|
|
328
|
+
with cli.client(settings) as client:
|
|
329
|
+
cli.emit(client.get(_table_base(cli, client, settings, schema, catalog, want=1)))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@table_app.command("get")
|
|
333
|
+
def get_table(
|
|
334
|
+
ctx: typer.Context,
|
|
335
|
+
table: str = typer.Argument(..., help="schema.table, or catalog.schema.table."),
|
|
336
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
337
|
+
) -> None:
|
|
338
|
+
"""One table's columns, partitioning and statistics."""
|
|
339
|
+
cli = context.of(ctx)
|
|
340
|
+
settings = cli.settings()
|
|
341
|
+
with cli.client(settings) as client:
|
|
342
|
+
cli.emit(client.get(_table_base(cli, client, settings, table, catalog)))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@table_app.command("create")
|
|
346
|
+
def create_table(
|
|
347
|
+
ctx: typer.Context,
|
|
348
|
+
table: str = typer.Argument(..., help="schema.table, or catalog.schema.table."),
|
|
349
|
+
column: list[str] = typer.Option(
|
|
350
|
+
..., "--column", "-c", help="Repeatable: name:type[:null|notnull]."
|
|
351
|
+
),
|
|
352
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Create an Iceberg table.
|
|
355
|
+
|
|
356
|
+
Columns are given as `name:type`, optionally with `:notnull`. Anything more
|
|
357
|
+
elaborate is a `CREATE TABLE` through `dh sql`, which is where DDL belongs.
|
|
358
|
+
"""
|
|
359
|
+
cli = context.of(ctx)
|
|
360
|
+
settings = cli.settings()
|
|
361
|
+
columns = [_column(spec) for spec in column]
|
|
362
|
+
# A table reference, but the POST goes to the *collection*: split it as a table
|
|
363
|
+
# so `sales.orders` is schema+table, then drop the last segment. Splitting it as
|
|
364
|
+
# a schema reference would read `sales` as the catalog.
|
|
365
|
+
explicit, schema, name = split_ref(table, want=2)
|
|
366
|
+
with cli.client(settings) as client:
|
|
367
|
+
target = resolve_catalog(cli, client, settings, explicit or catalog)
|
|
368
|
+
base = (
|
|
369
|
+
f"workspaces/{settings.require('workspace')}/catalogs/{target}/schemas/{schema}/tables"
|
|
370
|
+
)
|
|
371
|
+
cli.emit(client.post(base, json={"name": name, "columns": columns}))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _column(spec: str) -> dict[str, object]:
|
|
375
|
+
parts = spec.split(":")
|
|
376
|
+
if len(parts) not in (2, 3):
|
|
377
|
+
raise ConflictError(
|
|
378
|
+
"bad_column", f"Could not read column {spec!r}. Use name:type or name:type:notnull."
|
|
379
|
+
)
|
|
380
|
+
nullable = True
|
|
381
|
+
if len(parts) == 3:
|
|
382
|
+
if parts[2] not in ("null", "notnull"):
|
|
383
|
+
raise ConflictError("bad_column", f"Expected null or notnull, got {parts[2]!r}.")
|
|
384
|
+
nullable = parts[2] == "null"
|
|
385
|
+
return {"name": parts[0], "type": parts[1], "nullable": nullable}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@table_app.command("drop")
|
|
389
|
+
def drop_table(
|
|
390
|
+
ctx: typer.Context,
|
|
391
|
+
table: str,
|
|
392
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
393
|
+
yes: bool = context.YES,
|
|
394
|
+
) -> None:
|
|
395
|
+
"""Drop a table."""
|
|
396
|
+
cli = context.of(ctx)
|
|
397
|
+
cli.confirm("Drop table", table, yes=yes)
|
|
398
|
+
settings = cli.settings()
|
|
399
|
+
with cli.client(settings) as client:
|
|
400
|
+
client.delete(_table_base(cli, client, settings, table, catalog))
|
|
401
|
+
cli.note(f"Dropped {table}.")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _table_sub(name: str, help_text: str, *, method: str = "get"):
|
|
405
|
+
"""Register the read-only sub-resources, which differ only in their suffix."""
|
|
406
|
+
|
|
407
|
+
def command(
|
|
408
|
+
ctx: typer.Context, table: str, catalog: str = typer.Option(None, "--catalog")
|
|
409
|
+
) -> None:
|
|
410
|
+
cli = context.of(ctx)
|
|
411
|
+
settings = cli.settings()
|
|
412
|
+
with cli.client(settings) as client:
|
|
413
|
+
url = f"{_table_base(cli, client, settings, table, catalog)}/{name}"
|
|
414
|
+
cli.emit(client.get(url) if method == "get" else client.post(url))
|
|
415
|
+
|
|
416
|
+
command.__doc__ = help_text
|
|
417
|
+
return command
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
table_app.command("sample")(_table_sub("sample", "A page of rows, for previewing without SQL."))
|
|
421
|
+
table_app.command("snapshots")(_table_sub("snapshots", "The table's Iceberg snapshots."))
|
|
422
|
+
table_app.command("lineage")(_table_sub("lineage", "What this table was built from."))
|
|
423
|
+
table_app.command("health")(_table_sub("health", "Maintenance findings for this table."))
|
|
424
|
+
table_app.command("recount")(
|
|
425
|
+
_table_sub("recount", "Recount the table's rows and refresh its stats.", method="post")
|
|
426
|
+
)
|