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,478 @@
1
+ """``impreza order`` subcommand surface — Phase 3.6.
2
+
3
+ Four verbs over the SDK's :class:`~impreza.resources.orders.OrdersResource`
4
+ (shipped in 1.4d). Two read, two write — pure CLI work over methods
5
+ that already exist:
6
+
7
+ * ``impreza order list [--status STATUS]``
8
+ Wraps ``c.orders.list(status=...)``. Up to 50 most recent orders,
9
+ most recent first.
10
+
11
+ * ``impreza order show <id>``
12
+ Wraps ``c.orders.get(id)``. Renders the order summary plus its
13
+ line items as a follow-up table in table mode; JSON / YAML emit
14
+ the full :class:`OrderDetail` payload.
15
+
16
+ * ``impreza order create --product-id N --billing-cycle CYCLE
17
+ [--domain DOM] [--hostname HOST] [--config-option K=V ...]
18
+ [--custom-field K=V ...] [--yes]``
19
+ Wraps ``c.orders.create(...)``. Smart name/id resolution from
20
+ 1.4d carries through — pass ``--config-option "Disk Space=20 GB"``
21
+ by name or ``--config-option 3=5`` by ID. Same for
22
+ ``--custom-field``. Costs real money — gated by
23
+ ``confirm_or_exit`` since the balance is debited.
24
+
25
+ * ``impreza order upgrade --service-id N --new-product-id M
26
+ --billing-cycle CYCLE [--yes]``
27
+ Wraps ``c.orders.upgrade(...)``. Charges the prorated difference
28
+ from the client's balance. Same ``confirm_or_exit`` gate.
29
+
30
+ There's no ``impreza order cancel``: the SDK doesn't expose
31
+ ``c.orders.cancel()`` because order cancellation in Impreza Account is
32
+ actually service cancellation via ``AddCancelRequest`` (the same
33
+ verb VPS already exposes as ``vps cancel``). The non-VPS
34
+ equivalent — ``impreza service cancel`` — lands in Phase 3.7.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from typing import Any
40
+
41
+ import typer
42
+ from impreza.exceptions import ApiError, InsufficientCredit, InvalidRequest
43
+
44
+ from ..output import OutputFormat, error, print_dict, print_table, success
45
+ from ..sdk import make_client_or_exit
46
+ from ..state import confirm_or_exit, from_typer_context, resolve_output
47
+ from ._helpers import exit_on_api_error
48
+
49
+ app = typer.Typer(
50
+ name="order",
51
+ help="Browse orders and submit new product / upgrade orders.",
52
+ no_args_is_help=True,
53
+ )
54
+
55
+
56
+ _VALID_CYCLES = {
57
+ "monthly",
58
+ "quarterly",
59
+ "semiannually",
60
+ "annually",
61
+ "biennially",
62
+ "triennially",
63
+ }
64
+
65
+
66
+ # ── helpers ─────────────────────────────────────────────────────────
67
+
68
+
69
+ def _exit_on_insufficient_credit(exc: InsufficientCredit) -> None:
70
+ """Hint at ``impreza account topup`` (3.6) so users can chain the
71
+ fix without re-reading docs. Same pattern as 3.1's hint on
72
+ domain-purchase errors."""
73
+ parts = [exc.message]
74
+ if exc.code:
75
+ parts.append(f"(code={exc.code})")
76
+ if exc.request_id:
77
+ parts.append(f"[request_id={exc.request_id}]")
78
+ error(" ".join(parts))
79
+ error("→ Top up your balance with: impreza account topup --amount X")
80
+ raise typer.Exit(code=1)
81
+
82
+
83
+ def _parse_kv_option(
84
+ name: str,
85
+ raw: list[str],
86
+ ) -> dict[int | str, int | str]:
87
+ """Parse repeated ``--config-option K=V`` flags into the dict shape
88
+ the SDK accepts. Both K and V can be quoted strings or stringified
89
+ integers; the SDK then resolves names or trusts IDs as needed.
90
+
91
+ The conversion rule: if K (or V) parses as a Python int, treat it
92
+ as an ID; otherwise it's a name. This means quoting matters only
93
+ when a config-option name happens to be all digits — unlikely in
94
+ practice but worth flagging in ``--help``.
95
+ """
96
+ out: dict[int | str, int | str] = {}
97
+ for entry in raw:
98
+ if "=" not in entry:
99
+ error(
100
+ f"--{name} expects 'KEY=VALUE' format, got: {entry!r}"
101
+ )
102
+ raise typer.Exit(code=1)
103
+ k, _, v = entry.partition("=")
104
+ k = k.strip()
105
+ v = v.strip()
106
+ if not k or not v:
107
+ error(f"--{name} entry has an empty side: {entry!r}")
108
+ raise typer.Exit(code=1)
109
+ # Try int first; fall back to string.
110
+ try:
111
+ key: int | str = int(k)
112
+ except ValueError:
113
+ key = k
114
+ try:
115
+ val: int | str = int(v)
116
+ except ValueError:
117
+ val = v
118
+ out[key] = val
119
+ return out
120
+
121
+
122
+ def _parse_custom_fields(raw: list[str]) -> dict[int | str, str]:
123
+ """Like :func:`_parse_kv_option` but values are always strings
124
+ (custom-field values are free-form text)."""
125
+ out: dict[int | str, str] = {}
126
+ for entry in raw:
127
+ if "=" not in entry:
128
+ error(f"--custom-field expects 'KEY=VALUE' format, got: {entry!r}")
129
+ raise typer.Exit(code=1)
130
+ k, _, v = entry.partition("=")
131
+ k = k.strip()
132
+ v = v.strip()
133
+ if not k:
134
+ error(f"--custom-field entry has an empty key: {entry!r}")
135
+ raise typer.Exit(code=1)
136
+ try:
137
+ key: int | str = int(k)
138
+ except ValueError:
139
+ key = k
140
+ out[key] = v
141
+ return out
142
+
143
+
144
+ # ── order list ──────────────────────────────────────────────────────
145
+
146
+
147
+ _LIST_COLUMNS = [
148
+ "id", "order_number", "date", "amount",
149
+ "status", "invoice_id", "payment_method",
150
+ ]
151
+
152
+
153
+ @app.command("list")
154
+ def list_orders(
155
+ typer_ctx: typer.Context,
156
+ status: str | None = typer.Option(
157
+ None,
158
+ "--status",
159
+ help=(
160
+ "Filter by order status (Pending, Active, Cancelled, "
161
+ "Fraud). Case-sensitive — match the canonical labels."
162
+ ),
163
+ ),
164
+ output: OutputFormat | None = typer.Option(
165
+ None, "--output", "-o",
166
+ help="Output format. Overrides the global --output flag.",
167
+ case_sensitive=False,
168
+ ),
169
+ ) -> None:
170
+ """List up to the 50 most recent orders on this account.
171
+
172
+ Wraps ``c.orders.list(status=...)``. Returns orders most-recent
173
+ first. Use ``impreza order show <id>`` to fetch line items.
174
+ """
175
+ state = from_typer_context(typer_ctx)
176
+ fmt = resolve_output(state, output)
177
+
178
+ with make_client_or_exit(state) as client:
179
+ try:
180
+ orders = client.orders.list(status=status)
181
+ except ApiError as exc:
182
+ exit_on_api_error(exc)
183
+ return
184
+
185
+ if not orders:
186
+ if status:
187
+ typer.echo(f"No orders match status {status!r}.")
188
+ else:
189
+ typer.echo("No orders on this account.")
190
+ return
191
+
192
+ rows = [
193
+ {
194
+ "id": o.id,
195
+ "order_number": o.order_number if o.order_number is not None else "",
196
+ "date": o.date or "",
197
+ "amount": f"{o.amount:.2f}" if fmt is OutputFormat.TABLE else o.amount,
198
+ "status": o.status,
199
+ "invoice_id": o.invoice_id if o.invoice_id is not None else "",
200
+ "payment_method": o.payment_method or "",
201
+ }
202
+ for o in orders
203
+ ]
204
+ title = f"Orders ({len(rows)}"
205
+ if status:
206
+ title += f", status={status!r}"
207
+ title += ")"
208
+ print_table(title, rows, columns=_LIST_COLUMNS, fmt=fmt)
209
+
210
+
211
+ # ── order show ──────────────────────────────────────────────────────
212
+
213
+
214
+ @app.command("show")
215
+ def show_order(
216
+ typer_ctx: typer.Context,
217
+ order_id: int = typer.Argument(..., help="Order id."),
218
+ output: OutputFormat | None = typer.Option(
219
+ None, "--output", "-o",
220
+ help="Output format. Overrides the global --output flag.",
221
+ case_sensitive=False,
222
+ ),
223
+ ) -> None:
224
+ """Show full detail for one order, including its line items.
225
+
226
+ Wraps ``c.orders.get(id)``. Table mode renders the order summary
227
+ first, then the line items table; JSON / YAML emit the full
228
+ :class:`OrderDetail` model.
229
+ """
230
+ state = from_typer_context(typer_ctx)
231
+ fmt = resolve_output(state, output)
232
+
233
+ with make_client_or_exit(state) as client:
234
+ try:
235
+ order = client.orders.get(order_id)
236
+ except ApiError as exc:
237
+ exit_on_api_error(exc)
238
+ return
239
+
240
+ if fmt is OutputFormat.TABLE:
241
+ summary: dict[str, Any] = {
242
+ "id": order.id,
243
+ "order_number": order.order_number if order.order_number is not None else "",
244
+ "date": order.date or "",
245
+ "amount": f"{order.amount:.2f}",
246
+ "status": order.status,
247
+ "invoice_id": order.invoice_id if order.invoice_id is not None else "",
248
+ "payment_method": order.payment_method or "",
249
+ }
250
+ print_dict(f"Order {order_id}", summary, fmt=fmt)
251
+ if order.items:
252
+ item_rows = [
253
+ {
254
+ "service_id": it.service_id,
255
+ "domain": it.domain or "",
256
+ "product": it.product or "",
257
+ "status": it.status or "",
258
+ "billing_cycle": it.billing_cycle or "",
259
+ "amount": f"{it.amount:.2f}" if it.amount is not None else "",
260
+ }
261
+ for it in order.items
262
+ ]
263
+ print_table(
264
+ f"Line items ({len(item_rows)})",
265
+ item_rows,
266
+ columns=["service_id", "domain", "product", "status",
267
+ "billing_cycle", "amount"],
268
+ fmt=fmt,
269
+ )
270
+ else:
271
+ # JSON / YAML: emit the full order with items inlined.
272
+ data: dict[str, Any] = {
273
+ "id": order.id,
274
+ "order_number": order.order_number,
275
+ "date": order.date,
276
+ "amount": order.amount,
277
+ "status": order.status,
278
+ "invoice_id": order.invoice_id,
279
+ "payment_method": order.payment_method,
280
+ "items": [
281
+ {
282
+ "service_id": it.service_id,
283
+ "domain": it.domain,
284
+ "product": it.product,
285
+ "status": it.status,
286
+ "billing_cycle": it.billing_cycle,
287
+ "amount": it.amount,
288
+ }
289
+ for it in order.items
290
+ ],
291
+ }
292
+ print_dict(f"Order {order_id}", data, fmt=fmt)
293
+
294
+
295
+ # ── order create ────────────────────────────────────────────────────
296
+
297
+
298
+ @app.command("create")
299
+ def create_order(
300
+ typer_ctx: typer.Context,
301
+ product_id: int = typer.Option(
302
+ ...,
303
+ "--product-id", "-p",
304
+ help="Product id from `impreza catalog products`.",
305
+ ),
306
+ billing_cycle: str = typer.Option(
307
+ ...,
308
+ "--billing-cycle", "-b",
309
+ help=(
310
+ "Billing cycle: monthly / quarterly / semiannually / "
311
+ "annually / biennially / triennially. Validated client-side."
312
+ ),
313
+ ),
314
+ domain: str | None = typer.Option(
315
+ None,
316
+ "--domain",
317
+ help="Domain name to associate with the service (when applicable).",
318
+ ),
319
+ hostname: str | None = typer.Option(
320
+ None,
321
+ "--hostname",
322
+ help="Hostname (e.g. for VPS services). Optional.",
323
+ ),
324
+ config_option: list[str] = typer.Option(
325
+ [],
326
+ "--config-option", "-c",
327
+ help=(
328
+ "Configurable option: 'KEY=VALUE'. Pass multiple times. KEY "
329
+ "and VALUE can be names (e.g. 'Disk Space=20 GB') or IDs "
330
+ "(e.g. '3=5'). Names trigger one extra GET /products/{id} "
331
+ "call for resolution; IDs skip resolution."
332
+ ),
333
+ ),
334
+ custom_field: list[str] = typer.Option(
335
+ [],
336
+ "--custom-field", "-f",
337
+ help=(
338
+ "Custom field value: 'KEY=VALUE'. Pass multiple times. KEY "
339
+ "can be a name or ID (same rule as --config-option); the "
340
+ "value is always a free-form string."
341
+ ),
342
+ ),
343
+ yes: bool = typer.Option(
344
+ False, "--yes", "-y", help="Skip the balance-debit confirmation prompt."
345
+ ),
346
+ ) -> None:
347
+ """Create a new order. **Charges your account balance.**
348
+
349
+ Wraps ``c.orders.create(...)``. The order's cost is debited from
350
+ your credit balance — if the balance is insufficient, the SDK
351
+ raises :class:`InsufficientCredit` (HTTP 402) which the CLI maps
352
+ to a friendly stderr line plus a hint to run
353
+ ``impreza account topup`` (3.6).
354
+
355
+ Smart name/id resolution from 1.4d: ``--config-option`` and
356
+ ``--custom-field`` accept either ID-keyed or name-keyed entries.
357
+ Name resolution costs one extra GET /products/{id} call.
358
+ """
359
+ if billing_cycle not in _VALID_CYCLES:
360
+ error(
361
+ f"--billing-cycle must be one of {sorted(_VALID_CYCLES)!r}, "
362
+ f"got: {billing_cycle!r}"
363
+ )
364
+ raise typer.Exit(code=1)
365
+
366
+ config_opts = _parse_kv_option("config-option", config_option)
367
+ custom_flds = _parse_custom_fields(custom_field)
368
+
369
+ state = from_typer_context(typer_ctx)
370
+ msg = (
371
+ f"Creating order for product {product_id} on a "
372
+ f"{billing_cycle} cycle. Cost will be charged from your "
373
+ "account balance."
374
+ )
375
+ if domain:
376
+ msg = msg.rstrip(".") + f" Domain: {domain!r}."
377
+ confirm_or_exit(msg, yes=yes)
378
+
379
+ with make_client_or_exit(state) as client:
380
+ try:
381
+ result = client.orders.create(
382
+ product_id=product_id,
383
+ billing_cycle=billing_cycle,
384
+ domain=domain,
385
+ hostname=hostname,
386
+ config_options=config_opts or None,
387
+ custom_fields=custom_flds or None,
388
+ )
389
+ except InsufficientCredit as exc:
390
+ _exit_on_insufficient_credit(exc)
391
+ return
392
+ except InvalidRequest as exc:
393
+ # UNKNOWN_OPTION / UNKNOWN_FIELD from the SDK's resolver
394
+ # already carry friendly messages; pass through.
395
+ exit_on_api_error(exc)
396
+ return
397
+ except ApiError as exc:
398
+ exit_on_api_error(exc)
399
+ return
400
+
401
+ success(
402
+ f"Order {result.order_id} created: "
403
+ f"invoice {result.invoice_id}, "
404
+ f"{result.amount:.2f} {result.currency}, status={result.status!r}"
405
+ + (f" — {result.product!r}" if result.product else "")
406
+ )
407
+
408
+
409
+ # ── order upgrade ───────────────────────────────────────────────────
410
+
411
+
412
+ @app.command("upgrade")
413
+ def upgrade_order(
414
+ typer_ctx: typer.Context,
415
+ service_id: int = typer.Option(
416
+ ...,
417
+ "--service-id", "-s",
418
+ help="Service id of the service being upgraded.",
419
+ ),
420
+ new_product_id: int = typer.Option(
421
+ ...,
422
+ "--new-product-id", "-p",
423
+ help="Product id of the target product.",
424
+ ),
425
+ billing_cycle: str = typer.Option(
426
+ ...,
427
+ "--billing-cycle", "-b",
428
+ help=(
429
+ "Target billing cycle: monthly / quarterly / semiannually / "
430
+ "annually / biennially / triennially."
431
+ ),
432
+ ),
433
+ yes: bool = typer.Option(
434
+ False, "--yes", "-y", help="Skip the balance-debit confirmation prompt."
435
+ ),
436
+ ) -> None:
437
+ """Upgrade an existing service to a different product / cycle.
438
+ **Charges the prorated difference** from your balance.
439
+
440
+ Wraps ``c.orders.upgrade(service_id, new_product_id, billing_cycle)``.
441
+ Note: the upstream does NOT yet support changing
442
+ config_options / custom_fields on upgrade — only the product and
443
+ billing cycle. Existing customizations carry through.
444
+ """
445
+ if billing_cycle not in _VALID_CYCLES:
446
+ error(
447
+ f"--billing-cycle must be one of {sorted(_VALID_CYCLES)!r}, "
448
+ f"got: {billing_cycle!r}"
449
+ )
450
+ raise typer.Exit(code=1)
451
+
452
+ state = from_typer_context(typer_ctx)
453
+ confirm_or_exit(
454
+ f"Upgrading service {service_id} to product {new_product_id} "
455
+ f"on a {billing_cycle} cycle will charge the prorated "
456
+ "difference from your account balance.",
457
+ yes=yes,
458
+ )
459
+
460
+ with make_client_or_exit(state) as client:
461
+ try:
462
+ result = client.orders.upgrade(
463
+ service_id=service_id,
464
+ new_product_id=new_product_id,
465
+ billing_cycle=billing_cycle,
466
+ )
467
+ except InsufficientCredit as exc:
468
+ _exit_on_insufficient_credit(exc)
469
+ return
470
+ except ApiError as exc:
471
+ exit_on_api_error(exc)
472
+ return
473
+
474
+ success(
475
+ f"Service {service_id} upgrade order {result.order_id} created: "
476
+ f"invoice {result.invoice_id}, "
477
+ f"{result.amount:.2f} {result.currency}, status={result.status!r}"
478
+ )
@@ -0,0 +1,100 @@
1
+ """``impreza service cancel`` — Phase 3.7.
2
+
3
+ The non-backend-specific cancellation surface. Mirrors the
4
+ :func:`commands.vps.cancel` from 3.3 in shape and policy, but works
5
+ on any service id (hosting, email, domain, etc.) — not just VPSs.
6
+
7
+ Service cancellation is staff-owned by design: the customer submits an
8
+ ``AddCancelRequest`` via this endpoint, and staff approves the
9
+ actual termination later. There is **no** customer-facing path to
10
+ terminate a service immediately on the same call; the SDK's
11
+ ``c.account.services.cancel()`` reflects that.
12
+
13
+ For VPS-specific cancel (which routes through the same
14
+ ``AddCancelRequest`` on the server but goes via the bound model),
15
+ use ``impreza vps cancel``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import typer
21
+ from impreza.exceptions import ApiError
22
+
23
+ from ..output import error, success
24
+ from ..sdk import make_client_or_exit
25
+ from ..state import confirm_or_exit, from_typer_context
26
+ from ._helpers import exit_on_api_error
27
+
28
+ app = typer.Typer(
29
+ name="service",
30
+ help="Submit cancellation requests for non-VPS services.",
31
+ no_args_is_help=True,
32
+ )
33
+
34
+
35
+ _CANCEL_TYPES = {"Immediate", "End of Billing Period"}
36
+
37
+
38
+ @app.command("cancel")
39
+ def cancel(
40
+ typer_ctx: typer.Context,
41
+ service_id: int = typer.Argument(..., help="Service id."),
42
+ cancel_type: str = typer.Option(
43
+ "End of Billing Period",
44
+ "--type", "-t",
45
+ help=(
46
+ "'Immediate' (terminate now, lose prepaid time) or "
47
+ "'End of Billing Period' (keep until next due date). "
48
+ "Default: 'End of Billing Period' so you don't "
49
+ "accidentally throw away prepaid days."
50
+ ),
51
+ ),
52
+ reason: str | None = typer.Option(
53
+ None, "--reason", "-r",
54
+ help="Optional cancellation reason for billing.",
55
+ ),
56
+ yes: bool = typer.Option(
57
+ False, "--yes", "-y",
58
+ help="Skip the service-termination confirmation prompt.",
59
+ ),
60
+ ) -> None:
61
+ """Submit a cancellation request for a service. **Staff approves**
62
+ the actual termination — this verb only opens the request.
63
+
64
+ Wraps ``c.account.services.cancel(id, type=..., reason=...)``.
65
+ Works on any service the authenticated client owns. For VPS-
66
+ specific cancellation, prefer ``impreza vps cancel`` (which
67
+ routes through the same ``AddCancelRequest`` on the server but
68
+ goes via the bound model for backend-specific error messages).
69
+ """
70
+ if cancel_type not in _CANCEL_TYPES:
71
+ error(
72
+ f"--type must be one of {sorted(_CANCEL_TYPES)!r}, "
73
+ f"got: {cancel_type!r}"
74
+ )
75
+ raise typer.Exit(code=1)
76
+
77
+ state = from_typer_context(typer_ctx)
78
+ blast = (
79
+ "immediately terminates the service (prepaid time is forfeit) "
80
+ "once staff approves the request"
81
+ if cancel_type == "Immediate"
82
+ else "schedules termination at the end of the current billing "
83
+ "period once staff approves the request"
84
+ )
85
+ confirm_or_exit(
86
+ f"Cancelling service {service_id} ({cancel_type!r}) {blast}.",
87
+ yes=yes,
88
+ )
89
+ with make_client_or_exit(state) as client:
90
+ try:
91
+ client.account.services.cancel(
92
+ service_id, type=cancel_type, reason=reason
93
+ )
94
+ except ApiError as exc:
95
+ exit_on_api_error(exc)
96
+ return
97
+
98
+ success(
99
+ f"Cancellation submitted for service {service_id} ({cancel_type})."
100
+ )