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