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