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.
Files changed (86) hide show
  1. pm/__init__.py +1 -0
  2. pm/__main__.py +4 -0
  3. pm/_alias.py +164 -0
  4. pm/auth/__init__.py +0 -0
  5. pm/auth/device_flow.py +187 -0
  6. pm/auth/exchange.py +79 -0
  7. pm/auth/jwt_cache.py +163 -0
  8. pm/auth/secret_files.py +58 -0
  9. pm/auth/session.py +440 -0
  10. pm/auth/store.py +160 -0
  11. pm/auth/token.py +289 -0
  12. pm/cli.py +282 -0
  13. pm/client/__init__.py +68 -0
  14. pm/client/access_tokens.py +54 -0
  15. pm/client/base.py +229 -0
  16. pm/client/blocks.py +461 -0
  17. pm/client/databases.py +147 -0
  18. pm/client/favorites.py +14 -0
  19. pm/client/folders.py +10 -0
  20. pm/client/integrations.py +14 -0
  21. pm/client/organizations.py +20 -0
  22. pm/client/recipients.py +92 -0
  23. pm/client/reports.py +148 -0
  24. pm/client/roles.py +100 -0
  25. pm/client/runs.py +134 -0
  26. pm/client/saved_blocks.py +13 -0
  27. pm/client/tableau.py +108 -0
  28. pm/client/users.py +71 -0
  29. pm/client/webhooks.py +43 -0
  30. pm/client/workspaces.py +34 -0
  31. pm/commands/__init__.py +0 -0
  32. pm/commands/_block_factory.py +762 -0
  33. pm/commands/_helpers.py +178 -0
  34. pm/commands/auth.py +222 -0
  35. pm/commands/block.py +26 -0
  36. pm/commands/chart.py +20 -0
  37. pm/commands/db.py +291 -0
  38. pm/commands/email.py +13 -0
  39. pm/commands/explore.py +20 -0
  40. pm/commands/favorites.py +77 -0
  41. pm/commands/folders.py +113 -0
  42. pm/commands/help.py +643 -0
  43. pm/commands/integrations.py +95 -0
  44. pm/commands/metrics_view.py +195 -0
  45. pm/commands/organizations.py +56 -0
  46. pm/commands/recipient.py +226 -0
  47. pm/commands/reports.py +364 -0
  48. pm/commands/roles.py +102 -0
  49. pm/commands/runs.py +214 -0
  50. pm/commands/saved_blocks.py +97 -0
  51. pm/commands/sftp.py +19 -0
  52. pm/commands/slack.py +13 -0
  53. pm/commands/slides.py +216 -0
  54. pm/commands/sql.py +17 -0
  55. pm/commands/tableau.py +148 -0
  56. pm/commands/token.py +107 -0
  57. pm/commands/users.py +262 -0
  58. pm/commands/webhooks.py +132 -0
  59. pm/commands/workspace.py +156 -0
  60. pm/config.py +192 -0
  61. pm/errors.py +228 -0
  62. pm/log.py +63 -0
  63. pm/models/__init__.py +31 -0
  64. pm/models/access_tokens.py +25 -0
  65. pm/models/blocks.py +21 -0
  66. pm/models/databases.py +15 -0
  67. pm/models/favorites.py +14 -0
  68. pm/models/folders.py +14 -0
  69. pm/models/integrations.py +20 -0
  70. pm/models/organizations.py +13 -0
  71. pm/models/recipients.py +41 -0
  72. pm/models/reports.py +16 -0
  73. pm/models/roles.py +22 -0
  74. pm/models/runs.py +56 -0
  75. pm/models/saved_blocks.py +15 -0
  76. pm/models/users.py +36 -0
  77. pm/models/webhooks.py +23 -0
  78. pm/models/workspaces.py +16 -0
  79. pm/output.py +92 -0
  80. pm/types.py +130 -0
  81. pm/validators.py +499 -0
  82. pushmetrics_cli-0.1.0.dist-info/METADATA +221 -0
  83. pushmetrics_cli-0.1.0.dist-info/RECORD +86 -0
  84. pushmetrics_cli-0.1.0.dist-info/WHEEL +5 -0
  85. pushmetrics_cli-0.1.0.dist-info/entry_points.txt +2 -0
  86. 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