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/config.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The profile file: where it lives, how it is read, and how it is written.
|
|
2
|
+
|
|
3
|
+
A flat TOML document holding one table per profile:
|
|
4
|
+
|
|
5
|
+
default_profile = "prod"
|
|
6
|
+
|
|
7
|
+
[profile.prod]
|
|
8
|
+
host = "https://duckhaven.internal"
|
|
9
|
+
token = "dh_pat_..."
|
|
10
|
+
workspace = "analytics"
|
|
11
|
+
|
|
12
|
+
It holds a live credential, so the permission rules here are not advisory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from dh.errors import ConfigError
|
|
24
|
+
|
|
25
|
+
if sys.version_info >= (3, 11): # pragma: no cover - exercised by the 3.11+ matrix legs
|
|
26
|
+
import tomllib
|
|
27
|
+
else: # pragma: no cover - exercised by the 3.10 matrix leg
|
|
28
|
+
import tomli as tomllib
|
|
29
|
+
|
|
30
|
+
#: Every profile field, in the order they are written back out.
|
|
31
|
+
FIELDS = ("host", "token", "workspace", "catalog", "agent")
|
|
32
|
+
|
|
33
|
+
#: Mode for the file and its directory. The token is a bearer credential: anything
|
|
34
|
+
#: another account can read is a credential that account holds too.
|
|
35
|
+
_FILE_MODE = 0o600
|
|
36
|
+
_DIR_MODE = 0o700
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Profile:
|
|
41
|
+
name: str
|
|
42
|
+
host: str | None = None
|
|
43
|
+
token: str | None = None
|
|
44
|
+
workspace: str | None = None
|
|
45
|
+
catalog: str | None = None
|
|
46
|
+
agent: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Config:
|
|
51
|
+
path: Path
|
|
52
|
+
default_profile: str | None = None
|
|
53
|
+
profiles: dict[str, Profile] = None # type: ignore[assignment]
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.profiles is None:
|
|
57
|
+
object.__setattr__(self, "profiles", {})
|
|
58
|
+
|
|
59
|
+
def get(self, name: str) -> Profile | None:
|
|
60
|
+
return self.profiles.get(name)
|
|
61
|
+
|
|
62
|
+
def resolved_default(self) -> str | None:
|
|
63
|
+
"""The profile used when none is named.
|
|
64
|
+
|
|
65
|
+
``default_profile`` when it points at one that exists, else a profile
|
|
66
|
+
literally called ``default``, else the only profile there is -- a config
|
|
67
|
+
with exactly one profile should not need to name it.
|
|
68
|
+
"""
|
|
69
|
+
if self.default_profile and self.default_profile in self.profiles:
|
|
70
|
+
return self.default_profile
|
|
71
|
+
if "default" in self.profiles:
|
|
72
|
+
return "default"
|
|
73
|
+
if len(self.profiles) == 1:
|
|
74
|
+
return next(iter(self.profiles))
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def config_path() -> Path:
|
|
79
|
+
"""Where the profile file lives.
|
|
80
|
+
|
|
81
|
+
``DH_CONFIG_FILE`` wins, so a test or a CI job can point somewhere private
|
|
82
|
+
without touching the user's own. Otherwise XDG, then its documented default.
|
|
83
|
+
"""
|
|
84
|
+
override = os.environ.get("DH_CONFIG_FILE")
|
|
85
|
+
if override:
|
|
86
|
+
return Path(override).expanduser()
|
|
87
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
88
|
+
return Path(base).expanduser() / "duckhaven" / "config.toml"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def assert_permissions(path: Path) -> None:
|
|
92
|
+
"""Refuse a config that anyone but its owner can read or write.
|
|
93
|
+
|
|
94
|
+
Always, with no env flag to relax it. A warning about a world-readable token is
|
|
95
|
+
a warning nobody reads; the file is not usable, so the CLI treats it that way
|
|
96
|
+
and puts the fix in the message.
|
|
97
|
+
"""
|
|
98
|
+
if os.name == "nt": # pragma: no cover - POSIX modes do not apply on Windows
|
|
99
|
+
return
|
|
100
|
+
mode = path.stat().st_mode
|
|
101
|
+
if not mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
102
|
+
return
|
|
103
|
+
raise ConfigError(
|
|
104
|
+
"config_permissions",
|
|
105
|
+
f"{path} is readable or writable by other users and holds an access token. "
|
|
106
|
+
f"Fix it with: chmod 600 {path}",
|
|
107
|
+
{"path": str(path), "mode": oct(stat.S_IMODE(mode))},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load(path: Path | None = None) -> Config:
|
|
112
|
+
"""Read the profile file. A missing file is an empty config, not an error.
|
|
113
|
+
|
|
114
|
+
Nothing has gone wrong the first time someone runs `dh`; the commands that
|
|
115
|
+
need a profile say so themselves, with something more useful than "no such
|
|
116
|
+
file".
|
|
117
|
+
"""
|
|
118
|
+
path = path or config_path()
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return Config(path=path)
|
|
121
|
+
assert_permissions(path)
|
|
122
|
+
try:
|
|
123
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
125
|
+
raise ConfigError("config_invalid", f"{path} is not valid TOML: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
profiles: dict[str, Profile] = {}
|
|
128
|
+
for name, table in (raw.get("profile") or {}).items():
|
|
129
|
+
if not isinstance(table, dict):
|
|
130
|
+
raise ConfigError("config_invalid", f"{path}: [profile.{name}] is not a table")
|
|
131
|
+
profiles[name] = Profile(name=name, **{f: _as_str(table.get(f), name, f) for f in FIELDS})
|
|
132
|
+
default = raw.get("default_profile")
|
|
133
|
+
return Config(
|
|
134
|
+
path=path,
|
|
135
|
+
default_profile=str(default) if default is not None else None,
|
|
136
|
+
profiles=profiles,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _as_str(value: object, profile: str, field: str) -> str | None:
|
|
141
|
+
if value is None:
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(value, str):
|
|
144
|
+
raise ConfigError(
|
|
145
|
+
"config_invalid",
|
|
146
|
+
f"[profile.{profile}].{field} must be a string, got {type(value).__name__}",
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save(config: Config) -> None:
|
|
152
|
+
"""Write the profile file back, creating it 0600 from the outset.
|
|
153
|
+
|
|
154
|
+
The mode is passed to ``os.open`` rather than applied with a later ``chmod``:
|
|
155
|
+
between the two there is a window in which the token is world-readable, and a
|
|
156
|
+
window is all an attacker on a shared box needs.
|
|
157
|
+
"""
|
|
158
|
+
path = config.path
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_MODE)
|
|
160
|
+
# Write beside the target and rename over it. Truncating in place means a
|
|
161
|
+
# crash, a full disk or an interrupt between truncate and write destroys
|
|
162
|
+
# every profile and the tokens with them; `os.replace` is atomic on POSIX and
|
|
163
|
+
# Windows alike, so the file is either the old one or the new one.
|
|
164
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
165
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _FILE_MODE)
|
|
166
|
+
try:
|
|
167
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
168
|
+
handle.write(dumps(config))
|
|
169
|
+
handle.flush()
|
|
170
|
+
os.fsync(handle.fileno())
|
|
171
|
+
if os.name != "nt": # pragma: no branch - POSIX modes do not apply on Windows
|
|
172
|
+
os.chmod(tmp, _FILE_MODE)
|
|
173
|
+
os.replace(tmp, path)
|
|
174
|
+
except BaseException:
|
|
175
|
+
tmp.unlink(missing_ok=True)
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def dumps(config: Config) -> str:
|
|
180
|
+
"""Render the config as TOML.
|
|
181
|
+
|
|
182
|
+
Hand-rolled rather than pulling in a writer: the document is a flat set of
|
|
183
|
+
string tables, and the whole grammar this needs is a quoted string.
|
|
184
|
+
"""
|
|
185
|
+
lines: list[str] = []
|
|
186
|
+
default = config.default_profile
|
|
187
|
+
if default:
|
|
188
|
+
lines.append(f"default_profile = {_quote(default)}")
|
|
189
|
+
lines.append("")
|
|
190
|
+
for name in sorted(config.profiles):
|
|
191
|
+
profile = config.profiles[name]
|
|
192
|
+
lines.append(f"[profile.{_key(name)}]")
|
|
193
|
+
for field in FIELDS:
|
|
194
|
+
value = getattr(profile, field)
|
|
195
|
+
if value is not None:
|
|
196
|
+
lines.append(f"{field} = {_quote(value)}")
|
|
197
|
+
lines.append("")
|
|
198
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
#: The escapes TOML spells out; everything else in C0/DEL becomes \\uXXXX.
|
|
202
|
+
_SHORT_ESCAPES = {
|
|
203
|
+
"\\": "\\\\",
|
|
204
|
+
'"': '\\"',
|
|
205
|
+
"\n": "\\n",
|
|
206
|
+
"\r": "\\r",
|
|
207
|
+
"\t": "\\t",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_BARE_KEY = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _key(name: str) -> str:
|
|
214
|
+
"""A bare key where TOML allows one, a quoted key otherwise."""
|
|
215
|
+
return name if name and set(name) <= _BARE_KEY else _quote(name)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
#: The escapes TOML spells out. Everything else in C0/DEL becomes \uXXXX.
|
|
219
|
+
_SHORT_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _quote(value: str) -> str:
|
|
223
|
+
"""A TOML basic string.
|
|
224
|
+
|
|
225
|
+
TOML forbids raw U+0000-U+0008, U+000B-U+001F and U+007F in a basic string,
|
|
226
|
+
so anything in those ranges is escaped rather than written through. Emitting
|
|
227
|
+
one produced a file `tomllib` then refused to read -- and because `save`
|
|
228
|
+
replaces the whole file, every later `dh` invocation failed with
|
|
229
|
+
`config_invalid` until someone hand-edited it.
|
|
230
|
+
"""
|
|
231
|
+
out = []
|
|
232
|
+
for char in value:
|
|
233
|
+
if char in _SHORT_ESCAPES:
|
|
234
|
+
out.append(_SHORT_ESCAPES[char])
|
|
235
|
+
elif char < " " or char == "\x7f":
|
|
236
|
+
out.append(f"\\u{ord(char):04X}")
|
|
237
|
+
else:
|
|
238
|
+
out.append(char)
|
|
239
|
+
return '"' + "".join(out) + '"'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def upsert(config: Config, profile: Profile) -> Config:
|
|
243
|
+
"""Add or replace a profile, making it the default when there was none."""
|
|
244
|
+
profiles = dict(config.profiles)
|
|
245
|
+
profiles[profile.name] = profile
|
|
246
|
+
default = config.default_profile or profile.name
|
|
247
|
+
return replace(config, profiles=profiles, default_profile=default)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def set_default(config: Config, name: str) -> Config:
|
|
251
|
+
"""Point the default at an existing profile."""
|
|
252
|
+
if name not in config.profiles:
|
|
253
|
+
raise ConfigError("no_such_profile", f"No profile named {name!r} in {config.path}")
|
|
254
|
+
return replace(config, default_profile=name)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def remove(config: Config, name: str) -> Config:
|
|
258
|
+
"""Drop a profile, and the default pointer with it when it named that one."""
|
|
259
|
+
if name not in config.profiles:
|
|
260
|
+
raise ConfigError("no_such_profile", f"No profile named {name!r} in {config.path}")
|
|
261
|
+
profiles = {k: v for k, v in config.profiles.items() if k != name}
|
|
262
|
+
default = None if config.default_profile == name else config.default_profile
|
|
263
|
+
return replace(config, profiles=profiles, default_profile=default)
|
dh/context.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Per-invocation state: the global flags, resolved settings, and how to emit.
|
|
2
|
+
|
|
3
|
+
Lives apart from ``dh.main`` so command modules can depend on it without importing
|
|
4
|
+
the app they are registered on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from dh import config as config_mod
|
|
19
|
+
from dh.errors import Aborted, DhError
|
|
20
|
+
from dh.output import Format, color_enabled, default_format, render
|
|
21
|
+
from dh.resolve import Settings, resolve
|
|
22
|
+
from dh.rest import RestClient
|
|
23
|
+
|
|
24
|
+
#: Ways a confirmation prompt can end without an answer. Both classes because
|
|
25
|
+
#: typer vendors its own click and `typer.confirm` raises the vendored `Abort`.
|
|
26
|
+
_DECLINED = (EOFError, click.Abort, typer.Abort)
|
|
27
|
+
|
|
28
|
+
#: The opt-out on every destructive command. One spelling everywhere, so nobody
|
|
29
|
+
#: has to remember which command wanted `--force` instead.
|
|
30
|
+
YES = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _interactive() -> bool:
|
|
34
|
+
"""Whether there is a person here to answer a prompt.
|
|
35
|
+
|
|
36
|
+
Its own function because Click's test runner installs a non-tty stdin, so this
|
|
37
|
+
is the seam a test flips to exercise the branch where someone is asked.
|
|
38
|
+
"""
|
|
39
|
+
return sys.stdin.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CliContext:
|
|
44
|
+
overrides: dict[str, str | None] = field(default_factory=dict)
|
|
45
|
+
fmt: Format | None = None
|
|
46
|
+
output: Path | None = None
|
|
47
|
+
quiet: bool = False
|
|
48
|
+
no_color: bool = False
|
|
49
|
+
debug_enabled: bool = False
|
|
50
|
+
|
|
51
|
+
def format(self) -> Format:
|
|
52
|
+
"""The chosen format, or the TTY-derived default."""
|
|
53
|
+
return self.fmt or default_format(sys.stdout)
|
|
54
|
+
|
|
55
|
+
def settings(self) -> Settings:
|
|
56
|
+
"""Apply the precedence chain, reading the profile file once per command."""
|
|
57
|
+
return resolve(config_mod.load(), dict(os.environ), self.overrides)
|
|
58
|
+
|
|
59
|
+
def emit(self, data: Any, *, cursor: str | None = None, has_more: bool = False) -> None:
|
|
60
|
+
"""Write a payload to stdout, or to ``--output`` when one was given."""
|
|
61
|
+
fmt = self.format()
|
|
62
|
+
# A file gets no escape codes regardless of where stdout points; rendering
|
|
63
|
+
# once with the right colour setting avoids doing the work twice.
|
|
64
|
+
to_file = self.output is not None
|
|
65
|
+
body = render(
|
|
66
|
+
data,
|
|
67
|
+
fmt,
|
|
68
|
+
cursor=cursor,
|
|
69
|
+
has_more=has_more,
|
|
70
|
+
color=False if to_file else color_enabled(sys.stdout, no_color=self.no_color),
|
|
71
|
+
)
|
|
72
|
+
if to_file:
|
|
73
|
+
self.output.write_text(body + "\n", encoding="utf-8")
|
|
74
|
+
return
|
|
75
|
+
if body:
|
|
76
|
+
sys.stdout.write(body + "\n")
|
|
77
|
+
|
|
78
|
+
def client(self, settings: Settings | None = None) -> RestClient:
|
|
79
|
+
"""A REST client for the resolved host and token.
|
|
80
|
+
|
|
81
|
+
Both are required here rather than defaulted, so a command fails with
|
|
82
|
+
"no host configured, set it with ..." instead of a connection error
|
|
83
|
+
against nothing.
|
|
84
|
+
"""
|
|
85
|
+
settings = settings or self.settings()
|
|
86
|
+
return RestClient(settings.require("host"), settings.require("token"))
|
|
87
|
+
|
|
88
|
+
def debug(self, message: str) -> None:
|
|
89
|
+
"""A trace line, shown only under `--debug`.
|
|
90
|
+
|
|
91
|
+
Which catalog was resolved belongs here: a wrong default reads the wrong
|
|
92
|
+
data silently, and this is how someone finds out which one was used.
|
|
93
|
+
"""
|
|
94
|
+
if self.debug_enabled:
|
|
95
|
+
typer.echo(f"[dh] {message}", err=True)
|
|
96
|
+
|
|
97
|
+
def page(
|
|
98
|
+
self,
|
|
99
|
+
client,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
params: dict[str, Any] | None = None,
|
|
103
|
+
limit: int | None = None,
|
|
104
|
+
fetch_all: bool = False,
|
|
105
|
+
translate=None,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Emit one page of a collection, or every page under ``--all``.
|
|
108
|
+
|
|
109
|
+
The same six lines appeared in every list command. ``translate`` lets a
|
|
110
|
+
caller reshape an error before it surfaces -- the session routes use it to
|
|
111
|
+
turn the disabled-surface 404 into something actionable.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
if fetch_all:
|
|
115
|
+
self.emit(list(client.walk(path, params=params, limit=limit)))
|
|
116
|
+
return
|
|
117
|
+
rows, cursor, has_more = client.collect(path, params=params, limit=limit)
|
|
118
|
+
except DhError as exc:
|
|
119
|
+
raise (translate(exc) if translate else exc) from exc
|
|
120
|
+
self.emit(rows, cursor=cursor, has_more=has_more)
|
|
121
|
+
|
|
122
|
+
def note(self, message: str) -> None:
|
|
123
|
+
"""A diagnostic for a person, on stderr. Silenced by ``-q``.
|
|
124
|
+
|
|
125
|
+
Never stdout: a progress line in the payload stream is what makes a CLI
|
|
126
|
+
unpipeable.
|
|
127
|
+
"""
|
|
128
|
+
if not self.quiet:
|
|
129
|
+
typer.echo(message, err=True)
|
|
130
|
+
|
|
131
|
+
def confirm(self, action: str, target: str, *, yes: bool) -> None:
|
|
132
|
+
"""Stop a destructive command that nobody agreed to.
|
|
133
|
+
|
|
134
|
+
Three outcomes, and the middle one is the point. With ``--yes`` it returns.
|
|
135
|
+
At a terminal it asks. With neither -- a pipeline, a cron job, a CI step --
|
|
136
|
+
it *refuses*, because the alternatives are both wrong: prompting hangs the
|
|
137
|
+
job on a question no one will answer, and proceeding anyway makes ``--yes``
|
|
138
|
+
decorative and lets an unattended `dh table drop` delete data by accident.
|
|
139
|
+
|
|
140
|
+
Not silenced by ``-q``. A prompt is not a diagnostic; suppressing it would
|
|
141
|
+
turn `dh -q workspace delete` into the exact unattended deletion this
|
|
142
|
+
exists to prevent.
|
|
143
|
+
"""
|
|
144
|
+
if yes:
|
|
145
|
+
return
|
|
146
|
+
if not _interactive():
|
|
147
|
+
raise Aborted(
|
|
148
|
+
"confirmation_required",
|
|
149
|
+
f"{action} {target}: refusing to act without confirmation. "
|
|
150
|
+
"Pass --yes to confirm, or run this from a terminal.",
|
|
151
|
+
{"action": action, "target": target},
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
agreed = typer.confirm(f"{action} {target}?", default=False, err=True)
|
|
155
|
+
except _DECLINED:
|
|
156
|
+
# Ctrl-C or Ctrl-D at "are you sure?" means no, not "crash".
|
|
157
|
+
agreed = False
|
|
158
|
+
if not agreed:
|
|
159
|
+
raise Aborted("aborted", f"Not confirmed; {target} was left alone.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def of(ctx: typer.Context) -> CliContext:
|
|
163
|
+
"""The CliContext for this invocation, creating one if the callback was skipped."""
|
|
164
|
+
if not isinstance(ctx.obj, CliContext):
|
|
165
|
+
ctx.obj = CliContext()
|
|
166
|
+
return ctx.obj
|
dh/errors.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Failures, and the exit status each one leaves behind.
|
|
2
|
+
|
|
3
|
+
Every error the CLI surfaces is a :class:`DhError` carrying the same three fields
|
|
4
|
+
DuckHaven's own error envelope uses -- ``error``, ``message``, ``details`` -- so a
|
|
5
|
+
caller parses one shape whether the failure happened locally or on the server.
|
|
6
|
+
|
|
7
|
+
Exit codes are differentiated by default rather than hidden behind a flag. The one
|
|
8
|
+
that earns its keep is :attr:`ExitCode.QUERY_FAILED`: it separates "the SQL you sent
|
|
9
|
+
was wrong" from "the CLI or the server broke".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExitCode(IntEnum):
|
|
19
|
+
OK = 0
|
|
20
|
+
#: An unhandled exception or a response the CLI could not make sense of.
|
|
21
|
+
FAILURE = 1
|
|
22
|
+
#: Bad flags or arguments. Typer exits with this on its own.
|
|
23
|
+
USAGE = 2
|
|
24
|
+
#: 401/403, a missing or rejected credential, an unreadable config.
|
|
25
|
+
AUTH = 3
|
|
26
|
+
NOT_FOUND = 4
|
|
27
|
+
#: 409/410/422 -- the request was understood and refused.
|
|
28
|
+
CONFLICT = 5
|
|
29
|
+
#: The query ran and reached `failed`. The CLI worked; the SQL did not.
|
|
30
|
+
QUERY_FAILED = 6
|
|
31
|
+
TIMEOUT = 7
|
|
32
|
+
#: 503, connection refused, DNS failure.
|
|
33
|
+
UNAVAILABLE = 8
|
|
34
|
+
#: A destructive command was not confirmed, so nothing was done.
|
|
35
|
+
ABORTED = 9
|
|
36
|
+
#: Conventional for SIGINT. The server-side query is cancelled first.
|
|
37
|
+
INTERRUPTED = 130
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DhError(Exception):
|
|
41
|
+
"""A failure with a machine code, a displayable message, and an exit status."""
|
|
42
|
+
|
|
43
|
+
exit_code: ExitCode = ExitCode.FAILURE
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
code: str,
|
|
48
|
+
message: str,
|
|
49
|
+
details: dict[str, Any] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
53
|
+
self.message = message
|
|
54
|
+
self.details = details
|
|
55
|
+
|
|
56
|
+
def envelope(self) -> dict[str, Any]:
|
|
57
|
+
"""The wire form, identical in shape to the server's own error body."""
|
|
58
|
+
return {"error": self.code, "message": self.message, "details": self.details}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ConfigError(DhError):
|
|
62
|
+
"""The profile file is missing, malformed, or too widely readable.
|
|
63
|
+
|
|
64
|
+
Grouped under ``AUTH`` because from the caller's side it is the same problem as
|
|
65
|
+
a rejected token: the CLI has no usable credential.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
exit_code = ExitCode.AUTH
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AuthError(DhError):
|
|
72
|
+
exit_code = ExitCode.AUTH
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NotFoundError(DhError):
|
|
76
|
+
exit_code = ExitCode.NOT_FOUND
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ConflictError(DhError):
|
|
80
|
+
"""Understood and refused: 409, 410, 422, and other 4xx."""
|
|
81
|
+
|
|
82
|
+
exit_code = ExitCode.CONFLICT
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TimeoutError(DhError): # noqa: A001 - the CLI's timeout, not the builtin
|
|
86
|
+
exit_code = ExitCode.TIMEOUT
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UnavailableError(DhError):
|
|
90
|
+
exit_code = ExitCode.UNAVAILABLE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class QueryFailed(DhError):
|
|
94
|
+
"""The query ran and reached ``failed``. The CLI worked; the SQL did not."""
|
|
95
|
+
|
|
96
|
+
exit_code = ExitCode.QUERY_FAILED
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Aborted(DhError):
|
|
100
|
+
"""A destructive command was declined, or could not ask. Nothing was done.
|
|
101
|
+
|
|
102
|
+
Its own status rather than a reuse of ``CONFLICT``: a pipeline needs to tell
|
|
103
|
+
"the server refused this" from "I never sent it", and the second is not a
|
|
104
|
+
failure of anything but the invocation.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
exit_code = ExitCode.ABORTED
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: HTTP status to the class that carries the right exit code.
|
|
111
|
+
#:
|
|
112
|
+
#: 5xx other than the three below stays ``FAILURE``: a 500 is the server breaking
|
|
113
|
+
#: unexpectedly, which is not the same as it being unavailable, and telling a
|
|
114
|
+
#: pipeline to retry a crash wastes its time.
|
|
115
|
+
_BY_STATUS: dict[int, type[DhError]] = {
|
|
116
|
+
401: AuthError,
|
|
117
|
+
403: AuthError,
|
|
118
|
+
404: NotFoundError,
|
|
119
|
+
408: TimeoutError,
|
|
120
|
+
429: UnavailableError,
|
|
121
|
+
502: UnavailableError,
|
|
122
|
+
503: UnavailableError,
|
|
123
|
+
504: TimeoutError,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _class_for(status: int) -> type[DhError]:
|
|
128
|
+
if status in _BY_STATUS:
|
|
129
|
+
return _BY_STATUS[status]
|
|
130
|
+
if 400 <= status < 500:
|
|
131
|
+
return ConflictError
|
|
132
|
+
return DhError
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_envelope(body: object, status: int) -> tuple[str, str, dict[str, Any] | None]:
|
|
136
|
+
"""Pull ``(error, message, details)` out of a response body.
|
|
137
|
+
|
|
138
|
+
DuckHaven answers every 4xx and 5xx with that envelope, including crashes, so
|
|
139
|
+
this normally just reads three keys. It still degrades gracefully: a proxy's
|
|
140
|
+
HTML error page or a truncated body yields the status line rather than a
|
|
141
|
+
parse failure, because the one moment a caller most needs a message is when
|
|
142
|
+
something upstream is misbehaving.
|
|
143
|
+
"""
|
|
144
|
+
if isinstance(body, dict) and "error" in body:
|
|
145
|
+
code = str(body.get("error"))
|
|
146
|
+
message = str(body.get("message") or body.get("detail") or code)
|
|
147
|
+
details = body.get("details")
|
|
148
|
+
return code, message, details if isinstance(details, dict) else None
|
|
149
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), str):
|
|
150
|
+
# api_version 1 shape, still emitted by servers predating the envelope.
|
|
151
|
+
return f"http_{status}", body["detail"], None
|
|
152
|
+
text = body if isinstance(body, str) else ""
|
|
153
|
+
return f"http_{status}", text.strip() or f"HTTP {status}", None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def from_status(status: int, body: object) -> DhError:
|
|
157
|
+
"""Build the right error for an HTTP failure."""
|
|
158
|
+
code, message, details = parse_envelope(body, status)
|
|
159
|
+
return _class_for(status)(code, message, details)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def from_connector(exc: Exception) -> DhError:
|
|
163
|
+
"""Translate a ``duckhaven-sql-connector`` DB-API exception.
|
|
164
|
+
|
|
165
|
+
The connector preserves the server's slug and status on the exception it
|
|
166
|
+
raises, so the CLI re-derives the same exit code it would have chosen from the
|
|
167
|
+
response itself rather than flattening every SQL failure to 1.
|
|
168
|
+
"""
|
|
169
|
+
status = getattr(exc, "status_code", None)
|
|
170
|
+
code = getattr(exc, "code", None)
|
|
171
|
+
message = getattr(exc, "detail", None) or str(exc)
|
|
172
|
+
if status is None:
|
|
173
|
+
# A transport failure: no response was ever received.
|
|
174
|
+
return UnavailableError(code or "transport_error", message)
|
|
175
|
+
return _class_for(int(status))(code or f"http_{status}", message)
|