impreza-cli 0.3.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.
@@ -0,0 +1,483 @@
1
+ """``impreza webhook`` subcommand surface — Phase 3.7.
2
+
3
+ Eight verbs over the :class:`~impreza.WebhooksResource` shipped in
4
+ 1.6. All pure CLI work — the SDK side and server side are both
5
+ complete since Phase 0 (server) and Phase 1.6 (SDK).
6
+
7
+ * ``webhook list`` — every subscription on the account.
8
+ * ``webhook show <id>`` — one subscription's detail.
9
+ * ``webhook create --url URL --event TYPE [--event TYPE ...]
10
+ [--description D]`` — register a new subscription. The HMAC
11
+ secret is printed ONCE on this call and ONLY on this call (and
12
+ on ``rotate-secret``). Subsequent reads return null for the
13
+ secret field.
14
+ * ``webhook update <id> [--url ...] [--event TYPE ...]
15
+ [--description ...] [--activate/--deactivate]`` — PATCH semantics;
16
+ pass only the fields to change.
17
+ * ``webhook delete <id> [--yes]`` — remove the subscription.
18
+ Pending undelivered events are dropped at the server.
19
+ * ``webhook rotate-secret <id>`` — generate a fresh HMAC secret.
20
+ The previous secret stops working immediately; the new one is
21
+ printed ONCE.
22
+ * ``webhook deliveries <id>`` — up to 100 recent delivery
23
+ attempts for one subscription.
24
+ * ``webhook event-types`` — the catalog of subscribable event
25
+ types + wildcard patterns.
26
+
27
+ Event types are passed via repeated ``--event TYPE`` flags rather
28
+ than a comma-separated string so quoting edge cases (events with
29
+ periods, glob patterns like ``vps.*``) don't bite. The SDK accepts
30
+ wildcards (``"vps.*"``, ``"*"``) verbatim.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any
36
+
37
+ import typer
38
+ from impreza.exceptions import ApiError
39
+
40
+ from ..output import OutputFormat, error, print_dict, print_table, success
41
+ from ..sdk import make_client_or_exit
42
+ from ..state import confirm_or_exit, from_typer_context, resolve_output
43
+ from ._helpers import exit_on_api_error
44
+
45
+ app = typer.Typer(
46
+ name="webhook",
47
+ help="Manage webhook subscriptions and inspect delivery history.",
48
+ no_args_is_help=True,
49
+ )
50
+
51
+
52
+ # ── helpers ─────────────────────────────────────────────────────────
53
+
54
+
55
+ def _subscription_row(sub: Any, *, fmt: OutputFormat) -> dict[str, Any]:
56
+ """Lift a WebhookSubscription into a flat row. Table mode joins
57
+ the events list with commas; JSON / YAML keep the list shape."""
58
+ events_field = (
59
+ ", ".join(sub.events)
60
+ if fmt is OutputFormat.TABLE
61
+ else list(sub.events)
62
+ )
63
+ return {
64
+ "id": sub.id,
65
+ "url": sub.url,
66
+ "events": events_field,
67
+ "description": sub.description or "",
68
+ "is_active": "yes" if sub.is_active else "no",
69
+ "last_delivery_at": sub.last_delivery_at or "",
70
+ "last_delivery_status": (
71
+ sub.last_delivery_status
72
+ if sub.last_delivery_status is not None
73
+ else ""
74
+ ),
75
+ "created_at": sub.created_at or "",
76
+ }
77
+
78
+
79
+ _LIST_COLUMNS = [
80
+ "id", "url", "events", "is_active",
81
+ "last_delivery_at", "last_delivery_status",
82
+ ]
83
+
84
+
85
+ # ── webhook list ────────────────────────────────────────────────────
86
+
87
+
88
+ @app.command("list")
89
+ def list_webhooks(
90
+ typer_ctx: typer.Context,
91
+ output: OutputFormat | None = typer.Option(
92
+ None, "--output", "-o",
93
+ help="Output format. Overrides the global --output flag.",
94
+ case_sensitive=False,
95
+ ),
96
+ ) -> None:
97
+ """List every webhook subscription on the account.
98
+
99
+ Wraps ``c.webhooks.list()``. The ``secret`` field is null on
100
+ all entries — it's only returned on create / rotate-secret.
101
+ """
102
+ state = from_typer_context(typer_ctx)
103
+ fmt = resolve_output(state, output)
104
+
105
+ with make_client_or_exit(state) as client:
106
+ try:
107
+ subs = client.webhooks.list()
108
+ except ApiError as exc:
109
+ exit_on_api_error(exc)
110
+ return
111
+
112
+ if not subs:
113
+ typer.echo("No webhook subscriptions on this account.")
114
+ return
115
+
116
+ rows = [_subscription_row(s, fmt=fmt) for s in subs]
117
+ print_table(
118
+ f"Webhook subscriptions ({len(rows)})",
119
+ rows,
120
+ columns=_LIST_COLUMNS,
121
+ fmt=fmt,
122
+ )
123
+
124
+
125
+ # ── webhook show ────────────────────────────────────────────────────
126
+
127
+
128
+ @app.command("show")
129
+ def show_webhook(
130
+ typer_ctx: typer.Context,
131
+ subscription_id: int = typer.Argument(..., help="Subscription id."),
132
+ output: OutputFormat | None = typer.Option(
133
+ None, "--output", "-o",
134
+ help="Output format. Overrides the global --output flag.",
135
+ case_sensitive=False,
136
+ ),
137
+ ) -> None:
138
+ """Show one subscription's detail.
139
+
140
+ Wraps ``c.webhooks.get(id)``.
141
+ """
142
+ state = from_typer_context(typer_ctx)
143
+ fmt = resolve_output(state, output)
144
+
145
+ with make_client_or_exit(state) as client:
146
+ try:
147
+ sub = client.webhooks.get(subscription_id)
148
+ except ApiError as exc:
149
+ exit_on_api_error(exc)
150
+ return
151
+
152
+ data = _subscription_row(sub, fmt=fmt)
153
+ print_dict(f"Webhook subscription {subscription_id}", data, fmt=fmt)
154
+
155
+
156
+ # ── webhook create ──────────────────────────────────────────────────
157
+
158
+
159
+ @app.command("create")
160
+ def create_webhook(
161
+ typer_ctx: typer.Context,
162
+ url: str = typer.Option(
163
+ ...,
164
+ "--url", "-u",
165
+ help="HTTPS URL the server will POST events to.",
166
+ ),
167
+ event: list[str] = typer.Option(
168
+ ...,
169
+ "--event", "-e",
170
+ help=(
171
+ "Event type or wildcard. Pass multiple times. Examples: "
172
+ "`--event topup.paid --event vps.*` (concrete + wildcard); "
173
+ "`--event '*'` (everything). Run `webhook event-types` to "
174
+ "list the catalog."
175
+ ),
176
+ ),
177
+ description: str | None = typer.Option(
178
+ None, "--description", "-d",
179
+ help="Optional human-readable note for the subscription.",
180
+ ),
181
+ ) -> None:
182
+ """Create a new webhook subscription.
183
+
184
+ Wraps ``c.webhooks.create(url=..., events=[...],
185
+ description=...)``. The HMAC secret is printed **only on this
186
+ call** (and on ``rotate-secret``). Capture it before the
187
+ terminal closes — there's no way to retrieve it later other
188
+ than rotating.
189
+ """
190
+ state = from_typer_context(typer_ctx)
191
+
192
+ if not event:
193
+ error("--event requires at least one event type (pass it multiple times).")
194
+ raise typer.Exit(code=1)
195
+
196
+ with make_client_or_exit(state) as client:
197
+ try:
198
+ sub = client.webhooks.create(
199
+ url=url, events=event, description=description
200
+ )
201
+ except ApiError as exc:
202
+ exit_on_api_error(exc)
203
+ return
204
+
205
+ success(f"Subscription {sub.id} created: {sub.url}")
206
+ typer.echo(f" Events: {', '.join(sub.events)}")
207
+ if sub.secret:
208
+ typer.echo("")
209
+ typer.echo(f" HMAC SECRET (shown only once): {sub.secret}")
210
+ if sub.secret_warning:
211
+ typer.echo(f" WARNING: {sub.secret_warning}")
212
+ typer.echo(" Store this securely; rotate with `webhook rotate-secret`.")
213
+
214
+
215
+ # ── webhook update ──────────────────────────────────────────────────
216
+
217
+
218
+ @app.command("update")
219
+ def update_webhook(
220
+ typer_ctx: typer.Context,
221
+ subscription_id: int = typer.Argument(..., help="Subscription id."),
222
+ url: str | None = typer.Option(
223
+ None, "--url", "-u", help="New URL (optional)."
224
+ ),
225
+ event: list[str] = typer.Option(
226
+ [],
227
+ "--event", "-e",
228
+ help=(
229
+ "Replace the event list with these tokens. Pass multiple "
230
+ "times. Pass at least one ``--event`` flag to update; "
231
+ "omit entirely to leave events untouched. (PATCH semantics.)"
232
+ ),
233
+ ),
234
+ description: str | None = typer.Option(
235
+ None, "--description", "-d", help="New description (optional)."
236
+ ),
237
+ activate: bool = typer.Option(
238
+ False, "--activate", help="Set is_active=true."
239
+ ),
240
+ deactivate: bool = typer.Option(
241
+ False, "--deactivate", help="Set is_active=false."
242
+ ),
243
+ ) -> None:
244
+ """Update one or more fields on a subscription. **PATCH** — only
245
+ the flags you pass get updated.
246
+
247
+ Wraps ``c.webhooks.update(id, **kwargs)``. ``--activate`` and
248
+ ``--deactivate`` are mutually exclusive. If you pass neither and
249
+ no other flag, the SDK rejects the empty body with a ValueError
250
+ — the CLI surfaces that with a clear stderr line.
251
+ """
252
+ if activate and deactivate:
253
+ error("--activate and --deactivate are mutually exclusive.")
254
+ raise typer.Exit(code=1)
255
+
256
+ is_active: bool | None = None
257
+ if activate:
258
+ is_active = True
259
+ elif deactivate:
260
+ is_active = False
261
+
262
+ events_arg = event if event else None
263
+
264
+ state = from_typer_context(typer_ctx)
265
+ with make_client_or_exit(state) as client:
266
+ try:
267
+ sub = client.webhooks.update(
268
+ subscription_id,
269
+ url=url,
270
+ events=events_arg,
271
+ description=description,
272
+ is_active=is_active,
273
+ )
274
+ except ValueError as exc:
275
+ # SDK raises ValueError when no field was set to update
276
+ error(str(exc))
277
+ raise typer.Exit(code=1) from None
278
+ except ApiError as exc:
279
+ exit_on_api_error(exc)
280
+ return
281
+
282
+ success(
283
+ f"Subscription {sub.id} updated: {sub.url} "
284
+ f"(events: {', '.join(sub.events)}, "
285
+ f"is_active={sub.is_active})"
286
+ )
287
+
288
+
289
+ # ── webhook delete ──────────────────────────────────────────────────
290
+
291
+
292
+ @app.command("delete")
293
+ def delete_webhook(
294
+ typer_ctx: typer.Context,
295
+ subscription_id: int = typer.Argument(..., help="Subscription id."),
296
+ yes: bool = typer.Option(
297
+ False, "--yes", "-y", help="Skip the deletion confirmation prompt."
298
+ ),
299
+ ) -> None:
300
+ """Delete a subscription. **Irreversible.** Pending undelivered
301
+ events are dropped at the server.
302
+
303
+ Wraps ``c.webhooks.delete(id)``.
304
+ """
305
+ state = from_typer_context(typer_ctx)
306
+ confirm_or_exit(
307
+ f"Deleting webhook subscription {subscription_id} is "
308
+ "irreversible — pending undelivered events are dropped at "
309
+ "the server. Re-creating the subscription gets a fresh "
310
+ "secret and a new id.",
311
+ yes=yes,
312
+ )
313
+ with make_client_or_exit(state) as client:
314
+ try:
315
+ client.webhooks.delete(subscription_id)
316
+ except ApiError as exc:
317
+ exit_on_api_error(exc)
318
+ return
319
+ success(f"Webhook subscription {subscription_id} deleted.")
320
+
321
+
322
+ # ── webhook rotate-secret ───────────────────────────────────────────
323
+
324
+
325
+ @app.command("rotate-secret")
326
+ def rotate_secret(
327
+ typer_ctx: typer.Context,
328
+ subscription_id: int = typer.Argument(..., help="Subscription id."),
329
+ yes: bool = typer.Option(
330
+ False, "--yes", "-y",
331
+ help="Skip the rotation confirmation prompt.",
332
+ ),
333
+ ) -> None:
334
+ """Generate a fresh HMAC secret for a subscription. **The
335
+ previous secret stops working immediately.**
336
+
337
+ Wraps ``c.webhooks.rotate_secret(id)``. The new secret is
338
+ printed only on this call — capture it before the terminal
339
+ closes.
340
+ """
341
+ state = from_typer_context(typer_ctx)
342
+ confirm_or_exit(
343
+ f"Rotating the secret for subscription {subscription_id} "
344
+ "invalidates the previous secret immediately. Receivers "
345
+ "still verifying with the old secret will reject deliveries "
346
+ "until you update them with the new one.",
347
+ yes=yes,
348
+ )
349
+ with make_client_or_exit(state) as client:
350
+ try:
351
+ secret = client.webhooks.rotate_secret(subscription_id)
352
+ except ApiError as exc:
353
+ exit_on_api_error(exc)
354
+ return
355
+
356
+ if not secret:
357
+ # Server didn't echo the secret — surface a clear failure
358
+ # instead of silently printing an empty line.
359
+ error(
360
+ f"Secret rotation succeeded but no secret was returned. "
361
+ f"This shouldn't happen; check subscription "
362
+ f"{subscription_id} via `webhook show` and contact "
363
+ f"support if rotation didn't actually take effect."
364
+ )
365
+ raise typer.Exit(code=1)
366
+ success(f"Secret rotated for subscription {subscription_id}:")
367
+ typer.echo(f" HMAC SECRET (shown only once): {secret}")
368
+ typer.echo(" Update every receiver verifying with the previous secret.")
369
+
370
+
371
+ # ── webhook deliveries ──────────────────────────────────────────────
372
+
373
+
374
+ _DELIVERY_COLUMNS = [
375
+ "id", "event_type", "event_id", "attempts",
376
+ "last_attempted_at", "last_response_code",
377
+ "delivered", "delivered_at",
378
+ ]
379
+
380
+
381
+ @app.command("deliveries")
382
+ def deliveries(
383
+ typer_ctx: typer.Context,
384
+ subscription_id: int = typer.Argument(..., help="Subscription id."),
385
+ output: OutputFormat | None = typer.Option(
386
+ None, "--output", "-o",
387
+ help="Output format. Overrides the global --output flag.",
388
+ case_sensitive=False,
389
+ ),
390
+ ) -> None:
391
+ """List recent delivery attempts (up to 100) for a subscription.
392
+
393
+ Wraps ``c.webhooks.deliveries(id)``. Useful for debugging why a
394
+ receiver isn't getting events — the ``last_error`` and
395
+ ``last_response_code`` columns surface what the server saw on
396
+ the most recent attempt.
397
+ """
398
+ state = from_typer_context(typer_ctx)
399
+ fmt = resolve_output(state, output)
400
+
401
+ with make_client_or_exit(state) as client:
402
+ try:
403
+ history = client.webhooks.deliveries(subscription_id)
404
+ except ApiError as exc:
405
+ exit_on_api_error(exc)
406
+ return
407
+
408
+ if not history:
409
+ typer.echo(
410
+ f"No delivery history yet for subscription {subscription_id}."
411
+ )
412
+ return
413
+
414
+ rows = [
415
+ {
416
+ "id": d.id,
417
+ "event_type": d.event_type,
418
+ "event_id": d.event_id,
419
+ "attempts": d.attempts,
420
+ "last_attempted_at": d.last_attempted_at or "",
421
+ "last_response_code": (
422
+ d.last_response_code if d.last_response_code is not None else ""
423
+ ),
424
+ "delivered": "yes" if d.delivered else "no",
425
+ "delivered_at": d.delivered_at or "",
426
+ }
427
+ for d in history
428
+ ]
429
+ print_table(
430
+ f"Deliveries for subscription {subscription_id} ({len(rows)})",
431
+ rows,
432
+ columns=_DELIVERY_COLUMNS,
433
+ fmt=fmt,
434
+ )
435
+
436
+
437
+ # ── webhook event-types ─────────────────────────────────────────────
438
+
439
+
440
+ @app.command("event-types")
441
+ def event_types(
442
+ typer_ctx: typer.Context,
443
+ output: OutputFormat | None = typer.Option(
444
+ None, "--output", "-o",
445
+ help="Output format. Overrides the global --output flag.",
446
+ case_sensitive=False,
447
+ ),
448
+ ) -> None:
449
+ """Show the catalog of subscribable event types and wildcards.
450
+
451
+ Wraps ``c.webhooks.event_types()``. Use this to discover which
452
+ event names a receiver can subscribe to — the list grows as the
453
+ server adds events, so the SDK / CLI never hardcode a copy.
454
+ """
455
+ state = from_typer_context(typer_ctx)
456
+ fmt = resolve_output(state, output)
457
+
458
+ with make_client_or_exit(state) as client:
459
+ try:
460
+ catalog = client.webhooks.event_types()
461
+ except ApiError as exc:
462
+ exit_on_api_error(exc)
463
+ return
464
+
465
+ if fmt is OutputFormat.TABLE:
466
+ if not catalog.event_types and not catalog.wildcards:
467
+ typer.echo("No event types configured upstream.")
468
+ return
469
+ if catalog.event_types:
470
+ typer.echo("Event types:")
471
+ for ev in catalog.event_types:
472
+ typer.echo(f" - {ev}")
473
+ if catalog.wildcards:
474
+ typer.echo("")
475
+ typer.echo("Wildcards:")
476
+ for pat, desc in catalog.wildcards.items():
477
+ typer.echo(f" {pat} - {desc}")
478
+ else:
479
+ data: dict[str, Any] = {
480
+ "event_types": list(catalog.event_types),
481
+ "wildcards": dict(catalog.wildcards),
482
+ }
483
+ print_dict("Webhook event-type catalog", data, fmt=fmt)