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,576 @@
1
+ """``impreza account`` subcommand surface — Phase 2.2.
2
+
3
+ Three verbs reading from `c.account.*`:
4
+
5
+ * ``impreza account info``
6
+ Authenticated client's profile + balance, rendered as a
7
+ field/value table (or JSON / YAML).
8
+
9
+ * ``impreza account balance``
10
+ Just the numeric balance + currency. Terse, scriptable —
11
+ designed to drop into shell pipelines (``$(impreza account balance
12
+ --raw)`` returns a single number).
13
+
14
+ * ``impreza account services [--status STATUS]``
15
+ Active / pending / cancelled / etc. services on the account,
16
+ one row per service with id / domain / product / status / next-due
17
+ columns. Optional ``--status`` filter passes through to the SDK.
18
+
19
+ All three commands route through :func:`impreza_cli.sdk.make_client_or_exit`,
20
+ so context resolution failures (no contexts configured, missing
21
+ default, unknown ``--context`` override) surface as friendly stderr
22
+ errors with a non-zero exit code rather than tracebacks.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ import time
29
+ import webbrowser
30
+ from datetime import datetime, timezone
31
+ from typing import Any
32
+
33
+ import typer
34
+ from impreza import TopupInvoice
35
+ from impreza.exceptions import ApiError
36
+
37
+ from ..output import OutputFormat, error, print_dict, print_table, success
38
+ from ..output import info as info_msg # `info` is also the verb name @app.command("info")
39
+ from ..sdk import make_client_or_exit
40
+ from ..state import from_typer_context, resolve_output
41
+ from ._helpers import exit_on_api_error as _exit_on_api_error
42
+
43
+ app = typer.Typer(
44
+ name="account",
45
+ help="Read your account profile, balance, and active services.",
46
+ no_args_is_help=True,
47
+ )
48
+
49
+
50
+ # ── account info ─────────────────────────────────────────────────────
51
+
52
+
53
+ @app.command("info")
54
+ def info(
55
+ typer_ctx: typer.Context,
56
+ output: OutputFormat | None = typer.Option(
57
+ None,
58
+ "--output",
59
+ "-o",
60
+ help="Output format. Overrides the global --output flag.",
61
+ case_sensitive=False,
62
+ ),
63
+ ) -> None:
64
+ """Show your client profile and balance.
65
+
66
+ Wraps ``GET /account``. The fields rendered are:
67
+ name (first/last + optional company), email, balance + currency,
68
+ and the date the account was registered.
69
+ """
70
+ state = from_typer_context(typer_ctx)
71
+ fmt = resolve_output(state, output)
72
+
73
+ with make_client_or_exit(state) as client:
74
+ try:
75
+ me = client.account.get()
76
+ except ApiError as exc:
77
+ _exit_on_api_error(exc)
78
+
79
+ full_name = f"{me.first_name} {me.last_name}".strip()
80
+ if me.company:
81
+ full_name = f"{full_name} ({me.company})"
82
+
83
+ data: dict[str, Any] = {
84
+ "id": me.id,
85
+ "name": full_name,
86
+ "email": me.email,
87
+ "balance": f"{me.balance:.2f} {me.currency}"
88
+ if fmt is OutputFormat.TABLE
89
+ else me.balance,
90
+ "currency": me.currency,
91
+ "registered_at": me.registered_at,
92
+ }
93
+
94
+ print_dict("Account", data, fmt=fmt)
95
+
96
+
97
+ # ── account balance ──────────────────────────────────────────────────
98
+
99
+
100
+ @app.command("balance")
101
+ def balance(
102
+ typer_ctx: typer.Context,
103
+ raw: bool = typer.Option(
104
+ False,
105
+ "--raw",
106
+ help=(
107
+ "Print just the numeric balance with no currency or formatting "
108
+ "— useful in shell substitutions like `$(impreza account "
109
+ "balance --raw)`."
110
+ ),
111
+ ),
112
+ ) -> None:
113
+ """Print the current account balance.
114
+
115
+ Default output: ``45.32 USD`` on a single line, suitable for
116
+ quick eyeballing. ``--raw`` strips the currency for shell
117
+ arithmetic.
118
+ """
119
+ state = from_typer_context(typer_ctx)
120
+ with make_client_or_exit(state) as client:
121
+ try:
122
+ me = client.account.get()
123
+ except ApiError as exc:
124
+ _exit_on_api_error(exc)
125
+
126
+ if raw:
127
+ # Single line, no trailing currency. Stays parseable by bc /
128
+ # python -c / awk without futzing with split.
129
+ typer.echo(f"{me.balance:.2f}")
130
+ else:
131
+ typer.echo(f"{me.balance:.2f} {me.currency}")
132
+
133
+
134
+ # ── account services ─────────────────────────────────────────────────
135
+
136
+
137
+ _SERVICE_COLUMNS = [
138
+ "id",
139
+ "domain",
140
+ "product",
141
+ "status",
142
+ "billing_cycle",
143
+ "amount",
144
+ "next_due",
145
+ "vps_backend",
146
+ ]
147
+
148
+
149
+ @app.command("services")
150
+ def services(
151
+ typer_ctx: typer.Context,
152
+ status: str | None = typer.Option(
153
+ None,
154
+ "--status",
155
+ help=(
156
+ "Filter by service status (Active, Pending, Suspended, "
157
+ "Cancelled, Terminated, Fraud). Case-insensitive at the API "
158
+ "layer; passed through verbatim."
159
+ ),
160
+ ),
161
+ output: OutputFormat | None = typer.Option(
162
+ None,
163
+ "--output",
164
+ "-o",
165
+ help="Output format. Overrides the global --output flag.",
166
+ case_sensitive=False,
167
+ ),
168
+ ) -> None:
169
+ """List the account's services across all backends.
170
+
171
+ Wraps ``GET /account/services``. Each row carries the
172
+ service id (use this with ``impreza vps show <id>`` etc.),
173
+ domain, product name, status, billing cycle, recurring amount,
174
+ next-due date, and the resolved ``vps_backend`` discriminator
175
+ (``proxmox``, ``cloud``, or empty for non-VPS services).
176
+ """
177
+ state = from_typer_context(typer_ctx)
178
+ fmt = resolve_output(state, output)
179
+
180
+ with make_client_or_exit(state) as client:
181
+ try:
182
+ items = client.account.services.list(status=status)
183
+ except ApiError as exc:
184
+ _exit_on_api_error(exc)
185
+
186
+ rows: list[dict[str, Any]] = []
187
+ for svc in items:
188
+ rows.append(
189
+ {
190
+ "id": svc.id,
191
+ "domain": svc.domain,
192
+ "product": svc.product,
193
+ "status": svc.status,
194
+ "billing_cycle": svc.billing_cycle,
195
+ "amount": f"{svc.amount:.2f}"
196
+ if fmt is OutputFormat.TABLE
197
+ else svc.amount,
198
+ "next_due": svc.next_due,
199
+ "vps_backend": svc.vps_backend,
200
+ }
201
+ )
202
+
203
+ if not rows:
204
+ msg = (
205
+ f"No services with status {status!r} on this account."
206
+ if status
207
+ else "No services on this account yet."
208
+ )
209
+ typer.echo(msg)
210
+ return
211
+
212
+ title = (
213
+ f"Services ({status})"
214
+ if status
215
+ else f"Services ({len(rows)} total)"
216
+ )
217
+ print_table(title, rows, columns=_SERVICE_COLUMNS, fmt=fmt)
218
+
219
+
220
+ # ── account topup (Phase 3.6, polished in 4.2) ──────────────────────
221
+
222
+
223
+ # Crypto confirmations are slow; matches the SDK's default in _topup.py.
224
+ _TOPUP_POLL_INTERVAL_SECONDS = 30.0
225
+
226
+ # Width to pad the in-place progress line to, so each redraw fully
227
+ # overwrites the previous one regardless of terminal width. 100 chars
228
+ # is wider than the longest line the formatter produces and short
229
+ # enough to fit standard terminal widths without wrapping.
230
+ _PROGRESS_PAD_WIDTH = 100
231
+
232
+
233
+ def _seconds_until_expiry(expires_at: str | None) -> float | None:
234
+ """Parse an ISO 8601 timestamp and return seconds from now until
235
+ that moment, in UTC. Returns ``None`` if the input is missing or
236
+ unparseable — the caller decides whether to suppress the
237
+ "until expiry" portion of the progress line in that case.
238
+
239
+ Server emits the timestamp as ``"2026-05-11T20:00:00Z"`` (RFC
240
+ 3339-ish with Z suffix). Python's ``datetime.fromisoformat`` was
241
+ extended to accept ``Z`` only in 3.11+; we still target 3.10+ so
242
+ swap ``Z`` for ``+00:00`` before parsing for portability.
243
+ """
244
+ if not expires_at:
245
+ return None
246
+ iso = expires_at
247
+ if iso.endswith("Z"):
248
+ iso = iso[:-1] + "+00:00"
249
+ try:
250
+ target = datetime.fromisoformat(iso)
251
+ except ValueError:
252
+ return None
253
+ if target.tzinfo is None:
254
+ # Treat naive timestamps as UTC — matches what the server
255
+ # actually emits even when the Z gets stripped by an
256
+ # intermediate proxy.
257
+ target = target.replace(tzinfo=timezone.utc)
258
+ return (target - datetime.now(timezone.utc)).total_seconds()
259
+
260
+
261
+ def _format_progress_line(invoice: TopupInvoice, elapsed_s: float) -> str:
262
+ """Render the single-line in-place progress message:
263
+ ``Waiting on top-up invoice N — Xs elapsed / Ys until expiry``.
264
+
265
+ The "until expiry" portion is appended only when ``expires_at``
266
+ parses to a future timestamp. Past-expiry case omits it (the
267
+ next poll iteration will hit the ``--timeout`` branch and
268
+ surface the failure with the payment URL).
269
+ """
270
+ line = (
271
+ f"Waiting on top-up invoice {invoice.invoice_id} "
272
+ f"— {elapsed_s:.0f}s elapsed"
273
+ )
274
+ remaining = _seconds_until_expiry(invoice.expires_at)
275
+ if remaining is not None and remaining > 0:
276
+ line += f" / {remaining:.0f}s until expiry"
277
+ return line
278
+
279
+
280
+ def _write_progress(line: str) -> None:
281
+ """Write a carriage-return-redrawn progress line to stdout,
282
+ padded so previous-iteration leftover characters are
283
+ overwritten. ``flush()`` ensures the line appears immediately
284
+ even when stdout is line-buffered.
285
+
286
+ Test capture via ``CliRunner`` records every byte written so
287
+ assertions can still find the latest line text in the
288
+ accumulated output buffer.
289
+ """
290
+ sys.stdout.write("\r" + line.ljust(_PROGRESS_PAD_WIDTH))
291
+ sys.stdout.flush()
292
+
293
+
294
+ def _clear_progress() -> None:
295
+ """Erase the in-place progress line so the next ``typer.echo``
296
+ starts on a clean row."""
297
+ sys.stdout.write("\r" + " " * _PROGRESS_PAD_WIDTH + "\r")
298
+ sys.stdout.flush()
299
+
300
+
301
+ def _wait_for_topup(
302
+ invoice: TopupInvoice,
303
+ *,
304
+ timeout: int,
305
+ poll_interval: float = _TOPUP_POLL_INTERVAL_SECONDS,
306
+ ) -> None:
307
+ """Block on a :class:`TopupInvoice` future, redrawing a single
308
+ progress line each poll cycle so the user sees elapsed + ETA
309
+ until invoice expiry without scrolling the terminal.
310
+
311
+ Mirrors :func:`commands._helpers.wait_for_operation` for
312
+ the TopupInvoice future. Crypto poll intervals default to 30s
313
+ in the SDK — keep that here so the CLI doesn't hammer the
314
+ gateway faster than the SDK would in programmatic use.
315
+
316
+ The renderer uses bare ``sys.stdout`` (not ``typer.echo``)
317
+ because the in-place ``\\r`` rewrite needs to skip Click's
318
+ line buffering. Final state always lands as a clean settled
319
+ line so the next renderer call (the post-wait
320
+ ``_render_topup_invoice``) starts on a fresh row.
321
+ """
322
+ elapsed = 0.0
323
+ while not invoice.is_done():
324
+ if elapsed >= timeout:
325
+ _clear_progress()
326
+ error(
327
+ f"Top-up invoice {invoice.invoice_id} not paid within "
328
+ f"{timeout}s (last status: {invoice.status!r}). "
329
+ "Re-run with a larger --timeout, or check the payment "
330
+ f"URL: {invoice.payment_url or '(unknown)'}"
331
+ )
332
+ raise typer.Exit(code=1)
333
+ _write_progress(_format_progress_line(invoice, elapsed))
334
+ time.sleep(poll_interval)
335
+ elapsed += poll_interval
336
+ try:
337
+ invoice.refresh()
338
+ except ApiError as exc:
339
+ _clear_progress()
340
+ _exit_on_api_error(exc)
341
+ _clear_progress()
342
+ # Status-conditional rendering: paid → success (green); failed
343
+ # gets the error() line below + we keep this neutral via info_msg().
344
+ if invoice.is_paid():
345
+ success(
346
+ f"Top-up invoice {invoice.invoice_id} settled: "
347
+ f"status={invoice.status!r}"
348
+ )
349
+ else:
350
+ info_msg(
351
+ f"Top-up invoice {invoice.invoice_id} settled: "
352
+ f"status={invoice.status!r}"
353
+ )
354
+ if invoice.is_failed():
355
+ error(
356
+ f"Top-up invoice {invoice.invoice_id} ended in "
357
+ f"{invoice.status!r}. Funds were not credited."
358
+ )
359
+ raise typer.Exit(code=1)
360
+
361
+
362
+ def _open_payment_url(invoice: TopupInvoice) -> None:
363
+ """Best-effort: open the invoice's ``payment_url`` in the
364
+ default browser, or print a friendly message if the URL is
365
+ missing or the OS doesn't have a browser configured.
366
+
367
+ Never raises. ``webbrowser.open()`` returns False when no
368
+ browser was found, True when the OS spawned one — but on some
369
+ headless Linux setups it raises instead, hence the broad
370
+ except. Either way the user still has the URL printed by
371
+ :func:`_render_topup_invoice` and can copy/paste it.
372
+ """
373
+ if not invoice.payment_url:
374
+ info_msg(
375
+ " (no payment_url returned — invoice may already be "
376
+ "settled or the gateway is misconfigured)"
377
+ )
378
+ return
379
+ try:
380
+ opened = webbrowser.open(invoice.payment_url)
381
+ except Exception as exc: # noqa: BLE001 — webbrowser raises bare Exception on headless
382
+ info_msg(
383
+ f" (could not open browser: {exc}; copy the URL above to pay)"
384
+ )
385
+ return
386
+ if opened:
387
+ info_msg(" (payment URL opened in your default browser)")
388
+ else:
389
+ info_msg(
390
+ " (no default browser configured; copy the URL above to pay)"
391
+ )
392
+
393
+
394
+ def _render_topup_invoice(
395
+ invoice: TopupInvoice,
396
+ *,
397
+ fmt: OutputFormat,
398
+ title: str,
399
+ ) -> None:
400
+ """Common renderer for ``topup`` and ``topup-status``. Picks
401
+ fields that are useful in both states (just-created vs polled)."""
402
+ data: dict[str, Any] = {
403
+ "invoice_id": invoice.invoice_id,
404
+ "amount": (
405
+ f"{invoice.amount:.2f}"
406
+ if fmt is OutputFormat.TABLE
407
+ else invoice.amount
408
+ ),
409
+ "currency": invoice.currency,
410
+ "method": invoice.method or "",
411
+ "status": invoice.status,
412
+ "payment_url": invoice.payment_url or "",
413
+ "expires_at": invoice.expires_at or "",
414
+ "paid_at": invoice.paid_at or "",
415
+ "balance_after": (
416
+ f"{invoice.balance_after:.2f}"
417
+ if invoice.balance_after is not None and fmt is OutputFormat.TABLE
418
+ else invoice.balance_after
419
+ ),
420
+ }
421
+ print_dict(title, data, fmt=fmt)
422
+
423
+
424
+ @app.command("topup")
425
+ def topup(
426
+ typer_ctx: typer.Context,
427
+ amount: float = typer.Option(
428
+ ...,
429
+ "--amount", "-a",
430
+ help=(
431
+ "Amount to top up, in account currency (USD by default). "
432
+ "The crypto-gateway converts to the chosen --method at the "
433
+ "spot rate when the invoice is created."
434
+ ),
435
+ min=0.0,
436
+ ),
437
+ method: str | None = typer.Option(
438
+ None,
439
+ "--method", "-m",
440
+ help=(
441
+ "Optional crypto method hint (e.g. 'btc', 'xmr', "
442
+ "'usdt-trc20'). Defaults to the gateway's default if "
443
+ "omitted — the payment URL lets the customer pick at the "
444
+ "BTCPay step either way."
445
+ ),
446
+ ),
447
+ browser: bool = typer.Option(
448
+ False,
449
+ "--browser",
450
+ help=(
451
+ "Open the invoice's payment_url in the system browser "
452
+ "immediately after the create call. Opt-in — the default "
453
+ "flow stays scriptable. No-op when --output json is set "
454
+ "since the JSON consumer typically handles the URL itself."
455
+ ),
456
+ ),
457
+ wait: bool = typer.Option(
458
+ False,
459
+ "--wait",
460
+ help=(
461
+ "Block until the gateway confirms payment (or the invoice "
462
+ "expires). Crypto confirmations are slow — default timeout "
463
+ "matches the server-side invoice expiry of 2h. Progress "
464
+ "renders in place with elapsed time + ETA until expiry."
465
+ ),
466
+ ),
467
+ timeout: int = typer.Option(
468
+ 7200,
469
+ "--timeout",
470
+ help=(
471
+ "Max seconds to wait when --wait is set. Default 7200 (2h) "
472
+ "matches the server-side invoice expiry."
473
+ ),
474
+ ),
475
+ output: OutputFormat | None = typer.Option(
476
+ None, "--output", "-o",
477
+ help="Output format. Overrides the global --output flag.",
478
+ case_sensitive=False,
479
+ ),
480
+ ) -> None:
481
+ """Create a crypto top-up invoice.
482
+
483
+ Wraps ``c.account.topup(amount=..., method=...)``. The server
484
+ creates an ``AddFunds`` invoice routed to the ``btcpayinline``
485
+ gateway and returns a :class:`TopupInvoice` future with a
486
+ ``payment_url``. Open the URL in a browser to pay; once the
487
+ gateway confirms, your Impreza Account credits the balance automatically.
488
+
489
+ Use ``--browser`` to skip the copy-paste step and open the
490
+ payment URL automatically. Use ``--wait`` to block until paid
491
+ (or until the 2h invoice expires); the progress renderer shows
492
+ elapsed time and an ETA until expiry, redrawn in place.
493
+ Without ``--wait`` the CLI prints the invoice details and
494
+ exits — poll later with
495
+ ``impreza account topup-status <invoice-id>``.
496
+ """
497
+ state = from_typer_context(typer_ctx)
498
+ fmt = resolve_output(state, output)
499
+
500
+ with make_client_or_exit(state) as client:
501
+ try:
502
+ invoice = client.account.topup(amount=amount, method=method)
503
+ except ApiError as exc:
504
+ _exit_on_api_error(exc)
505
+ return
506
+
507
+ # Render the freshly-created invoice (payment URL is critical here).
508
+ _render_topup_invoice(
509
+ invoice,
510
+ fmt=fmt,
511
+ title=f"Top-up invoice {invoice.invoice_id} (just created)",
512
+ )
513
+
514
+ # --browser kicks the OS browser at the payment_url. Suppressed
515
+ # in JSON mode because the JSON consumer is a script that
516
+ # presumably handles the URL itself; opening a browser would
517
+ # be a surprise side effect.
518
+ if browser and fmt is OutputFormat.TABLE:
519
+ _open_payment_url(invoice)
520
+
521
+ if not wait:
522
+ return
523
+
524
+ if invoice.is_done():
525
+ # Edge case: gateway confirmed before we got here. Render
526
+ # the final state and exit.
527
+ typer.echo(
528
+ f"Top-up invoice {invoice.invoice_id} is already "
529
+ f"{invoice.status!r}."
530
+ )
531
+ return
532
+
533
+ _wait_for_topup(invoice, timeout=timeout)
534
+ # After wait, render the settled state.
535
+ _render_topup_invoice(
536
+ invoice,
537
+ fmt=fmt,
538
+ title=f"Top-up invoice {invoice.invoice_id} (settled)",
539
+ )
540
+
541
+
542
+ @app.command("topup-status")
543
+ def topup_status(
544
+ typer_ctx: typer.Context,
545
+ invoice_id: int = typer.Argument(
546
+ ...,
547
+ help="Invoice id returned by `impreza account topup`.",
548
+ ),
549
+ output: OutputFormat | None = typer.Option(
550
+ None, "--output", "-o",
551
+ help="Output format. Overrides the global --output flag.",
552
+ case_sensitive=False,
553
+ ),
554
+ ) -> None:
555
+ """Check the current status of a top-up invoice.
556
+
557
+ Wraps ``c.account.topup_status(invoice_id)``. Returns the latest
558
+ gateway state without blocking. Note that ``payment_url`` and
559
+ ``expires_at`` are not echoed by this endpoint (they're set
560
+ once on creation) — they'll appear empty here.
561
+ """
562
+ state = from_typer_context(typer_ctx)
563
+ fmt = resolve_output(state, output)
564
+
565
+ with make_client_or_exit(state) as client:
566
+ try:
567
+ invoice = client.account.topup_status(invoice_id)
568
+ except ApiError as exc:
569
+ _exit_on_api_error(exc)
570
+ return
571
+
572
+ _render_topup_invoice(
573
+ invoice,
574
+ fmt=fmt,
575
+ title=f"Top-up invoice {invoice_id}",
576
+ )