pushmetrics-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.
- pm/__init__.py +1 -0
- pm/__main__.py +4 -0
- pm/_alias.py +164 -0
- pm/auth/__init__.py +0 -0
- pm/auth/device_flow.py +187 -0
- pm/auth/exchange.py +79 -0
- pm/auth/jwt_cache.py +163 -0
- pm/auth/secret_files.py +58 -0
- pm/auth/session.py +440 -0
- pm/auth/store.py +160 -0
- pm/auth/token.py +289 -0
- pm/cli.py +282 -0
- pm/client/__init__.py +68 -0
- pm/client/access_tokens.py +54 -0
- pm/client/base.py +229 -0
- pm/client/blocks.py +461 -0
- pm/client/databases.py +147 -0
- pm/client/favorites.py +14 -0
- pm/client/folders.py +10 -0
- pm/client/integrations.py +14 -0
- pm/client/organizations.py +20 -0
- pm/client/recipients.py +92 -0
- pm/client/reports.py +148 -0
- pm/client/roles.py +100 -0
- pm/client/runs.py +134 -0
- pm/client/saved_blocks.py +13 -0
- pm/client/tableau.py +108 -0
- pm/client/users.py +71 -0
- pm/client/webhooks.py +43 -0
- pm/client/workspaces.py +34 -0
- pm/commands/__init__.py +0 -0
- pm/commands/_block_factory.py +762 -0
- pm/commands/_helpers.py +178 -0
- pm/commands/auth.py +222 -0
- pm/commands/block.py +26 -0
- pm/commands/chart.py +20 -0
- pm/commands/db.py +291 -0
- pm/commands/email.py +13 -0
- pm/commands/explore.py +20 -0
- pm/commands/favorites.py +77 -0
- pm/commands/folders.py +113 -0
- pm/commands/help.py +643 -0
- pm/commands/integrations.py +95 -0
- pm/commands/metrics_view.py +195 -0
- pm/commands/organizations.py +56 -0
- pm/commands/recipient.py +226 -0
- pm/commands/reports.py +364 -0
- pm/commands/roles.py +102 -0
- pm/commands/runs.py +214 -0
- pm/commands/saved_blocks.py +97 -0
- pm/commands/sftp.py +19 -0
- pm/commands/slack.py +13 -0
- pm/commands/slides.py +216 -0
- pm/commands/sql.py +17 -0
- pm/commands/tableau.py +148 -0
- pm/commands/token.py +107 -0
- pm/commands/users.py +262 -0
- pm/commands/webhooks.py +132 -0
- pm/commands/workspace.py +156 -0
- pm/config.py +192 -0
- pm/errors.py +228 -0
- pm/log.py +63 -0
- pm/models/__init__.py +31 -0
- pm/models/access_tokens.py +25 -0
- pm/models/blocks.py +21 -0
- pm/models/databases.py +15 -0
- pm/models/favorites.py +14 -0
- pm/models/folders.py +14 -0
- pm/models/integrations.py +20 -0
- pm/models/organizations.py +13 -0
- pm/models/recipients.py +41 -0
- pm/models/reports.py +16 -0
- pm/models/roles.py +22 -0
- pm/models/runs.py +56 -0
- pm/models/saved_blocks.py +15 -0
- pm/models/users.py +36 -0
- pm/models/webhooks.py +23 -0
- pm/models/workspaces.py +16 -0
- pm/output.py +92 -0
- pm/types.py +130 -0
- pm/validators.py +499 -0
- pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
- pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
- pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
- pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pushmetrics_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Factory for block-typed Click command groups (pm-tool plan §3.8).
|
|
2
|
+
|
|
3
|
+
``make_block_group(name, block_type, *, crud_verbs, execute_verb, ...)`` returns
|
|
4
|
+
a Click group exposing some subset of the standard block lifecycle:
|
|
5
|
+
|
|
6
|
+
- ``get`` / ``list`` / ``create`` / ``update`` / ``delete`` — opt-in CRUD verbs
|
|
7
|
+
- ``run`` / ``send`` — execute an existing block by uid OR atomic
|
|
8
|
+
create+execute from ``--stdin`` (today's ``run_sql --stdin`` ergonomic)
|
|
9
|
+
- any number of *custom verbs* — direct-API ops that don't fit the standard
|
|
10
|
+
lifecycle (Tableau ``search``, metrics-view ``query``, slides
|
|
11
|
+
``parse-template``, etc.)
|
|
12
|
+
|
|
13
|
+
``block_type=None`` is the ``pm block`` plumbing case — ``--type X`` is
|
|
14
|
+
required on commands that need a discriminator (create, run, send). When
|
|
15
|
+
``block_type`` is bound (every domain group), the discriminator is implicit
|
|
16
|
+
and the ``--type`` flag is hidden.
|
|
17
|
+
|
|
18
|
+
One factory, no per-group conditionals. Every domain command file ends up
|
|
19
|
+
as a single call to this function plus optional ``custom_verbs`` for the
|
|
20
|
+
non-lifecycle operations. Adding a new block type later is data
|
|
21
|
+
(a new domain command file), not new factory code.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
|
|
31
|
+
from pm._alias import AliasedGroup
|
|
32
|
+
from pm.client import PMClient
|
|
33
|
+
from pm.errors import PMError, emit_error
|
|
34
|
+
from pm.commands._helpers import (
|
|
35
|
+
confirm_delete,
|
|
36
|
+
json_option,
|
|
37
|
+
limit_option,
|
|
38
|
+
name_filter_option,
|
|
39
|
+
peek_name,
|
|
40
|
+
report_deleted,
|
|
41
|
+
select_rows,
|
|
42
|
+
yes_option,
|
|
43
|
+
)
|
|
44
|
+
from pm.output import emit
|
|
45
|
+
from pm.types import CANONICAL_TYPES, canonicalize_type
|
|
46
|
+
from pm.validators import normalize_content_json, validate_content_json
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Constants ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
CRUD_VERBS = ("get", "list", "create", "update", "delete")
|
|
52
|
+
EXECUTE_VERBS = ("run", "send")
|
|
53
|
+
_LIST_COLUMNS = (
|
|
54
|
+
("UID", "uid"),
|
|
55
|
+
("TYPE", "type"),
|
|
56
|
+
("NAME", lambda r: r.get("verbose_name") or r.get("name") or ""),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── Decorators ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Display names for help text: article + name, so generated strings read
|
|
64
|
+
# "an email block", "an SFTP block", "a chart (Plotly) block".
|
|
65
|
+
_DISPLAY: Dict[str, tuple] = {
|
|
66
|
+
"sql": ("a", "SQL"),
|
|
67
|
+
"email": ("an", "email"),
|
|
68
|
+
"slack": ("a", "Slack"),
|
|
69
|
+
"sftp": ("an", "SFTP"),
|
|
70
|
+
"plotly": ("a", "chart (Plotly)"),
|
|
71
|
+
"explore": ("an", "explore"),
|
|
72
|
+
"metrics_view": ("a", "metrics view"),
|
|
73
|
+
"tableau": ("a", "Tableau view"),
|
|
74
|
+
"google_slides": ("a", "Google Slides"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _a_block(block_type: str) -> str:
|
|
79
|
+
article, name = _DISPLAY.get(block_type, ("a", block_type))
|
|
80
|
+
return f"{article} {name} block"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _blocks(block_type: str) -> str:
|
|
84
|
+
return f"{_DISPLAY.get(block_type, ('a', block_type))[1]} blocks"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Helpers ───────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_stdin_json() -> Dict[str, Any]:
|
|
91
|
+
"""Read all of stdin and parse it as a JSON object.
|
|
92
|
+
|
|
93
|
+
Surfaces ``PMError(argv_invalid)`` for empty input or non-object JSON
|
|
94
|
+
so the agent gets a structured envelope it can recover from. Reserved
|
|
95
|
+
for ``--stdin`` paths; flag-based bodies use ``parse_data``.
|
|
96
|
+
"""
|
|
97
|
+
raw = sys.stdin.read()
|
|
98
|
+
if not raw.strip():
|
|
99
|
+
raise PMError(
|
|
100
|
+
error_code="argv_invalid",
|
|
101
|
+
message="--stdin set but stdin was empty",
|
|
102
|
+
help="pm help block",
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
parsed = json.loads(raw)
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
raise PMError(
|
|
108
|
+
error_code="argv_invalid",
|
|
109
|
+
message=f"--stdin: invalid JSON ({exc.msg})",
|
|
110
|
+
received=raw[:120],
|
|
111
|
+
help="pm help block",
|
|
112
|
+
)
|
|
113
|
+
if not isinstance(parsed, dict):
|
|
114
|
+
raise PMError(
|
|
115
|
+
error_code="argv_invalid",
|
|
116
|
+
message=f"--stdin body must be a JSON object, got {type(parsed).__name__}",
|
|
117
|
+
received=type(parsed).__name__,
|
|
118
|
+
help="pm help block",
|
|
119
|
+
)
|
|
120
|
+
return parsed
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_type(
|
|
124
|
+
bound: Optional[str],
|
|
125
|
+
flag: Optional[str],
|
|
126
|
+
*,
|
|
127
|
+
verb: str,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Pick the block type for a generated command.
|
|
130
|
+
|
|
131
|
+
A group built with ``block_type='sql'`` always uses ``'sql'`` and
|
|
132
|
+
ignores the ``--type`` flag (it's hidden on those groups anyway). A
|
|
133
|
+
plumbing group (``block_type=None``) requires ``--type X``; X is
|
|
134
|
+
canonicalized via :mod:`pm.types` so silent aliases (``query``,
|
|
135
|
+
``plot``, ``chart``) and case-different inputs resolve correctly.
|
|
136
|
+
"""
|
|
137
|
+
if bound is not None:
|
|
138
|
+
return bound
|
|
139
|
+
if not flag:
|
|
140
|
+
raise PMError(
|
|
141
|
+
error_code="argv_invalid",
|
|
142
|
+
message=f"`pm block {verb}` requires --type",
|
|
143
|
+
allowed=sorted(CANONICAL_TYPES),
|
|
144
|
+
help="pm help block",
|
|
145
|
+
)
|
|
146
|
+
return canonicalize_type(flag)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _client(ctx: click.Context) -> PMClient:
|
|
150
|
+
return PMClient.from_profile(ctx.obj["profile"])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Generated command bodies (private — called by factory subcommands) ─
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _do_get(ctx: click.Context, uid: str) -> None:
|
|
157
|
+
client = _client(ctx)
|
|
158
|
+
block = client.blocks.get(uid)
|
|
159
|
+
client.persist()
|
|
160
|
+
emit(block, json_output=ctx.obj["json"])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _do_list(
|
|
164
|
+
ctx: click.Context,
|
|
165
|
+
*,
|
|
166
|
+
bound_type: Optional[str],
|
|
167
|
+
type_flag: Optional[str],
|
|
168
|
+
name_filter: Optional[str] = None,
|
|
169
|
+
limit: Optional[int] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
type_filter = bound_type
|
|
172
|
+
if type_filter is None and type_flag:
|
|
173
|
+
try:
|
|
174
|
+
type_filter = canonicalize_type(type_flag)
|
|
175
|
+
except PMError as err:
|
|
176
|
+
emit_error(err)
|
|
177
|
+
client = _client(ctx)
|
|
178
|
+
items = select_rows(
|
|
179
|
+
client.blocks.list(type=type_filter),
|
|
180
|
+
name=name_filter,
|
|
181
|
+
fields=("name", "verbose_name"),
|
|
182
|
+
limit=limit,
|
|
183
|
+
)
|
|
184
|
+
client.persist()
|
|
185
|
+
emit(items, json_output=ctx.obj["json"], columns=_LIST_COLUMNS)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _do_create(
|
|
189
|
+
ctx: click.Context,
|
|
190
|
+
*,
|
|
191
|
+
bound_type: Optional[str],
|
|
192
|
+
type_flag: Optional[str],
|
|
193
|
+
name: str,
|
|
194
|
+
verbose_name: Optional[str],
|
|
195
|
+
use_stdin: bool,
|
|
196
|
+
) -> None:
|
|
197
|
+
try:
|
|
198
|
+
block_type = _resolve_type(bound_type, type_flag, verb="create")
|
|
199
|
+
if not use_stdin:
|
|
200
|
+
raise PMError(
|
|
201
|
+
error_code="argv_invalid",
|
|
202
|
+
message="`create` requires --stdin (content_json body)",
|
|
203
|
+
help=f"pm help {block_type}",
|
|
204
|
+
)
|
|
205
|
+
body = _read_stdin_json()
|
|
206
|
+
validate_content_json(block_type, body, mode="create")
|
|
207
|
+
except PMError as err:
|
|
208
|
+
emit_error(err)
|
|
209
|
+
# Translate user-facing keys (body/message/channels-as-str) to the
|
|
210
|
+
# backend keys (value/value_markdown/channels-as-list) the worker
|
|
211
|
+
# reads. Codex review pass 2 fix.
|
|
212
|
+
body = normalize_content_json(block_type, body)
|
|
213
|
+
client = _client(ctx)
|
|
214
|
+
block = client.blocks.create(
|
|
215
|
+
type=block_type,
|
|
216
|
+
name=name,
|
|
217
|
+
content_json=body,
|
|
218
|
+
verbose_name=verbose_name,
|
|
219
|
+
)
|
|
220
|
+
client.persist()
|
|
221
|
+
emit(block, json_output=ctx.obj["json"])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _do_update(
|
|
225
|
+
ctx: click.Context,
|
|
226
|
+
uid: str,
|
|
227
|
+
*,
|
|
228
|
+
name: Optional[str],
|
|
229
|
+
verbose_name: Optional[str],
|
|
230
|
+
use_stdin: bool,
|
|
231
|
+
) -> None:
|
|
232
|
+
content_json: Any = None
|
|
233
|
+
if use_stdin:
|
|
234
|
+
try:
|
|
235
|
+
content_json = _read_stdin_json()
|
|
236
|
+
except PMError as err:
|
|
237
|
+
emit_error(err)
|
|
238
|
+
if content_json is None and name is None and verbose_name is None:
|
|
239
|
+
raise click.ClickException(
|
|
240
|
+
"Nothing to update; pass --stdin, --name, or --verbose-name.",
|
|
241
|
+
)
|
|
242
|
+
# Update path: fetch the block to learn its type, then translate
|
|
243
|
+
# user-facing keys to backend keys. Without this, `pm sql update
|
|
244
|
+
# <uid> --stdin <<< '{"body": "x"}'` for an email block would
|
|
245
|
+
# persist `body` instead of `value` and the next run would send an
|
|
246
|
+
# empty body. Codex review pass 2 fix.
|
|
247
|
+
if content_json is not None:
|
|
248
|
+
client_for_type = _client(ctx)
|
|
249
|
+
current = client_for_type.blocks.get(uid)
|
|
250
|
+
if current.type:
|
|
251
|
+
content_json = normalize_content_json(current.type, content_json)
|
|
252
|
+
|
|
253
|
+
client = _client(ctx)
|
|
254
|
+
block = client.blocks.update(
|
|
255
|
+
uid,
|
|
256
|
+
content_json=content_json,
|
|
257
|
+
name=name,
|
|
258
|
+
verbose_name=verbose_name,
|
|
259
|
+
)
|
|
260
|
+
client.persist()
|
|
261
|
+
emit(block, json_output=ctx.obj["json"])
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _do_delete(ctx: click.Context, uid: str, *, yes: bool) -> None:
|
|
265
|
+
if not yes:
|
|
266
|
+
# Same pattern as `pm reports delete` — show a verifiable name in
|
|
267
|
+
# the prompt instead of a bare hashid.
|
|
268
|
+
label = peek_name(lambda: _client(ctx).blocks.get(uid), "verbose_name", "name")
|
|
269
|
+
confirm_delete("block", uid, label)
|
|
270
|
+
client = _client(ctx)
|
|
271
|
+
client.blocks.delete(uid)
|
|
272
|
+
client.persist()
|
|
273
|
+
report_deleted(ctx, "block", uid)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _do_results(
|
|
277
|
+
ctx: click.Context,
|
|
278
|
+
uid: str,
|
|
279
|
+
*,
|
|
280
|
+
last: bool,
|
|
281
|
+
result_id: Optional[str],
|
|
282
|
+
) -> None:
|
|
283
|
+
if last and result_id:
|
|
284
|
+
raise click.ClickException("Pass --last OR --result-id, not both.")
|
|
285
|
+
rid = "last_result" if last else result_id
|
|
286
|
+
client = _client(ctx)
|
|
287
|
+
payload = client.blocks.results(uid, result_id=rid)
|
|
288
|
+
client.persist()
|
|
289
|
+
emit(payload, json_output=ctx.obj["json"])
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _do_execute(
|
|
293
|
+
ctx: click.Context,
|
|
294
|
+
uid: Optional[str],
|
|
295
|
+
*,
|
|
296
|
+
verb: str,
|
|
297
|
+
bound_type: Optional[str],
|
|
298
|
+
type_flag: Optional[str],
|
|
299
|
+
use_stdin: bool,
|
|
300
|
+
group: str = "block",
|
|
301
|
+
) -> None:
|
|
302
|
+
help_topic = f"pm help {group}"
|
|
303
|
+
# Mixed uid + --stdin: the contract is one or the other (codex R4 #5).
|
|
304
|
+
try:
|
|
305
|
+
if uid and use_stdin:
|
|
306
|
+
raise PMError(
|
|
307
|
+
error_code="argv_invalid",
|
|
308
|
+
message=(
|
|
309
|
+
"Pass <uid> OR --stdin, not both. "
|
|
310
|
+
"<uid> runs an existing block; --stdin runs a transient "
|
|
311
|
+
"override against an assigned scratch block."
|
|
312
|
+
),
|
|
313
|
+
help=help_topic,
|
|
314
|
+
)
|
|
315
|
+
if not uid and not use_stdin:
|
|
316
|
+
raise PMError(
|
|
317
|
+
error_code="argv_invalid",
|
|
318
|
+
message=f"`{verb}` needs either <uid> or --stdin",
|
|
319
|
+
help=help_topic,
|
|
320
|
+
)
|
|
321
|
+
# When running an existing block by uid, the stored block.type is
|
|
322
|
+
# authoritative — silently accepting --type would let a stale or
|
|
323
|
+
# mistaken override hide a real type mismatch (e.g. a user thinks
|
|
324
|
+
# they're running a sql block but the uid actually belongs to an
|
|
325
|
+
# email block). Force the operator to remove the flag.
|
|
326
|
+
if uid and type_flag:
|
|
327
|
+
raise PMError(
|
|
328
|
+
error_code="argv_invalid",
|
|
329
|
+
message=(
|
|
330
|
+
f"`{verb} <uid>` ignores --type; the block's stored type "
|
|
331
|
+
f"is authoritative. Remove --type, or use `--stdin` "
|
|
332
|
+
f"(without <uid>) to {verb} a transient override "
|
|
333
|
+
f"against an assigned scratch block of the specified type."
|
|
334
|
+
),
|
|
335
|
+
received=f"--type={type_flag!r}",
|
|
336
|
+
help=help_topic,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if uid:
|
|
340
|
+
client = _client(ctx)
|
|
341
|
+
result = client.blocks.execute_existing(uid, verb=verb)
|
|
342
|
+
client.persist()
|
|
343
|
+
emit(result, json_output=ctx.obj["json"])
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# --stdin path: validate body against the bound or --type-provided
|
|
347
|
+
# type. The body is the transient content_json override applied to
|
|
348
|
+
# an assigned scratch block of `block_type` — no new persisted
|
|
349
|
+
# block. Mirrors the legacy ``run_sql`` agent tool.
|
|
350
|
+
block_type = _resolve_type(bound_type, type_flag, verb=verb)
|
|
351
|
+
body = _read_stdin_json()
|
|
352
|
+
validate_content_json(block_type, body, mode="create")
|
|
353
|
+
except PMError as err:
|
|
354
|
+
emit_error(err)
|
|
355
|
+
|
|
356
|
+
# Translate user-facing keys to backend keys (same as the regular
|
|
357
|
+
# create path — codex review pass 2 fix).
|
|
358
|
+
body = normalize_content_json(block_type, body)
|
|
359
|
+
|
|
360
|
+
client = _client(ctx)
|
|
361
|
+
result = client.blocks.execute_against_scratch(
|
|
362
|
+
block_type=block_type,
|
|
363
|
+
content_json=body,
|
|
364
|
+
verb=verb,
|
|
365
|
+
)
|
|
366
|
+
client.persist()
|
|
367
|
+
emit(result, json_output=ctx.obj["json"])
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ── Subcommand constructors ────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _make_get() -> click.Command:
|
|
374
|
+
@click.command(name="get")
|
|
375
|
+
@click.argument("uid")
|
|
376
|
+
@json_option
|
|
377
|
+
@click.pass_context
|
|
378
|
+
def get(ctx: click.Context, uid: str) -> None:
|
|
379
|
+
"""Show one block (full JSON in --json mode)."""
|
|
380
|
+
_do_get(ctx, uid)
|
|
381
|
+
return get
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _make_list(block_type: Optional[str]) -> click.Command:
|
|
385
|
+
# Plumbing groups expose --type; bound groups hide it (the filter is implicit).
|
|
386
|
+
if block_type is None:
|
|
387
|
+
@click.command(name="list")
|
|
388
|
+
@click.option("--type", "type_flag", default=None,
|
|
389
|
+
help="Filter by block type (e.g. sql, email, plotly).")
|
|
390
|
+
@name_filter_option(matches="name or display name")
|
|
391
|
+
@limit_option
|
|
392
|
+
@json_option
|
|
393
|
+
@click.pass_context
|
|
394
|
+
def list_(
|
|
395
|
+
ctx: click.Context,
|
|
396
|
+
type_flag: Optional[str],
|
|
397
|
+
name_filter: Optional[str],
|
|
398
|
+
limit: Optional[int],
|
|
399
|
+
) -> None:
|
|
400
|
+
"""List blocks in the current workspace."""
|
|
401
|
+
_do_list(ctx, bound_type=None, type_flag=type_flag, name_filter=name_filter, limit=limit)
|
|
402
|
+
return list_
|
|
403
|
+
|
|
404
|
+
@click.command(name="list")
|
|
405
|
+
@name_filter_option(matches="name or display name")
|
|
406
|
+
@limit_option
|
|
407
|
+
@json_option
|
|
408
|
+
@click.pass_context
|
|
409
|
+
def list_(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
|
|
410
|
+
_do_list(ctx, bound_type=block_type, type_flag=None, name_filter=name_filter, limit=limit)
|
|
411
|
+
list_.help = f"List {_blocks(block_type)} in the current workspace."
|
|
412
|
+
return list_
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _make_create(
|
|
416
|
+
block_type: Optional[str],
|
|
417
|
+
*,
|
|
418
|
+
create_has_side_effect: bool,
|
|
419
|
+
custom_help: Optional[str],
|
|
420
|
+
) -> click.Command:
|
|
421
|
+
bound = block_type
|
|
422
|
+
|
|
423
|
+
@click.command(name="create")
|
|
424
|
+
@click.option("--name", required=True, help="Block name.")
|
|
425
|
+
@click.option("--verbose-name", default=None,
|
|
426
|
+
help="Display name shown in the app (defaults to --name).")
|
|
427
|
+
@click.option("--stdin", "use_stdin", is_flag=True,
|
|
428
|
+
help="Read the JSON body from stdin. Shape: the block's content fields (see pm help <group>).")
|
|
429
|
+
@click.option(
|
|
430
|
+
"--type", "type_flag", default=None,
|
|
431
|
+
hidden=bound is not None,
|
|
432
|
+
help="Block type (sql, email, plotly, ...).",
|
|
433
|
+
)
|
|
434
|
+
@json_option
|
|
435
|
+
@click.pass_context
|
|
436
|
+
def create(
|
|
437
|
+
ctx: click.Context,
|
|
438
|
+
name: str,
|
|
439
|
+
verbose_name: Optional[str],
|
|
440
|
+
use_stdin: bool,
|
|
441
|
+
type_flag: Optional[str],
|
|
442
|
+
) -> None:
|
|
443
|
+
_do_create(
|
|
444
|
+
ctx,
|
|
445
|
+
bound_type=bound,
|
|
446
|
+
type_flag=type_flag,
|
|
447
|
+
name=name,
|
|
448
|
+
verbose_name=verbose_name,
|
|
449
|
+
use_stdin=use_stdin,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if custom_help:
|
|
453
|
+
create.help = custom_help
|
|
454
|
+
elif create_has_side_effect:
|
|
455
|
+
create.help = (
|
|
456
|
+
f"Create {_a_block(bound)} and render it.\n\n"
|
|
457
|
+
"Creating this block type also renders it once; the result is "
|
|
458
|
+
"available with results <uid> --last."
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
create.help = (
|
|
462
|
+
f"Create {_a_block(bound)} without running it."
|
|
463
|
+
if bound is not None
|
|
464
|
+
else (
|
|
465
|
+
"Create a block of any type without running it.\n\n"
|
|
466
|
+
"--type is required here; the typed groups (pm sql, pm email, "
|
|
467
|
+
"...) set it for you."
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
return create
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _make_update() -> click.Command:
|
|
474
|
+
@click.command(name="update")
|
|
475
|
+
@click.argument("uid")
|
|
476
|
+
@click.option("--name", default=None, help="Rename the block.")
|
|
477
|
+
@click.option("--verbose-name", default=None, help="Change the display name.")
|
|
478
|
+
@click.option("--stdin", "use_stdin", is_flag=True,
|
|
479
|
+
help="Read the JSON body from stdin. Replaces the block's content fields.")
|
|
480
|
+
@json_option
|
|
481
|
+
@click.pass_context
|
|
482
|
+
def update(
|
|
483
|
+
ctx: click.Context,
|
|
484
|
+
uid: str,
|
|
485
|
+
name: Optional[str],
|
|
486
|
+
verbose_name: Optional[str],
|
|
487
|
+
use_stdin: bool,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Update editable fields on an existing block.
|
|
490
|
+
|
|
491
|
+
At least one of --stdin, --name, or --verbose-name must be passed.
|
|
492
|
+
"""
|
|
493
|
+
_do_update(
|
|
494
|
+
ctx,
|
|
495
|
+
uid,
|
|
496
|
+
name=name,
|
|
497
|
+
verbose_name=verbose_name,
|
|
498
|
+
use_stdin=use_stdin,
|
|
499
|
+
)
|
|
500
|
+
return update
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _make_delete() -> click.Command:
|
|
504
|
+
@click.command(name="delete")
|
|
505
|
+
@click.argument("uid")
|
|
506
|
+
@yes_option
|
|
507
|
+
@json_option
|
|
508
|
+
@click.pass_context
|
|
509
|
+
def delete(ctx: click.Context, uid: str, yes: bool) -> None:
|
|
510
|
+
"""Delete a block."""
|
|
511
|
+
_do_delete(ctx, uid, yes=yes)
|
|
512
|
+
return delete
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _make_results() -> click.Command:
|
|
516
|
+
"""`<group> results <uid>` — fetch past execution result(s) for a block.
|
|
517
|
+
|
|
518
|
+
Auto-injected by `make_block_group` on every group whose `execute_verb`
|
|
519
|
+
is set (results only make sense for blocks that execute). Block-type-
|
|
520
|
+
agnostic: same logic regardless of group, because the backend's
|
|
521
|
+
`/results/` endpoint normalizes across the `query` (SQL) and
|
|
522
|
+
`block_execution` (every other type) underlying tables.
|
|
523
|
+
"""
|
|
524
|
+
@click.command(name="results")
|
|
525
|
+
@click.argument("uid")
|
|
526
|
+
@click.option(
|
|
527
|
+
"--last",
|
|
528
|
+
is_flag=True,
|
|
529
|
+
help=(
|
|
530
|
+
"Fetch only the most recent result. Shorthand for "
|
|
531
|
+
"`--result-id last_result`. Mutually exclusive with --result-id."
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
@click.option(
|
|
535
|
+
"--result-id",
|
|
536
|
+
default=None,
|
|
537
|
+
help=(
|
|
538
|
+
"Fetch one specific result by id. Without --last or this flag, "
|
|
539
|
+
"returns the list of recent results (backend caps ~10)."
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
@json_option
|
|
543
|
+
@click.pass_context
|
|
544
|
+
def results(
|
|
545
|
+
ctx: click.Context,
|
|
546
|
+
uid: str,
|
|
547
|
+
last: bool,
|
|
548
|
+
result_id: Optional[str],
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Show past execution result(s) for this block.
|
|
551
|
+
|
|
552
|
+
Use --last (or --result-id last_result) to get the most recent
|
|
553
|
+
payload — the typical follow-up to `<group> run`. Default (no
|
|
554
|
+
flags) returns the recent-results list with metadata only.
|
|
555
|
+
"""
|
|
556
|
+
_do_results(ctx, uid, last=last, result_id=result_id)
|
|
557
|
+
return results
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def make_execute_subcommand(
|
|
561
|
+
verb: str,
|
|
562
|
+
block_type: Optional[str],
|
|
563
|
+
*,
|
|
564
|
+
custom_help: Optional[str] = None,
|
|
565
|
+
group: str = "block",
|
|
566
|
+
) -> click.Command:
|
|
567
|
+
"""Return a Click command for `run` / `send`.
|
|
568
|
+
|
|
569
|
+
With <uid>: hits BlocksClient.execute_existing(uid, verb=verb).
|
|
570
|
+
With --stdin (and bound or --type-provided block_type): hits
|
|
571
|
+
BlocksClient.execute_against_scratch(block_type=..., content_json=...,
|
|
572
|
+
verb=verb) — finds an assigned scratch block of the type, runs with
|
|
573
|
+
the body as a transient content_json override. Mirrors the legacy
|
|
574
|
+
``run_sql`` / ``send_email`` agent tools — no new block is persisted.
|
|
575
|
+
Mixed (uid + --stdin): structured error envelope (codex R4 #5).
|
|
576
|
+
|
|
577
|
+
Exposed standalone for groups that want to bolt execute on without
|
|
578
|
+
going through ``make_block_group`` — none today, but the symmetry
|
|
579
|
+
matches the §3.8 spec.
|
|
580
|
+
"""
|
|
581
|
+
if verb not in EXECUTE_VERBS:
|
|
582
|
+
raise ValueError(
|
|
583
|
+
f"make_execute_subcommand: verb must be 'run' or 'send', got {verb!r}"
|
|
584
|
+
)
|
|
585
|
+
bound = block_type
|
|
586
|
+
|
|
587
|
+
@click.command(name=verb)
|
|
588
|
+
@click.argument("uid", required=False)
|
|
589
|
+
@click.option("--stdin", "use_stdin", is_flag=True,
|
|
590
|
+
help=(
|
|
591
|
+
f"Read the JSON body from stdin and {verb} it once "
|
|
592
|
+
"without saving a block."
|
|
593
|
+
))
|
|
594
|
+
@click.option(
|
|
595
|
+
"--type", "type_flag", default=None,
|
|
596
|
+
hidden=bound is not None,
|
|
597
|
+
help="Block type (sql, email, plotly, ...).",
|
|
598
|
+
)
|
|
599
|
+
@json_option
|
|
600
|
+
@click.pass_context
|
|
601
|
+
def cmd(
|
|
602
|
+
ctx: click.Context,
|
|
603
|
+
uid: Optional[str],
|
|
604
|
+
use_stdin: bool,
|
|
605
|
+
type_flag: Optional[str],
|
|
606
|
+
) -> None:
|
|
607
|
+
_do_execute(
|
|
608
|
+
ctx,
|
|
609
|
+
uid,
|
|
610
|
+
verb=verb,
|
|
611
|
+
bound_type=bound,
|
|
612
|
+
type_flag=type_flag,
|
|
613
|
+
use_stdin=use_stdin,
|
|
614
|
+
group=group,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if custom_help:
|
|
618
|
+
cmd.help = custom_help
|
|
619
|
+
else:
|
|
620
|
+
action = "Run" if verb == "run" else "Send"
|
|
621
|
+
what = _a_block(bound) if bound is not None else "a block of any type"
|
|
622
|
+
cmd.help = (
|
|
623
|
+
f"{action} {what}.\n\n"
|
|
624
|
+
f"Pass UID to {verb} a saved block, or --stdin to {verb} a JSON "
|
|
625
|
+
f"body once without saving it"
|
|
626
|
+
+ ("" if bound is not None else " (--type required)")
|
|
627
|
+
+ "."
|
|
628
|
+
)
|
|
629
|
+
return cmd
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ── Public entrypoint ─────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def make_block_group(
|
|
636
|
+
name: str,
|
|
637
|
+
block_type: Optional[str] = None,
|
|
638
|
+
*,
|
|
639
|
+
crud_verbs: tuple[str, ...] = (),
|
|
640
|
+
execute_verb: Optional[str] = None,
|
|
641
|
+
create_has_side_effect: bool = False,
|
|
642
|
+
custom_verbs: Optional[Dict[str, click.Command]] = None,
|
|
643
|
+
verb_help: Optional[Dict[str, str]] = None,
|
|
644
|
+
help_text: Optional[str] = None,
|
|
645
|
+
) -> click.Group:
|
|
646
|
+
"""Build a Click group for a block-typed CLI surface.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
name: group name (``"sql"``, ``"email"``, ``"block"``, etc.).
|
|
650
|
+
block_type: bound block type for domain groups, or ``None`` for
|
|
651
|
+
plumbing (``pm block``), where ``--type`` is required on
|
|
652
|
+
create / run / send.
|
|
653
|
+
crud_verbs: subset of ``CRUD_VERBS`` (``"get"``, ``"list"``,
|
|
654
|
+
``"create"``, ``"update"``, ``"delete"``).
|
|
655
|
+
execute_verb: ``"run"``, ``"send"``, or ``None`` (no execute).
|
|
656
|
+
Groups have at most one — execute semantics are unambiguous
|
|
657
|
+
per group.
|
|
658
|
+
create_has_side_effect: only changes the ``create`` help text
|
|
659
|
+
(currently used for ``plotly``, where create auto-renders the
|
|
660
|
+
PNG as a side effect — plan §3.0 carve-out).
|
|
661
|
+
custom_verbs: optional ``{verb_name: click.Command}`` mapping for
|
|
662
|
+
direct-API operations that don't fit the standard lifecycle
|
|
663
|
+
(Tableau ``search``, metrics-view ``query``, slides
|
|
664
|
+
``parse-template`` / ``batch-update`` / ``export``).
|
|
665
|
+
verb_help: optional ``{verb: help_text}`` mapping that overrides
|
|
666
|
+
the factory-generated help string per verb. Used for the
|
|
667
|
+
documented exceptions (codex R3 #6 — tableau ``run`` must
|
|
668
|
+
start with "Exports/downloads a Tableau view ...").
|
|
669
|
+
help_text: group-level docstring shown by ``pm <group> --help``.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
A ``click.Group`` (``AliasedGroup`` subclass; ``ls`` aliases to
|
|
673
|
+
``list``) with the requested verbs registered. Adding more
|
|
674
|
+
commands later is just ``group.add_command(cmd, name=...)``.
|
|
675
|
+
"""
|
|
676
|
+
invalid_crud = [v for v in crud_verbs if v not in CRUD_VERBS]
|
|
677
|
+
if invalid_crud:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
f"make_block_group({name!r}): unknown crud_verbs {invalid_crud!r}; "
|
|
680
|
+
f"must be a subset of {CRUD_VERBS}"
|
|
681
|
+
)
|
|
682
|
+
if execute_verb is not None and execute_verb not in EXECUTE_VERBS:
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"make_block_group({name!r}): execute_verb must be 'run', 'send', "
|
|
685
|
+
f"or None; got {execute_verb!r}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
overrides = verb_help or {}
|
|
689
|
+
|
|
690
|
+
@click.group(
|
|
691
|
+
name=name,
|
|
692
|
+
cls=AliasedGroup,
|
|
693
|
+
aliases={"ls": "list"},
|
|
694
|
+
)
|
|
695
|
+
def group() -> None:
|
|
696
|
+
pass
|
|
697
|
+
|
|
698
|
+
group.help = help_text or (
|
|
699
|
+
"Blocks of any type."
|
|
700
|
+
if block_type is None
|
|
701
|
+
else f"{_blocks(block_type)[0].upper()}{_blocks(block_type)[1:]}.\n\nBlock type: {block_type}."
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
if "get" in crud_verbs:
|
|
705
|
+
cmd = _make_get()
|
|
706
|
+
cmd.help = overrides.get("get", cmd.help)
|
|
707
|
+
group.add_command(cmd)
|
|
708
|
+
if "list" in crud_verbs:
|
|
709
|
+
cmd = _make_list(block_type)
|
|
710
|
+
cmd.help = overrides.get("list", cmd.help)
|
|
711
|
+
group.add_command(cmd)
|
|
712
|
+
if "create" in crud_verbs:
|
|
713
|
+
cmd = _make_create(
|
|
714
|
+
block_type,
|
|
715
|
+
create_has_side_effect=create_has_side_effect,
|
|
716
|
+
custom_help=overrides.get("create"),
|
|
717
|
+
)
|
|
718
|
+
group.add_command(cmd)
|
|
719
|
+
if "update" in crud_verbs:
|
|
720
|
+
cmd = _make_update()
|
|
721
|
+
cmd.help = overrides.get("update", cmd.help)
|
|
722
|
+
group.add_command(cmd)
|
|
723
|
+
if "delete" in crud_verbs:
|
|
724
|
+
cmd = _make_delete()
|
|
725
|
+
cmd.help = overrides.get("delete", cmd.help)
|
|
726
|
+
group.add_command(cmd)
|
|
727
|
+
|
|
728
|
+
if execute_verb is not None:
|
|
729
|
+
group.add_command(
|
|
730
|
+
make_execute_subcommand(
|
|
731
|
+
execute_verb,
|
|
732
|
+
block_type,
|
|
733
|
+
custom_help=overrides.get(execute_verb),
|
|
734
|
+
group=name,
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
# `results` is the natural follow-up to `run`/`send` — auto-inject
|
|
738
|
+
# so `pm sql results <uid>`, `pm email results <uid>`, etc. all
|
|
739
|
+
# work without each domain file having to wire it explicitly.
|
|
740
|
+
# Groups without execute_verb (explore today) don't get results
|
|
741
|
+
# because there's no execution history to fetch.
|
|
742
|
+
results_cmd = _make_results()
|
|
743
|
+
results_cmd.help = overrides.get("results", results_cmd.help)
|
|
744
|
+
group.add_command(results_cmd)
|
|
745
|
+
|
|
746
|
+
if custom_verbs:
|
|
747
|
+
for verb_name, cmd in custom_verbs.items():
|
|
748
|
+
if verb_name in CRUD_VERBS or verb_name in EXECUTE_VERBS:
|
|
749
|
+
raise ValueError(
|
|
750
|
+
f"make_block_group({name!r}): custom_verb {verb_name!r} "
|
|
751
|
+
f"collides with a standard lifecycle verb; rename it."
|
|
752
|
+
)
|
|
753
|
+
if verb_name == "results":
|
|
754
|
+
raise ValueError(
|
|
755
|
+
f"make_block_group({name!r}): custom_verb 'results' "
|
|
756
|
+
f"collides with the auto-injected results verb; remove "
|
|
757
|
+
f"the custom_verb entry (results is included automatically "
|
|
758
|
+
f"on every group with execute_verb set)."
|
|
759
|
+
)
|
|
760
|
+
group.add_command(cmd, name=verb_name)
|
|
761
|
+
|
|
762
|
+
return group
|