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
pm/commands/reports.py ADDED
@@ -0,0 +1,364 @@
1
+ """Report commands."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import click
8
+
9
+ from pm._alias import AliasedGroup
10
+ from pm.client import PMClient
11
+ from pm.client.base import PMApiError
12
+ from pm.commands._helpers import (
13
+ confirm_delete,
14
+ info,
15
+ json_option,
16
+ merge_fields,
17
+ parse_data,
18
+ peek_name,
19
+ report_deleted,
20
+ yes_option,
21
+ limit_option,
22
+ name_filter_option,
23
+ select_rows,
24
+ )
25
+ from pm.output import emit
26
+
27
+
28
+ @click.group(name="reports", cls=AliasedGroup, aliases={"ls": "list"})
29
+ def reports() -> None:
30
+ """Reports and their runs."""
31
+
32
+
33
+ @reports.command(name="list")
34
+ @name_filter_option
35
+ @limit_option
36
+ @json_option
37
+ @click.pass_context
38
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
39
+ """List reports in the current workspace."""
40
+ profile = ctx.obj["profile"]
41
+ json_output = ctx.obj["json"]
42
+
43
+ client = PMClient.from_profile(profile)
44
+ # list_all (not list): FAB caps a single page at ~100 rows, so a plain
45
+ # list() lets the agent undercount a large workspace and present the
46
+ # capped page as the total (support session GYmXdRekqy, 2026-07-19).
47
+ # --limit without --name only needs the first N rows, so stop paging
48
+ # there; a plain `report list` (what the agent calls) still walks every
49
+ # page so the count is the workspace total.
50
+ fetch_limit = limit if name_filter is None else None
51
+ items = select_rows(client.reports.list_all(limit=fetch_limit), name=name_filter, limit=limit)
52
+ client.persist()
53
+
54
+ emit(
55
+ items,
56
+ json_output=json_output,
57
+ columns=[
58
+ ("UID", "uid"),
59
+ ("NAME", "name"),
60
+ ("CREATED", "created_on"),
61
+ ("WORKFLOW", lambda r: "y" if r.get("workflow_enabled") else ""),
62
+ ],
63
+ )
64
+
65
+
66
+ @reports.command(name="get")
67
+ @click.argument("uid")
68
+ @json_option
69
+ @click.pass_context
70
+ def get(ctx: click.Context, uid: str) -> None:
71
+ """Show one report (use --json for the full record)."""
72
+ profile = ctx.obj["profile"]
73
+ json_output = ctx.obj["json"]
74
+
75
+ client = PMClient.from_profile(profile)
76
+ report = client.reports.get(uid)
77
+ client.persist()
78
+
79
+ if json_output:
80
+ emit(report, json_output=True)
81
+ else:
82
+ emit(
83
+ {
84
+ "uid": report.uid,
85
+ "name": report.name,
86
+ "workflow_enabled": report.workflow_enabled,
87
+ },
88
+ json_output=False,
89
+ )
90
+
91
+
92
+ @reports.command(name="run")
93
+ @click.argument("uid")
94
+ @click.option(
95
+ "--send-to-me",
96
+ is_flag=True,
97
+ help=(
98
+ "Redirect email destination blocks to the calling user (or "
99
+ "--email) and suppress all other destination blocks "
100
+ "(slack/webhook/sftp/cloud-storage). Useful for preview runs "
101
+ "that shouldn't fire real notifications."
102
+ ),
103
+ )
104
+ @click.option(
105
+ "--email",
106
+ default=None,
107
+ help=(
108
+ "Address that receives the email blocks when --send-to-me is set. "
109
+ "Defaults to your own address; pass a saved recipient's address to "
110
+ "preview as them. Requires --send-to-me."
111
+ ),
112
+ )
113
+ @json_option
114
+ @click.pass_context
115
+ def run(ctx: click.Context, uid: str, send_to_me: bool, email: Optional[str]) -> None:
116
+ """Run a report now.
117
+
118
+ Prints the new run's uid immediately; the run itself happens in the
119
+ background. Check on it with pm run get <uid> or pm run logs <uid>.
120
+ """
121
+ profile = ctx.obj["profile"]
122
+ json_output = ctx.obj["json"]
123
+
124
+ if email and not send_to_me:
125
+ raise click.UsageError("--email requires --send-to-me")
126
+
127
+ client = PMClient.from_profile(profile)
128
+
129
+ # Auto-detect: without an email, the backend suppresses email blocks
130
+ # instead of redirecting them.
131
+ if send_to_me and not email:
132
+ try:
133
+ email = client.users.current().email
134
+ except PMApiError as exc:
135
+ raise click.ClickException(
136
+ f"--send-to-me needs an email; current user lookup failed: {exc}"
137
+ )
138
+
139
+ result = client.reports.run(uid, send_to_me=send_to_me, email=email)
140
+ client.persist()
141
+
142
+ payload = result.get("result") if isinstance(result, dict) and isinstance(result.get("result"), dict) else result
143
+ run_uid = payload.get("noteflow_run_uid") if isinstance(payload, dict) else None
144
+ if json_output or not run_uid:
145
+ emit(result, json_output=json_output)
146
+ return
147
+ # stdout is data: the uid alone, so RUN=$(pm report run X) works.
148
+ click.echo(run_uid)
149
+ info(ctx, f"Started run {run_uid}. Follow it with: pm run get {run_uid}")
150
+
151
+
152
+ @reports.command(name="yaml")
153
+ @click.argument("uid")
154
+ @json_option
155
+ @click.pass_context
156
+ def yaml_(ctx: click.Context, uid: str) -> None:
157
+ """Export a report as YAML to stdout.
158
+
159
+ --json is ignored here: the output is the YAML document itself, ready
160
+ for pm report apply.
161
+ """
162
+ profile = ctx.obj["profile"]
163
+ client = PMClient.from_profile(profile)
164
+ yaml_text = client.reports.yaml(uid)
165
+ client.persist()
166
+ # Write directly to avoid emit's trailing newline (YAML already has one).
167
+ sys.stdout.write(yaml_text)
168
+ if not yaml_text.endswith("\n"):
169
+ sys.stdout.write("\n")
170
+
171
+
172
+ @reports.command(name="delete")
173
+ @click.argument("uid")
174
+ @yes_option
175
+ @json_option
176
+ @click.pass_context
177
+ def delete(ctx: click.Context, uid: str, yes: bool) -> None:
178
+ """Delete a report.
179
+
180
+ The report's blocks stay in the workspace; its run history is removed
181
+ with it. This cannot be undone.
182
+ """
183
+ profile = ctx.obj["profile"]
184
+
185
+ if not yes:
186
+ name = peek_name(lambda: PMClient.from_profile(profile).reports.get(uid), "name")
187
+ confirm_delete("report", uid, name)
188
+
189
+ client = PMClient.from_profile(profile)
190
+ client.reports.delete(uid)
191
+ client.persist()
192
+ report_deleted(ctx, "report", uid)
193
+
194
+
195
+ @reports.command(name="apply")
196
+ @click.argument("file", type=click.File("r", encoding="utf-8"))
197
+ @click.option(
198
+ "--uid",
199
+ default=None,
200
+ help=(
201
+ "Update this existing report instead of creating a new one. "
202
+ "Pass - as FILE to read the YAML from stdin."
203
+ ),
204
+ )
205
+ @click.option(
206
+ "--allow-create-folder",
207
+ is_flag=True,
208
+ help=(
209
+ "Create the folder named in the YAML if it does not exist. By "
210
+ "default an unknown folder path is rejected."
211
+ ),
212
+ )
213
+ @click.option(
214
+ "--no-prune",
215
+ is_flag=True,
216
+ help=(
217
+ "With --uid: keep blocks that are in the report but missing from "
218
+ "the YAML. By default they are removed."
219
+ ),
220
+ )
221
+ @click.option(
222
+ "--dry-run",
223
+ is_flag=True,
224
+ help=(
225
+ "With --uid: show what would change (a summary and a YAML diff) "
226
+ "without saving anything."
227
+ ),
228
+ )
229
+ @json_option
230
+ @click.pass_context
231
+ def apply(
232
+ ctx: click.Context,
233
+ file,
234
+ uid: Optional[str],
235
+ allow_create_folder: bool,
236
+ no_prune: bool,
237
+ dry_run: bool,
238
+ ) -> None:
239
+ """Create or update a report from YAML.
240
+
241
+ \b
242
+ To round-trip an existing report:
243
+ pm report yaml <uid> > report.yaml
244
+ pm report apply report.yaml --uid <uid> full replace
245
+ pm report apply report.yaml --uid <uid> --no-prune keep extra blocks
246
+ pm report apply report.yaml --uid <uid> --dry-run preview the diff
247
+ """
248
+ profile = ctx.obj["profile"]
249
+ json_output = ctx.obj["json"]
250
+
251
+ yaml_text = file.read()
252
+ if not yaml_text.strip():
253
+ raise click.ClickException("YAML body is empty.")
254
+
255
+ if (no_prune or dry_run) and not uid:
256
+ raise click.UsageError("--no-prune and --dry-run need --uid.")
257
+
258
+ client = PMClient.from_profile(profile)
259
+ if uid:
260
+ result = client.reports.update_yaml(
261
+ uid,
262
+ yaml_text,
263
+ prune=not no_prune,
264
+ dry_run=dry_run,
265
+ allow_create_folder=allow_create_folder,
266
+ )
267
+ action = "dry-run" if dry_run else "updated"
268
+ else:
269
+ result = client.reports.apply_yaml(
270
+ yaml_text,
271
+ allow_create_folder=allow_create_folder,
272
+ )
273
+ action = "created"
274
+ client.persist()
275
+
276
+ if json_output:
277
+ emit(result, json_output=True)
278
+ return
279
+
280
+ if dry_run:
281
+ # Human-mode dry-run summary
282
+ click.echo(f"dry-run for {uid}:")
283
+ if not result.get("would_change"):
284
+ click.echo(" no changes")
285
+ return
286
+ summary = result.get("summary") or {}
287
+ for k, v in summary.items():
288
+ click.echo(f" {k}: {v}")
289
+ diff = result.get("yaml_diff", "")
290
+ if diff:
291
+ click.echo("")
292
+ click.echo(diff)
293
+ return
294
+
295
+ res_uid = result.get("uid", "")
296
+ res_name = result.get("name", "")
297
+ click.echo(f"{action}: {res_uid} {res_name}")
298
+ warn = result.get("warning_duplicate_uids")
299
+ if warn:
300
+ click.echo(
301
+ f" warning: {len(warn)} existing report(s) with the same name in this workspace: "
302
+ + ", ".join(warn)
303
+ + " -- use `pm report apply ... --uid <one>` to update instead."
304
+ )
305
+
306
+
307
+ @reports.command(name="update")
308
+ @click.argument("uid")
309
+ @click.option("--folder-id", type=int, default=None,
310
+ help="Move the report into the folder with this numeric id.")
311
+ @click.option("--name", default=None, help="Rename the report.")
312
+ @click.option("--data", default=None,
313
+ help="Extra fields as a JSON object (merged with --folder-id / --name).")
314
+ @json_option
315
+ @click.pass_context
316
+ def update(
317
+ ctx: click.Context,
318
+ uid: str,
319
+ folder_id: Optional[int],
320
+ name: Optional[str],
321
+ data: Optional[str],
322
+ ) -> None:
323
+ """Update a report's name, folder or other fields.
324
+
325
+ Use --data for fields without a flag of their own, e.g.
326
+ --data '{"options": {...}}'. Unknown fields are ignored.
327
+ """
328
+ payload = merge_fields(parse_data(data), {
329
+ "folder_id": folder_id,
330
+ "name": name,
331
+ })
332
+ if not payload:
333
+ raise click.UsageError("Nothing to update; pass --folder-id, --name, or --data.")
334
+ profile = ctx.obj["profile"]
335
+ client = PMClient.from_profile(profile)
336
+ report = client.reports.update(uid, payload)
337
+ client.persist()
338
+ emit(report, json_output=ctx.obj["json"])
339
+
340
+
341
+ @reports.command(name="runs")
342
+ @click.argument("uid")
343
+ @json_option
344
+ @click.pass_context
345
+ def runs(ctx: click.Context, uid: str) -> None:
346
+ """List the runs of a report."""
347
+ profile = ctx.obj["profile"]
348
+ json_output = ctx.obj["json"]
349
+
350
+ client = PMClient.from_profile(profile)
351
+ items = client.reports.runs(uid)
352
+ client.persist()
353
+
354
+ emit(
355
+ items,
356
+ json_output=json_output,
357
+ columns=[
358
+ ("UID", "uid"),
359
+ ("NAME", "name"),
360
+ ("STATUS", "status"),
361
+ ("START", "start_time"),
362
+ ("END", "end_time"),
363
+ ],
364
+ )
pm/commands/roles.py ADDED
@@ -0,0 +1,102 @@
1
+ """Role / group commands."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import click
7
+
8
+ from pm._alias import AliasedGroup
9
+ from pm.client import PMClient
10
+ from pm.client.roles import MAIN_ROLE_NAMES
11
+ from pm.commands._helpers import json_option, limit_option, name_filter_option, select_rows
12
+ from pm.output import emit
13
+
14
+
15
+ @click.group(name="roles", cls=AliasedGroup, aliases={"ls": "list"})
16
+ def roles() -> None:
17
+ """Roles and groups in the current workspace."""
18
+
19
+
20
+ def _kind(name: str) -> str:
21
+ return "main" if name in MAIN_ROLE_NAMES else "group"
22
+
23
+
24
+ @roles.command(name="list")
25
+ @name_filter_option
26
+ @limit_option
27
+ @json_option
28
+ @click.pass_context
29
+ def ls(ctx: click.Context, name_filter: Optional[str], limit: Optional[int]) -> None:
30
+ """List roles and groups in the current workspace.
31
+
32
+ KIND is "main" for Admin, User and Guest, and "group" for everything
33
+ else.
34
+ """
35
+ profile = ctx.obj["profile"]
36
+ json_output = ctx.obj["json"]
37
+
38
+ client = PMClient.from_profile(profile)
39
+ items = select_rows(client.roles.list(), name=name_filter, limit=limit)
40
+ client.persist()
41
+
42
+ emit(
43
+ items,
44
+ json_output=json_output,
45
+ columns=[
46
+ ("UID", "uid"),
47
+ ("ID", "id"),
48
+ ("NAME", "name"),
49
+ ("KIND", lambda r: _kind(r.get("name") or "")),
50
+ ],
51
+ )
52
+
53
+
54
+ @roles.command(name="assign")
55
+ @click.argument("role_ref")
56
+ @click.argument("user_uids", nargs=-1, required=True)
57
+ @json_option
58
+ @click.pass_context
59
+ def assign(ctx: click.Context, role_ref: str, user_uids: tuple) -> None:
60
+ """Add users to a role or group.
61
+
62
+ ROLE_REF is the role uid or name (case-insensitive). Users already in
63
+ the role are left as they are.
64
+ """
65
+ profile = ctx.obj["profile"]
66
+ json_output = ctx.obj["json"]
67
+
68
+ client = PMClient.from_profile(profile)
69
+ result = client.roles.assign_users(role_ref, list(user_uids))
70
+ client.persist()
71
+
72
+ if json_output:
73
+ emit(result, json_output=True)
74
+ return
75
+ role_info = result.get("role") if isinstance(result, dict) else None
76
+ role_name = (role_info or {}).get("name", role_ref)
77
+ click.echo(f"Assigned {len(user_uids)} user(s) to role {role_name!r}.", err=True)
78
+
79
+
80
+ @roles.command(name="revoke")
81
+ @click.argument("role_ref")
82
+ @click.argument("user_uids", nargs=-1, required=True)
83
+ @json_option
84
+ @click.pass_context
85
+ def revoke(ctx: click.Context, role_ref: str, user_uids: tuple) -> None:
86
+ """Remove users from a role or group.
87
+
88
+ ROLE_REF is the role uid or name (case-insensitive).
89
+ """
90
+ profile = ctx.obj["profile"]
91
+ json_output = ctx.obj["json"]
92
+
93
+ client = PMClient.from_profile(profile)
94
+ result = client.roles.revoke_users(role_ref, list(user_uids))
95
+ client.persist()
96
+
97
+ if json_output:
98
+ emit(result, json_output=True)
99
+ return
100
+ role_info = result.get("role") if isinstance(result, dict) else None
101
+ role_name = (role_info or {}).get("name", role_ref)
102
+ click.echo(f"Removed {len(user_uids)} user(s) from role {role_name!r}.", err=True)
pm/commands/runs.py ADDED
@@ -0,0 +1,214 @@
1
+ """Noteflow run commands."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ import click
7
+
8
+ from pm._alias import AliasedGroup
9
+ from pm.client import PMClient
10
+ from pm.commands._helpers import info, json_option
11
+ from pm.output import emit
12
+
13
+ # A prefect ``flow_run_id`` is always a UUID (8-4-4-4-12 hex); a run ``uid``
14
+ # is a short hashid with no hyphens. Lets ``pm run logs`` accept either.
15
+ _UUID_RE = re.compile(
16
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
17
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
18
+ )
19
+
20
+
21
+ @click.group(name="runs", cls=AliasedGroup, aliases={"ls": "list"})
22
+ def runs() -> None:
23
+ """Report runs: status, results and logs."""
24
+
25
+
26
+ @runs.command(name="list")
27
+ @click.option(
28
+ "--limit",
29
+ type=int,
30
+ default=None,
31
+ help="Maximum number of runs to return (default 25, max 5000).",
32
+ )
33
+ @click.option(
34
+ "--report",
35
+ "report_uid",
36
+ default=None,
37
+ help="Only runs of this report (report uid).",
38
+ )
39
+ @click.option(
40
+ "--last-days",
41
+ type=click.IntRange(min=1),
42
+ default=None,
43
+ help="Only runs started in the last N days.",
44
+ )
45
+ @click.option(
46
+ "--status",
47
+ default=None,
48
+ help="Only runs with this status (failed, success, running, pending).",
49
+ )
50
+ @json_option
51
+ @click.pass_context
52
+ def ls(ctx: click.Context, limit, report_uid, last_days, status) -> None:
53
+ """List recent runs, newest first.
54
+
55
+ \b
56
+ Filters combine, so "why did report X fail recently?" is:
57
+ pm run list --report <report_uid> --last-days 2 --status failed
58
+ pm run logs <run_uid>
59
+ """
60
+ profile = ctx.obj["profile"]
61
+ json_output = ctx.obj["json"]
62
+
63
+ client = PMClient.from_profile(profile)
64
+ items = client.runs.list(
65
+ limit=limit,
66
+ report_uid=report_uid,
67
+ last_days=last_days,
68
+ status=status,
69
+ )
70
+ client.persist()
71
+
72
+ emit(
73
+ items,
74
+ json_output=json_output,
75
+ columns=[
76
+ ("UID", "uid"),
77
+ ("NAME", "name"),
78
+ ("STATUS", "status"),
79
+ ("START", "start_time"),
80
+ ("END", "end_time"),
81
+ ("RE_RUNS", "re_run_count"),
82
+ ],
83
+ )
84
+
85
+
86
+ @runs.command(name="get")
87
+ @click.argument("run_uid")
88
+ @json_option
89
+ @click.pass_context
90
+ def get(ctx: click.Context, run_uid: str) -> None:
91
+ """Show one run's status and timing."""
92
+ profile = ctx.obj["profile"]
93
+ json_output = ctx.obj["json"]
94
+
95
+ client = PMClient.from_profile(profile)
96
+ run = client.runs.get(run_uid)
97
+ client.persist()
98
+
99
+ if json_output:
100
+ emit(run, json_output=True)
101
+ else:
102
+ emit(
103
+ {
104
+ "uid": run.uid,
105
+ "name": run.name,
106
+ "status": run.status,
107
+ "flow_run_id": run.flow_run_id,
108
+ "start_time": run.start_time,
109
+ "end_time": run.end_time,
110
+ "re_run_count": run.re_run_count,
111
+ },
112
+ json_output=False,
113
+ )
114
+
115
+
116
+ @runs.command(name="results")
117
+ @click.argument("run_uid")
118
+ @json_option
119
+ @click.pass_context
120
+ def results(ctx: click.Context, run_uid: str) -> None:
121
+ """Show the per-block results of a run."""
122
+ profile = ctx.obj["profile"]
123
+ json_output = ctx.obj["json"]
124
+
125
+ client = PMClient.from_profile(profile)
126
+ items = client.runs.results(run_uid)
127
+ client.persist()
128
+
129
+ # Backend ``task_to_result`` returns ``block_uid``, ``status``,
130
+ # ``results_key``, ``uid`` (the run uid) — no block_type or
131
+ # timestamps. Render the columns that actually exist.
132
+ emit(
133
+ items,
134
+ json_output=json_output,
135
+ columns=[
136
+ ("BLOCK_UID", "block_uid"),
137
+ ("STATUS", "status"),
138
+ ("RESULTS_KEY", "results_key"),
139
+ ("RUN_UID", "uid"),
140
+ ],
141
+ )
142
+
143
+
144
+ @runs.command(name="logs")
145
+ @click.argument("run_ref")
146
+ @click.option(
147
+ "--limit",
148
+ type=int,
149
+ default=None,
150
+ help="Maximum number of log lines to return (default 1000, max 5000).",
151
+ )
152
+ @click.option(
153
+ "--level",
154
+ default=None,
155
+ help="Only lines at this level (ERROR, WARNING, INFO).",
156
+ )
157
+ @json_option
158
+ @click.pass_context
159
+ def logs(ctx: click.Context, run_ref: str, limit, level) -> None:
160
+ """Show a run's log lines, oldest first.
161
+
162
+ RUN_REF is the run uid or its flow run id (UUID). To answer "why did
163
+ this run fail?", add --level ERROR.
164
+ """
165
+ profile = ctx.obj["profile"]
166
+ json_output = ctx.obj["json"]
167
+
168
+ client = PMClient.from_profile(profile)
169
+
170
+ if _UUID_RE.match(run_ref):
171
+ # Already a flow_run_id — no run lookup needed.
172
+ log_filter = {"flow_run_id": run_ref}
173
+ else:
174
+ run = client.runs.get(run_ref)
175
+
176
+ # Prefer the stable numeric run id (noteflow_run_id) — populated on
177
+ # every current log row and unchanged across in-flow retries. Fall
178
+ # back to flow_run_id only if the run somehow lacks a numeric id.
179
+ if run.id is not None:
180
+ log_filter = {"noteflow_run_id": run.id}
181
+ elif run.flow_run_id:
182
+ log_filter = {"flow_run_id": run.flow_run_id}
183
+ else:
184
+ client.persist()
185
+ status = f" (status: {run.status})" if run.status else ""
186
+ raise click.ClickException(
187
+ f"Run {run_ref} has no id or flow_run_id yet{status}. "
188
+ "Logs appear once the run starts executing."
189
+ )
190
+
191
+ items = client.runs.logs(limit=limit, **log_filter)
192
+ client.persist()
193
+
194
+ if level:
195
+ wanted = level.upper()
196
+ items = [
197
+ i for i in items
198
+ if (i.effective_level or "").upper() == wanted
199
+ ]
200
+
201
+ if not items and not json_output:
202
+ at_level = f" at level {level.upper()}" if level else ""
203
+ info(ctx, f"No log lines{at_level} for run {run_ref}.")
204
+ return
205
+
206
+ emit(
207
+ items,
208
+ json_output=json_output,
209
+ columns=[
210
+ ("CREATED", "created_on"),
211
+ ("LEVEL", "effective_level"),
212
+ ("MESSAGE", "message"),
213
+ ],
214
+ )