duckhaven-cli 0.2.0__tar.gz → 0.4.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.
- duckhaven_cli-0.4.0/CHANGELOG.md +99 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/PKG-INFO +31 -3
- duckhaven_cli-0.4.0/README.md +67 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/pyproject.toml +2 -2
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/_version.py +2 -2
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/admin.py +118 -5
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/catalog.py +107 -3
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/query.py +15 -1
- duckhaven_cli-0.4.0/src/dh/commands/saved.py +371 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/session.py +21 -3
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/execute.py +52 -22
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/main.py +1 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/repl.py +1 -1
- duckhaven_cli-0.2.0/CHANGELOG.md +0 -48
- duckhaven_cli-0.2.0/README.md +0 -39
- duckhaven_cli-0.2.0/src/dh/commands/saved.py +0 -213
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/.gitignore +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/LICENSE +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/__init__.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/__init__.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/api.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/auth.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/dbt.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/grant.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/health.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/commands/profile.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/config.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/context.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/errors.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/introspect.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/output/__init__.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/resolve.py +0 -0
- {duckhaven_cli-0.2.0 → duckhaven_cli-0.4.0}/src/dh/rest.py +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
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.4.0] - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `dh sql` and the REPL ask the server to hold the submit for up to two seconds, so a quick
|
|
14
|
+
query finishes in one request instead of a submit and a poll. `--no-wait` still returns at
|
|
15
|
+
once. A server that predates the hold ignores it.
|
|
16
|
+
- Ready for DuckHaven `api_version` 3: `DECIMAL` and wide integers, which it sends as
|
|
17
|
+
strings, print as before in table and CSV output and stay exact strings with
|
|
18
|
+
`--format json`.
|
|
19
|
+
|
|
20
|
+
## [0.3.0] - 2026-10-13
|
|
21
|
+
|
|
22
|
+
Brings the CLI up to date with the DuckHaven server's result cache, DuckLake catalogs,
|
|
23
|
+
agent runtimes, worksheets and reshaped monitoring. Everything new needs a server that
|
|
24
|
+
offers it; against an older one the command fails with that server's own error.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Result cache.** `--no-cache` on `dh sql`, `dh query run`, `dh session open` and
|
|
29
|
+
`dh session exec`, and `dh workspace update --result-cache/--no-result-cache` (owner
|
|
30
|
+
only). When a query was answered from the cache, `dh sql` and `dh session exec` say so on
|
|
31
|
+
stderr. The REPL is not cache-aware.
|
|
32
|
+
- **Catalogs.** `dh catalog create --kind` (`iceberg_polaris` or `ducklake`),
|
|
33
|
+
`dh catalog kinds`, `dh catalog rotate-role`, and `dh catalog export list|create|cancel`
|
|
34
|
+
for copying a DuckLake catalog out as Iceberg. `dh admin catalog reconcile-roles` creates
|
|
35
|
+
any DuckLake database role that is missing (idempotent, deployment-wide).
|
|
36
|
+
- **Runtimes.** `dh agent runtimes`, and `--runtime` / `--allow-beta` on
|
|
37
|
+
`dh admin agent elastic-create`.
|
|
38
|
+
- **Monitoring.** `dh admin agent monitoring` takes `--window` or `--start`/`--end`, and
|
|
39
|
+
`dh admin agent queries` lists the runs behind a time range, sortable by wait, duration,
|
|
40
|
+
peak memory, CPU time, spill or bytes read.
|
|
41
|
+
- **Maintenance.** `dh admin maintenance recommendations` and `dh admin maintenance apply`
|
|
42
|
+
(asks first; needs `maintenance:manage`).
|
|
43
|
+
- **Worksheets.** `dh worksheet list|get|create|update|delete`. Changing a worksheet's
|
|
44
|
+
title or SQL requires `--base-version`; a concurrent edit is refused with exit `5` and
|
|
45
|
+
the current worksheet in the error's details.
|
|
46
|
+
- `dh saved-query create --on-conflict error` fails with exit `5` instead of overwriting
|
|
47
|
+
a query with the same name, and `dh search --type` limits the kinds of object searched.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- With no agent named, `dh sql` and `dh session open` no longer list agents first and leave
|
|
52
|
+
the choice to the server, which picks a connected agent on the default runtime. Before,
|
|
53
|
+
an account that could see several agents sent no choice and a server without elastic
|
|
54
|
+
compute refused with `agent_required`.
|
|
55
|
+
- Status polls ask the server to hold the call until the query finishes, and a query the
|
|
56
|
+
submit response already reports as finished is not polled at all.
|
|
57
|
+
- The pinned API contract is refreshed, and now also checks the query parameters the new
|
|
58
|
+
commands send.
|
|
59
|
+
|
|
60
|
+
## [0.2.0] - 2026-09-09
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Drop support for Python 3.10; the minimum supported version is now 3.11.
|
|
65
|
+
|
|
66
|
+
## [0.1.1] - 2026-09-09
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Declare `click` as a direct dependency. `dh` failed to start with
|
|
71
|
+
`ModuleNotFoundError: No module named 'click'` when installed standalone:
|
|
72
|
+
`context.py`/`repl.py` catch `click.Abort` directly, but `typer>=0.27` no longer
|
|
73
|
+
depends on `click` itself, and the workspace dev environment masked this because
|
|
74
|
+
other packages pulled `click` in transitively.
|
|
75
|
+
|
|
76
|
+
## [0.1.0] - 2026-09-09
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- `dh`, the DuckHaven command-line interface.
|
|
81
|
+
- **Running SQL.** `dh sql` submits, polls to a terminal state and fetches every page
|
|
82
|
+
of results; `--no-wait` returns the id instead. Ctrl-C cancels the query on the
|
|
83
|
+
server rather than orphaning it, and a query that runs and fails exits `6` so a
|
|
84
|
+
pipeline can tell bad SQL from a broken CLI. `dh sql` with no arguments on a
|
|
85
|
+
terminal opens a REPL, session-backed where the server allows it.
|
|
86
|
+
- **The dbt path.** `dh lineage import` and `dh semantic import` publish build
|
|
87
|
+
artifacts, including the `manifest.json` + `catalog.json` pair that unlocks
|
|
88
|
+
column-level lineage, plus `validate`/`publish` to promote imported drafts.
|
|
89
|
+
- **Catalog and access.** `dh workspace`, `dh catalog`, `dh schema`, `dh table` and
|
|
90
|
+
`dh grant`, with catalog resolution and `catalog.schema.table` references.
|
|
91
|
+
- **History.** `dh query list` with the full server-side filter set, `dh saved-query`,
|
|
92
|
+
`dh schedule` and `dh search`.
|
|
93
|
+
- **Operators.** A thin `dh admin` over service accounts, tokens, users and agents,
|
|
94
|
+
and `dh api` for anything without a hand-written command.
|
|
95
|
+
- **Auth.** `dh auth login` signs in and mints a personal access token, storing only
|
|
96
|
+
the token at mode 0600 and refusing a config other users can read.
|
|
97
|
+
- Output as `--format json|table|csv` behind one stable envelope
|
|
98
|
+
(`{data, cursor, has_more}`), defaulting to a table on a terminal and JSON when
|
|
99
|
+
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.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -21,10 +21,10 @@ Classifier: Topic :: Database
|
|
|
21
21
|
Classifier: Topic :: Utilities
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.11
|
|
24
|
-
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: click>=8.5.0
|
|
25
25
|
Requires-Dist: duckhaven-sql-connector>=0.4.1
|
|
26
26
|
Requires-Dist: httpx>=0.28
|
|
27
|
-
Requires-Dist: typer>=0.
|
|
27
|
+
Requires-Dist: typer>=0.27.3
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
30
|
# duckhaven-cli
|
|
@@ -57,6 +57,34 @@ dh auth login --host https://duckhaven.example.com
|
|
|
57
57
|
dh sql -q "select 1"
|
|
58
58
|
```
|
|
59
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
|
+
|
|
80
|
+
## Exact numbers
|
|
81
|
+
|
|
82
|
+
A DuckHaven with `api_version` 3 or later sends `DECIMAL` values and 64-bit-or-wider integers
|
|
83
|
+
(`BIGINT`, `HUGEINT`, `count(*)`) as strings, because JSON numbers lose precision past about
|
|
84
|
+
16 digits. `dh` prints them exactly as before in table and CSV output. With `--format json`
|
|
85
|
+
they stay strings (`"12.50"`, `"9007199254740993"`), exact. Scripts reading `dh` JSON should
|
|
86
|
+
convert those columns themselves rather than expect JSON numbers.
|
|
87
|
+
|
|
60
88
|
## Documentation
|
|
61
89
|
|
|
62
90
|
- [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# duckhaven-cli
|
|
2
|
+
|
|
3
|
+
`dh` — the command-line interface for [DuckHaven](https://github.com/tamasmrtn/duckhaven).
|
|
4
|
+
|
|
5
|
+
It gives analysts, CI pipelines and operators scriptable access to DuckHaven's REST API:
|
|
6
|
+
running SQL, browsing the catalog, publishing dbt lineage and semantic models, managing
|
|
7
|
+
grants, and operating service accounts, users and agents.
|
|
8
|
+
|
|
9
|
+
Like every member of this repo it is a **pure HTTP client of DuckHaven's public REST
|
|
10
|
+
API**, authenticating with a Personal Access Token (`dh_pat_…`). The interactive REPL
|
|
11
|
+
holds a SQL session through
|
|
12
|
+
[`duckhaven-sql-connector`](../duckhaven-sql-connector/README.md); everything else,
|
|
13
|
+
including one-shot `dh sql`, goes over the REST API directly, because the one-shot query
|
|
14
|
+
route works on deployments where SQL sessions are switched off.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install duckhaven-cli
|
|
20
|
+
# or, to get an isolated tool install:
|
|
21
|
+
uv tool install duckhaven-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Getting started
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
dh auth login --host https://duckhaven.example.com
|
|
28
|
+
dh sql -q "select 1"
|
|
29
|
+
```
|
|
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
|
+
|
|
51
|
+
## Exact numbers
|
|
52
|
+
|
|
53
|
+
A DuckHaven with `api_version` 3 or later sends `DECIMAL` values and 64-bit-or-wider integers
|
|
54
|
+
(`BIGINT`, `HUGEINT`, `count(*)`) as strings, because JSON numbers lose precision past about
|
|
55
|
+
16 digits. `dh` prints them exactly as before in table and CSV output. With `--format json`
|
|
56
|
+
they stay strings (`"12.50"`, `"9007199254740993"`), exact. Scripts reading `dh` JSON should
|
|
57
|
+
convert those columns themselves rather than expect JSON numbers.
|
|
58
|
+
|
|
59
|
+
## Documentation
|
|
60
|
+
|
|
61
|
+
- [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
|
|
62
|
+
run a query
|
|
63
|
+
- [CLI reference](https://tamasmrtn.github.io/duckhaven/reference/cli/) — every command and flag
|
|
64
|
+
|
|
65
|
+
## Licence
|
|
66
|
+
|
|
67
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -22,10 +22,10 @@ classifiers = [
|
|
|
22
22
|
"Typing :: Typed",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"typer>=0.
|
|
25
|
+
"typer>=0.27.3",
|
|
26
26
|
# click.Abort is caught directly (Ctrl-C/Ctrl-D handling); typer>=0.27 no longer
|
|
27
27
|
# pulls click in as its own dependency, so it must be declared here too.
|
|
28
|
-
"click>=8.0",
|
|
28
|
+
"click>=8.5.0",
|
|
29
29
|
"httpx>=0.28",
|
|
30
30
|
# The SQL execution path (sessions, statements, polling, row pagination) is the
|
|
31
31
|
# connector's, not a second implementation of it.
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 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(
|
|
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(
|
|
270
|
-
|
|
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"))
|
|
@@ -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
|
-
|
|
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(
|
|
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,20 @@ 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,
|
|
67
|
+
hold=not no_wait,
|
|
65
68
|
)
|
|
66
69
|
if no_wait:
|
|
67
70
|
cli.note(f"Submitted {submitted['id']}.")
|
|
68
71
|
cli.emit(submitted)
|
|
69
72
|
return
|
|
70
73
|
|
|
71
|
-
finished = execute.wait(client, submitted["id"], timeout=budget)
|
|
74
|
+
finished = execute.wait(client, submitted["id"], timeout=budget, known=submitted)
|
|
72
75
|
execute.raise_for_status(finished)
|
|
73
76
|
page = execute.fetch_rows(client, finished["id"], limit=None if fetch_all else limit)
|
|
74
77
|
|
|
78
|
+
if finished.get("cache_status") == "hit":
|
|
79
|
+
cli.note("Answered from the result cache. Use --no-cache to run it again.")
|
|
75
80
|
if page["truncated"]:
|
|
76
81
|
cli.note(f"Showing {len(page['rows'])} of {page['total']} rows. Use --all for the rest.")
|
|
77
82
|
cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
|
|
@@ -85,6 +90,11 @@ _TIMEOUT = typer.Option("20m", "--timeout", help="Give up after this long, e.g.
|
|
|
85
90
|
_LIMIT = typer.Option(None, "--limit", help="Stop after this many rows.")
|
|
86
91
|
_ALL = typer.Option(False, "--all", help="Fetch every row, ignoring --limit.")
|
|
87
92
|
_AGENT = typer.Option(None, "--agent", help="Agent name or id to run on.")
|
|
93
|
+
_NO_CACHE = typer.Option(
|
|
94
|
+
False,
|
|
95
|
+
"--no-cache",
|
|
96
|
+
help="Always run the query instead of answering it from the result cache.",
|
|
97
|
+
)
|
|
88
98
|
|
|
89
99
|
|
|
90
100
|
def sql(
|
|
@@ -97,6 +107,7 @@ def sql(
|
|
|
97
107
|
limit: int = _LIMIT,
|
|
98
108
|
fetch_all: bool = _ALL,
|
|
99
109
|
agent: str = _AGENT,
|
|
110
|
+
no_cache: bool = _NO_CACHE,
|
|
100
111
|
) -> None:
|
|
101
112
|
"""Run SQL and print the results.
|
|
102
113
|
|
|
@@ -117,6 +128,7 @@ def sql(
|
|
|
117
128
|
limit=limit,
|
|
118
129
|
fetch_all=fetch_all,
|
|
119
130
|
agent=agent,
|
|
131
|
+
no_cache=no_cache,
|
|
120
132
|
)
|
|
121
133
|
|
|
122
134
|
|
|
@@ -131,6 +143,7 @@ def run_query(
|
|
|
131
143
|
limit: int = _LIMIT,
|
|
132
144
|
fetch_all: bool = _ALL,
|
|
133
145
|
agent: str = _AGENT,
|
|
146
|
+
no_cache: bool = _NO_CACHE,
|
|
134
147
|
) -> None:
|
|
135
148
|
"""Run SQL and print the results. The noun-first spelling of `dh sql`."""
|
|
136
149
|
_run(
|
|
@@ -143,6 +156,7 @@ def run_query(
|
|
|
143
156
|
limit=limit,
|
|
144
157
|
fetch_all=fetch_all,
|
|
145
158
|
agent=agent,
|
|
159
|
+
no_cache=no_cache,
|
|
146
160
|
)
|
|
147
161
|
|
|
148
162
|
|