pushmetrics-cli 0.1.0__py3-none-any.whl

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