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,97 @@
|
|
|
1
|
+
"""Saved block (template) commands.
|
|
2
|
+
|
|
3
|
+
Backend only allows updating `parameters_schema`. The `create` flow on the FE
|
|
4
|
+
is composite (create block, then create saved_block wrapper) — out of scope
|
|
5
|
+
for Phase 6.1. Use `ls`, `update --schema`, or `delete`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from pm._alias import AliasedGroup
|
|
14
|
+
from pm.client import PMClient
|
|
15
|
+
from pm.commands._helpers import (
|
|
16
|
+
confirm_delete,
|
|
17
|
+
json_option,
|
|
18
|
+
parse_data,
|
|
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="saved-blocks", cls=AliasedGroup, aliases={"ls": "list"})
|
|
29
|
+
def saved_blocks() -> None:
|
|
30
|
+
"""Saved blocks (reusable block templates)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@saved_blocks.command(name="list")
|
|
34
|
+
@name_filter_option(matches="the saved block's name")
|
|
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 saved blocks in the current workspace."""
|
|
40
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
41
|
+
items = select_rows(
|
|
42
|
+
client.saved_blocks.list(),
|
|
43
|
+
name=name_filter,
|
|
44
|
+
fields=(lambda r: (r.get("block") or {}).get("name") if isinstance(r.get("block"), dict) else None,),
|
|
45
|
+
limit=limit,
|
|
46
|
+
)
|
|
47
|
+
client.persist()
|
|
48
|
+
emit(
|
|
49
|
+
items,
|
|
50
|
+
json_output=ctx.obj["json"],
|
|
51
|
+
columns=[
|
|
52
|
+
("UID", "uid"),
|
|
53
|
+
("BLOCK_ID", "block_id"),
|
|
54
|
+
("NAME", lambda r: (r.get("block") or {}).get("name", "") if isinstance(r.get("block"), dict) else ""),
|
|
55
|
+
("TYPE", lambda r: (r.get("block") or {}).get("type", "") if isinstance(r.get("block"), dict) else ""),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@saved_blocks.command(name="update")
|
|
61
|
+
@click.argument("uid")
|
|
62
|
+
@click.option(
|
|
63
|
+
"--schema",
|
|
64
|
+
"schema_str",
|
|
65
|
+
default=None,
|
|
66
|
+
help="The new parameters schema, as a JSON object.",
|
|
67
|
+
)
|
|
68
|
+
@json_option
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def update(ctx: click.Context, uid: str, schema_str: Optional[str]) -> None:
|
|
71
|
+
"""Replace a saved block's parameters schema.
|
|
72
|
+
|
|
73
|
+
The parameters schema is the only field of a saved block that can be
|
|
74
|
+
changed.
|
|
75
|
+
"""
|
|
76
|
+
if not schema_str:
|
|
77
|
+
raise click.UsageError("Pass --schema '<json>'.")
|
|
78
|
+
parameters_schema = parse_data(schema_str)
|
|
79
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
80
|
+
sb = client.saved_blocks.update(uid, {"parameters_schema": parameters_schema})
|
|
81
|
+
client.persist()
|
|
82
|
+
emit(sb, json_output=ctx.obj["json"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@saved_blocks.command(name="delete")
|
|
86
|
+
@click.argument("uid")
|
|
87
|
+
@yes_option
|
|
88
|
+
@json_option
|
|
89
|
+
@click.pass_context
|
|
90
|
+
def delete(ctx: click.Context, uid: str, yes: bool) -> None:
|
|
91
|
+
"""Delete a saved block."""
|
|
92
|
+
if not yes:
|
|
93
|
+
confirm_delete("saved block", uid)
|
|
94
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
95
|
+
client.saved_blocks.delete(uid)
|
|
96
|
+
client.persist()
|
|
97
|
+
report_deleted(ctx, "saved block", uid)
|
pm/commands/sftp.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""`pm sftp` — create + send SFTP upload blocks.
|
|
2
|
+
|
|
3
|
+
pm-tool introduces SFTP as a NEW agent capability (no direct LLM tool
|
|
4
|
+
exists today). Per codex R3 #5 the agent-side mode should still suppress
|
|
5
|
+
this from `single_tool` help/dispatch until the email/slack soak proves
|
|
6
|
+
out — but the human-facing CLI exposes it fully here.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pm.commands._block_factory import make_block_group
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
sftp = make_block_group(
|
|
14
|
+
"sftp",
|
|
15
|
+
"sftp",
|
|
16
|
+
help_text="SFTP upload blocks.\n\nBlock type: sftp.",
|
|
17
|
+
crud_verbs=("create", "list", "get"),
|
|
18
|
+
execute_verb="send",
|
|
19
|
+
)
|
pm/commands/slack.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""`pm slack` — create + send Slack message blocks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pm.commands._block_factory import make_block_group
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
slack = make_block_group(
|
|
8
|
+
"slack",
|
|
9
|
+
"slack",
|
|
10
|
+
help_text="Slack message blocks.\n\nBlock type: slack.",
|
|
11
|
+
crud_verbs=("create", "list", "get"),
|
|
12
|
+
execute_verb="send",
|
|
13
|
+
)
|
pm/commands/slides.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""`pm slides` — create + run Google Slides blocks + advanced verbs.
|
|
2
|
+
|
|
3
|
+
A Slides block points at a Google Slides template via integration_id +
|
|
4
|
+
template_id; running it renders the template with placeholder data and
|
|
5
|
+
produces a new presentation.
|
|
6
|
+
|
|
7
|
+
NOTE: the backend stores these as ``block.type = "google_slides"`` — see
|
|
8
|
+
`prefect/queryme/noteflow/template_block/google_slides.py`. The CLI group
|
|
9
|
+
name is the short `slides`; the bound block_type is the full backend
|
|
10
|
+
name so create/run dispatch correctly.
|
|
11
|
+
|
|
12
|
+
Three custom verbs ship here:
|
|
13
|
+
|
|
14
|
+
- ``parse-template <uid>`` — hits the dedicated
|
|
15
|
+
``POST /api/v1/block/<uid>/parse_slides_template`` endpoint. Parses a
|
|
16
|
+
master deck into template partials + sample composition_json,
|
|
17
|
+
persists them onto the block, returns them. Run this once after
|
|
18
|
+
creating a slides block from a master deck.
|
|
19
|
+
|
|
20
|
+
- ``export <uid> [--filename name.pdf]`` — convenience wrapper over
|
|
21
|
+
``pm slides run``. Sets ``action="export_pdf"`` in the block's
|
|
22
|
+
content_json (temporarily, for this run only) so the executor renders
|
|
23
|
+
the existing presentation to PDF and uploads to S3.
|
|
24
|
+
|
|
25
|
+
- ``batch-update <uid> --stdin`` — convenience wrapper over
|
|
26
|
+
``pm slides run``. Reads a JSON array of Google Slides batchUpdate
|
|
27
|
+
request objects from stdin and dispatches via
|
|
28
|
+
``action="batch_update"``. Advanced escape hatch for arbitrary slide
|
|
29
|
+
mutations.
|
|
30
|
+
|
|
31
|
+
The ``export`` and ``batch-update`` verbs use a content-json-override
|
|
32
|
+
helper because the backend's run endpoint shallow-merges request.json
|
|
33
|
+
into block fields; partial overrides into content_json have to
|
|
34
|
+
fetch+merge+serialize CLI-side.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import sys
|
|
40
|
+
from typing import Any, Dict, Optional
|
|
41
|
+
|
|
42
|
+
import click
|
|
43
|
+
|
|
44
|
+
from pm.client import PMClient
|
|
45
|
+
from pm.commands._block_factory import make_block_group
|
|
46
|
+
from pm.commands._helpers import json_option
|
|
47
|
+
from pm.output import emit
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Helper: run with content_json overrides ──────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_with_content_overrides(
|
|
54
|
+
client: PMClient,
|
|
55
|
+
uid: str,
|
|
56
|
+
*,
|
|
57
|
+
overrides: Dict[str, Any],
|
|
58
|
+
) -> Dict[str, Any]:
|
|
59
|
+
"""Fetch the block, merge ``overrides`` into its content_json, and run.
|
|
60
|
+
|
|
61
|
+
The backend's `/run` handler shallow-merges request.json into block
|
|
62
|
+
fields (FAB's ``_merge_update_item``). For partial overrides INTO
|
|
63
|
+
content_json (not full content_json replacement), the CLI has to do
|
|
64
|
+
the merge itself: fetch → parse content_json → update → re-serialize
|
|
65
|
+
→ send the merged content_json as a top-level field.
|
|
66
|
+
|
|
67
|
+
The merge is in-memory on the backend (the run handler reverts
|
|
68
|
+
block.content_json after dispatch), so the override is per-run only.
|
|
69
|
+
"""
|
|
70
|
+
block = client.blocks.get(uid)
|
|
71
|
+
raw = block.content_json or "{}"
|
|
72
|
+
if isinstance(raw, str):
|
|
73
|
+
try:
|
|
74
|
+
current = json.loads(raw) if raw else {}
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise click.ClickException(
|
|
77
|
+
f"Block {uid} has invalid content_json — cannot merge "
|
|
78
|
+
f"overrides ({exc.msg})",
|
|
79
|
+
)
|
|
80
|
+
elif isinstance(raw, dict):
|
|
81
|
+
current = dict(raw)
|
|
82
|
+
else:
|
|
83
|
+
current = {}
|
|
84
|
+
current.update(overrides)
|
|
85
|
+
return client.blocks.execute_existing(
|
|
86
|
+
uid,
|
|
87
|
+
verb="run",
|
|
88
|
+
block_properties={
|
|
89
|
+
"content_json": json.dumps(current),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── parse-template ───────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.command(name="parse-template")
|
|
98
|
+
@click.argument("uid")
|
|
99
|
+
@json_option
|
|
100
|
+
@click.pass_context
|
|
101
|
+
def parse_template(ctx: click.Context, uid: str) -> None:
|
|
102
|
+
"""Read a master deck into reusable slide templates.
|
|
103
|
+
|
|
104
|
+
The block must name its source_file_id and integration_id. Run this
|
|
105
|
+
once after pm slides create; it returns the template definition and
|
|
106
|
+
a sample composition to start from.
|
|
107
|
+
"""
|
|
108
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
109
|
+
result = client.blocks.parse_slides_template(uid)
|
|
110
|
+
client.persist()
|
|
111
|
+
emit(result, json_output=ctx.obj["json"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── export ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@click.command(name="export")
|
|
118
|
+
@click.argument("uid")
|
|
119
|
+
@click.option(
|
|
120
|
+
"--filename",
|
|
121
|
+
default=None,
|
|
122
|
+
help="Name of the PDF, e.g. q4-report.pdf (default: the presentation's name).",
|
|
123
|
+
)
|
|
124
|
+
@json_option
|
|
125
|
+
@click.pass_context
|
|
126
|
+
def export(
|
|
127
|
+
ctx: click.Context,
|
|
128
|
+
uid: str,
|
|
129
|
+
filename: Optional[str],
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Export the block's presentation as a PDF.
|
|
132
|
+
|
|
133
|
+
Returns the download URL. The block must have been run at least once
|
|
134
|
+
so that it has a presentation.
|
|
135
|
+
"""
|
|
136
|
+
overrides: Dict[str, Any] = {
|
|
137
|
+
"action": "export_pdf",
|
|
138
|
+
}
|
|
139
|
+
if filename:
|
|
140
|
+
overrides["export_options"] = {
|
|
141
|
+
"pdf_filename": filename,
|
|
142
|
+
}
|
|
143
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
144
|
+
result = _run_with_content_overrides(client, uid, overrides=overrides)
|
|
145
|
+
client.persist()
|
|
146
|
+
emit(result, json_output=ctx.obj["json"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── batch-update ─────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@click.command(name="batch-update")
|
|
153
|
+
@click.argument("uid")
|
|
154
|
+
@click.option(
|
|
155
|
+
"--stdin",
|
|
156
|
+
"use_stdin",
|
|
157
|
+
is_flag=True,
|
|
158
|
+
required=True,
|
|
159
|
+
help=(
|
|
160
|
+
"Read the JSON body from stdin: an array of Google Slides "
|
|
161
|
+
"batchUpdate request objects."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
@json_option
|
|
165
|
+
@click.pass_context
|
|
166
|
+
def batch_update(
|
|
167
|
+
ctx: click.Context,
|
|
168
|
+
uid: str,
|
|
169
|
+
use_stdin: bool,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Apply Google Slides batchUpdate requests to the deck.
|
|
172
|
+
|
|
173
|
+
For edits the template flow does not cover. Requests are forwarded to
|
|
174
|
+
the Google Slides API; destructive request types are refused.
|
|
175
|
+
"""
|
|
176
|
+
raw = sys.stdin.read()
|
|
177
|
+
if not raw.strip():
|
|
178
|
+
raise click.UsageError("--stdin was given but stdin was empty.")
|
|
179
|
+
try:
|
|
180
|
+
requests_list = json.loads(raw)
|
|
181
|
+
except json.JSONDecodeError as exc:
|
|
182
|
+
raise click.ClickException(f"--stdin: invalid JSON ({exc.msg})")
|
|
183
|
+
if not isinstance(requests_list, list):
|
|
184
|
+
raise click.ClickException(
|
|
185
|
+
f"--stdin must be a JSON array of batchUpdate request objects, "
|
|
186
|
+
f"got {type(requests_list).__name__}",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
overrides: Dict[str, Any] = {
|
|
190
|
+
"action": "batch_update",
|
|
191
|
+
# The backend reads this list from `content.get("requests", [])`
|
|
192
|
+
# (google_slides.py:149). Previously sent under `requests_list`
|
|
193
|
+
# which silently dropped every batch update — codex catch.
|
|
194
|
+
"requests": requests_list,
|
|
195
|
+
}
|
|
196
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
197
|
+
result = _run_with_content_overrides(client, uid, overrides=overrides)
|
|
198
|
+
client.persist()
|
|
199
|
+
emit(result, json_output=ctx.obj["json"])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Group ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
slides = make_block_group(
|
|
206
|
+
"slides",
|
|
207
|
+
"google_slides",
|
|
208
|
+
crud_verbs=("create", "list", "get"),
|
|
209
|
+
execute_verb="run",
|
|
210
|
+
help_text="Google Slides blocks.\n\nBlock type: google_slides.",
|
|
211
|
+
custom_verbs={
|
|
212
|
+
"parse-template": parse_template,
|
|
213
|
+
"export": export,
|
|
214
|
+
"batch-update": batch_update,
|
|
215
|
+
},
|
|
216
|
+
)
|
pm/commands/sql.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""`pm sql` — create + run SQL blocks.
|
|
2
|
+
|
|
3
|
+
Per pm-tool plan §3.8. Domain group with the standard CRUD discovery
|
|
4
|
+
(``list`` / ``get``) plus the typed ``create`` and ``run`` verbs.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pm.commands._block_factory import make_block_group
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
sql = make_block_group(
|
|
12
|
+
"sql",
|
|
13
|
+
"sql",
|
|
14
|
+
help_text="SQL query blocks.\n\nBlock type: sql.",
|
|
15
|
+
crud_verbs=("create", "list", "get"),
|
|
16
|
+
execute_verb="run",
|
|
17
|
+
)
|
pm/commands/tableau.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""`pm tableau` — create + run Tableau view blocks + discovery verbs.
|
|
2
|
+
|
|
3
|
+
A Tableau block points at a Tableau view via an integration_id + view_id;
|
|
4
|
+
running it exports/downloads the rendered view as PNG/PDF (or returns an
|
|
5
|
+
embed URL).
|
|
6
|
+
|
|
7
|
+
Standard create + run + results come from the factory. Two custom verbs
|
|
8
|
+
hit the Tableau integration API directly (no block needed) for discovery
|
|
9
|
+
before creating a block:
|
|
10
|
+
|
|
11
|
+
- ``pm tableau search`` — list views (default) or workbooks (``--workbooks``)
|
|
12
|
+
available through a Tableau integration, with optional ``--keyword`` and
|
|
13
|
+
``--workbook-id`` filters.
|
|
14
|
+
- ``pm tableau view`` — fetch one view's metadata by ``--view-id`` (the
|
|
15
|
+
natural follow-up to search: drill into details before creating a block).
|
|
16
|
+
|
|
17
|
+
``run`` help is overridden per codex R3 #6 so agents don't mistake it for
|
|
18
|
+
report execution.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from pm.client import PMClient
|
|
27
|
+
from pm.commands._block_factory import make_block_group
|
|
28
|
+
from pm.commands._helpers import json_option
|
|
29
|
+
from pm.output import emit
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── search ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.command(name="search")
|
|
36
|
+
@click.option(
|
|
37
|
+
"--integration",
|
|
38
|
+
"integration_uid",
|
|
39
|
+
required=True,
|
|
40
|
+
help="Tableau integration uid (from pm integration list).",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--keyword",
|
|
44
|
+
default=None,
|
|
45
|
+
help="Only views (or workbooks) whose name contains this text.",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--workbook-id",
|
|
49
|
+
default=None,
|
|
50
|
+
help="Only views in this Tableau workbook id. Ignored with --workbooks.",
|
|
51
|
+
)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--workbooks",
|
|
54
|
+
is_flag=True,
|
|
55
|
+
help="Search workbooks instead of views.",
|
|
56
|
+
)
|
|
57
|
+
@json_option
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def search(
|
|
60
|
+
ctx: click.Context,
|
|
61
|
+
integration_uid: str,
|
|
62
|
+
keyword: Optional[str],
|
|
63
|
+
workbook_id: Optional[str],
|
|
64
|
+
workbooks: bool,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Search the views or workbooks of a Tableau site.
|
|
67
|
+
|
|
68
|
+
Each result has an id, name and content URL; views also carry their
|
|
69
|
+
workbook id.
|
|
70
|
+
"""
|
|
71
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
72
|
+
if workbooks:
|
|
73
|
+
results = client.tableau.list_workbooks(
|
|
74
|
+
integration_uid,
|
|
75
|
+
keyword=keyword,
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
results = client.tableau.list_views(
|
|
79
|
+
integration_uid,
|
|
80
|
+
keyword=keyword,
|
|
81
|
+
workbook_id=workbook_id,
|
|
82
|
+
)
|
|
83
|
+
client.persist()
|
|
84
|
+
emit(
|
|
85
|
+
results,
|
|
86
|
+
json_output=ctx.obj["json"],
|
|
87
|
+
columns=[
|
|
88
|
+
("ID", lambda r: r.get("id", "")),
|
|
89
|
+
("NAME", lambda r: r.get("name", "")),
|
|
90
|
+
("WORKBOOK", lambda r: r.get("workbook_id") or r.get("contentUrl") or ""),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── view ──────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@click.command(name="view")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--integration",
|
|
101
|
+
"integration_uid",
|
|
102
|
+
required=True,
|
|
103
|
+
help="Tableau integration uid (from pm integration list).",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--view-id",
|
|
107
|
+
required=True,
|
|
108
|
+
help="Tableau view id (from pm tableau search).",
|
|
109
|
+
)
|
|
110
|
+
@json_option
|
|
111
|
+
@click.pass_context
|
|
112
|
+
def view(
|
|
113
|
+
ctx: click.Context,
|
|
114
|
+
integration_uid: str,
|
|
115
|
+
view_id: str,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Show one Tableau view.
|
|
118
|
+
|
|
119
|
+
The follow-up to tableau search before creating a Tableau block.
|
|
120
|
+
"""
|
|
121
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
122
|
+
result = client.tableau.get_view(integration_uid, view_id)
|
|
123
|
+
client.persist()
|
|
124
|
+
emit(result, json_output=ctx.obj["json"])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Group ────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
tableau = make_block_group(
|
|
131
|
+
"tableau",
|
|
132
|
+
"tableau",
|
|
133
|
+
crud_verbs=("create", "list", "get"),
|
|
134
|
+
execute_verb="run",
|
|
135
|
+
help_text="Tableau view blocks.\n\nBlock type: tableau.",
|
|
136
|
+
verb_help={
|
|
137
|
+
"run": (
|
|
138
|
+
"Export a Tableau view as PNG, PDF or an embed URL.\n\n"
|
|
139
|
+
"Pass UID to render a saved Tableau block, or --stdin with a "
|
|
140
|
+
"body holding integration_id and view_id to render once "
|
|
141
|
+
"without saving."
|
|
142
|
+
),
|
|
143
|
+
},
|
|
144
|
+
custom_verbs={
|
|
145
|
+
"search": search,
|
|
146
|
+
"view": view,
|
|
147
|
+
},
|
|
148
|
+
)
|
pm/commands/token.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Personal Access Token commands (`pm token create|list|revoke`).
|
|
2
|
+
|
|
3
|
+
Tokens authenticate the CLI / any programmatic client via
|
|
4
|
+
``Authorization: Bearer pmpat_...`` (exchanged for a short-lived JWT). Manage
|
|
5
|
+
them here while logged in (cookie or token auth both work); store a created
|
|
6
|
+
token with `pm login --token`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from pm._alias import AliasedGroup
|
|
15
|
+
from pm.client import PMClient
|
|
16
|
+
from pm.commands._helpers import info, json_option, limit_option, name_filter_option, select_rows
|
|
17
|
+
from pm.output import emit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group(
|
|
21
|
+
name="token",
|
|
22
|
+
cls=AliasedGroup,
|
|
23
|
+
aliases={
|
|
24
|
+
"ls": "list",
|
|
25
|
+
"rm": "revoke",
|
|
26
|
+
"delete": "revoke",
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
def token() -> None:
|
|
30
|
+
"""Personal access tokens for API / CLI authentication."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@token.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 your tokens in the current workspace."""
|
|
40
|
+
profile = ctx.obj["profile"]
|
|
41
|
+
json_output = ctx.obj["json"]
|
|
42
|
+
|
|
43
|
+
client = PMClient.from_profile(profile)
|
|
44
|
+
items = select_rows(client.access_tokens.list(), name=name_filter, limit=limit)
|
|
45
|
+
client.persist()
|
|
46
|
+
|
|
47
|
+
emit(
|
|
48
|
+
items,
|
|
49
|
+
json_output=json_output,
|
|
50
|
+
columns=[
|
|
51
|
+
("ID", "id"),
|
|
52
|
+
("NAME", "name"),
|
|
53
|
+
("PREFIX", "token_prefix"),
|
|
54
|
+
("CREATED", "created_on"),
|
|
55
|
+
("LAST_USED", "last_used_on"),
|
|
56
|
+
("EXPIRES", "expires_on"),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@token.command(name="create")
|
|
62
|
+
@click.argument("name")
|
|
63
|
+
@click.option(
|
|
64
|
+
"--expires-on",
|
|
65
|
+
default=None,
|
|
66
|
+
help="Expiry date, ISO 8601, e.g. 2026-12-31 or 2026-12-31T23:59:59 (default: none).",
|
|
67
|
+
)
|
|
68
|
+
@json_option
|
|
69
|
+
@click.pass_context
|
|
70
|
+
def create(ctx: click.Context, name: str, expires_on: str) -> None:
|
|
71
|
+
"""Create a token; its value is shown once.
|
|
72
|
+
|
|
73
|
+
The token value goes to stdout so it can be captured or piped; the
|
|
74
|
+
reminder goes to stderr.
|
|
75
|
+
"""
|
|
76
|
+
profile = ctx.obj["profile"]
|
|
77
|
+
json_output = ctx.obj["json"]
|
|
78
|
+
|
|
79
|
+
client = PMClient.from_profile(profile)
|
|
80
|
+
raw_token, meta = client.access_tokens.create(name, expires_on=expires_on)
|
|
81
|
+
client.persist()
|
|
82
|
+
|
|
83
|
+
if json_output:
|
|
84
|
+
payload = meta.model_dump()
|
|
85
|
+
payload["token"] = raw_token
|
|
86
|
+
emit(payload, json_output=True)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
click.echo(raw_token)
|
|
90
|
+
info(ctx, "Copy your access token now. You won't be able to see this again.")
|
|
91
|
+
info(ctx, "Use it with: pm login --token (paste it at the hidden prompt). "
|
|
92
|
+
"In CI, set PM_API_TOKEN from the secret store.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@token.command(name="revoke")
|
|
96
|
+
@click.argument("token_id")
|
|
97
|
+
@json_option
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def revoke(ctx: click.Context, token_id: str) -> None:
|
|
100
|
+
"""Revoke a token by its numeric ID (from pm token list)."""
|
|
101
|
+
profile = ctx.obj["profile"]
|
|
102
|
+
|
|
103
|
+
client = PMClient.from_profile(profile)
|
|
104
|
+
client.access_tokens.revoke(token_id)
|
|
105
|
+
client.persist()
|
|
106
|
+
|
|
107
|
+
info(ctx, f"Revoked token {token_id}.")
|