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/commands/query.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""`dh sql` and `dh query` — running SQL and working with the runs.
|
|
2
|
+
|
|
3
|
+
`dh sql` is the one deliberately verb-first command in the tree.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from dh import context, execute, repl
|
|
13
|
+
from dh.errors import ConflictError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="query", help="Run SQL and inspect past runs.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_sql(query: str | None, file: typer.FileText | None, stdin: bool) -> str:
|
|
19
|
+
"""The SQL, from exactly one of the three ways of supplying it."""
|
|
20
|
+
given = [source for source in (query, file, stdin or None) if source]
|
|
21
|
+
if len(given) != 1:
|
|
22
|
+
raise ConflictError(
|
|
23
|
+
"no_sql",
|
|
24
|
+
"Give the SQL exactly one way: --query, --file, or --stdin.",
|
|
25
|
+
)
|
|
26
|
+
if query:
|
|
27
|
+
return query
|
|
28
|
+
if file:
|
|
29
|
+
return file.read()
|
|
30
|
+
return sys.stdin.read()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
*,
|
|
36
|
+
query: str | None,
|
|
37
|
+
file: typer.FileText | None,
|
|
38
|
+
stdin: bool,
|
|
39
|
+
no_wait: bool,
|
|
40
|
+
timeout: str,
|
|
41
|
+
limit: int | None,
|
|
42
|
+
fetch_all: bool,
|
|
43
|
+
agent: str | None,
|
|
44
|
+
) -> None:
|
|
45
|
+
cli = context.of(ctx)
|
|
46
|
+
settings = cli.settings()
|
|
47
|
+
workspace = settings.require("workspace")
|
|
48
|
+
if not (query or file or stdin) and repl.is_interactive():
|
|
49
|
+
# No SQL and a terminal: the user wants a shell, not a usage error.
|
|
50
|
+
raise typer.Exit(repl.run(cli, settings, agent=agent))
|
|
51
|
+
sql = _read_sql(query, file, stdin)
|
|
52
|
+
budget = execute.parse_duration(timeout)
|
|
53
|
+
|
|
54
|
+
with cli.client(settings) as client:
|
|
55
|
+
chosen = execute.resolve_agent(client, agent or settings.get("agent"))
|
|
56
|
+
submitted = execute.submit(
|
|
57
|
+
client,
|
|
58
|
+
workspace,
|
|
59
|
+
sql,
|
|
60
|
+
agent=chosen,
|
|
61
|
+
catalog=settings.get("catalog"),
|
|
62
|
+
# The same budget bounds the client's wait and the server's execution.
|
|
63
|
+
# Two timeouts would be a contract people get wrong; one cannot drift.
|
|
64
|
+
timeout_s=budget,
|
|
65
|
+
)
|
|
66
|
+
if no_wait:
|
|
67
|
+
cli.note(f"Submitted {submitted['id']}.")
|
|
68
|
+
cli.emit(submitted)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
finished = execute.wait(client, submitted["id"], timeout=budget)
|
|
72
|
+
execute.raise_for_status(finished)
|
|
73
|
+
page = execute.fetch_rows(client, finished["id"], limit=None if fetch_all else limit)
|
|
74
|
+
|
|
75
|
+
if page["truncated"]:
|
|
76
|
+
cli.note(f"Showing {len(page['rows'])} of {page['total']} rows. Use --all for the rest.")
|
|
77
|
+
cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_QUERY = typer.Option(None, "--query", "-q", help="SQL to run.")
|
|
81
|
+
_FILE = typer.Option(None, "--file", "-f", help="Read the SQL from a file.")
|
|
82
|
+
_STDIN = typer.Option(False, "--stdin", "-i", help="Read the SQL from standard input.")
|
|
83
|
+
_NO_WAIT = typer.Option(False, "--no-wait", help="Print the query id and exit without waiting.")
|
|
84
|
+
_TIMEOUT = typer.Option("20m", "--timeout", help="Give up after this long, e.g. 30s, 20m, 1h.")
|
|
85
|
+
_LIMIT = typer.Option(None, "--limit", help="Stop after this many rows.")
|
|
86
|
+
_ALL = typer.Option(False, "--all", help="Fetch every row, ignoring --limit.")
|
|
87
|
+
_AGENT = typer.Option(None, "--agent", help="Agent name or id to run on.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sql(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
query: str = _QUERY,
|
|
93
|
+
file: typer.FileText = _FILE,
|
|
94
|
+
stdin: bool = _STDIN,
|
|
95
|
+
no_wait: bool = _NO_WAIT,
|
|
96
|
+
timeout: str = _TIMEOUT,
|
|
97
|
+
limit: int = _LIMIT,
|
|
98
|
+
fetch_all: bool = _ALL,
|
|
99
|
+
agent: str = _AGENT,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Run SQL and print the results.
|
|
102
|
+
|
|
103
|
+
Submits to the workspace's one-shot query route, waits for the run to reach a
|
|
104
|
+
terminal state, then fetches every page of results. Ctrl-C cancels the query
|
|
105
|
+
on the server rather than orphaning it.
|
|
106
|
+
|
|
107
|
+
A query that runs and fails exits 6, so a pipeline can tell bad SQL from a
|
|
108
|
+
broken CLI or an unreachable server.
|
|
109
|
+
"""
|
|
110
|
+
_run(
|
|
111
|
+
ctx,
|
|
112
|
+
query=query,
|
|
113
|
+
file=file,
|
|
114
|
+
stdin=stdin,
|
|
115
|
+
no_wait=no_wait,
|
|
116
|
+
timeout=timeout,
|
|
117
|
+
limit=limit,
|
|
118
|
+
fetch_all=fetch_all,
|
|
119
|
+
agent=agent,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("run")
|
|
124
|
+
def run_query(
|
|
125
|
+
ctx: typer.Context,
|
|
126
|
+
query: str = _QUERY,
|
|
127
|
+
file: typer.FileText = _FILE,
|
|
128
|
+
stdin: bool = _STDIN,
|
|
129
|
+
no_wait: bool = _NO_WAIT,
|
|
130
|
+
timeout: str = _TIMEOUT,
|
|
131
|
+
limit: int = _LIMIT,
|
|
132
|
+
fetch_all: bool = _ALL,
|
|
133
|
+
agent: str = _AGENT,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Run SQL and print the results. The noun-first spelling of `dh sql`."""
|
|
136
|
+
_run(
|
|
137
|
+
ctx,
|
|
138
|
+
query=query,
|
|
139
|
+
file=file,
|
|
140
|
+
stdin=stdin,
|
|
141
|
+
no_wait=no_wait,
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
limit=limit,
|
|
144
|
+
fetch_all=fetch_all,
|
|
145
|
+
agent=agent,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("get")
|
|
150
|
+
def get_query(ctx: typer.Context, query_id: str) -> None:
|
|
151
|
+
"""One run's status, timings and error."""
|
|
152
|
+
cli = context.of(ctx)
|
|
153
|
+
with cli.client() as client:
|
|
154
|
+
cli.emit(client.get(f"queries/{query_id}"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("rows")
|
|
158
|
+
def query_rows(
|
|
159
|
+
ctx: typer.Context,
|
|
160
|
+
query_id: str,
|
|
161
|
+
limit: int = _LIMIT,
|
|
162
|
+
fetch_all: bool = _ALL,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""The results of a finished run, following the cursor to the end."""
|
|
165
|
+
cli = context.of(ctx)
|
|
166
|
+
with cli.client() as client:
|
|
167
|
+
page = execute.fetch_rows(client, query_id, limit=None if fetch_all else limit)
|
|
168
|
+
if page["truncated"]:
|
|
169
|
+
cli.note(f"Showing {len(page['rows'])} of {page['total']} rows. Use --all for the rest.")
|
|
170
|
+
cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command("profile")
|
|
174
|
+
def query_profile(ctx: typer.Context, query_id: str) -> None:
|
|
175
|
+
"""The execution profile captured for a finished run, if there was one."""
|
|
176
|
+
cli = context.of(ctx)
|
|
177
|
+
with cli.client() as client:
|
|
178
|
+
cli.emit(client.get(f"queries/{query_id}/profile"))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command("cancel")
|
|
182
|
+
def cancel_query(ctx: typer.Context, query_id: str) -> None:
|
|
183
|
+
"""Ask the agent to stop a running query.
|
|
184
|
+
|
|
185
|
+
Idempotent: cancelling a finished query succeeds and changes nothing.
|
|
186
|
+
"""
|
|
187
|
+
cli = context.of(ctx)
|
|
188
|
+
with cli.client() as client:
|
|
189
|
+
client.delete(f"queries/{query_id}")
|
|
190
|
+
cli.note(f"Cancelled {query_id}.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command("list")
|
|
194
|
+
def list_queries(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
status: list[str] = typer.Option(
|
|
197
|
+
None, "--status", help="Repeatable: queued, running, done, failed, cancelled."
|
|
198
|
+
),
|
|
199
|
+
statement_type: list[str] = typer.Option(None, "--statement-type", help="Repeatable."),
|
|
200
|
+
since: str = typer.Option(None, "--since", help="ISO-8601 lower bound on start time."),
|
|
201
|
+
until: str = typer.Option(None, "--until", help="ISO-8601 upper bound on start time."),
|
|
202
|
+
origin: str = typer.Option(None, "--origin", help="interactive, session, schedule, elastic."),
|
|
203
|
+
session: str = typer.Option(None, "--session", help="Only statements from this session."),
|
|
204
|
+
agent: str = typer.Option(None, "--agent", help="Only runs on this agent id."),
|
|
205
|
+
user: str = typer.Option(None, "--user", help="Only this user's runs (admin for others)."),
|
|
206
|
+
search: str = typer.Option(None, "--search", "-q", help="Free text over the SQL."),
|
|
207
|
+
slower_than: int = typer.Option(None, "--slower-than", help="Milliseconds."),
|
|
208
|
+
sort: str = typer.Option(None, "--sort", help="started_at or duration."),
|
|
209
|
+
direction: str = typer.Option(None, "--dir", help="asc or desc."),
|
|
210
|
+
all_workspaces: bool = typer.Option(False, "--all-workspaces", help="Admin only."),
|
|
211
|
+
limit: int = typer.Option(None, "--limit", help="Rows per page."),
|
|
212
|
+
fetch_all: bool = typer.Option(False, "--all", help="Walk every page."),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""The query log, newest first. Doubles as the audit trail.
|
|
215
|
+
|
|
216
|
+
Filtering and sorting happen server-side over the whole history before the
|
|
217
|
+
page is cut, so narrowing here is not the same as filtering what you were
|
|
218
|
+
shown. Omitting a filter means "everything": `--status` has no default.
|
|
219
|
+
"""
|
|
220
|
+
cli = context.of(ctx)
|
|
221
|
+
settings = cli.settings()
|
|
222
|
+
params = {
|
|
223
|
+
"status": status or None,
|
|
224
|
+
"statement_type": statement_type or None,
|
|
225
|
+
"since": since,
|
|
226
|
+
"until": until,
|
|
227
|
+
"origin": origin,
|
|
228
|
+
"session_id": session,
|
|
229
|
+
"agent_id": agent,
|
|
230
|
+
"user_id": user,
|
|
231
|
+
"q": search,
|
|
232
|
+
"slower_than_ms": slower_than,
|
|
233
|
+
"sort": sort,
|
|
234
|
+
"dir": direction,
|
|
235
|
+
"all_workspaces": all_workspaces or None,
|
|
236
|
+
}
|
|
237
|
+
path = f"workspaces/{settings.require('workspace')}/queries"
|
|
238
|
+
with cli.client(settings) as client:
|
|
239
|
+
cli.page(client, path, params=params, limit=limit, fetch_all=fetch_all)
|
dh/commands/saved.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""`dh saved-query`, `dh schedule` and `dh search`.
|
|
2
|
+
|
|
3
|
+
Saved queries and schedules are the two places where a CLI beats the worksheet
|
|
4
|
+
UI outright: both are things people want in version control and applied from CI,
|
|
5
|
+
not clicked into a browser.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from dh import context
|
|
13
|
+
from dh.errors import ConflictError
|
|
14
|
+
|
|
15
|
+
saved_app = typer.Typer(name="saved-query", help="Named SQL saved in the workspace.")
|
|
16
|
+
schedule_app = typer.Typer(name="schedule", help="Cron schedules for saved queries.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _paged(cli, path: str, *, params=None, limit=None, fetch_all=False) -> None:
|
|
20
|
+
"""List through the shared envelope, whichever shape the endpoint returns."""
|
|
21
|
+
with cli.client() as client:
|
|
22
|
+
cli.page(client, path, params=params, limit=limit, fetch_all=fetch_all)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# --- Saved queries ---------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@saved_app.command("list")
|
|
29
|
+
def list_saved(
|
|
30
|
+
ctx: typer.Context,
|
|
31
|
+
limit: int = typer.Option(None, "--limit"),
|
|
32
|
+
fetch_all: bool = typer.Option(False, "--all"),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""The workspace's saved queries, newest first."""
|
|
35
|
+
cli = context.of(ctx)
|
|
36
|
+
workspace = cli.settings().require("workspace")
|
|
37
|
+
_paged(cli, f"workspaces/{workspace}/saved-queries", limit=limit, fetch_all=fetch_all)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@saved_app.command("create")
|
|
41
|
+
def create_saved(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
name: str = typer.Argument(..., help="Name to save under."),
|
|
44
|
+
query: str = typer.Option(None, "--query", "-q", help="SQL to save."),
|
|
45
|
+
file: typer.FileText = typer.Option(None, "--file", "-f", help="Read the SQL from a file."),
|
|
46
|
+
agent: str = typer.Option(None, "--agent", help="Default agent id for runs."),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Save SQL under a name, replacing any query already using that name.
|
|
49
|
+
|
|
50
|
+
Overwrite-by-name is the server's behaviour and is deliberate: saving over
|
|
51
|
+
"report" updates that query rather than accumulating "report v2".
|
|
52
|
+
"""
|
|
53
|
+
cli = context.of(ctx)
|
|
54
|
+
settings = cli.settings()
|
|
55
|
+
if bool(query) == bool(file):
|
|
56
|
+
raise ConflictError("no_sql", "Give the SQL exactly one way: --query or --file.")
|
|
57
|
+
body = {"name": name, "sql": query or file.read()}
|
|
58
|
+
if agent:
|
|
59
|
+
body["default_agent_id"] = agent
|
|
60
|
+
with cli.client(settings) as client:
|
|
61
|
+
cli.emit(
|
|
62
|
+
client.post(f"workspaces/{settings.require('workspace')}/saved-queries", json=body)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@saved_app.command("update")
|
|
67
|
+
def update_saved(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
saved_query_id: str,
|
|
70
|
+
name: str = typer.Option(None, "--name"),
|
|
71
|
+
query: str = typer.Option(None, "--query", "-q"),
|
|
72
|
+
file: typer.FileText = typer.Option(None, "--file", "-f"),
|
|
73
|
+
agent: str = typer.Option(None, "--agent"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Change a saved query's name, SQL or default agent. Omitted fields are left alone."""
|
|
76
|
+
cli = context.of(ctx)
|
|
77
|
+
settings = cli.settings()
|
|
78
|
+
body = {
|
|
79
|
+
k: v
|
|
80
|
+
for k, v in {
|
|
81
|
+
"name": name,
|
|
82
|
+
"sql": query or (file.read() if file else None),
|
|
83
|
+
"default_agent_id": agent,
|
|
84
|
+
}.items()
|
|
85
|
+
if v is not None
|
|
86
|
+
}
|
|
87
|
+
if not body:
|
|
88
|
+
raise ConflictError("nothing_to_update", "Give at least one of --name, --query or --agent.")
|
|
89
|
+
path = f"workspaces/{settings.require('workspace')}/saved-queries/{saved_query_id}"
|
|
90
|
+
with cli.client(settings) as client:
|
|
91
|
+
cli.emit(client.patch(path, json=body))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@saved_app.command("delete")
|
|
95
|
+
def delete_saved(ctx: typer.Context, saved_query_id: str) -> None:
|
|
96
|
+
"""Delete a saved query."""
|
|
97
|
+
cli = context.of(ctx)
|
|
98
|
+
settings = cli.settings()
|
|
99
|
+
path = f"workspaces/{settings.require('workspace')}/saved-queries/{saved_query_id}"
|
|
100
|
+
with cli.client(settings) as client:
|
|
101
|
+
client.delete(path)
|
|
102
|
+
cli.note(f"Deleted saved query {saved_query_id}.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- Schedules -------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@schedule_app.command("list")
|
|
109
|
+
def list_schedules(ctx: typer.Context) -> None:
|
|
110
|
+
"""The workspace's schedules."""
|
|
111
|
+
cli = context.of(ctx)
|
|
112
|
+
workspace = cli.settings().require("workspace")
|
|
113
|
+
_paged(cli, f"workspaces/{workspace}/schedules")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@schedule_app.command("create")
|
|
117
|
+
def create_schedule(
|
|
118
|
+
ctx: typer.Context,
|
|
119
|
+
saved_query_id: str = typer.Argument(..., help="The saved query to run."),
|
|
120
|
+
cron: str = typer.Option(..., "--cron", help="Five-field cron expression."),
|
|
121
|
+
agent: str = typer.Option(None, "--agent", help="Agent id to run on."),
|
|
122
|
+
disabled: bool = typer.Option(False, "--disabled", help="Create it paused."),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Schedule a saved query to run on a cron expression."""
|
|
125
|
+
cli = context.of(ctx)
|
|
126
|
+
settings = cli.settings()
|
|
127
|
+
body = {
|
|
128
|
+
"job_type": "saved_query",
|
|
129
|
+
"saved_query_id": saved_query_id,
|
|
130
|
+
"cron": cron,
|
|
131
|
+
"enabled": not disabled,
|
|
132
|
+
}
|
|
133
|
+
if agent:
|
|
134
|
+
body["agent_id"] = agent
|
|
135
|
+
with cli.client(settings) as client:
|
|
136
|
+
cli.emit(client.post(f"workspaces/{settings.require('workspace')}/schedules", json=body))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@schedule_app.command("update")
|
|
140
|
+
def update_schedule(
|
|
141
|
+
ctx: typer.Context,
|
|
142
|
+
schedule_id: str,
|
|
143
|
+
cron: str = typer.Option(None, "--cron"),
|
|
144
|
+
agent: str = typer.Option(None, "--agent"),
|
|
145
|
+
enabled: bool = typer.Option(None, "--enabled/--disabled"),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Change a schedule's cron, agent, or whether it runs at all."""
|
|
148
|
+
cli = context.of(ctx)
|
|
149
|
+
settings = cli.settings()
|
|
150
|
+
body = {
|
|
151
|
+
k: v
|
|
152
|
+
for k, v in {"cron": cron, "agent_id": agent, "enabled": enabled}.items()
|
|
153
|
+
if v is not None
|
|
154
|
+
}
|
|
155
|
+
if not body:
|
|
156
|
+
raise ConflictError(
|
|
157
|
+
"nothing_to_update", "Give at least one of --cron, --agent, --enabled or --disabled."
|
|
158
|
+
)
|
|
159
|
+
path = f"workspaces/{settings.require('workspace')}/schedules/{schedule_id}"
|
|
160
|
+
with cli.client(settings) as client:
|
|
161
|
+
cli.emit(client.patch(path, json=body))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@schedule_app.command("delete")
|
|
165
|
+
def delete_schedule(ctx: typer.Context, schedule_id: str) -> None:
|
|
166
|
+
"""Delete a schedule. The saved query it ran is left alone."""
|
|
167
|
+
cli = context.of(ctx)
|
|
168
|
+
settings = cli.settings()
|
|
169
|
+
with cli.client(settings) as client:
|
|
170
|
+
client.delete(f"workspaces/{settings.require('workspace')}/schedules/{schedule_id}")
|
|
171
|
+
cli.note(f"Deleted schedule {schedule_id}.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@schedule_app.command("runs")
|
|
175
|
+
def schedule_runs(
|
|
176
|
+
ctx: typer.Context,
|
|
177
|
+
schedule_id: str = typer.Argument(None, help="Omit for every schedule's runs."),
|
|
178
|
+
limit: int = typer.Option(None, "--limit"),
|
|
179
|
+
fetch_all: bool = typer.Option(False, "--all"),
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Runs produced by a schedule, or by all of them."""
|
|
182
|
+
cli = context.of(ctx)
|
|
183
|
+
workspace = cli.settings().require("workspace")
|
|
184
|
+
path = (
|
|
185
|
+
f"workspaces/{workspace}/schedules/{schedule_id}/runs"
|
|
186
|
+
if schedule_id
|
|
187
|
+
else f"workspaces/{workspace}/schedule-runs"
|
|
188
|
+
)
|
|
189
|
+
_paged(cli, path, limit=limit, fetch_all=fetch_all)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- Search ----------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def search(
|
|
196
|
+
ctx: typer.Context,
|
|
197
|
+
query: str = typer.Argument(..., help="Text to look for."),
|
|
198
|
+
limit: int = typer.Option(None, "--limit"),
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Find catalogs, schemas, tables and saved queries by name.
|
|
201
|
+
|
|
202
|
+
A truncated report rather than a page: `limit` caps how many come back and
|
|
203
|
+
`has_more` says whether it cut. There is no cursor to walk -- narrow the
|
|
204
|
+
query instead.
|
|
205
|
+
"""
|
|
206
|
+
cli = context.of(ctx)
|
|
207
|
+
settings = cli.settings()
|
|
208
|
+
with cli.client(settings) as client:
|
|
209
|
+
body = client.get(
|
|
210
|
+
f"workspaces/{settings.require('workspace')}/search",
|
|
211
|
+
params={"q": query, "limit": limit},
|
|
212
|
+
)
|
|
213
|
+
cli.emit(body.get("items", body), has_more=bool(body.get("has_more")))
|
dh/commands/session.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""`dh session` and the interactive REPL.
|
|
2
|
+
|
|
3
|
+
Two different jobs, so two different mechanisms.
|
|
4
|
+
|
|
5
|
+
`dh session *` operates a session's lifecycle from *separate* CLI invocations --
|
|
6
|
+
open now, run statements later, close tomorrow -- so it is plain REST over the
|
|
7
|
+
session routes; a connection object cannot outlive the process that made it.
|
|
8
|
+
|
|
9
|
+
The REPL is the opposite: one long-lived process holding one session, which is
|
|
10
|
+
exactly what `duckhaven-sql-connector` is for. It handles elastic cold-start
|
|
11
|
+
waiting, retries and row pagination, so the REPL does not reimplement any of it.
|
|
12
|
+
|
|
13
|
+
Sessions are disabled by default on the server and the routes answer **404** when
|
|
14
|
+
they are. Rendered as "not found" that would be actively misleading, so it is
|
|
15
|
+
translated -- and the REPL falls back to one-shot execution rather than refusing
|
|
16
|
+
to start.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from dh import context, execute
|
|
26
|
+
from dh.errors import DhError, NotFoundError
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(name="session", help="Stateful SQL sessions, for dbt, dlt and the REPL.")
|
|
29
|
+
|
|
30
|
+
#: Long enough to cover an elastic cold start, which is what the wait is for.
|
|
31
|
+
_DEFAULT_WAIT = 300.0
|
|
32
|
+
|
|
33
|
+
#: How long the *server* is asked to hold the request before answering.
|
|
34
|
+
#:
|
|
35
|
+
#: It must stay under the client's socket timeout: a longer server-side block
|
|
36
|
+
#: aborts the request socket-side while the server goes on to open a session
|
|
37
|
+
#: nobody holds. The connector in this workspace documents the same hazard. Any
|
|
38
|
+
#: remaining wait is spent polling instead, which is interruptible and leaves the
|
|
39
|
+
#: session id in hand.
|
|
40
|
+
_SERVER_HOLD_S = 10.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SessionsDisabled(DhError):
|
|
44
|
+
"""The session surface is switched off on this deployment."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _translate(exc: DhError) -> DhError:
|
|
48
|
+
"""Turn the disabled-surface 404 into something actionable."""
|
|
49
|
+
if isinstance(exc, NotFoundError) and "not enabled" in exc.message.lower():
|
|
50
|
+
return SessionsDisabled(
|
|
51
|
+
"sessions_disabled",
|
|
52
|
+
"SQL sessions are not enabled on this server. An operator turns them on with "
|
|
53
|
+
"DH_SQL_SESSIONS_ENABLED=true; `dh sql` works without them.",
|
|
54
|
+
)
|
|
55
|
+
return exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("open")
|
|
59
|
+
def open_session(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
agent: str = typer.Option(None, "--agent", help="Agent name or id to bind the session to."),
|
|
62
|
+
catalog: str = typer.Option(None, "--catalog"),
|
|
63
|
+
wait: float = typer.Option(_DEFAULT_WAIT, "--wait", help="Seconds to wait for compute."),
|
|
64
|
+
no_wait: bool = typer.Option(
|
|
65
|
+
False, "--no-wait", help="Return a pending session instead of holding the request."
|
|
66
|
+
),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Open a session and print its id.
|
|
69
|
+
|
|
70
|
+
Opening can take a while when compute has to start, so `--no-wait` hands back
|
|
71
|
+
a pending session to poll with `dh session get` instead of holding the
|
|
72
|
+
request open.
|
|
73
|
+
"""
|
|
74
|
+
cli = context.of(ctx)
|
|
75
|
+
settings = cli.settings()
|
|
76
|
+
# `--no-wait` must not hold the request at all; otherwise ask the server for a
|
|
77
|
+
# short hold and poll out the rest ourselves.
|
|
78
|
+
body: dict[str, object] = {
|
|
79
|
+
"wait_timeout_s": 0.0 if no_wait else min(wait, _SERVER_HOLD_S),
|
|
80
|
+
"on_wait_timeout": "continue",
|
|
81
|
+
}
|
|
82
|
+
if catalog or settings.get("catalog"):
|
|
83
|
+
body["catalog"] = catalog or settings.get("catalog")
|
|
84
|
+
with cli.client(settings) as client:
|
|
85
|
+
chosen = execute.resolve_agent(client, agent or settings.get("agent"))
|
|
86
|
+
if chosen:
|
|
87
|
+
body["agent_id"] = chosen
|
|
88
|
+
try:
|
|
89
|
+
session = client.post(
|
|
90
|
+
f"workspaces/{settings.require('workspace')}/sql/sessions", json=body
|
|
91
|
+
)
|
|
92
|
+
except DhError as exc:
|
|
93
|
+
raise _translate(exc) from exc
|
|
94
|
+
if not no_wait:
|
|
95
|
+
session = _await_open(cli, client, session, deadline=wait)
|
|
96
|
+
cli.note(f"Session {session['id']} is {session.get('status')}.")
|
|
97
|
+
cli.emit(session)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _await_open(cli, client, session: dict, *, deadline: float) -> dict:
|
|
101
|
+
"""Poll a pending session to `open`, or hand back whatever it reached.
|
|
102
|
+
|
|
103
|
+
`on_wait_timeout=continue` means the server answers with a session still
|
|
104
|
+
starting rather than failing, so the id is already in hand -- an interrupt or
|
|
105
|
+
a timeout here leaves something the caller can close, not an orphan.
|
|
106
|
+
"""
|
|
107
|
+
started = time.monotonic()
|
|
108
|
+
delay = 0.5
|
|
109
|
+
while session.get("status") in ("pending", "opening"):
|
|
110
|
+
if time.monotonic() - started >= deadline:
|
|
111
|
+
cli.note(
|
|
112
|
+
f"Session {session['id']} is still {session.get('status')}; "
|
|
113
|
+
"poll it with `dh session get`."
|
|
114
|
+
)
|
|
115
|
+
return session
|
|
116
|
+
time.sleep(min(delay, 2.0))
|
|
117
|
+
delay *= 1.5
|
|
118
|
+
session = client.get(f"sql/sessions/{session['id']}")
|
|
119
|
+
return session
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("list")
|
|
123
|
+
def list_sessions(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
status: list[str] = typer.Option(None, "--status", help="Repeatable."),
|
|
126
|
+
fetch_all: bool = typer.Option(False, "--all"),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""The workspace's sessions, newest first. The audit list."""
|
|
129
|
+
cli = context.of(ctx)
|
|
130
|
+
settings = cli.settings()
|
|
131
|
+
path = f"workspaces/{settings.require('workspace')}/sql/sessions"
|
|
132
|
+
params = {"status": status or None}
|
|
133
|
+
with cli.client(settings) as client:
|
|
134
|
+
cli.page(client, path, params=params, fetch_all=fetch_all, translate=_translate)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("get")
|
|
138
|
+
def get_session(ctx: typer.Context, session_id: str) -> None:
|
|
139
|
+
"""One session's status and the agent holding it."""
|
|
140
|
+
cli = context.of(ctx)
|
|
141
|
+
with cli.client() as client:
|
|
142
|
+
try:
|
|
143
|
+
cli.emit(client.get(f"sql/sessions/{session_id}"))
|
|
144
|
+
except DhError as exc:
|
|
145
|
+
raise _translate(exc) from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("close")
|
|
149
|
+
def close_session(ctx: typer.Context, session_id: str) -> None:
|
|
150
|
+
"""Close a session and release its connection.
|
|
151
|
+
|
|
152
|
+
Idempotent. Everything session-local -- temp tables, `SET`s -- is gone
|
|
153
|
+
afterwards.
|
|
154
|
+
"""
|
|
155
|
+
cli = context.of(ctx)
|
|
156
|
+
with cli.client() as client:
|
|
157
|
+
try:
|
|
158
|
+
client.delete(f"sql/sessions/{session_id}")
|
|
159
|
+
except DhError as exc:
|
|
160
|
+
raise _translate(exc) from exc
|
|
161
|
+
cli.note(f"Closed session {session_id}.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command("exec")
|
|
165
|
+
def exec_statement(
|
|
166
|
+
ctx: typer.Context,
|
|
167
|
+
session_id: str,
|
|
168
|
+
query: str = typer.Option(None, "--query", "-q"),
|
|
169
|
+
file: typer.FileText = typer.Option(None, "--file", "-f"),
|
|
170
|
+
timeout: str = typer.Option("20m", "--timeout"),
|
|
171
|
+
limit: int = typer.Option(None, "--limit"),
|
|
172
|
+
fetch_all: bool = typer.Option(False, "--all"),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Run one statement on an existing session's connection.
|
|
175
|
+
|
|
176
|
+
Unlike `dh sql`, this reuses a held connection, so temp tables, `SET`s and
|
|
177
|
+
attached catalogs persist between statements.
|
|
178
|
+
"""
|
|
179
|
+
from dh.commands.query import _read_sql
|
|
180
|
+
|
|
181
|
+
cli = context.of(ctx)
|
|
182
|
+
sql = _read_sql(query, file, False)
|
|
183
|
+
budget = execute.parse_duration(timeout)
|
|
184
|
+
with cli.client() as client:
|
|
185
|
+
try:
|
|
186
|
+
statement = client.post(
|
|
187
|
+
f"sql/sessions/{session_id}/statements",
|
|
188
|
+
json={"sql": sql, "timeout_s": budget},
|
|
189
|
+
)
|
|
190
|
+
except DhError as exc:
|
|
191
|
+
raise _translate(exc) from exc
|
|
192
|
+
finished = execute.wait(client, statement["id"], timeout=budget)
|
|
193
|
+
execute.raise_for_status(finished)
|
|
194
|
+
page = execute.fetch_rows(client, finished["id"], limit=None if fetch_all else limit)
|
|
195
|
+
cli.emit({"columns": page["columns"], "rows": page["rows"], "total": page["total"]})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("statements")
|
|
199
|
+
def list_statements(
|
|
200
|
+
ctx: typer.Context, session_id: str, fetch_all: bool = typer.Option(False, "--all")
|
|
201
|
+
) -> None:
|
|
202
|
+
"""A session's statements in execution order.
|
|
203
|
+
|
|
204
|
+
Ascending, unlike the newest-first history feeds: a session is one workload
|
|
205
|
+
read top to bottom, so a `dbt run` reads as the sequence it actually was.
|
|
206
|
+
"""
|
|
207
|
+
cli = context.of(ctx)
|
|
208
|
+
path = f"sql/sessions/{session_id}/statements"
|
|
209
|
+
with cli.client() as client:
|
|
210
|
+
cli.page(client, path, fetch_all=fetch_all, translate=_translate)
|