duckhaven-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dh/__init__.py +20 -0
- dh/_version.py +24 -0
- dh/commands/__init__.py +0 -0
- dh/commands/admin.py +353 -0
- dh/commands/api.py +79 -0
- dh/commands/auth.py +266 -0
- dh/commands/catalog.py +426 -0
- dh/commands/dbt.py +272 -0
- dh/commands/grant.py +129 -0
- dh/commands/health.py +64 -0
- dh/commands/profile.py +80 -0
- dh/commands/query.py +239 -0
- dh/commands/saved.py +213 -0
- dh/commands/session.py +210 -0
- dh/config.py +263 -0
- dh/context.py +166 -0
- dh/errors.py +175 -0
- dh/execute.py +204 -0
- dh/introspect.py +78 -0
- dh/main.py +224 -0
- dh/output/__init__.py +175 -0
- dh/repl.py +158 -0
- dh/resolve.py +146 -0
- dh/rest.py +201 -0
- duckhaven_cli-0.1.0.dist-info/METADATA +69 -0
- duckhaven_cli-0.1.0.dist-info/RECORD +29 -0
- duckhaven_cli-0.1.0.dist-info/WHEEL +4 -0
- duckhaven_cli-0.1.0.dist-info/entry_points.txt +2 -0
- duckhaven_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
dh/repl.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The interactive `dh sql` shell.
|
|
2
|
+
|
|
3
|
+
Prefers a **session**, so temp tables, `SET`s and attached catalogs persist
|
|
4
|
+
between statements the way they do in any other SQL shell. Sessions are disabled
|
|
5
|
+
by default on the server, so it falls back to one-shot execution and says so
|
|
6
|
+
once: a stateless REPL is worth far more than a REPL that refuses to start.
|
|
7
|
+
|
|
8
|
+
The session path goes through `duckhaven-sql-connector`, which is exactly what it
|
|
9
|
+
is for -- one long-lived process holding one connection, with elastic cold-start
|
|
10
|
+
waiting and row pagination already handled.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from dh import execute
|
|
22
|
+
from dh.errors import DhError, from_connector
|
|
23
|
+
|
|
24
|
+
#: Everything end-of-input can arrive as. See `read_statements`.
|
|
25
|
+
_END_OF_INPUT = (EOFError, click.Abort, typer.Abort)
|
|
26
|
+
|
|
27
|
+
_BANNER = "dh sql. Statements end with ';'. Ctrl-D or \\q to quit."
|
|
28
|
+
_QUIT = {"\\q", "quit", "exit", "\\quit"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_statements(read_line):
|
|
32
|
+
"""Yield complete statements, buffering until a line ends with `;`.
|
|
33
|
+
|
|
34
|
+
A REPL that submitted every line separately could not accept the multi-line
|
|
35
|
+
SQL people actually paste in.
|
|
36
|
+
"""
|
|
37
|
+
buffer: list[str] = []
|
|
38
|
+
while True:
|
|
39
|
+
prompt = "dh> " if not buffer else "..> "
|
|
40
|
+
try:
|
|
41
|
+
line = read_line(prompt)
|
|
42
|
+
except _END_OF_INPUT:
|
|
43
|
+
# Ctrl-D is the exit key the banner advertises, and it must leave 0
|
|
44
|
+
# with anything half-typed still run. Three classes because typer
|
|
45
|
+
# vendors its own click: `typer.prompt` raises
|
|
46
|
+
# `typer.exceptions.Abort`, which is *not* `click.exceptions.Abort`,
|
|
47
|
+
# and `read_line` is an arbitrary callable that may raise EOFError.
|
|
48
|
+
if buffer:
|
|
49
|
+
yield "\n".join(buffer)
|
|
50
|
+
return
|
|
51
|
+
stripped = line.strip()
|
|
52
|
+
if not buffer and stripped.lower() in _QUIT:
|
|
53
|
+
return
|
|
54
|
+
if not stripped and not buffer:
|
|
55
|
+
continue
|
|
56
|
+
buffer.append(line)
|
|
57
|
+
if stripped.endswith(";"):
|
|
58
|
+
yield "\n".join(buffer).rstrip().rstrip(";")
|
|
59
|
+
buffer = []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(cli, settings, *, agent: str | None = None) -> int:
|
|
63
|
+
"""Run the shell until end of input. Returns the process exit code."""
|
|
64
|
+
connection = _open_session(cli, settings, agent)
|
|
65
|
+
if connection is None:
|
|
66
|
+
cli.note("SQL sessions are not enabled here; running each statement on its own.")
|
|
67
|
+
try:
|
|
68
|
+
return _loop(cli, settings, connection, agent)
|
|
69
|
+
finally:
|
|
70
|
+
if connection is not None:
|
|
71
|
+
# Always, including on interrupt: an unclosed session holds an agent
|
|
72
|
+
# slot until the server's reaper notices.
|
|
73
|
+
try:
|
|
74
|
+
connection.close()
|
|
75
|
+
except Exception: # noqa: BLE001 - closing is best effort
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _open_session(cli, settings, agent: str | None):
|
|
80
|
+
"""A connector session, or None when the server has them switched off."""
|
|
81
|
+
try:
|
|
82
|
+
from duckhaven_sql_connector import connect
|
|
83
|
+
except ImportError: # pragma: no cover - the dependency is declared
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
return connect(
|
|
87
|
+
host=settings.require("host"),
|
|
88
|
+
workspace=settings.require("workspace"),
|
|
89
|
+
token=settings.require("token"),
|
|
90
|
+
agent=agent or settings.get("agent"),
|
|
91
|
+
catalog=settings.get("catalog"),
|
|
92
|
+
application="dh-repl",
|
|
93
|
+
)
|
|
94
|
+
except Exception as exc: # noqa: BLE001 - any failure means "fall back"
|
|
95
|
+
cli.debug(f"session unavailable: {exc}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _loop(cli, settings, connection, agent: str | None) -> int:
|
|
100
|
+
typer.echo(_BANNER, err=True)
|
|
101
|
+
for sql in read_statements(lambda prompt: typer.prompt(prompt, prompt_suffix="")):
|
|
102
|
+
try:
|
|
103
|
+
if connection is not None:
|
|
104
|
+
_run_on_session(cli, connection, sql)
|
|
105
|
+
else:
|
|
106
|
+
_run_one_shot(cli, settings, sql, agent)
|
|
107
|
+
except KeyboardInterrupt:
|
|
108
|
+
# Interrupting a statement returns to the prompt; the shell itself
|
|
109
|
+
# ends at end of input.
|
|
110
|
+
cli.note("Cancelled.")
|
|
111
|
+
except DhError as exc:
|
|
112
|
+
# A failed statement is a normal event in a shell, so report it and
|
|
113
|
+
# keep the session -- exiting would throw away the temp tables that
|
|
114
|
+
# are the whole reason for holding one.
|
|
115
|
+
cli.note(f"Error: {exc.message}")
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_on_session(cli, connection, sql: str) -> None:
|
|
120
|
+
cursor = connection.cursor()
|
|
121
|
+
try:
|
|
122
|
+
cursor.execute(sql)
|
|
123
|
+
columns = [d[0] for d in cursor.description or []]
|
|
124
|
+
# Keyed by column name, matching `RowsPageOut`: the envelope is one public
|
|
125
|
+
# contract, so the session path must not emit a different row shape from
|
|
126
|
+
# the one-shot path.
|
|
127
|
+
rows: list[dict[str, Any]] = (
|
|
128
|
+
[dict(zip(columns, row)) for row in cursor.fetchall()] if columns else []
|
|
129
|
+
)
|
|
130
|
+
cli.emit({"columns": columns, "rows": rows, "total": len(rows)})
|
|
131
|
+
except Exception as exc: # noqa: BLE001 - mapped to the CLI taxonomy
|
|
132
|
+
raise from_connector(exc) from exc
|
|
133
|
+
finally:
|
|
134
|
+
cursor.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_one_shot(cli, settings, sql: str, agent: str | None) -> None:
|
|
138
|
+
with cli.client(settings) as client:
|
|
139
|
+
chosen = execute.resolve_agent(client, agent or settings.get("agent"))
|
|
140
|
+
submitted = execute.submit(
|
|
141
|
+
client,
|
|
142
|
+
settings.require("workspace"),
|
|
143
|
+
sql,
|
|
144
|
+
agent=chosen,
|
|
145
|
+
catalog=settings.get("catalog"),
|
|
146
|
+
)
|
|
147
|
+
finished = execute.wait(client, submitted["id"], timeout=1200)
|
|
148
|
+
execute.raise_for_status(finished)
|
|
149
|
+
page = execute.fetch_rows(client, finished["id"])
|
|
150
|
+
cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_interactive() -> bool:
|
|
154
|
+
"""Whether to start a shell rather than read a script from a pipe."""
|
|
155
|
+
try:
|
|
156
|
+
return sys.stdin.isatty()
|
|
157
|
+
except (AttributeError, ValueError): # pragma: no cover - detached stream
|
|
158
|
+
return False
|
dh/resolve.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Settings resolution: flag, then environment, then profile, then default."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from dh.config import Config, Profile
|
|
8
|
+
from dh.errors import ConfigError
|
|
9
|
+
|
|
10
|
+
#: Setting name -> the environment variable that supplies it.
|
|
11
|
+
ENV_VARS = {
|
|
12
|
+
"profile": "DH_PROFILE",
|
|
13
|
+
"host": "DH_HOST",
|
|
14
|
+
"token": "DH_TOKEN",
|
|
15
|
+
"workspace": "DH_WORKSPACE",
|
|
16
|
+
"catalog": "DH_CATALOG",
|
|
17
|
+
"agent": "DH_AGENT",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#: Settings drawn from the profile table, in `dh auth describe` order.
|
|
21
|
+
SETTINGS = ("host", "token", "workspace", "catalog", "agent")
|
|
22
|
+
|
|
23
|
+
#: Settings whose value must never be printed.
|
|
24
|
+
SECRET = frozenset({"token"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Value:
|
|
29
|
+
"""A resolved setting and the reason it has the value it has."""
|
|
30
|
+
|
|
31
|
+
value: str | None
|
|
32
|
+
source: str
|
|
33
|
+
|
|
34
|
+
def __bool__(self) -> bool:
|
|
35
|
+
return self.value is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Settings:
|
|
40
|
+
profile_name: str | None
|
|
41
|
+
values: dict[str, Value]
|
|
42
|
+
|
|
43
|
+
def get(self, name: str) -> str | None:
|
|
44
|
+
return self.values[name].value
|
|
45
|
+
|
|
46
|
+
def source(self, name: str) -> str:
|
|
47
|
+
return self.values[name].source
|
|
48
|
+
|
|
49
|
+
def require(self, name: str) -> str:
|
|
50
|
+
"""The value, or a message naming every way it could have been supplied.
|
|
51
|
+
|
|
52
|
+
A bare "host is required" leaves the reader to guess whether that means a
|
|
53
|
+
flag, a variable, or a file, so say all three.
|
|
54
|
+
"""
|
|
55
|
+
value = self.get(name)
|
|
56
|
+
if value:
|
|
57
|
+
return value
|
|
58
|
+
raise ConfigError(
|
|
59
|
+
f"no_{name}",
|
|
60
|
+
f"No {name} configured. Set it with --{name}, ${ENV_VARS[name]}, "
|
|
61
|
+
f"or `dh auth login` to write a profile.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve(
|
|
66
|
+
config: Config,
|
|
67
|
+
env: dict[str, str],
|
|
68
|
+
overrides: dict[str, str | None] | None = None,
|
|
69
|
+
) -> Settings:
|
|
70
|
+
"""Apply the precedence chain to one invocation.
|
|
71
|
+
|
|
72
|
+
``env`` is passed in rather than read from :mod:`os` so the whole chain is
|
|
73
|
+
testable without mutating process state.
|
|
74
|
+
"""
|
|
75
|
+
overrides = {k: v for k, v in (overrides or {}).items() if v is not None}
|
|
76
|
+
|
|
77
|
+
profile_name, profile_source = _pick_profile(config, env, overrides)
|
|
78
|
+
profile = config.get(profile_name) if profile_name else None
|
|
79
|
+
|
|
80
|
+
values: dict[str, Value] = {"profile": Value(profile_name, profile_source)}
|
|
81
|
+
for name in SETTINGS:
|
|
82
|
+
values[name] = _pick(name, profile, env, overrides)
|
|
83
|
+
return Settings(profile_name=profile_name, values=values)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_profile(
|
|
87
|
+
config: Config, env: dict[str, str], overrides: dict[str, str]
|
|
88
|
+
) -> tuple[str | None, str]:
|
|
89
|
+
"""Which profile this invocation uses, and why.
|
|
90
|
+
|
|
91
|
+
A profile named explicitly but absent from the file is an error rather than a
|
|
92
|
+
silent fall back to the default: the caller asked for a specific one, and
|
|
93
|
+
quietly using another is how the wrong workspace gets written to.
|
|
94
|
+
"""
|
|
95
|
+
for value, source in ((overrides.get("profile"), "flag"), (env.get("DH_PROFILE"), "env")):
|
|
96
|
+
if value:
|
|
97
|
+
if value not in config.profiles:
|
|
98
|
+
raise ConfigError(
|
|
99
|
+
"no_such_profile",
|
|
100
|
+
f"No profile named {value!r} in {config.path}. "
|
|
101
|
+
f"Known profiles: {', '.join(sorted(config.profiles)) or 'none'}",
|
|
102
|
+
)
|
|
103
|
+
return value, "flag --profile" if source == "flag" else "env DH_PROFILE"
|
|
104
|
+
resolved = config.resolved_default()
|
|
105
|
+
return resolved, f"default profile {resolved!r}" if resolved else "unset"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pick(
|
|
109
|
+
name: str, profile: Profile | None, env: dict[str, str], overrides: dict[str, str]
|
|
110
|
+
) -> Value:
|
|
111
|
+
if name in overrides:
|
|
112
|
+
return Value(overrides[name], f"flag --{name}")
|
|
113
|
+
env_var = ENV_VARS[name]
|
|
114
|
+
if env.get(env_var):
|
|
115
|
+
return Value(env[env_var], f"env {env_var}")
|
|
116
|
+
if profile is not None and getattr(profile, name) is not None:
|
|
117
|
+
return Value(getattr(profile, name), f"profile {profile.name}")
|
|
118
|
+
return Value(None, "unset")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def describe(settings: Settings) -> list[dict[str, str | None]]:
|
|
122
|
+
"""Rows for `dh auth describe`: what each setting is, and where it came from.
|
|
123
|
+
|
|
124
|
+
The token is reported as present or absent and never by value. Someone
|
|
125
|
+
debugging a credential needs to know *which* one is in play, not what it is,
|
|
126
|
+
and `dh auth describe` is exactly the command people paste into a bug report.
|
|
127
|
+
"""
|
|
128
|
+
rows: list[dict[str, str | None]] = [
|
|
129
|
+
{
|
|
130
|
+
"setting": "profile",
|
|
131
|
+
"value": settings.values["profile"].value,
|
|
132
|
+
"source": settings.values["profile"].source,
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
for name in SETTINGS:
|
|
136
|
+
resolved = settings.values[name]
|
|
137
|
+
shown = resolved.value
|
|
138
|
+
if name in SECRET and shown is not None:
|
|
139
|
+
shown = _mask(shown)
|
|
140
|
+
rows.append({"setting": name, "value": shown, "source": resolved.source})
|
|
141
|
+
return rows
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mask(token: str) -> str:
|
|
145
|
+
"""Enough of the token to tell two apart, not enough to use one."""
|
|
146
|
+
return f"{token[:11]}…" if len(token) > 14 else "…"
|
dh/rest.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The REST client for everything the SQL connector does not cover.
|
|
2
|
+
|
|
3
|
+
The connector owns sessions, statements, polling and row pagination. This covers
|
|
4
|
+
the other ~95 operations -- catalog, grants, schedules, lineage, semantic, admin --
|
|
5
|
+
and shares nothing with it but the host and token, because a DB-API driver has no
|
|
6
|
+
business modelling a catalog.
|
|
7
|
+
|
|
8
|
+
Two things it does that every hand-written `curl` invocation gets wrong: it joins
|
|
9
|
+
the `/api` mount exactly once, and it walks `cursor` to the end of a paged
|
|
10
|
+
collection instead of silently returning the first hundred rows.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from dh import __version__
|
|
21
|
+
from dh.errors import TimeoutError as DhTimeoutError
|
|
22
|
+
from dh.errors import UnavailableError, from_status
|
|
23
|
+
|
|
24
|
+
#: Read from the response before deciding it is an error, so a proxy's HTML page
|
|
25
|
+
#: still yields something to show the user.
|
|
26
|
+
_MAX_ERROR_BODY = 64 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RestClient:
|
|
30
|
+
"""A thin, synchronous client bound to one host and token."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
host: str,
|
|
35
|
+
token: str | None = None,
|
|
36
|
+
*,
|
|
37
|
+
timeout: float = 60.0,
|
|
38
|
+
verify: bool = True,
|
|
39
|
+
transport: httpx.BaseTransport | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.base_url = f"{host.rstrip('/')}/api"
|
|
42
|
+
headers = {
|
|
43
|
+
# Named so the server can attribute traffic; the session router
|
|
44
|
+
# records it per session.
|
|
45
|
+
"User-Agent": f"duckhaven-cli/{__version__}",
|
|
46
|
+
"Accept": "application/json",
|
|
47
|
+
}
|
|
48
|
+
# No token is a deliberate mode, not an oversight: `dh auth login` signs in
|
|
49
|
+
# with a password, holds the session cookie for three requests while it
|
|
50
|
+
# mints a PAT, and signs out. httpx keeps cookies on the client, so that
|
|
51
|
+
# whole exchange runs on one instance and the cookie never reaches disk.
|
|
52
|
+
if token:
|
|
53
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
54
|
+
self._client = httpx.Client(
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
verify=verify,
|
|
57
|
+
transport=transport,
|
|
58
|
+
headers=headers,
|
|
59
|
+
# A host given as `http://` behind a TLS-terminating proxy answers 301.
|
|
60
|
+
# Unfollowed that is a <400 response with no body, so the caller got
|
|
61
|
+
# `None` and tripped over it rather than reaching the API.
|
|
62
|
+
follow_redirects=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> RestClient:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(self, *_exc: object) -> None:
|
|
69
|
+
self.close()
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._client.close()
|
|
73
|
+
|
|
74
|
+
# --- Requests ----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def request(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str,
|
|
80
|
+
*,
|
|
81
|
+
params: dict[str, Any] | None = None,
|
|
82
|
+
json: Any = None,
|
|
83
|
+
content: bytes | None = None,
|
|
84
|
+
headers: dict[str, str] | None = None,
|
|
85
|
+
) -> Any:
|
|
86
|
+
"""Send one request and return its decoded body, or raise a ``DhError``.
|
|
87
|
+
|
|
88
|
+
``content`` sends bytes verbatim -- the semantic import route reads its
|
|
89
|
+
body raw so a hand-written YAML document arrives exactly as published, and
|
|
90
|
+
re-encoding it through ``json=`` would defeat that.
|
|
91
|
+
"""
|
|
92
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
93
|
+
try:
|
|
94
|
+
response = self._client.request(
|
|
95
|
+
method,
|
|
96
|
+
url,
|
|
97
|
+
params=_clean(params),
|
|
98
|
+
json=json,
|
|
99
|
+
content=content,
|
|
100
|
+
headers=headers,
|
|
101
|
+
)
|
|
102
|
+
except httpx.TimeoutException as exc:
|
|
103
|
+
raise DhTimeoutError("client_timeout", f"{method} {path} timed out: {exc}") from exc
|
|
104
|
+
except httpx.TransportError as exc:
|
|
105
|
+
raise UnavailableError(
|
|
106
|
+
"transport_error", f"Could not reach {self.base_url}: {exc}"
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
if response.status_code >= 400:
|
|
110
|
+
raise from_status(response.status_code, _body(response))
|
|
111
|
+
if response.status_code == 204 or not response.content:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
return response.json()
|
|
115
|
+
except ValueError:
|
|
116
|
+
# `/metrics` is Prometheus text and the assistant route streams SSE.
|
|
117
|
+
# Both are reachable only through `dh api`, so hand the body back
|
|
118
|
+
# rather than failing outside the error taxonomy it advertises.
|
|
119
|
+
return response.text
|
|
120
|
+
|
|
121
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
122
|
+
return self.request("GET", path, **kwargs)
|
|
123
|
+
|
|
124
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
125
|
+
return self.request("POST", path, **kwargs)
|
|
126
|
+
|
|
127
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
128
|
+
return self.request("PUT", path, **kwargs)
|
|
129
|
+
|
|
130
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
131
|
+
return self.request("PATCH", path, **kwargs)
|
|
132
|
+
|
|
133
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
134
|
+
return self.request("DELETE", path, **kwargs)
|
|
135
|
+
|
|
136
|
+
# --- Collections -------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def collect(
|
|
139
|
+
self,
|
|
140
|
+
path: str,
|
|
141
|
+
*,
|
|
142
|
+
params: dict[str, Any] | None = None,
|
|
143
|
+
limit: int | None = None,
|
|
144
|
+
) -> tuple[list[Any], str | None, bool]:
|
|
145
|
+
"""One page of a collection, whichever shape the endpoint returns.
|
|
146
|
+
|
|
147
|
+
DuckHaven's conventions define two: an unbounded collection answers
|
|
148
|
+
``{items, cursor, has_more}``, a bounded one a bare array. Which is which
|
|
149
|
+
is decided per endpoint and documented, but a caller should not have to
|
|
150
|
+
know, so this reports both as ``(rows, cursor, has_more)``.
|
|
151
|
+
"""
|
|
152
|
+
body = self.get(path, params={**(params or {}), **({"limit": limit} if limit else {})})
|
|
153
|
+
if isinstance(body, list):
|
|
154
|
+
return body, None, False
|
|
155
|
+
if isinstance(body, dict) and isinstance(body.get("items"), list):
|
|
156
|
+
return body["items"], body.get("cursor"), bool(body.get("has_more"))
|
|
157
|
+
# A single object where a collection was expected: report it as one row
|
|
158
|
+
# rather than raising, so a caller-facing command still shows something.
|
|
159
|
+
return [body], None, False
|
|
160
|
+
|
|
161
|
+
def walk(
|
|
162
|
+
self,
|
|
163
|
+
path: str,
|
|
164
|
+
*,
|
|
165
|
+
params: dict[str, Any] | None = None,
|
|
166
|
+
limit: int | None = None,
|
|
167
|
+
) -> Iterator[Any]:
|
|
168
|
+
"""Every row of a collection, following ``cursor`` to the end.
|
|
169
|
+
|
|
170
|
+
The truncation trap this exists to close: a single fetch of a paged
|
|
171
|
+
endpoint returns the first page and looks complete. A bare-array endpoint
|
|
172
|
+
has no cursor and simply yields once.
|
|
173
|
+
"""
|
|
174
|
+
cursor: str | None = None
|
|
175
|
+
while True:
|
|
176
|
+
page_params = dict(params or {})
|
|
177
|
+
if cursor:
|
|
178
|
+
page_params["cursor"] = cursor
|
|
179
|
+
rows, cursor, has_more = self.collect(path, params=page_params, limit=limit)
|
|
180
|
+
yield from rows
|
|
181
|
+
if not cursor or not has_more or not rows:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _clean(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
186
|
+
"""Drop unset parameters so an omitted filter is absent, not empty.
|
|
187
|
+
|
|
188
|
+
`status` and friends have no server-side default by design -- a caller who
|
|
189
|
+
omits one should get everything -- and sending `status=` would narrow it to
|
|
190
|
+
nothing instead.
|
|
191
|
+
"""
|
|
192
|
+
if not params:
|
|
193
|
+
return None
|
|
194
|
+
return {k: v for k, v in params.items() if v is not None and v != []}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _body(response: httpx.Response) -> object:
|
|
198
|
+
try:
|
|
199
|
+
return response.json()
|
|
200
|
+
except ValueError:
|
|
201
|
+
return response.text[:_MAX_ERROR_BODY]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: duckhaven-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: dh — the command-line interface for DuckHaven
|
|
5
|
+
Project-URL: Homepage, https://github.com/tamasmrtn/duckhaven-clients
|
|
6
|
+
Project-URL: Source, https://github.com/tamasmrtn/duckhaven-clients
|
|
7
|
+
Author: DuckHaven
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,duckdb,duckhaven,iceberg,lakehouse
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: duckhaven-sql-connector>=0.4.1
|
|
26
|
+
Requires-Dist: httpx>=0.28
|
|
27
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
28
|
+
Requires-Dist: typer>=0.15
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# duckhaven-cli
|
|
32
|
+
|
|
33
|
+
`dh` — the command-line interface for [DuckHaven](https://github.com/tamasmrtn/duckhaven).
|
|
34
|
+
|
|
35
|
+
It gives analysts, CI pipelines and operators scriptable access to DuckHaven's REST API:
|
|
36
|
+
running SQL, browsing the catalog, publishing dbt lineage and semantic models, managing
|
|
37
|
+
grants, and operating service accounts, users and agents.
|
|
38
|
+
|
|
39
|
+
Like every member of this repo it is a **pure HTTP client of DuckHaven's public REST
|
|
40
|
+
API**, authenticating with a Personal Access Token (`dh_pat_…`). The interactive REPL
|
|
41
|
+
holds a SQL session through
|
|
42
|
+
[`duckhaven-sql-connector`](../duckhaven-sql-connector/README.md); everything else,
|
|
43
|
+
including one-shot `dh sql`, goes over the REST API directly, because the one-shot query
|
|
44
|
+
route works on deployments where SQL sessions are switched off.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pip install duckhaven-cli
|
|
50
|
+
# or, to get an isolated tool install:
|
|
51
|
+
uv tool install duckhaven-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Getting started
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
dh auth login --host https://duckhaven.example.com
|
|
58
|
+
dh sql -q "select 1"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Documentation
|
|
62
|
+
|
|
63
|
+
- [Command-line quickstart](https://tamasmrtn.github.io/duckhaven/getting-started/cli-quickstart/) — install, sign in,
|
|
64
|
+
run a query
|
|
65
|
+
- [CLI reference](https://tamasmrtn.github.io/duckhaven/reference/cli/) — every command and flag
|
|
66
|
+
|
|
67
|
+
## Licence
|
|
68
|
+
|
|
69
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
dh/__init__.py,sha256=sTWxNIX959ccfy-L6TwSw2-nkD6JIQjhAvNf2KQumkY,826
|
|
2
|
+
dh/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
dh/config.py,sha256=yIcKPmYolr2vZ27mEtgT6J1dKLKFGOmyLE22UzGScgE,9397
|
|
4
|
+
dh/context.py,sha256=wcIIkWneSQ6hqvebdyNcIyY6cWMmnHmXVZ4c_vZ2ClI,6342
|
|
5
|
+
dh/errors.py,sha256=bvYYuyngNOVR5u8iwZY567XJ7FvrvnpwTTOMOdRnQvY,5957
|
|
6
|
+
dh/execute.py,sha256=qAcBpxeigv6zrD3f618I8hiEivwGPm5BlIbY_7-o2Gs,6906
|
|
7
|
+
dh/introspect.py,sha256=zPD0ubm7S29W6dCGvMEfnoCud2cH1B6kpD5jTHwqkIU,2830
|
|
8
|
+
dh/main.py,sha256=4zGO8KywL_adYKijOyA7CTLPdT78QqhAYPqeyK55c0Q,7837
|
|
9
|
+
dh/repl.py,sha256=IuPzthYLD24-3N1O_iMy3FqC3_evZ2kCVELhRI83NeU,6067
|
|
10
|
+
dh/resolve.py,sha256=TZ4dMdIyALHFDZz5JlDAmxwoINkvlBHCYzBY0Q5AePE,5005
|
|
11
|
+
dh/rest.py,sha256=Xr6cRGk59hS7Sn0k_P3xLAONDow9dzlLE0V8GugYKJU,7547
|
|
12
|
+
dh/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
dh/commands/admin.py,sha256=1eDxi2ry5KosPU5hkJqgkGxuLRWLW1_2GAA2QFwXw2k,12597
|
|
14
|
+
dh/commands/api.py,sha256=8RxwhMpluWaFOk5aWK1WtE5b_5DaUFUX7Bc82pAgWGs,2615
|
|
15
|
+
dh/commands/auth.py,sha256=p4_V3qglAqP4419EnfI77HUeva7dglednNZAmzW_96o,10643
|
|
16
|
+
dh/commands/catalog.py,sha256=KjS8_Zdn3-FA1pDcI7SNArw19eZ_KklRllMUsMeU6gg,15724
|
|
17
|
+
dh/commands/dbt.py,sha256=mdYacYmQJaj7eZa3K_ExB2qsZ5vvxh5RC4XgmSwJYUo,10305
|
|
18
|
+
dh/commands/grant.py,sha256=z1vSKBm86Omt9nG6lZM6cUiqqjDY2oNXAhzl9GHFsNc,5229
|
|
19
|
+
dh/commands/health.py,sha256=ZDcxMWGmkkA8pMfXIn9uuz42uNMSw4RPG9hsE_RHFKk,2515
|
|
20
|
+
dh/commands/profile.py,sha256=TMfUoIV0XomaMtDGj80Vmb0AvuOdfhBmziqxivv86DI,2466
|
|
21
|
+
dh/commands/query.py,sha256=qJEthxwd3u_Yt80HGXB7mccAD_Y05BB5IAcAGzd7ajU,8438
|
|
22
|
+
dh/commands/saved.py,sha256=9DpuikxiNrzzJ8DJWoGnQx4fJ0FxxDqSFBtQ2UEeG60,7658
|
|
23
|
+
dh/commands/session.py,sha256=bwPdoH9dgE9gQaOEhk2Gix5_ud4rOW8s5s4tnm8XwOE,7986
|
|
24
|
+
dh/output/__init__.py,sha256=pXDRpU0o2PKcoRKioAMH4OjfDG5oFB3QjXIzMj7nXZk,6017
|
|
25
|
+
duckhaven_cli-0.1.0.dist-info/METADATA,sha256=2Tk-LSpHkKjikzeTvF-lT3XNlQby_3AWXgEds677YnM,2466
|
|
26
|
+
duckhaven_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
27
|
+
duckhaven_cli-0.1.0.dist-info/entry_points.txt,sha256=b_2m18bQul2Qc2i9i18ZuRKkPx0WGrb1k5wbRED22Zc,35
|
|
28
|
+
duckhaven_cli-0.1.0.dist-info/licenses/LICENSE,sha256=YjVDAaUD-K0JDNSP-Yr2VgQ0cUmduwQ5iwpvrjKySNc,11339
|
|
29
|
+
duckhaven_cli-0.1.0.dist-info/RECORD,,
|