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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""`dh` — the DuckHaven command-line interface.
|
|
2
|
+
|
|
3
|
+
A pure HTTP client of DuckHaven's public REST API, authenticating with a Personal
|
|
4
|
+
Access Token. The interactive REPL holds a session through
|
|
5
|
+
``duckhaven-sql-connector``; everything else, one-shot ``dh sql`` included, goes
|
|
6
|
+
over the REST API directly -- the one-shot query route is the one that works when
|
|
7
|
+
SQL sessions are disabled, and the connector does not expose it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
try: # populated by hatch-vcs at build time
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
except ImportError: # pragma: no cover - source checkout without a build
|
|
13
|
+
try:
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
__version__ = version("duckhaven-cli")
|
|
17
|
+
except PackageNotFoundError: # pragma: no cover
|
|
18
|
+
__version__ = "0.0.0+unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__"]
|
dh/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
dh/commands/__init__.py
ADDED
|
File without changes
|
dh/commands/admin.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""`dh admin` — the operator surface, hand-written but thin.
|
|
2
|
+
|
|
3
|
+
Deliberately not the whole `admin` tag. These are the operations operators
|
|
4
|
+
actually script: issuing credentials, managing who has them, and agent
|
|
5
|
+
lifecycle. Storage-backend creation and per-agent grants are install-time,
|
|
6
|
+
once-off and high blast radius, so they stay behind `dh api` until someone
|
|
7
|
+
asks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from dh import context
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="admin", help="Operator tasks: accounts, users, agents.")
|
|
17
|
+
|
|
18
|
+
sa_app = typer.Typer(name="service-account", help="Machine identities for unattended callers.")
|
|
19
|
+
pat_app = typer.Typer(name="pat", help="Tokens issued to a service account.")
|
|
20
|
+
user_app = typer.Typer(name="user", help="People and their workspace roles.")
|
|
21
|
+
agent_app = typer.Typer(name="agent", help="Compute agents.")
|
|
22
|
+
storage_app = typer.Typer(name="storage", help="Storage backends.")
|
|
23
|
+
maintenance_app = typer.Typer(name="maintenance", help="Deployment maintenance policy.")
|
|
24
|
+
|
|
25
|
+
for sub in (sa_app, pat_app, user_app, agent_app, storage_app, maintenance_app):
|
|
26
|
+
app.add_typer(sub)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Service accounts ------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@sa_app.command("list")
|
|
33
|
+
def list_service_accounts(ctx: typer.Context, fetch_all: bool = typer.Option(False, "--all")):
|
|
34
|
+
"""Service accounts and how many live tokens each holds."""
|
|
35
|
+
cli = context.of(ctx)
|
|
36
|
+
with cli.client() as client:
|
|
37
|
+
cli.page(client, "admin/service-accounts", fetch_all=fetch_all)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@sa_app.command("create")
|
|
41
|
+
def create_service_account(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
name: str,
|
|
44
|
+
role: str = typer.Option("user", "--role", help="Global role. Defaults to no permissions."),
|
|
45
|
+
):
|
|
46
|
+
"""Create a service account.
|
|
47
|
+
|
|
48
|
+
The role defaults to `user`, which carries no global permissions, so a new
|
|
49
|
+
account is never accidentally an admin.
|
|
50
|
+
"""
|
|
51
|
+
cli = context.of(ctx)
|
|
52
|
+
with cli.client() as client:
|
|
53
|
+
cli.emit(client.post("admin/service-accounts", json={"name": name, "role": role}))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@sa_app.command("update")
|
|
57
|
+
def update_service_account(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
service_account_id: str,
|
|
60
|
+
role: str = typer.Option(None, "--role"),
|
|
61
|
+
active: bool = typer.Option(None, "--active/--inactive"),
|
|
62
|
+
):
|
|
63
|
+
"""Change a service account's role, or disable it.
|
|
64
|
+
|
|
65
|
+
Disabling blocks every one of its tokens immediately, which is the fast way
|
|
66
|
+
to cut off a compromised credential without hunting down each PAT.
|
|
67
|
+
"""
|
|
68
|
+
cli = context.of(ctx)
|
|
69
|
+
body = {k: v for k, v in {"role": role, "is_active": active}.items() if v is not None}
|
|
70
|
+
with cli.client() as client:
|
|
71
|
+
cli.emit(client.patch(f"admin/service-accounts/{service_account_id}", json=body))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@sa_app.command("delete")
|
|
75
|
+
def delete_service_account(ctx: typer.Context, service_account_id: str):
|
|
76
|
+
"""Delete a service account.
|
|
77
|
+
|
|
78
|
+
Refused with a 409 once the account has run queries, because deleting it
|
|
79
|
+
would orphan that audit trail; disable it instead.
|
|
80
|
+
"""
|
|
81
|
+
cli = context.of(ctx)
|
|
82
|
+
with cli.client() as client:
|
|
83
|
+
client.delete(f"admin/service-accounts/{service_account_id}")
|
|
84
|
+
cli.note(f"Deleted service account {service_account_id}.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- Tokens ----------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pat_app.command("issue")
|
|
91
|
+
def issue_pat(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
service_account_id: str,
|
|
94
|
+
expires_in_days: int = typer.Option(
|
|
95
|
+
90, "--expires-in-days", help="Pass 0 for a token that never expires."
|
|
96
|
+
),
|
|
97
|
+
):
|
|
98
|
+
"""Issue a token for a service account. The secret is shown once.
|
|
99
|
+
|
|
100
|
+
This is the credential CI should use. A person signing in for themselves
|
|
101
|
+
uses `dh auth login` instead, which mints a bounded token for their own
|
|
102
|
+
identity.
|
|
103
|
+
"""
|
|
104
|
+
cli = context.of(ctx)
|
|
105
|
+
body = {"expires_in_days": None if expires_in_days == 0 else expires_in_days}
|
|
106
|
+
with cli.client() as client:
|
|
107
|
+
issued = client.post(f"admin/service-accounts/{service_account_id}/pats", json=body)
|
|
108
|
+
cli.note("Copy the token now; it cannot be shown again.")
|
|
109
|
+
cli.emit(issued)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pat_app.command("list")
|
|
113
|
+
def list_pats(ctx: typer.Context, service_account_id: str):
|
|
114
|
+
"""Tokens issued to a service account: creation and expiry, never the secret."""
|
|
115
|
+
cli = context.of(ctx)
|
|
116
|
+
with cli.client() as client:
|
|
117
|
+
cli.emit(client.get(f"admin/service-accounts/{service_account_id}/pats"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pat_app.command("revoke")
|
|
121
|
+
def revoke_pat(ctx: typer.Context, service_account_id: str, pat_id: str):
|
|
122
|
+
"""Revoke one token. It stops authenticating immediately."""
|
|
123
|
+
cli = context.of(ctx)
|
|
124
|
+
with cli.client() as client:
|
|
125
|
+
client.delete(f"admin/service-accounts/{service_account_id}/pats/{pat_id}")
|
|
126
|
+
cli.note(f"Revoked token {pat_id}.")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- Users -----------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@user_app.command("list")
|
|
133
|
+
def list_users(ctx: typer.Context, fetch_all: bool = typer.Option(False, "--all")):
|
|
134
|
+
"""People known to the deployment."""
|
|
135
|
+
cli = context.of(ctx)
|
|
136
|
+
with cli.client() as client:
|
|
137
|
+
cli.page(client, "admin/users", fetch_all=fetch_all)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@user_app.command("create")
|
|
141
|
+
def create_user(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
email: str,
|
|
144
|
+
name: str = typer.Option(..., "--name"),
|
|
145
|
+
password: str = typer.Option(..., "--password", prompt=True, hide_input=True),
|
|
146
|
+
role: str = typer.Option("user", "--role"),
|
|
147
|
+
):
|
|
148
|
+
"""Create a local user. The password is prompted for rather than passed as a flag."""
|
|
149
|
+
cli = context.of(ctx)
|
|
150
|
+
body = {"email": email, "name": name, "password": password, "role": role}
|
|
151
|
+
with cli.client() as client:
|
|
152
|
+
cli.emit(client.post("admin/users", json=body))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@user_app.command("update")
|
|
156
|
+
def update_user(
|
|
157
|
+
ctx: typer.Context,
|
|
158
|
+
user_id: str,
|
|
159
|
+
role: str = typer.Option(None, "--role"),
|
|
160
|
+
active: bool = typer.Option(None, "--active/--inactive"),
|
|
161
|
+
):
|
|
162
|
+
"""Change a user's global role, or deactivate them."""
|
|
163
|
+
cli = context.of(ctx)
|
|
164
|
+
body = {k: v for k, v in {"role": role, "is_active": active}.items() if v is not None}
|
|
165
|
+
with cli.client() as client:
|
|
166
|
+
cli.emit(client.patch(f"admin/users/{user_id}", json=body))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@user_app.command("revoke-sessions")
|
|
170
|
+
def revoke_sessions(ctx: typer.Context, user_id: str):
|
|
171
|
+
"""Sign a user out everywhere. Their tokens are unaffected."""
|
|
172
|
+
cli = context.of(ctx)
|
|
173
|
+
with cli.client() as client:
|
|
174
|
+
client.post(f"admin/users/{user_id}/revoke-sessions")
|
|
175
|
+
cli.note(f"Revoked every session for {user_id}.")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@user_app.command("workspaces")
|
|
179
|
+
def user_workspaces(ctx: typer.Context, user_id: str):
|
|
180
|
+
"""Which workspaces a user belongs to, and in what role."""
|
|
181
|
+
cli = context.of(ctx)
|
|
182
|
+
with cli.client() as client:
|
|
183
|
+
cli.emit(client.get(f"admin/users/{user_id}/workspaces"))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@user_app.command("set-workspace-role")
|
|
187
|
+
def set_workspace_role(ctx: typer.Context, user_id: str, workspace: str, role: str):
|
|
188
|
+
"""Add a user to a workspace, or change the role they hold there."""
|
|
189
|
+
cli = context.of(ctx)
|
|
190
|
+
with cli.client() as client:
|
|
191
|
+
cli.emit(client.put(f"admin/users/{user_id}/workspaces/{workspace}", json={"role": role}))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@user_app.command("remove-from-workspace")
|
|
195
|
+
def remove_from_workspace(ctx: typer.Context, user_id: str, workspace: str):
|
|
196
|
+
"""Remove a user from a workspace."""
|
|
197
|
+
cli = context.of(ctx)
|
|
198
|
+
with cli.client() as client:
|
|
199
|
+
client.delete(f"admin/users/{user_id}/workspaces/{workspace}")
|
|
200
|
+
cli.note(f"Removed {user_id} from {workspace}.")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- Agents ----------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@agent_app.command("list")
|
|
207
|
+
def list_agents(ctx: typer.Context):
|
|
208
|
+
"""Every agent, including ones this caller could not run queries on."""
|
|
209
|
+
cli = context.of(ctx)
|
|
210
|
+
with cli.client() as client:
|
|
211
|
+
cli.emit(client.get("admin/agents"))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@agent_app.command("get")
|
|
215
|
+
def get_agent(ctx: typer.Context, agent_id: str):
|
|
216
|
+
"""One agent's registration, capabilities and lifecycle state."""
|
|
217
|
+
cli = context.of(ctx)
|
|
218
|
+
with cli.client() as client:
|
|
219
|
+
cli.emit(client.get(f"admin/agents/{agent_id}"))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@agent_app.command("bootstrap")
|
|
223
|
+
def bootstrap_agent(ctx: typer.Context):
|
|
224
|
+
"""Mint a single-use token for an agent to register itself.
|
|
225
|
+
|
|
226
|
+
The response carries the WebSocket URL and image as well, so it is
|
|
227
|
+
everything needed to start an agent. Shown once and not listable.
|
|
228
|
+
"""
|
|
229
|
+
cli = context.of(ctx)
|
|
230
|
+
with cli.client() as client:
|
|
231
|
+
token = client.post("admin/agents/bootstrap")
|
|
232
|
+
cli.note("Copy this now; a bootstrap token is single-use and cannot be listed again.")
|
|
233
|
+
cli.emit(token)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@agent_app.command("elastic-create")
|
|
237
|
+
def create_elastic_agent(
|
|
238
|
+
ctx: typer.Context,
|
|
239
|
+
cpu: float = typer.Option(..., "--cpu"),
|
|
240
|
+
memory_gb: float = typer.Option(..., "--memory-gb"),
|
|
241
|
+
idle_timeout_minutes: int = typer.Option(None, "--idle-timeout-minutes"),
|
|
242
|
+
):
|
|
243
|
+
"""Provision an elastic agent. Accepted asynchronously; poll `admin agent get`."""
|
|
244
|
+
cli = context.of(ctx)
|
|
245
|
+
body: dict[str, object] = {"cpu": cpu, "memory_gb": memory_gb}
|
|
246
|
+
if idle_timeout_minutes is not None:
|
|
247
|
+
body["idle_timeout_minutes"] = idle_timeout_minutes
|
|
248
|
+
with cli.client() as client:
|
|
249
|
+
cli.emit(client.post("admin/agents/elastic", json=body))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@agent_app.command("compute-options")
|
|
253
|
+
def compute_options(ctx: typer.Context):
|
|
254
|
+
"""The CPU and memory shapes this deployment will provision."""
|
|
255
|
+
cli = context.of(ctx)
|
|
256
|
+
with cli.client() as client:
|
|
257
|
+
cli.emit(client.get("admin/agents/compute-options"))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@agent_app.command("metrics")
|
|
261
|
+
def agent_metrics(ctx: typer.Context):
|
|
262
|
+
"""Current load and capacity for every agent."""
|
|
263
|
+
cli = context.of(ctx)
|
|
264
|
+
with cli.client() as client:
|
|
265
|
+
cli.emit(client.get("admin/agents/metrics"))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@agent_app.command("monitoring")
|
|
269
|
+
def agent_monitoring(ctx: typer.Context, agent_id: str):
|
|
270
|
+
"""One agent's monitoring detail."""
|
|
271
|
+
cli = context.of(ctx)
|
|
272
|
+
with cli.client() as client:
|
|
273
|
+
cli.emit(client.get(f"admin/agents/{agent_id}/monitoring"))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@agent_app.command("access")
|
|
277
|
+
def agent_access(ctx: typer.Context, agent_id: str):
|
|
278
|
+
"""Who may use this agent, and how that was decided."""
|
|
279
|
+
cli = context.of(ctx)
|
|
280
|
+
with cli.client() as client:
|
|
281
|
+
cli.emit(client.get(f"admin/agents/{agent_id}/access"))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _agent_action(action: str, doc: str):
|
|
285
|
+
"""The lifecycle transitions, which differ only in their path segment."""
|
|
286
|
+
|
|
287
|
+
def command(ctx: typer.Context, agent_id: str) -> None:
|
|
288
|
+
cli = context.of(ctx)
|
|
289
|
+
with cli.client() as client:
|
|
290
|
+
cli.emit(client.post(f"admin/agents/{agent_id}/{action}"))
|
|
291
|
+
|
|
292
|
+
command.__doc__ = doc
|
|
293
|
+
return command
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
agent_app.command("restart")(_agent_action("restart", "Restart an elastic agent."))
|
|
297
|
+
agent_app.command("terminate")(_agent_action("terminate", "Terminate an elastic agent."))
|
|
298
|
+
agent_app.command("disconnect")(
|
|
299
|
+
_agent_action("disconnect", "Force an agent's control channel closed. It may reconnect.")
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@agent_app.command("delete")
|
|
304
|
+
def delete_agent(ctx: typer.Context, agent_id: str):
|
|
305
|
+
"""Delete an agent registration."""
|
|
306
|
+
cli = context.of(ctx)
|
|
307
|
+
with cli.client() as client:
|
|
308
|
+
client.delete(f"admin/agents/{agent_id}")
|
|
309
|
+
cli.note(f"Deleted agent {agent_id}.")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@agent_app.command("revoke-credential")
|
|
313
|
+
def revoke_agent_credential(ctx: typer.Context, agent_id: str):
|
|
314
|
+
"""Revoke an agent's credential, so it cannot reconnect."""
|
|
315
|
+
cli = context.of(ctx)
|
|
316
|
+
with cli.client() as client:
|
|
317
|
+
client.delete(f"admin/agents/{agent_id}/credential")
|
|
318
|
+
cli.note(f"Revoked the credential for agent {agent_id}.")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# --- Storage and maintenance ----------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@storage_app.command("list")
|
|
325
|
+
def list_backends(ctx: typer.Context):
|
|
326
|
+
"""Configured storage backends."""
|
|
327
|
+
cli = context.of(ctx)
|
|
328
|
+
with cli.client() as client:
|
|
329
|
+
cli.emit(client.get("admin/storage-backends"))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@storage_app.command("health")
|
|
333
|
+
def backend_health(ctx: typer.Context, storage_backend_id: str):
|
|
334
|
+
"""Check a backend end to end, by vending credentials against a probe table."""
|
|
335
|
+
cli = context.of(ctx)
|
|
336
|
+
with cli.client() as client:
|
|
337
|
+
cli.emit(client.post(f"admin/storage-backends/{storage_backend_id}/health"))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@maintenance_app.command("policy")
|
|
341
|
+
def get_policy(ctx: typer.Context):
|
|
342
|
+
"""The deployment's maintenance policy."""
|
|
343
|
+
cli = context.of(ctx)
|
|
344
|
+
with cli.client() as client:
|
|
345
|
+
cli.emit(client.get("admin/maintenance/policy"))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@maintenance_app.command("scan")
|
|
349
|
+
def trigger_scan(ctx: typer.Context):
|
|
350
|
+
"""Run a maintenance scan now rather than waiting for the schedule."""
|
|
351
|
+
cli = context.of(ctx)
|
|
352
|
+
with cli.client() as client:
|
|
353
|
+
cli.emit(client.post("admin/maintenance/scan"))
|
dh/commands/api.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`dh api` — a raw request against any endpoint.
|
|
2
|
+
|
|
3
|
+
The escape hatch that makes the coverage cut honest. Roughly forty operations get
|
|
4
|
+
no hand-written command, and without this that would mean "you cannot do it from
|
|
5
|
+
the CLI" rather than "you type the path yourself".
|
|
6
|
+
|
|
7
|
+
It still carries the parts worth having: the `/api` mount, the bearer token, the
|
|
8
|
+
error envelope, and the exit-code contract. Only the path and body are yours.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from dh import context
|
|
21
|
+
from dh.errors import ConflictError
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(name="api", help="Call any endpoint directly.")
|
|
24
|
+
|
|
25
|
+
_DATA = typer.Option(
|
|
26
|
+
None, "--data", "-d", help="JSON body, or @file to read one. Use @- for standard input."
|
|
27
|
+
)
|
|
28
|
+
_PARAM = typer.Option(None, "--param", "-p", help="Repeatable query parameter: name=value.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _body(data: str | None) -> Any:
|
|
32
|
+
if data is None:
|
|
33
|
+
return None
|
|
34
|
+
if data == "@-":
|
|
35
|
+
raw = sys.stdin.read()
|
|
36
|
+
elif data.startswith("@"):
|
|
37
|
+
path = Path(data[1:])
|
|
38
|
+
if not path.exists():
|
|
39
|
+
raise ConflictError("no_such_file", f"No such file: {path}")
|
|
40
|
+
raw = path.read_text(encoding="utf-8")
|
|
41
|
+
else:
|
|
42
|
+
raw = data
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(raw)
|
|
45
|
+
except json.JSONDecodeError as exc:
|
|
46
|
+
raise ConflictError("bad_json", f"--data is not valid JSON: {exc}") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _params(pairs: list[str]) -> dict[str, list[str]]:
|
|
50
|
+
"""Query parameters, keeping repeats so `status` can be given twice."""
|
|
51
|
+
out: dict[str, list[str]] = {}
|
|
52
|
+
for pair in pairs or []:
|
|
53
|
+
name, sep, value = pair.partition("=")
|
|
54
|
+
if not sep:
|
|
55
|
+
raise ConflictError("bad_param", f"Could not read {pair!r}. Use name=value.")
|
|
56
|
+
out.setdefault(name, []).append(value)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _verb(method: str, doc: str):
|
|
61
|
+
def command(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
path: str = typer.Argument(..., help="Path below /api, e.g. workspaces/analytics/queries."),
|
|
64
|
+
data: str = _DATA,
|
|
65
|
+
param: list[str] = _PARAM,
|
|
66
|
+
) -> None:
|
|
67
|
+
cli = context.of(ctx)
|
|
68
|
+
with cli.client() as client:
|
|
69
|
+
cli.emit(client.request(method, path, json=_body(data), params=_params(param) or None))
|
|
70
|
+
|
|
71
|
+
command.__doc__ = doc
|
|
72
|
+
return command
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
app.command("get")(_verb("GET", "Send a GET request."))
|
|
76
|
+
app.command("post")(_verb("POST", "Send a POST request."))
|
|
77
|
+
app.command("put")(_verb("PUT", "Send a PUT request."))
|
|
78
|
+
app.command("patch")(_verb("PATCH", "Send a PATCH request."))
|
|
79
|
+
app.command("delete")(_verb("DELETE", "Send a DELETE request."))
|