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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dh = dh.main:run