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,95 @@
|
|
|
1
|
+
"""Integration commands — ``pm integration list`` with --name / --type filters.
|
|
2
|
+
|
|
3
|
+
Backend exposes ``search_columns = ["name", "type"]`` on the integration
|
|
4
|
+
REST API, so we can build a FAB rison ``filters:!(...)`` query directly
|
|
5
|
+
from the CLI. ``--name`` uses ``ct`` (case-insensitive substring) and
|
|
6
|
+
``--type`` uses ``eq`` (types are short enum-ish tokens like ``slack`` /
|
|
7
|
+
``tableau`` / ``gws`` — exact match is the natural read).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from pm._alias import AliasedGroup
|
|
16
|
+
from pm.client import PMClient
|
|
17
|
+
from pm.commands._helpers import json_option
|
|
18
|
+
from pm.output import emit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _rison_str(s: str) -> str:
|
|
22
|
+
"""Encode a string for inclusion as a Rison value.
|
|
23
|
+
|
|
24
|
+
Bare identifiers pass through unquoted; anything else gets
|
|
25
|
+
single-quote-wrapped with embedded quotes escaped as ``!'`` per the
|
|
26
|
+
Rison spec. JSON-style double quotes are NOT valid Rison — the
|
|
27
|
+
backend rejects with a parse error. Mirrors the helpers in
|
|
28
|
+
``recipient.py`` and ``client/runs.py``.
|
|
29
|
+
|
|
30
|
+
Quote digit-leading values: a bare Rison id must not start with a
|
|
31
|
+
digit — the parser commits to a number and fails on the first
|
|
32
|
+
non-numeric char (uids/UUIDs routinely start with digits).
|
|
33
|
+
"""
|
|
34
|
+
if (
|
|
35
|
+
s
|
|
36
|
+
and (s[0].isalpha() or s[0] == "_")
|
|
37
|
+
and all(c.isalnum() or c in "_-." for c in s)
|
|
38
|
+
):
|
|
39
|
+
return s
|
|
40
|
+
return "'" + s.replace("!", "!!").replace("'", "!'") + "'"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@click.group(name="integrations", cls=AliasedGroup, aliases={"ls": "list"})
|
|
44
|
+
def integrations() -> None:
|
|
45
|
+
"""Connected services (Slack, Tableau, Google Drive, ...)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@integrations.command(name="list")
|
|
49
|
+
@click.option(
|
|
50
|
+
"--name",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Filter by name (case-insensitive substring).",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--type",
|
|
56
|
+
"type_",
|
|
57
|
+
default=None,
|
|
58
|
+
help="Only this integration type (slack, tableau, gws, ...).",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--limit",
|
|
62
|
+
type=click.IntRange(min=1),
|
|
63
|
+
default=None,
|
|
64
|
+
help="Maximum number of rows to return (default: all).",
|
|
65
|
+
)
|
|
66
|
+
@json_option
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def ls(
|
|
69
|
+
ctx: click.Context,
|
|
70
|
+
name: Optional[str],
|
|
71
|
+
type_: Optional[str],
|
|
72
|
+
limit: Optional[int],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""List the integrations connected to this workspace."""
|
|
75
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
76
|
+
|
|
77
|
+
q_parts = []
|
|
78
|
+
if name:
|
|
79
|
+
q_parts.append(f"(col:name,opr:ct,value:{_rison_str(name)})")
|
|
80
|
+
if type_:
|
|
81
|
+
q_parts.append(f"(col:type,opr:eq,value:{_rison_str(type_)})")
|
|
82
|
+
page_size = f"page_size:{int(limit)}" if limit is not None else None
|
|
83
|
+
params: Dict[str, Any] = {}
|
|
84
|
+
if q_parts or page_size:
|
|
85
|
+
filters_str = f"filters:!({','.join(q_parts)})" if q_parts else None
|
|
86
|
+
bits = [b for b in (filters_str, page_size) if b]
|
|
87
|
+
params["q"] = f"({','.join(bits)})"
|
|
88
|
+
|
|
89
|
+
items = client.integrations.list(params=params or None)
|
|
90
|
+
client.persist()
|
|
91
|
+
emit(
|
|
92
|
+
items,
|
|
93
|
+
json_output=ctx.obj["json"],
|
|
94
|
+
columns=[("UID", "uid"), ("NAME", "name"), ("TYPE", "type")],
|
|
95
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""`pm metrics-view` — manage and query metrics view blocks.
|
|
2
|
+
|
|
3
|
+
Standard CRUD (create/list/get/update) comes from the factory. Two custom
|
|
4
|
+
verbs hit semantic-layer endpoints that don't fit the standard block
|
|
5
|
+
lifecycle:
|
|
6
|
+
|
|
7
|
+
- ``pm metrics-view query <uid>`` — POST /api/v1/block/metrics_query.
|
|
8
|
+
Executes a semantic query (measures + dimensions + filters + time
|
|
9
|
+
range, etc.) and returns rows. The metrics_view_uid lives in the
|
|
10
|
+
request body, not the URL path.
|
|
11
|
+
- ``pm metrics-view schema <uid>`` — GET /api/v1/block/<uid>/metrics_schema.
|
|
12
|
+
Returns measures/dimensions/parameters available — the "what can I
|
|
13
|
+
query?" companion to query.
|
|
14
|
+
|
|
15
|
+
Two input modes for query:
|
|
16
|
+
- Flag-driven: ``--measure m1 --measure m2 [--dimension d1 ...] [--time-range
|
|
17
|
+
last_30d] [--limit 100]`` — convenient for simple queries.
|
|
18
|
+
- ``--stdin`` JSON body — required for complex queries (dimension_grains,
|
|
19
|
+
filters, order_by, parameters). Body keys override flag values; the
|
|
20
|
+
positional ``<uid>`` is auto-injected as ``metrics_view_uid`` if absent
|
|
21
|
+
from the body.
|
|
22
|
+
|
|
23
|
+
Mixed flags + --stdin is allowed; --stdin wins on collision. Rationale:
|
|
24
|
+
the agent can compose a base body via flags and override one field via
|
|
25
|
+
--stdin without rebuilding the entire body.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from typing import Optional, Tuple
|
|
32
|
+
|
|
33
|
+
import click
|
|
34
|
+
|
|
35
|
+
from pm.client import PMClient
|
|
36
|
+
from pm.commands._block_factory import make_block_group
|
|
37
|
+
from pm.commands._helpers import json_option
|
|
38
|
+
from pm.output import emit
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── query ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_stdin_json_or_none() -> Optional[dict]:
|
|
45
|
+
"""Read stdin as JSON if any data is present; return None on empty.
|
|
46
|
+
|
|
47
|
+
Unlike the factory's ``_read_stdin_json``, this is non-raising for the
|
|
48
|
+
empty case — query's stdin is OPTIONAL (the caller may have provided
|
|
49
|
+
measures via flags).
|
|
50
|
+
"""
|
|
51
|
+
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
|
|
52
|
+
if not raw.strip():
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(raw)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise click.ClickException(f"--stdin: invalid JSON ({exc.msg})")
|
|
58
|
+
if not isinstance(parsed, dict):
|
|
59
|
+
raise click.ClickException(
|
|
60
|
+
f"--stdin body must be a JSON object, got {type(parsed).__name__}",
|
|
61
|
+
)
|
|
62
|
+
return parsed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command(name="query")
|
|
66
|
+
@click.argument("uid")
|
|
67
|
+
@click.option(
|
|
68
|
+
"--measure", "measures",
|
|
69
|
+
multiple=True,
|
|
70
|
+
help="Measure to query (at least one, here or in the --stdin body). Repeatable.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--dimension", "dimensions",
|
|
74
|
+
multiple=True,
|
|
75
|
+
help="Dimension to group by. Repeatable.",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--time-range",
|
|
79
|
+
default=None,
|
|
80
|
+
help="Time range (e.g. 'last_30d', 'last_1y', '2026-01-01:2026-04-01').",
|
|
81
|
+
)
|
|
82
|
+
@click.option(
|
|
83
|
+
"--time-grain",
|
|
84
|
+
default=None,
|
|
85
|
+
help="Time grain: day / week / month / quarter / year.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--limit",
|
|
89
|
+
type=int,
|
|
90
|
+
default=None,
|
|
91
|
+
help="Row limit.",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--offset",
|
|
95
|
+
type=int,
|
|
96
|
+
default=None,
|
|
97
|
+
help="Row offset for paging.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--no-cache",
|
|
101
|
+
is_flag=True,
|
|
102
|
+
help="Recompute instead of using the 5-minute cache.",
|
|
103
|
+
)
|
|
104
|
+
@click.option(
|
|
105
|
+
"--stdin",
|
|
106
|
+
"use_stdin",
|
|
107
|
+
is_flag=True,
|
|
108
|
+
help=(
|
|
109
|
+
"Read the JSON query body from stdin, for filters, order_by, "
|
|
110
|
+
"dimension_grains or parameters. Body fields override the flags."
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
@json_option
|
|
114
|
+
@click.pass_context
|
|
115
|
+
def query(
|
|
116
|
+
ctx: click.Context,
|
|
117
|
+
uid: str,
|
|
118
|
+
measures: Tuple[str, ...],
|
|
119
|
+
dimensions: Tuple[str, ...],
|
|
120
|
+
time_range: Optional[str],
|
|
121
|
+
time_grain: Optional[str],
|
|
122
|
+
limit: Optional[int],
|
|
123
|
+
offset: Optional[int],
|
|
124
|
+
no_cache: bool,
|
|
125
|
+
use_stdin: bool,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Query a metrics view.
|
|
128
|
+
|
|
129
|
+
Returns the rows and the SQL that produced them.
|
|
130
|
+
"""
|
|
131
|
+
extra: dict = {}
|
|
132
|
+
if use_stdin:
|
|
133
|
+
body = _read_stdin_json_or_none()
|
|
134
|
+
if body:
|
|
135
|
+
extra = body
|
|
136
|
+
|
|
137
|
+
# Merge: flag-driven kwargs are the base; --stdin extras override on collision.
|
|
138
|
+
# metrics_view_uid is auto-injected from the positional arg.
|
|
139
|
+
measures_final = list(extra.get("measures") or measures)
|
|
140
|
+
if not measures_final:
|
|
141
|
+
raise click.ClickException(
|
|
142
|
+
"At least one measure is required. Pass --measure NAME (repeatable) "
|
|
143
|
+
"or include `measures: [...]` in --stdin body.",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
147
|
+
payload = client.blocks.metrics_query(
|
|
148
|
+
uid,
|
|
149
|
+
measures=measures_final,
|
|
150
|
+
dimensions=list(extra.get("dimensions") or dimensions) or None,
|
|
151
|
+
dimension_grains=extra.get("dimension_grains"),
|
|
152
|
+
filters=extra.get("filters"),
|
|
153
|
+
time_range=extra.get("time_range") or time_range,
|
|
154
|
+
time_grain=extra.get("time_grain") or time_grain,
|
|
155
|
+
order_by=extra.get("order_by"),
|
|
156
|
+
limit=extra.get("limit") if extra.get("limit") is not None else limit,
|
|
157
|
+
offset=extra.get("offset") if extra.get("offset") is not None else offset,
|
|
158
|
+
no_cache=bool(extra.get("no_cache") or no_cache),
|
|
159
|
+
parameters=extra.get("parameters"),
|
|
160
|
+
)
|
|
161
|
+
client.persist()
|
|
162
|
+
emit(payload, json_output=ctx.obj["json"])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── schema ────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@click.command(name="schema")
|
|
169
|
+
@click.argument("uid")
|
|
170
|
+
@json_option
|
|
171
|
+
@click.pass_context
|
|
172
|
+
def schema(ctx: click.Context, uid: str) -> None:
|
|
173
|
+
"""Show what a metrics view can be queried by.
|
|
174
|
+
|
|
175
|
+
Lists its measures, dimensions and parameters; run it before query.
|
|
176
|
+
"""
|
|
177
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
178
|
+
result = client.blocks.metrics_schema(uid)
|
|
179
|
+
client.persist()
|
|
180
|
+
emit(result, json_output=ctx.obj["json"])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── Group ────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
metrics_view = make_block_group(
|
|
187
|
+
"metrics-view",
|
|
188
|
+
"metrics_view",
|
|
189
|
+
crud_verbs=("create", "list", "get", "update"),
|
|
190
|
+
help_text="Metrics views (the semantic layer).\n\nBlock type: metrics_view.",
|
|
191
|
+
custom_verbs={
|
|
192
|
+
"query": query,
|
|
193
|
+
"schema": schema,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Organization commands (get/update/current — no list; backend uses ES for listing)."""
|
|
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.commands._helpers import json_option, merge_fields, parse_data
|
|
11
|
+
from pm.output import emit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(name="organizations", cls=AliasedGroup)
|
|
15
|
+
def organizations() -> None:
|
|
16
|
+
"""Your organization (the owner of workspaces)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@organizations.command(name="current")
|
|
20
|
+
@json_option
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def current(ctx: click.Context) -> None:
|
|
23
|
+
"""Show your organization."""
|
|
24
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
25
|
+
org = client.organizations.current()
|
|
26
|
+
client.persist()
|
|
27
|
+
emit(org, json_output=ctx.obj["json"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@organizations.command(name="get")
|
|
31
|
+
@click.argument("uid")
|
|
32
|
+
@json_option
|
|
33
|
+
@click.pass_context
|
|
34
|
+
def get(ctx: click.Context, uid: str) -> None:
|
|
35
|
+
"""Show one organization."""
|
|
36
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
37
|
+
org = client.organizations.get(uid)
|
|
38
|
+
client.persist()
|
|
39
|
+
emit(org, json_output=ctx.obj["json"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@organizations.command(name="update")
|
|
43
|
+
@click.argument("uid")
|
|
44
|
+
@click.option("--name", default=None, help="Rename the organization.")
|
|
45
|
+
@click.option("--data", default=None, help="Extra fields as a JSON object.")
|
|
46
|
+
@json_option
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def update(ctx: click.Context, uid: str, name: Optional[str], data: Optional[str]) -> None:
|
|
49
|
+
"""Update an organization."""
|
|
50
|
+
payload = merge_fields(parse_data(data), {"name": name})
|
|
51
|
+
if not payload:
|
|
52
|
+
raise click.UsageError("Nothing to update; pass --name or --data.")
|
|
53
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
54
|
+
org = client.organizations.update(uid, payload)
|
|
55
|
+
client.persist()
|
|
56
|
+
emit(org, json_output=ctx.obj["json"])
|
pm/commands/recipient.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""`pm recipient` — workspace CRM for delivery targets.
|
|
2
|
+
|
|
3
|
+
A recipient is a named record (person or team) with one or more
|
|
4
|
+
destinations (email, slack). Used as the delivery target for email /
|
|
5
|
+
slack sends; also gated by ``tools_json.messaging_policy`` on the
|
|
6
|
+
agent side (``recipients_only`` / ``recipients_only_with_approval`` —
|
|
7
|
+
see ``pm_tool._apply_messaging_policy``).
|
|
8
|
+
|
|
9
|
+
Verbs:
|
|
10
|
+
|
|
11
|
+
list — paginated list (with optional --name substring filter)
|
|
12
|
+
get <uid> — single recipient + destinations
|
|
13
|
+
details <uid> — recipient + destinations + connected reports + recent executions
|
|
14
|
+
create --stdin — create from a JSON body
|
|
15
|
+
update <uid> --stdin — update existing
|
|
16
|
+
delete <uid> — remove (gated by confirm)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from pm._alias import AliasedGroup
|
|
27
|
+
from pm.client import PMClient
|
|
28
|
+
from pm.commands._helpers import (
|
|
29
|
+
confirm_delete,
|
|
30
|
+
json_option,
|
|
31
|
+
peek_name,
|
|
32
|
+
report_deleted,
|
|
33
|
+
yes_option,
|
|
34
|
+
)
|
|
35
|
+
from pm.output import emit
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_stdin_json() -> Dict[str, Any]:
|
|
39
|
+
raw = sys.stdin.read()
|
|
40
|
+
if not raw.strip():
|
|
41
|
+
raise click.UsageError("--stdin was given but stdin was empty.")
|
|
42
|
+
try:
|
|
43
|
+
parsed = json.loads(raw)
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
raise click.ClickException(f"--stdin: invalid JSON ({exc.msg})")
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
raise click.ClickException(
|
|
48
|
+
f"--stdin body must be a JSON object, got {type(parsed).__name__}",
|
|
49
|
+
)
|
|
50
|
+
return parsed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@click.group(name="recipient", cls=AliasedGroup, aliases={"ls": "list"})
|
|
54
|
+
def recipient() -> None:
|
|
55
|
+
"""Recipients: who reports are delivered to."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_LIST_COLUMNS = [
|
|
59
|
+
("UID", "uid"),
|
|
60
|
+
("NAME", "name"),
|
|
61
|
+
("DESTINATIONS", lambda r: _format_destinations(r.get("destinations") or [])),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_destinations(dests: Any) -> str:
|
|
66
|
+
"""Compact one-line summary: ``email:a@b.c, slack:#ops``."""
|
|
67
|
+
if not isinstance(dests, list):
|
|
68
|
+
return ""
|
|
69
|
+
parts = []
|
|
70
|
+
for dest in dests:
|
|
71
|
+
if isinstance(dest, dict):
|
|
72
|
+
dest_type = dest.get("destination_type") or dest.get("type") or "?"
|
|
73
|
+
endpoint = dest.get("endpoint") or "?"
|
|
74
|
+
parts.append(f"{dest_type}:{endpoint}")
|
|
75
|
+
return ", ".join(parts)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@recipient.command(name="list")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--name",
|
|
81
|
+
default=None,
|
|
82
|
+
help="Filter by name (case-insensitive substring).",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--limit",
|
|
86
|
+
type=click.IntRange(min=1),
|
|
87
|
+
default=None,
|
|
88
|
+
help="Maximum number of rows to return (default: all).",
|
|
89
|
+
)
|
|
90
|
+
@json_option
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def list_(ctx: click.Context, name: Optional[str], limit: Optional[int]) -> None:
|
|
93
|
+
"""List recipients in the current workspace."""
|
|
94
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
95
|
+
# FAB rison query: filters list of (col,opr,value) tuples plus
|
|
96
|
+
# page_size. The CLI builds it directly so the client stays a
|
|
97
|
+
# thin shape over the API.
|
|
98
|
+
#
|
|
99
|
+
# Rison string encoding (codex round 8 P2 #2): bare alphanumerics
|
|
100
|
+
# need no quoting; values with shell-meta / spaces / colons need
|
|
101
|
+
# single-quote wrapping with embedded single-quotes escaped as
|
|
102
|
+
# ``!'``. JSON-style double quotes (``"..."``) are NOT valid Rison
|
|
103
|
+
# — the backend rejects with a parse error.
|
|
104
|
+
def _rison_str(s: str) -> str:
|
|
105
|
+
# Quote digit-leading values: a bare Rison id must not start
|
|
106
|
+
# with a digit — the parser commits to a number and fails on
|
|
107
|
+
# the first non-numeric char. Mirrors client/runs.py and
|
|
108
|
+
# integrations.py.
|
|
109
|
+
if (
|
|
110
|
+
s
|
|
111
|
+
and (s[0].isalpha() or s[0] == "_")
|
|
112
|
+
and all(c.isalnum() or c in "_-." for c in s)
|
|
113
|
+
):
|
|
114
|
+
return s
|
|
115
|
+
return "'" + s.replace("!", "!!").replace("'", "!'") + "'"
|
|
116
|
+
|
|
117
|
+
q_parts = []
|
|
118
|
+
if name:
|
|
119
|
+
# ``ct`` (contains) is the case-insensitive substring operator
|
|
120
|
+
# FAB exposes via ``search_columns``.
|
|
121
|
+
q_parts.append(f"(col:name,opr:ct,value:{_rison_str(name)})")
|
|
122
|
+
page_size = f"page_size:{int(limit)}" if limit is not None else None
|
|
123
|
+
params: Dict[str, Any] = {}
|
|
124
|
+
if q_parts or page_size:
|
|
125
|
+
filters_str = f"filters:!({','.join(q_parts)})" if q_parts else None
|
|
126
|
+
bits = [b for b in (filters_str, page_size) if b]
|
|
127
|
+
params["q"] = f"({','.join(bits)})"
|
|
128
|
+
items = client.recipients.list(params=params or None)
|
|
129
|
+
client.persist()
|
|
130
|
+
emit(items, json_output=ctx.obj["json"], columns=_LIST_COLUMNS)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@recipient.command(name="get")
|
|
134
|
+
@click.argument("uid")
|
|
135
|
+
@json_option
|
|
136
|
+
@click.pass_context
|
|
137
|
+
def get(ctx: click.Context, uid: str) -> None:
|
|
138
|
+
"""Show one recipient and its destinations."""
|
|
139
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
140
|
+
item = client.recipients.get(uid)
|
|
141
|
+
client.persist()
|
|
142
|
+
emit(item, json_output=ctx.obj["json"])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@recipient.command(name="details")
|
|
146
|
+
@click.argument("uid")
|
|
147
|
+
@json_option
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def details(ctx: click.Context, uid: str) -> None:
|
|
150
|
+
"""Show a recipient with its reports and recent runs."""
|
|
151
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
152
|
+
payload = client.recipients.details(uid)
|
|
153
|
+
client.persist()
|
|
154
|
+
emit(payload, json_output=ctx.obj["json"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@recipient.command(name="create")
|
|
158
|
+
@click.option(
|
|
159
|
+
"--stdin",
|
|
160
|
+
"use_stdin",
|
|
161
|
+
is_flag=True,
|
|
162
|
+
required=True,
|
|
163
|
+
help=(
|
|
164
|
+
"Read the JSON body from stdin. Shape: {\"name\": ..., "
|
|
165
|
+
"\"destinations\": [{\"destination_type\": \"email\", "
|
|
166
|
+
"\"endpoint\": ...}]}."
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
@json_option
|
|
170
|
+
@click.pass_context
|
|
171
|
+
def create(ctx: click.Context, use_stdin: bool) -> None:
|
|
172
|
+
"""Create a recipient from a JSON body on stdin.
|
|
173
|
+
|
|
174
|
+
The body needs a name and at least one destination; each destination
|
|
175
|
+
has a destination_type (email or slack) and an endpoint (the address
|
|
176
|
+
or channel).
|
|
177
|
+
"""
|
|
178
|
+
body = _read_stdin_json()
|
|
179
|
+
if not body.get("name"):
|
|
180
|
+
raise click.ClickException("Body missing required field: name")
|
|
181
|
+
if not body.get("destinations"):
|
|
182
|
+
raise click.ClickException(
|
|
183
|
+
"Body missing required field: destinations (at least one entry)",
|
|
184
|
+
)
|
|
185
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
186
|
+
item = client.recipients.create(body)
|
|
187
|
+
client.persist()
|
|
188
|
+
emit(item, json_output=ctx.obj["json"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@recipient.command(name="update")
|
|
192
|
+
@click.argument("uid")
|
|
193
|
+
@click.option(
|
|
194
|
+
"--stdin",
|
|
195
|
+
"use_stdin",
|
|
196
|
+
is_flag=True,
|
|
197
|
+
required=True,
|
|
198
|
+
help="Read the JSON body from stdin. Only the fields present are changed.",
|
|
199
|
+
)
|
|
200
|
+
@json_option
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def update(ctx: click.Context, uid: str, use_stdin: bool) -> None:
|
|
203
|
+
"""Update a recipient from a JSON body on stdin."""
|
|
204
|
+
body = _read_stdin_json()
|
|
205
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
206
|
+
item = client.recipients.update(uid, body)
|
|
207
|
+
client.persist()
|
|
208
|
+
emit(item, json_output=ctx.obj["json"])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@recipient.command(name="delete")
|
|
212
|
+
@click.argument("uid")
|
|
213
|
+
@yes_option
|
|
214
|
+
@json_option
|
|
215
|
+
@click.pass_context
|
|
216
|
+
def delete(ctx: click.Context, uid: str, yes: bool) -> None:
|
|
217
|
+
"""Delete a recipient."""
|
|
218
|
+
if not yes:
|
|
219
|
+
name = peek_name(
|
|
220
|
+
lambda: PMClient.from_profile(ctx.obj["profile"]).recipients.get(uid), "name"
|
|
221
|
+
)
|
|
222
|
+
confirm_delete("recipient", uid, name)
|
|
223
|
+
client = PMClient.from_profile(ctx.obj["profile"])
|
|
224
|
+
client.recipients.delete(uid)
|
|
225
|
+
client.persist()
|
|
226
|
+
report_deleted(ctx, "recipient", uid)
|