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