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/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
+ )