duckhaven-cli 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. duckhaven_cli-0.3.0/CHANGELOG.md +88 -0
  2. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/PKG-INFO +24 -6
  3. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/README.md +20 -0
  4. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/pyproject.toml +3 -6
  5. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/_version.py +2 -2
  6. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/admin.py +118 -5
  7. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/auth.py +2 -5
  8. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/catalog.py +107 -3
  9. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/query.py +14 -1
  10. duckhaven_cli-0.3.0/src/dh/commands/saved.py +371 -0
  11. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/session.py +21 -3
  12. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/config.py +1 -6
  13. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/execute.py +37 -20
  14. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/main.py +1 -0
  15. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/output/__init__.py +2 -2
  16. duckhaven_cli-0.1.1/CHANGELOG.md +0 -42
  17. duckhaven_cli-0.1.1/src/dh/commands/saved.py +0 -213
  18. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/.gitignore +0 -0
  19. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/LICENSE +0 -0
  20. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/__init__.py +0 -0
  21. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/__init__.py +0 -0
  22. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/api.py +0 -0
  23. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/dbt.py +0 -0
  24. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/grant.py +0 -0
  25. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/health.py +0 -0
  26. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/commands/profile.py +0 -0
  27. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/context.py +0 -0
  28. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/errors.py +0 -0
  29. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/introspect.py +0 -0
  30. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/repl.py +0 -0
  31. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/resolve.py +0 -0
  32. {duckhaven_cli-0.1.1 → duckhaven_cli-0.3.0}/src/dh/rest.py +0 -0
@@ -0,0 +1,88 @@
1
+ # Changelog
2
+
3
+ All notable changes to `duckhaven-cli` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions come from git tags
5
+ (`duckhaven-cli-vX.Y.Z`).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-10-13
10
+
11
+ Brings the CLI up to date with the DuckHaven server's result cache, DuckLake catalogs,
12
+ agent runtimes, worksheets and reshaped monitoring. Everything new needs a server that
13
+ offers it; against an older one the command fails with that server's own error.
14
+
15
+ ### Added
16
+
17
+ - **Result cache.** `--no-cache` on `dh sql`, `dh query run`, `dh session open` and
18
+ `dh session exec`, and `dh workspace update --result-cache/--no-result-cache` (owner
19
+ only). When a query was answered from the cache, `dh sql` and `dh session exec` say so on
20
+ stderr. The REPL is not cache-aware.
21
+ - **Catalogs.** `dh catalog create --kind` (`iceberg_polaris` or `ducklake`),
22
+ `dh catalog kinds`, `dh catalog rotate-role`, and `dh catalog export list|create|cancel`
23
+ for copying a DuckLake catalog out as Iceberg. `dh admin catalog reconcile-roles` creates
24
+ any DuckLake database role that is missing (idempotent, deployment-wide).
25
+ - **Runtimes.** `dh agent runtimes`, and `--runtime` / `--allow-beta` on
26
+ `dh admin agent elastic-create`.
27
+ - **Monitoring.** `dh admin agent monitoring` takes `--window` or `--start`/`--end`, and
28
+ `dh admin agent queries` lists the runs behind a time range, sortable by wait, duration,
29
+ peak memory, CPU time, spill or bytes read.
30
+ - **Maintenance.** `dh admin maintenance recommendations` and `dh admin maintenance apply`
31
+ (asks first; needs `maintenance:manage`).
32
+ - **Worksheets.** `dh worksheet list|get|create|update|delete`. Changing a worksheet's
33
+ title or SQL requires `--base-version`; a concurrent edit is refused with exit `5` and
34
+ the current worksheet in the error's details.
35
+ - `dh saved-query create --on-conflict error` fails with exit `5` instead of overwriting
36
+ a query with the same name, and `dh search --type` limits the kinds of object searched.
37
+
38
+ ### Changed
39
+
40
+ - With no agent named, `dh sql` and `dh session open` no longer list agents first and leave
41
+ the choice to the server, which picks a connected agent on the default runtime. Before,
42
+ an account that could see several agents sent no choice and a server without elastic
43
+ compute refused with `agent_required`.
44
+ - Status polls ask the server to hold the call until the query finishes, and a query the
45
+ submit response already reports as finished is not polled at all.
46
+ - The pinned API contract is refreshed, and now also checks the query parameters the new
47
+ commands send.
48
+
49
+ ## [0.2.0] - 2026-09-09
50
+
51
+ ### Changed
52
+
53
+ - Drop support for Python 3.10; the minimum supported version is now 3.11.
54
+
55
+ ## [0.1.1] - 2026-09-09
56
+
57
+ ### Fixed
58
+
59
+ - Declare `click` as a direct dependency. `dh` failed to start with
60
+ `ModuleNotFoundError: No module named 'click'` when installed standalone:
61
+ `context.py`/`repl.py` catch `click.Abort` directly, but `typer>=0.27` no longer
62
+ depends on `click` itself, and the workspace dev environment masked this because
63
+ other packages pulled `click` in transitively.
64
+
65
+ ## [0.1.0] - 2026-09-09
66
+
67
+ ### Added
68
+
69
+ - `dh`, the DuckHaven command-line interface.
70
+ - **Running SQL.** `dh sql` submits, polls to a terminal state and fetches every page
71
+ of results; `--no-wait` returns the id instead. Ctrl-C cancels the query on the
72
+ server rather than orphaning it, and a query that runs and fails exits `6` so a
73
+ pipeline can tell bad SQL from a broken CLI. `dh sql` with no arguments on a
74
+ terminal opens a REPL, session-backed where the server allows it.
75
+ - **The dbt path.** `dh lineage import` and `dh semantic import` publish build
76
+ artifacts, including the `manifest.json` + `catalog.json` pair that unlocks
77
+ column-level lineage, plus `validate`/`publish` to promote imported drafts.
78
+ - **Catalog and access.** `dh workspace`, `dh catalog`, `dh schema`, `dh table` and
79
+ `dh grant`, with catalog resolution and `catalog.schema.table` references.
80
+ - **History.** `dh query list` with the full server-side filter set, `dh saved-query`,
81
+ `dh schedule` and `dh search`.
82
+ - **Operators.** A thin `dh admin` over service accounts, tokens, users and agents,
83
+ and `dh api` for anything without a hand-written command.
84
+ - **Auth.** `dh auth login` signs in and mints a personal access token, storing only
85
+ the token at mode 0600 and refusing a config other users can read.
86
+ - Output as `--format json|table|csv` behind one stable envelope
87
+ (`{data, cursor, has_more}`), defaulting to a table on a terminal and JSON when
88
+ piped. Errors are JSON too when JSON was asked for.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: duckhaven-cli
3
- Version: 0.1.1
3
+ Version: 0.3.0
4
4
  Summary: dh — the command-line interface for DuckHaven
5
5
  Project-URL: Homepage, https://github.com/tamasmrtn/duckhaven-clients
6
6
  Project-URL: Source, https://github.com/tamasmrtn/duckhaven-clients
@@ -13,7 +13,6 @@ Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Intended Audience :: System Administrators
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
17
  Classifier: Programming Language :: Python :: 3.12
19
18
  Classifier: Programming Language :: Python :: 3.13
@@ -21,12 +20,11 @@ Classifier: Programming Language :: Python :: 3.14
21
20
  Classifier: Topic :: Database
22
21
  Classifier: Topic :: Utilities
23
22
  Classifier: Typing :: Typed
24
- Requires-Python: >=3.10
25
- Requires-Dist: click>=8.0
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: click>=8.5.0
26
25
  Requires-Dist: duckhaven-sql-connector>=0.4.1
27
26
  Requires-Dist: httpx>=0.28
28
- Requires-Dist: tomli>=2.0; python_version < '3.11'
29
- Requires-Dist: typer>=0.15
27
+ Requires-Dist: typer>=0.27.3
30
28
  Description-Content-Type: text/markdown
31
29
 
32
30
  # duckhaven-cli
@@ -59,6 +57,26 @@ dh auth login --host https://duckhaven.example.com
59
57
  dh sql -q "select 1"
60
58
  ```
61
59
 
60
+ ## The result cache
61
+
62
+ DuckHaven answers a repeated read from its result cache when nothing the query reads has
63
+ changed. `dh sql` says so on stderr when it happens; pass `--no-cache` to run the query
64
+ anyway (also on `dh session open` and `dh session exec`). An owner can switch the cache off
65
+ for a whole workspace with `dh workspace update --no-result-cache`. The interactive REPL
66
+ does not take `--no-cache`: it holds its session through the connector, which has its own
67
+ `use_cache` option, and the shell does not expose it yet.
68
+
69
+ ## Newer server features
70
+
71
+ A server that supports them offers: DuckLake catalogs (`dh catalog kinds`,
72
+ `dh catalog create --kind ducklake`, `dh catalog rotate-role`, `dh catalog export`), the
73
+ DuckDB runtimes agents run (`dh agent runtimes`, `--runtime` on
74
+ `dh admin agent elastic-create`), per-agent monitoring over a window or range and the
75
+ queries behind it (`dh admin agent monitoring`, `dh admin agent queries`), applying
76
+ maintenance recommendations (`dh admin maintenance recommendations|apply`), and your
77
+ private editor tabs (`dh worksheet`). Against an older server these commands answer with the
78
+ server's own not-found or validation error.
79
+
62
80
  ## Documentation
63
81
 
64
82
  - [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
@@ -28,6 +28,26 @@ dh auth login --host https://duckhaven.example.com
28
28
  dh sql -q "select 1"
29
29
  ```
30
30
 
31
+ ## The result cache
32
+
33
+ DuckHaven answers a repeated read from its result cache when nothing the query reads has
34
+ changed. `dh sql` says so on stderr when it happens; pass `--no-cache` to run the query
35
+ anyway (also on `dh session open` and `dh session exec`). An owner can switch the cache off
36
+ for a whole workspace with `dh workspace update --no-result-cache`. The interactive REPL
37
+ does not take `--no-cache`: it holds its session through the connector, which has its own
38
+ `use_cache` option, and the shell does not expose it yet.
39
+
40
+ ## Newer server features
41
+
42
+ A server that supports them offers: DuckLake catalogs (`dh catalog kinds`,
43
+ `dh catalog create --kind ducklake`, `dh catalog rotate-role`, `dh catalog export`), the
44
+ DuckDB runtimes agents run (`dh agent runtimes`, `--runtime` on
45
+ `dh admin agent elastic-create`), per-agent monitoring over a window or range and the
46
+ queries behind it (`dh admin agent monitoring`, `dh admin agent queries`), applying
47
+ maintenance recommendations (`dh admin maintenance recommendations|apply`), and your
48
+ private editor tabs (`dh worksheet`). Against an older server these commands answer with the
49
+ server's own not-found or validation error.
50
+
31
51
  ## Documentation
32
52
 
33
53
  - [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
@@ -2,7 +2,7 @@
2
2
  name = "duckhaven-cli"
3
3
  description = "dh — the command-line interface for DuckHaven"
4
4
  readme = "README.md"
5
- requires-python = ">=3.10"
5
+ requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [{ name = "DuckHaven" }]
@@ -13,7 +13,6 @@ classifiers = [
13
13
  "Intended Audience :: Developers",
14
14
  "Intended Audience :: System Administrators",
15
15
  "Programming Language :: Python :: 3",
16
- "Programming Language :: Python :: 3.10",
17
16
  "Programming Language :: Python :: 3.11",
18
17
  "Programming Language :: Python :: 3.12",
19
18
  "Programming Language :: Python :: 3.13",
@@ -23,16 +22,14 @@ classifiers = [
23
22
  "Typing :: Typed",
24
23
  ]
25
24
  dependencies = [
26
- "typer>=0.15",
25
+ "typer>=0.27.3",
27
26
  # click.Abort is caught directly (Ctrl-C/Ctrl-D handling); typer>=0.27 no longer
28
27
  # pulls click in as its own dependency, so it must be declared here too.
29
- "click>=8.0",
28
+ "click>=8.5.0",
30
29
  "httpx>=0.28",
31
30
  # The SQL execution path (sessions, statements, polling, row pagination) is the
32
31
  # connector's, not a second implementation of it.
33
32
  "duckhaven-sql-connector>=0.4.1",
34
- # tomllib is stdlib only from 3.11; the workspace floor is 3.10.
35
- "tomli>=2.0; python_version < '3.11'",
36
33
  ]
37
34
  dynamic = ["version"]
38
35
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.1'
22
- __version_tuple__ = version_tuple = (0, 1, 1)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import typer
13
13
 
14
14
  from dh import context
15
+ from dh.errors import ConflictError
15
16
 
16
17
  app = typer.Typer(name="admin", help="Operator tasks: accounts, users, agents.")
17
18
 
@@ -20,9 +21,12 @@ pat_app = typer.Typer(name="pat", help="Tokens issued to a service account.")
20
21
  user_app = typer.Typer(name="user", help="People and their workspace roles.")
21
22
  agent_app = typer.Typer(name="agent", help="Compute agents.")
22
23
  storage_app = typer.Typer(name="storage", help="Storage backends.")
23
- maintenance_app = typer.Typer(name="maintenance", help="Deployment maintenance policy.")
24
+ maintenance_app = typer.Typer(
25
+ name="maintenance", help="Maintenance policy, scans and recommendations."
26
+ )
27
+ catalog_app = typer.Typer(name="catalog", help="Catalog-wide operations.")
24
28
 
25
- for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app):
29
+ for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app, catalog_app):
26
30
  app.add_typer(sub)
27
31
 
28
32
 
@@ -239,12 +243,24 @@ def create_elastic_agent(
239
243
  cpu: float = typer.Option(..., "--cpu"),
240
244
  memory_gb: float = typer.Option(..., "--memory-gb"),
241
245
  idle_timeout_minutes: int = typer.Option(None, "--idle-timeout-minutes"),
246
+ runtime: str = typer.Option(
247
+ None,
248
+ "--runtime",
249
+ help="DuckDB runtime id, from `dh agent runtimes`. Omitted: the deployment's default.",
250
+ ),
251
+ allow_beta: bool = typer.Option(
252
+ False, "--allow-beta", help="Permit a beta runtime, which is otherwise refused."
253
+ ),
242
254
  ):
243
255
  """Provision an elastic agent. Accepted asynchronously; poll `admin agent get`."""
244
256
  cli = context.of(ctx)
245
257
  body: dict[str, object] = {"cpu": cpu, "memory_gb": memory_gb}
246
258
  if idle_timeout_minutes is not None:
247
259
  body["idle_timeout_minutes"] = idle_timeout_minutes
260
+ if runtime:
261
+ body["runtime_id"] = runtime
262
+ if allow_beta:
263
+ body["allow_beta"] = True
248
264
  with cli.client() as client:
249
265
  cli.emit(client.post("admin/agents/elastic", json=body))
250
266
 
@@ -266,11 +282,60 @@ def agent_metrics(ctx: typer.Context):
266
282
 
267
283
 
268
284
  @agent_app.command("monitoring")
269
- def agent_monitoring(ctx: typer.Context, agent_id: str):
270
- """One agent's monitoring detail."""
285
+ def agent_monitoring(
286
+ ctx: typer.Context,
287
+ agent_id: str,
288
+ window: str = typer.Option(
289
+ None, "--window", help="A preset ending now: 1h, 3h, 8h (the default), 12h, 24h, 3d or 7d."
290
+ ),
291
+ start: str = typer.Option(None, "--start", help="ISO-8601 start of a custom range."),
292
+ end: str = typer.Option(None, "--end", help="ISO-8601 end of a custom range."),
293
+ ):
294
+ """One agent's monitoring detail over a window or a custom range.
295
+
296
+ Returns flat time buckets (busy, idle, concurrency, outcomes, CPU and memory)
297
+ on one shared grid, the lifecycle spans and a summary for the range.
298
+ """
271
299
  cli = context.of(ctx)
300
+ if (start is None) != (end is None):
301
+ raise ConflictError("bad_range", "Give both --start and --end, or neither.")
302
+ if window and start:
303
+ raise ConflictError("bad_range", "Give --window or --start/--end, not both.")
304
+ params = {"window": window, "start": start, "end": end}
272
305
  with cli.client() as client:
273
- cli.emit(client.get(f"admin/agents/{agent_id}/monitoring"))
306
+ cli.emit(client.get(f"admin/agents/{agent_id}/monitoring", params=params))
307
+
308
+
309
+ @agent_app.command("queries")
310
+ def agent_queries(
311
+ ctx: typer.Context,
312
+ agent_id: str,
313
+ start: str = typer.Option(..., "--start", help="ISO-8601 start of the range."),
314
+ end: str = typer.Option(..., "--end", help="ISO-8601 end of the range."),
315
+ status: list[str] = typer.Option(None, "--status", help="Repeatable."),
316
+ sort: str = typer.Option(
317
+ None,
318
+ "--sort",
319
+ help="started_at (default), duration, wait, peak_memory, cpu_time, spill or bytes_read.",
320
+ ),
321
+ direction: str = typer.Option(None, "--dir", help="asc or desc (default)."),
322
+ limit: int = typer.Option(None, "--limit"),
323
+ fetch_all: bool = typer.Option(False, "--all"),
324
+ ):
325
+ """The runs that were running or waiting on an agent during a range, with what they cost.
326
+
327
+ Needs the permission to see queries across workspaces as well as `use` on the agent.
328
+ """
329
+ cli = context.of(ctx)
330
+ params = {"start": start, "end": end, "status": status, "sort": sort, "dir": direction}
331
+ with cli.client() as client:
332
+ cli.page(
333
+ client,
334
+ f"admin/agents/{agent_id}/queries",
335
+ params=params,
336
+ limit=limit,
337
+ fetch_all=fetch_all,
338
+ )
274
339
 
275
340
 
276
341
  @agent_app.command("access")
@@ -351,3 +416,51 @@ def trigger_scan(ctx: typer.Context):
351
416
  cli = context.of(ctx)
352
417
  with cli.client() as client:
353
418
  cli.emit(client.post("admin/maintenance/scan"))
419
+
420
+
421
+ @maintenance_app.command("recommendations")
422
+ def list_recommendations(
423
+ ctx: typer.Context,
424
+ status: list[str] = typer.Option(None, "--status", help="Repeatable."),
425
+ severity: list[str] = typer.Option(None, "--severity", help="Repeatable."),
426
+ limit: int = typer.Option(None, "--limit"),
427
+ fetch_all: bool = typer.Option(False, "--all"),
428
+ ):
429
+ """What the maintenance scan recommends, with the outcome of any apply."""
430
+ cli = context.of(ctx)
431
+ params = {"status": status, "severity": severity}
432
+ with cli.client() as client:
433
+ cli.page(
434
+ client, "maintenance/recommendations", params=params, limit=limit, fetch_all=fetch_all
435
+ )
436
+
437
+
438
+ @maintenance_app.command("apply")
439
+ def apply_recommendation(ctx: typer.Context, recommendation_id: str, yes: bool = context.YES):
440
+ """Run the maintenance a recommendation asks for.
441
+
442
+ Rewrites or deletes data files, so it asks first. Needs `maintenance:manage` and
443
+ only works for catalog kinds that support applying (see `dh catalog kinds`).
444
+ Accepted asynchronously: the recommendation's `apply_status` reports the outcome,
445
+ so follow with `dh admin maintenance recommendations`.
446
+ """
447
+ cli = context.of(ctx)
448
+ cli.confirm("Apply maintenance recommendation", recommendation_id, yes=yes)
449
+ with cli.client() as client:
450
+ cli.emit(client.post(f"maintenance/recommendations/{recommendation_id}/apply"))
451
+
452
+
453
+ # --- Catalogs ----------------------------------------------------------------
454
+
455
+
456
+ @catalog_app.command("reconcile-roles")
457
+ def reconcile_ducklake_roles(ctx: typer.Context):
458
+ """Create any DuckLake database role that does not exist yet.
459
+
460
+ A post-upgrade step for operators: a migration can only write the credentials,
461
+ not the roles themselves in the catalog database. Idempotent, and it also repairs
462
+ a role that was dropped. Prints how many catalogs it saw and how many it reconciled.
463
+ """
464
+ cli = context.of(ctx)
465
+ with cli.client() as client:
466
+ cli.emit(client.post("admin/catalogs/ducklake/reconcile-roles"))
@@ -3,10 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
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
6
+ from datetime import UTC, datetime, timedelta
10
7
 
11
8
  import typer
12
9
 
@@ -198,7 +195,7 @@ def _warn_if_expiring(cli, expires_at: str | None) -> None:
198
195
  moment = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
199
196
  except ValueError: # pragma: no cover - the server sends ISO-8601
200
197
  return
201
- left = moment - datetime.now(tz=moment.tzinfo or timezone.utc)
198
+ left = moment - datetime.now(tz=moment.tzinfo or UTC)
202
199
  if left <= timedelta(0):
203
200
  cli.note("This token has expired. Run `dh auth login` to get a new one.")
204
201
  elif left <= _EXPIRY_WARNING:
@@ -102,13 +102,21 @@ def update_workspace(
102
102
  workspace: str = typer.Argument(None),
103
103
  name: str = typer.Option(None, "--name"),
104
104
  description: str = typer.Option(None, "--description"),
105
+ result_cache: bool = typer.Option(
106
+ None,
107
+ "--result-cache/--no-result-cache",
108
+ help="Whether repeated queries may be answered from the result cache. Owner only.",
109
+ ),
105
110
  ) -> None:
106
- """Rename or re-describe a workspace."""
111
+ """Rename or re-describe a workspace, or switch its result cache on or off."""
107
112
  cli = context.of(ctx)
108
113
  settings = cli.settings()
109
- body = {k: v for k, v in {"name": name, "description": description}.items() if v is not None}
114
+ fields = {"name": name, "description": description, "result_cache_enabled": result_cache}
115
+ body = {k: v for k, v in fields.items() if v is not None}
110
116
  if not body:
111
- raise ConflictError("nothing_to_update", "Give --name or --description.")
117
+ raise ConflictError(
118
+ "nothing_to_update", "Give --name, --description or --result-cache/--no-result-cache."
119
+ )
112
120
  with cli.client(settings) as client:
113
121
  target = workspace or settings.require("workspace")
114
122
  cli.emit(client.patch(f"workspaces/{target}", json=body))
@@ -171,6 +179,19 @@ def list_agents(ctx: typer.Context) -> None:
171
179
  cli.emit(client.get("agents"))
172
180
 
173
181
 
182
+ @agent_app.command("runtimes")
183
+ def list_runtimes(ctx: typer.Context) -> None:
184
+ """The DuckDB runtimes agents can run, and which one is the default.
185
+
186
+ Each is a DuckDB line with its baked-in extensions and a lifecycle status
187
+ (`beta`, `ga`, `deprecated`, `retired`). Retired runtimes are listed so an agent
188
+ still running one can be recognised, but the server will not send them work.
189
+ """
190
+ cli = context.of(ctx)
191
+ with cli.client() as client:
192
+ cli.emit(client.get("runtimes"))
193
+
194
+
174
195
  # --- Catalogs --------------------------------------------------------------
175
196
 
176
197
 
@@ -191,6 +212,11 @@ def list_catalogs(
191
212
  def create_catalog(
192
213
  ctx: typer.Context,
193
214
  name: str = typer.Argument(..., help="Identifier-safe name."),
215
+ kind: str = typer.Option(
216
+ None,
217
+ "--kind",
218
+ help="iceberg_polaris (the server default) or ducklake. See `dh catalog kinds`.",
219
+ ),
194
220
  storage_backend: str = typer.Option(None, "--storage-backend", help="Backend id."),
195
221
  access_mode: str = typer.Option(None, "--access-mode", help="open or scoped."),
196
222
  ) -> None:
@@ -198,6 +224,8 @@ def create_catalog(
198
224
  cli = context.of(ctx)
199
225
  settings = cli.settings()
200
226
  body = {"name": name}
227
+ if kind:
228
+ body["kind"] = kind
201
229
  if storage_backend:
202
230
  body["storage_backend_id"] = storage_backend
203
231
  if access_mode:
@@ -206,6 +234,82 @@ def create_catalog(
206
234
  cli.emit(client.post(f"workspaces/{settings.require('workspace')}/catalogs", json=body))
207
235
 
208
236
 
237
+ @catalog_app.command("kinds")
238
+ def list_catalog_kinds(ctx: typer.Context) -> None:
239
+ """The catalog kinds this deployment offers, and what each can do.
240
+
241
+ A kind that is known but switched off is listed with `available: false` and the
242
+ reason, so `dh catalog create --kind` failing is explicable.
243
+ """
244
+ cli = context.of(ctx)
245
+ with cli.client() as client:
246
+ cli.emit(client.get("catalog-kinds"))
247
+
248
+
249
+ @catalog_app.command("rotate-role")
250
+ def rotate_role(ctx: typer.Context, catalog_id: str, yes: bool = context.YES) -> None:
251
+ """Rotate the database credential of a DuckLake catalog.
252
+
253
+ Queries already running keep their connection; ones that start afterwards use
254
+ the new credential.
255
+ """
256
+ cli = context.of(ctx)
257
+ cli.confirm("Rotate the database role of catalog", catalog_id, yes=yes)
258
+ with cli.client() as client:
259
+ cli.emit(client.post(f"catalogs/{catalog_id}/ducklake/rotate-role"))
260
+
261
+
262
+ export_app = typer.Typer(name="export", help="Copy a DuckLake catalog out as Iceberg.")
263
+ catalog_app.add_typer(export_app)
264
+
265
+
266
+ @export_app.command("list")
267
+ def list_exports(
268
+ ctx: typer.Context, catalog_id: str, fetch_all: bool = typer.Option(False, "--all")
269
+ ) -> None:
270
+ """A catalog's exports, newest first."""
271
+ cli = context.of(ctx)
272
+ with cli.client() as client:
273
+ cli.page(client, f"catalogs/{catalog_id}/exports", fetch_all=fetch_all)
274
+
275
+
276
+ @export_app.command("create")
277
+ def create_export(
278
+ ctx: typer.Context,
279
+ catalog_id: str,
280
+ target_name: str = typer.Option(..., "--target-name", help="Name of the new Iceberg catalog."),
281
+ storage_backend: str = typer.Option(
282
+ ..., "--storage-backend", help="Storage backend id for the new catalog."
283
+ ),
284
+ ) -> None:
285
+ """Start exporting a DuckLake catalog to a new Iceberg catalog.
286
+
287
+ The new catalog is attached to the current workspace. This only starts the
288
+ export; follow it with `dh catalog export list`.
289
+ """
290
+ cli = context.of(ctx)
291
+ settings = cli.settings()
292
+ with cli.client(settings) as client:
293
+ cli.emit(
294
+ client.post(
295
+ f"catalogs/{catalog_id}/exports",
296
+ params={"workspace": settings.require("workspace")},
297
+ json={
298
+ "target_name": target_name,
299
+ "target_storage_backend_id": storage_backend,
300
+ },
301
+ )
302
+ )
303
+
304
+
305
+ @export_app.command("cancel")
306
+ def cancel_export(ctx: typer.Context, catalog_id: str, export_id: str) -> None:
307
+ """Stop an export that has not finished."""
308
+ cli = context.of(ctx)
309
+ with cli.client() as client:
310
+ cli.emit(client.post(f"catalogs/{catalog_id}/exports/{export_id}/cancel"))
311
+
312
+
209
313
  @catalog_app.command("attach")
210
314
  def attach_catalog(
211
315
  ctx: typer.Context,
@@ -41,6 +41,7 @@ def _run(
41
41
  limit: int | None,
42
42
  fetch_all: bool,
43
43
  agent: str | None,
44
+ no_cache: bool,
44
45
  ) -> None:
45
46
  cli = context.of(ctx)
46
47
  settings = cli.settings()
@@ -62,16 +63,19 @@ def _run(
62
63
  # The same budget bounds the client's wait and the server's execution.
63
64
  # Two timeouts would be a contract people get wrong; one cannot drift.
64
65
  timeout_s=budget,
66
+ use_cache=False if no_cache else None,
65
67
  )
66
68
  if no_wait:
67
69
  cli.note(f"Submitted {submitted['id']}.")
68
70
  cli.emit(submitted)
69
71
  return
70
72
 
71
- finished = execute.wait(client, submitted["id"], timeout=budget)
73
+ finished = execute.wait(client, submitted["id"], timeout=budget, known=submitted)
72
74
  execute.raise_for_status(finished)
73
75
  page = execute.fetch_rows(client, finished["id"], limit=None if fetch_all else limit)
74
76
 
77
+ if finished.get("cache_status") == "hit":
78
+ cli.note("Answered from the result cache. Use --no-cache to run it again.")
75
79
  if page["truncated"]:
76
80
  cli.note(f"Showing {len(page['rows'])} of {page['total']} rows. Use --all for the rest.")
77
81
  cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
@@ -85,6 +89,11 @@ _TIMEOUT = typer.Option("20m", "--timeout", help="Give up after this long, e.g.
85
89
  _LIMIT = typer.Option(None, "--limit", help="Stop after this many rows.")
86
90
  _ALL = typer.Option(False, "--all", help="Fetch every row, ignoring --limit.")
87
91
  _AGENT = typer.Option(None, "--agent", help="Agent name or id to run on.")
92
+ _NO_CACHE = typer.Option(
93
+ False,
94
+ "--no-cache",
95
+ help="Always run the query instead of answering it from the result cache.",
96
+ )
88
97
 
89
98
 
90
99
  def sql(
@@ -97,6 +106,7 @@ def sql(
97
106
  limit: int = _LIMIT,
98
107
  fetch_all: bool = _ALL,
99
108
  agent: str = _AGENT,
109
+ no_cache: bool = _NO_CACHE,
100
110
  ) -> None:
101
111
  """Run SQL and print the results.
102
112
 
@@ -117,6 +127,7 @@ def sql(
117
127
  limit=limit,
118
128
  fetch_all=fetch_all,
119
129
  agent=agent,
130
+ no_cache=no_cache,
120
131
  )
121
132
 
122
133
 
@@ -131,6 +142,7 @@ def run_query(
131
142
  limit: int = _LIMIT,
132
143
  fetch_all: bool = _ALL,
133
144
  agent: str = _AGENT,
145
+ no_cache: bool = _NO_CACHE,
134
146
  ) -> None:
135
147
  """Run SQL and print the results. The noun-first spelling of `dh sql`."""
136
148
  _run(
@@ -143,6 +155,7 @@ def run_query(
143
155
  limit=limit,
144
156
  fetch_all=fetch_all,
145
157
  agent=agent,
158
+ no_cache=no_cache,
146
159
  )
147
160
 
148
161