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.
impreza_cli/output.py ADDED
@@ -0,0 +1,207 @@
1
+ """Output formatting for CLI commands.
2
+
3
+ The CLI supports three output modes selected via the global
4
+ ``--output`` flag (default: ``table``):
5
+
6
+ * ``table`` — Rich-rendered tables for human consumption (default).
7
+ * ``json`` — UTF-8 JSON, pretty-printed with 2-space indent. Pipes
8
+ cleanly into ``jq`` and friends.
9
+ * ``yaml`` — defer-imported PyYAML output (only loaded when actually
10
+ selected so the import cost is paid only on use).
11
+
12
+ Phase 2.1 ships only ``table`` and ``json`` since the context
13
+ commands' output is small. ``yaml`` lands in Phase 2.7 alongside the
14
+ output polish + tab completion pass; the ``OutputFormat`` enum
15
+ already lists it so command code can target the final API today and
16
+ the 2.7 work is purely additive.
17
+
18
+ Stderr is a separate channel via :func:`error` — used for
19
+ user-visible errors (typed config failures, etc.) so the success
20
+ output on stdout stays scriptable.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import sys
27
+ from enum import Enum
28
+ from typing import Any
29
+
30
+ import typer
31
+ from rich.console import Console
32
+ from rich.table import Table
33
+
34
+ __all__ = [
35
+ "OutputFormat",
36
+ "error",
37
+ "info",
38
+ "print_dict",
39
+ "print_table",
40
+ "success",
41
+ "warning",
42
+ ]
43
+
44
+
45
+ class OutputFormat(str, Enum):
46
+ """Selectable output mode. ``str``-mixin so it round-trips
47
+ cleanly through Typer's `--output` flag."""
48
+
49
+ TABLE = "table"
50
+ JSON = "json"
51
+ YAML = "yaml"
52
+
53
+
54
+ _stdout = Console()
55
+
56
+
57
+ def error(message: str) -> None:
58
+ """Print a user-facing error message to stderr in red.
59
+
60
+ Uses ``typer.secho`` (line-based, via Click) rather than Rich
61
+ so the output doesn't wrap based on detected terminal width.
62
+ Wrapping was hiding the back half of long error messages
63
+ (e.g. ``[request_id=...]`` suffixes) from stderr capture in
64
+ CliRunner-driven tests, and would do the same to grep-piped
65
+ output on narrow real terminals.
66
+
67
+ Bugs should still raise so the traceback isn't swallowed —
68
+ this helper is only for friendly errors on expected failures.
69
+ """
70
+ typer.secho(f"Error: {message}", err=True, fg=typer.colors.RED, bold=True)
71
+
72
+
73
+ def success(message: str) -> None:
74
+ """Print a user-facing success message to stdout in green.
75
+
76
+ Companion to :func:`error`. Use for the trailing "X created /
77
+ deleted / updated" line that confirms an operation took effect.
78
+ No "OK:" prefix — Phase 1.6's cp1252 lesson kept us off Unicode
79
+ glyphs and the colour itself reads as success cue without one.
80
+
81
+ Note: success messages go to stdout (not stderr) so they don't
82
+ confuse scripts that pipe stderr to /dev/null while parsing
83
+ stdout. Tests asserting against output should look at
84
+ ``result.stdout``.
85
+ """
86
+ typer.secho(message, fg=typer.colors.GREEN, bold=True)
87
+
88
+
89
+ def info(message: str) -> None:
90
+ """Print a user-facing informational message to stdout in cyan.
91
+
92
+ Use for hints / status notes that aren't success per se —
93
+ "Reboot to apply", "Operation queued — uuid X", etc. The cyan
94
+ is distinct enough from green-success that a script-reading
95
+ user can tell at a glance whether something completed or is
96
+ waiting on an action.
97
+ """
98
+ typer.secho(message, fg=typer.colors.CYAN)
99
+
100
+
101
+ def warning(message: str) -> None:
102
+ """Print a user-facing warning to stderr in yellow.
103
+
104
+ Goes to stderr (not stdout) so scripts capturing stdout for
105
+ parsing don't see the warning in their data stream. Use for
106
+ "the call succeeded but something looks off" cases —
107
+ deprecation hints, surprising upstream behaviour, edge
108
+ conditions that don't actually fail.
109
+ """
110
+ typer.secho(f"Warning: {message}", err=True, fg=typer.colors.YELLOW)
111
+
112
+
113
+ def print_table(
114
+ title: str,
115
+ rows: list[dict[str, Any]],
116
+ *,
117
+ columns: list[str] | None = None,
118
+ fmt: OutputFormat = OutputFormat.TABLE,
119
+ ) -> None:
120
+ """Render ``rows`` (list of homogenous dicts) per the chosen format.
121
+
122
+ ``columns`` overrides the column order. When omitted, columns are
123
+ taken from the first row's key order — Python 3.7+ preserves
124
+ dict insertion order so this is deterministic per the calling
125
+ code.
126
+
127
+ Empty ``rows`` is acceptable; we render an empty table with the
128
+ headers (or print ``[]`` for JSON).
129
+ """
130
+ if fmt is OutputFormat.JSON:
131
+ sys.stdout.write(json.dumps(rows, indent=2, ensure_ascii=False))
132
+ sys.stdout.write("\n")
133
+ return
134
+
135
+ if fmt is OutputFormat.YAML:
136
+ _yaml_dump(rows)
137
+ return
138
+
139
+ # Table rendering.
140
+ if columns is None:
141
+ columns = list(rows[0].keys()) if rows else []
142
+ table = Table(title=title, header_style="bold cyan", show_lines=False)
143
+ for col in columns:
144
+ table.add_column(col)
145
+ for row in rows:
146
+ table.add_row(*[_render_cell(row.get(col)) for col in columns])
147
+ _stdout.print(table)
148
+
149
+
150
+ def print_dict(
151
+ title: str,
152
+ data: dict[str, Any],
153
+ *,
154
+ fmt: OutputFormat = OutputFormat.TABLE,
155
+ ) -> None:
156
+ """Render a single resource (dict) — two-column ``Field / Value``
157
+ table by default."""
158
+ if fmt is OutputFormat.JSON:
159
+ sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False))
160
+ sys.stdout.write("\n")
161
+ return
162
+
163
+ if fmt is OutputFormat.YAML:
164
+ _yaml_dump(data)
165
+ return
166
+
167
+ table = Table(title=title, header_style="bold cyan", show_header=True)
168
+ table.add_column("Field")
169
+ table.add_column("Value")
170
+ for k, v in data.items():
171
+ table.add_row(str(k), _render_cell(v))
172
+ _stdout.print(table)
173
+
174
+
175
+ def _render_cell(value: Any) -> str:
176
+ """Format a single cell for table rendering.
177
+
178
+ Uses ASCII glyphs (``-``, ``yes`` / ``no``) rather than fancy
179
+ Unicode (``—`` / ``✓`` / ``✗``) because Windows legacy consoles
180
+ default to cp1252 and crash on the latter — Phase 1.6's smoke
181
+ learned this the hard way and we want CLI output to read on
182
+ every terminal, not just modern UTF-8 ones.
183
+ """
184
+ if value is None:
185
+ return "[dim]-[/]"
186
+ if isinstance(value, bool):
187
+ return "yes" if value else "no"
188
+ return str(value)
189
+
190
+
191
+ def _yaml_dump(data: Any) -> None:
192
+ """Defer-import PyYAML so installs that never touch YAML output
193
+ don't pay the dependency cost.
194
+
195
+ Phase 2.7 wired pyyaml as the optional ``[yaml]`` extra. Calling
196
+ this function on an install that didn't pull in that extra
197
+ surfaces a clear ImportError telling the user how to upgrade or
198
+ pick a different format.
199
+ """
200
+ try:
201
+ import yaml
202
+ except ImportError as exc:
203
+ raise RuntimeError(
204
+ "YAML output requires the optional `pyyaml` dependency. "
205
+ "Install with: pip install impreza-cli[yaml]"
206
+ ) from exc
207
+ sys.stdout.write(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
impreza_cli/sdk.py ADDED
@@ -0,0 +1,97 @@
1
+ """SDK bootstrap — single source of truth for "given a context, hand
2
+ me an :class:`impreza.Client`".
3
+
4
+ Every command that needs to call the API goes through this module so
5
+ the resolution rules (which context, which base URL, Tor or not)
6
+ live in one place. Adding new resolution sources later (per-context
7
+ ``proxy=`` overrides, env-var fallbacks, etc.) is a one-file change.
8
+
9
+ The SDK's own :func:`Client.from_env` reads ``IMPREZA_API_KEY`` /
10
+ ``IMPREZA_API_SECRET`` directly from the environment — that path
11
+ stays available for users who prefer env-var auth and bypass the
12
+ context machinery entirely. This module is for the context-driven
13
+ path (the CLI's primary UX).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import typer
22
+ from impreza import Client
23
+
24
+ from .config import Config, ConfigError
25
+ from .output import error
26
+ from .state import GlobalState
27
+
28
+ __all__ = [
29
+ "make_client",
30
+ "make_client_or_exit",
31
+ ]
32
+
33
+
34
+ def make_client(
35
+ state: GlobalState,
36
+ *,
37
+ config_path: Path | None = None,
38
+ ) -> Client:
39
+ """Build a sync :class:`impreza.Client` from the active context.
40
+
41
+ Args:
42
+ state: The CLI's :class:`~.state.GlobalState`. Reads
43
+ ``state.context_override`` to pick a non-default context.
44
+ config_path: Optional override for the config file location
45
+ (mostly used in tests; production flow always uses the
46
+ default).
47
+
48
+ Raises:
49
+ ConfigError: any of the config-resolution errors —
50
+ :class:`~.config.NoContextsConfigured`,
51
+ :class:`~.config.NoActiveContext`,
52
+ :class:`~.config.ContextNotFound`. The :func:`make_client_or_exit`
53
+ wrapper catches these for the typical CLI command flow;
54
+ tests / library callers can let them propagate.
55
+
56
+ Returns:
57
+ A sync :class:`Client` ready to call. Caller is responsible
58
+ for closing it (the standard `with Client(...) as c:` pattern
59
+ works — Typer commands just let it close on process exit
60
+ which is fine for short-lived CLI invocations).
61
+ """
62
+ cfg = Config.load(config_path)
63
+ ctx = cfg.get_context(state.context_override)
64
+
65
+ kwargs: dict[str, Any] = {
66
+ "api_key": ctx.api_key,
67
+ "api_secret": ctx.api_secret,
68
+ }
69
+ if ctx.base_url:
70
+ kwargs["base_url"] = ctx.base_url
71
+
72
+ # Tor preference comes from the [settings] block (CLI-wide). A
73
+ # future per-context override (one VPN context, one clearnet, etc.)
74
+ # would slot in here without touching callers.
75
+ if cfg.settings.use_tor:
76
+ kwargs["use_tor"] = True
77
+
78
+ return Client(**kwargs)
79
+
80
+
81
+ def make_client_or_exit(
82
+ state: GlobalState,
83
+ *,
84
+ config_path: Path | None = None,
85
+ ) -> Client:
86
+ """Same as :func:`make_client`, but converts config errors into
87
+ friendly stderr output and a non-zero exit.
88
+
89
+ This is the path every Typer command follows. Bugs (network
90
+ errors at construction time, etc.) still raise so the traceback
91
+ isn't swallowed.
92
+ """
93
+ try:
94
+ return make_client(state, config_path=config_path)
95
+ except ConfigError as exc:
96
+ error(str(exc))
97
+ raise typer.Exit(code=1) from None
impreza_cli/state.py ADDED
@@ -0,0 +1,94 @@
1
+ """CLI-wide shared state passed between the root callback and
2
+ subcommands via ``typer.Context.obj``.
3
+
4
+ Subcommands never read these flags from their own argument list —
5
+ they pull them off ``ctx.obj`` so the same flag can be set at the
6
+ ``impreza`` root (`impreza --output json account info`) or at the
7
+ subcommand level (`impreza account info --output json`). Per-command
8
+ overrides win; falling back through to the context override and
9
+ then the table default.
10
+
11
+ The state object is intentionally tiny — only fields that are read
12
+ by more than one command should live here. Anything specific to a
13
+ single command stays as a regular function argument.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import typer
21
+
22
+ from .output import OutputFormat
23
+
24
+
25
+ @dataclass
26
+ class GlobalState:
27
+ """Values populated by the root callback and shared with every
28
+ subcommand."""
29
+
30
+ #: When set, the named context is used instead of the default.
31
+ #: ``None`` means "use the config file's ``default_context``".
32
+ context_override: str | None = None
33
+
34
+ #: Default output format selected via the global ``--output`` flag.
35
+ #: Per-command ``--output`` flags can override this on a single
36
+ #: invocation; ``None`` here means "no preference, fall back to
37
+ #: the table default".
38
+ output: OutputFormat | None = None
39
+
40
+
41
+ def from_typer_context(ctx: typer.Context) -> GlobalState:
42
+ """Pull the :class:`GlobalState` off a Typer context.
43
+
44
+ Typer's :attr:`Context.obj` is typed as ``Any`` — this helper
45
+ narrows it back to ``GlobalState`` so callers don't have to
46
+ sprinkle ``cast()`` calls. If for any reason the obj is missing
47
+ (test invocation that bypasses the root callback, etc.), an
48
+ empty ``GlobalState`` is returned so commands don't NPE.
49
+ """
50
+ obj = getattr(ctx, "obj", None)
51
+ if isinstance(obj, GlobalState):
52
+ return obj
53
+ return GlobalState()
54
+
55
+
56
+ def resolve_output(
57
+ state: GlobalState,
58
+ per_command: OutputFormat | None,
59
+ ) -> OutputFormat:
60
+ """Pick the output format for a single command invocation.
61
+
62
+ Resolution order: per-command `--output` flag > global `--output`
63
+ flag > :attr:`OutputFormat.TABLE` default.
64
+ """
65
+ if per_command is not None:
66
+ return per_command
67
+ if state.output is not None:
68
+ return state.output
69
+ return OutputFormat.TABLE
70
+
71
+
72
+ def confirm_or_exit(message: str, *, yes: bool) -> None:
73
+ """Prompt the user to confirm a destructive / costly action.
74
+
75
+ The standard pattern across every Phase 3 mutating command:
76
+
77
+ .. code-block:: python
78
+
79
+ confirm_or_exit("This will charge $12.99 from your balance.", yes=yes)
80
+
81
+ When ``yes=True`` the prompt is skipped (intent already opted-in
82
+ via the ``--yes`` flag). When the user declines, the CLI prints
83
+ "Cancelled." on stdout and exits 0 — declining is not an error.
84
+
85
+ The message should be a complete sentence describing what's
86
+ about to happen; the helper appends "Continue?" automatically.
87
+ """
88
+ import typer # local import keeps state.py importable without typer
89
+
90
+ if yes:
91
+ return
92
+ if not typer.confirm(f"{message} Continue?", default=False):
93
+ typer.echo("Cancelled.")
94
+ raise typer.Exit(code=0)
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.4
2
+ Name: impreza-cli
3
+ Version: 0.3.0
4
+ Summary: Official command-line interface for the Impreza Host public REST API
5
+ Author-email: Impreza Host <support@imprezahost.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://imprezahost.com
8
+ Project-URL: Documentation, https://docs.imprezahost.com
9
+ Project-URL: Repository, https://github.com/imprezahost/impreza-devkit
10
+ Project-URL: Changelog, https://github.com/imprezahost/impreza-devkit/blob/master/CHANGELOG.md
11
+ Keywords: impreza,hosting,cli,offshore,crypto
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: impreza-sdk
27
+ Requires-Dist: typer>=0.12
28
+ Requires-Dist: rich>=13.7
29
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
30
+ Requires-Dist: tomli-w>=1.0
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest>=8.0; extra == "test"
33
+ Requires-Dist: pytest-cov>=4.1; extra == "test"
34
+ Requires-Dist: pyyaml>=6.0; extra == "test"
35
+ Requires-Dist: types-PyYAML>=6.0; extra == "test"
36
+ Provides-Extra: dev
37
+ Requires-Dist: ruff>=0.5; extra == "dev"
38
+ Requires-Dist: mypy>=1.8; extra == "dev"
39
+ Provides-Extra: yaml
40
+ Requires-Dist: pyyaml>=6.0; extra == "yaml"
41
+
42
+ # `impreza-cli` — Official CLI for Impreza Host
43
+
44
+ Command-line interface for the Impreza Host public REST API.
45
+ Built on top of [`impreza-sdk`](../sdk-python/README.md) — same
46
+ auth model, same Tor support, same retry behaviour, plus
47
+ multi-context configuration and Rich-rendered tables for human-
48
+ friendly output.
49
+
50
+ ```bash
51
+ pip install impreza-cli
52
+ ```
53
+
54
+ Requires Python 3.10+. See [`../CHANGELOG.md`](../CHANGELOG.md) for
55
+ release history.
56
+
57
+ ## Quickstart
58
+
59
+ ```bash
60
+ # 1. Add a context with your API credentials. Generate keys in
61
+ # Impreza Account → API Keys; whitelist the calling
62
+ # machine's IP at the same screen.
63
+ $ impreza context create personal --key imp_... --secret ...
64
+ Context 'personal' created and set as default.
65
+
66
+ # 2. Confirm everything works. impreza doctor runs five sequenced
67
+ # health checks (config, API reachable, key status, IP
68
+ # whitelist, account profile) and exits 0 only if all pass.
69
+ $ impreza doctor
70
+
71
+ impreza doctor
72
+ ----------------------------------------
73
+ [OK] active-context: Default context
74
+ [OK] api-reachable: GET /account/api-keys/self OK (142ms)
75
+ key prefix='imp_a1b2c3d4', label='devkit'
76
+ [OK] key-status: status='active'
77
+ [OK] ip-whitelist: request_ip 200.1.2.3 matches entry ('home')
78
+ [OK] account-profile: Jane Doe <jane@example.com>, balance 5.00 USD
79
+ registered 2024-01-15
80
+ ----------------------------------------
81
+ All checks passed. 5/5.
82
+
83
+ # 3. Read commands span every resource group:
84
+ $ impreza account info # profile + balance
85
+ $ impreza vps list # across both backends
86
+ $ impreza domain check example.com mydomain.io
87
+ $ impreza catalog products --group "VPS"
88
+
89
+ # 4. Pipe into jq for scripting (every read verb supports --output
90
+ # json | yaml):
91
+ $ impreza invoice list --output json \
92
+ | jq '[.[] | select(.status == "Unpaid")] | length'
93
+
94
+ # 5. Write verbs are gated by confirm_or_exit so you don't lose
95
+ # data accidentally; pass --yes / -y to skip prompts in scripts:
96
+ $ impreza vps reboot 17988
97
+ $ impreza vps proxmox snapshots create 17988 pre-update
98
+ $ impreza domain dns add example.com --type A --name www --value 1.2.3.4
99
+
100
+ # 6. Crypto top-up. --browser opens the BTCPay invoice URL
101
+ # automatically; --wait polls until the gateway confirms
102
+ # (default 2h timeout matches server-side invoice expiry).
103
+ $ impreza account topup --amount 50 --method xmr --browser --wait
104
+ ```
105
+
106
+ ## Authentication
107
+
108
+ Two ways to authenticate. The CLI tries them in order:
109
+
110
+ 1. **Context** (recommended) — `impreza context create <name>` stores
111
+ credentials in a config file; commands read them automatically.
112
+ Per-invocation override via `impreza --context other <command>`.
113
+
114
+ 2. **Environment variables** — `IMPREZA_API_KEY` + `IMPREZA_API_SECRET`.
115
+ Useful in CI, but contexts are preferred for local work.
116
+
117
+ The config file lives at:
118
+
119
+ | OS | Path |
120
+ |---|---|
121
+ | Linux | `$XDG_CONFIG_HOME/impreza/config.toml` (default `~/.config/impreza/config.toml`) |
122
+ | macOS | `~/Library/Application Support/impreza/config.toml` |
123
+ | Windows | `%APPDATA%\impreza\config.toml` |
124
+
125
+ Override with `IMPREZA_CONFIG=/path/to/config.toml` for testing or
126
+ non-standard layouts.
127
+
128
+ On POSIX, the config file is `chmod 0o600` after every write so only
129
+ the owner can read the credentials. Windows ACLs are left to the OS
130
+ default.
131
+
132
+ ## Commands
133
+
134
+ The CLI groups commands by resource. Run `impreza <group> --help`
135
+ to see the full subcommand list, or `impreza <group> <command>
136
+ --help` for option-level detail.
137
+
138
+ | Group | Verbs | Notes |
139
+ |---|---|---|
140
+ | `context` | `create / use / list / current / delete` | Local credential management — never hits the network |
141
+ | `doctor` | (single command) | Health check — config + API reachable + key status + IP whitelist + account profile |
142
+ | `account` | `info / balance / services / topup / topup-status` | Profile + balance + services + crypto top-up |
143
+ | `catalog` | `products / product / product-groups / tlds` | Pre-purchase discovery |
144
+ | `domain` | `show / check / pricing / register / transfer / set-nameservers / lock / unlock / id-protection / raa-verify / gdpr-auth / transfer-approval` + `domain dns list / add / update / delete / activate` | Domain registrations + full DNS CRUD |
145
+ | `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso / rdns / ssh-keys / vnc / vnc-password / resize / boot-order / ipv6` | Cross-backend (Proxmox + Cloud) VPS with smart dispatch |
146
+ | `order` | `list / show / create / upgrade` | Submit / browse product orders |
147
+ | `service` | `cancel` | Submit cancellation request (any service) |
148
+ | `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history |
149
+ | `invoice` | `list / show` | Invoices with line items + transactions |
150
+ | `key` | `whoami` | Active API key identity + IP whitelist |
151
+
152
+ **Conventions:**
153
+
154
+ - Destructive verbs prompt for confirmation; pass `--yes` / `-y`
155
+ to skip the prompt in scripts.
156
+ - Operation-returning verbs (`vps reinstall`, `vps migrate`,
157
+ `vps proxmox snapshots rollback`, `vps proxmox backups
158
+ create/restore`) accept `--wait` to block on the Proxmox queue,
159
+ with `--timeout` (default 600 s for fast ops, 1800 s for the
160
+ slower restores).
161
+ - Cost-incurring verbs (`domain register/transfer/id-protection`,
162
+ `order create/upgrade`, `account topup`) call out the
163
+ balance impact in the confirmation prompt; an
164
+ `InsufficientCredit` 402 surfaces with a hint pointing at
165
+ `impreza account topup`.
166
+ - Verbs that mutate a resource emit a green success line on
167
+ stdout; queued / reboot-required state changes emit a cyan
168
+ info line. Errors are red on stderr.
169
+
170
+ **Service termination policy:** `service cancel` / `vps cancel`
171
+ submit an `AddCancelRequest` — staff approves the actual
172
+ termination. There is no direct customer path to terminate a
173
+ service or remove a service suspension (suspension is
174
+ billing-state and is removed automatically when the overdue
175
+ invoice is paid, or manually by staff after an abuse hold is
176
+ resolved).
177
+
178
+ ## Output formats
179
+
180
+ Every command supports `--output table|json|yaml` (short form `-o`).
181
+
182
+ | Format | Default | Best for |
183
+ |---|---|---|
184
+ | `table` | yes | human reading at the terminal |
185
+ | `json` | | piping into `jq`, automation, scripting |
186
+ | `yaml` | | human-editable config snapshots, CI/CD pipelines |
187
+
188
+ YAML output requires the optional `pyyaml` dependency:
189
+
190
+ ```bash
191
+ pip install impreza-cli[yaml]
192
+ ```
193
+
194
+ The CLI raises a clear `RuntimeError` pointing at the install hint
195
+ if you select `--output yaml` without it.
196
+
197
+ The flag works at both the global level and per-command:
198
+
199
+ ```bash
200
+ # Global default for the invocation
201
+ impreza --output json account info
202
+
203
+ # Per-command override (wins over global)
204
+ impreza --output yaml account info --output table
205
+ ```
206
+
207
+ ## Tab completion
208
+
209
+ Typer ships completion for `bash`, `zsh`, `fish`, and PowerShell
210
+ out of the box:
211
+
212
+ ```bash
213
+ # Install for the current shell (auto-detected)
214
+ impreza --install-completion
215
+
216
+ # Or explicitly
217
+ impreza --install-completion bash # / zsh / fish / powershell
218
+
219
+ # Inspect the script before installing
220
+ impreza --show-completion bash
221
+ ```
222
+
223
+ After installing, restart the shell (or `source ~/.bashrc` /
224
+ equivalent) and `impreza <TAB>` should suggest resource groups,
225
+ `impreza account <TAB>` should suggest verbs, and so on.
226
+
227
+ ## Tor
228
+
229
+ Inherited from the SDK. Three knobs:
230
+
231
+ ```bash
232
+ # Per-context override at create time
233
+ impreza context create offshore \
234
+ --key imp_... --secret ... \
235
+ # No --proxy flag yet; for now, set IMPREZA_USE_TOR before invoking
236
+
237
+ # Env var, picked up by the SDK transparently
238
+ IMPREZA_USE_TOR=1 impreza account info
239
+
240
+ # Programmatic via the SDK (Python users skip the CLI for this)
241
+ ```
242
+
243
+ The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
244
+ isn't surfaced through the CLI yet — coming in a future release
245
+ alongside the `--via-tor` shortcut.
246
+
247
+ ## Error handling
248
+
249
+ The CLI maps SDK exceptions to friendly stderr messages and a
250
+ non-zero exit code, matching the format `ImprezaError.__str__`
251
+ produces:
252
+
253
+ ```
254
+ Error: Invalid API credentials. (code=UNAUTHORIZED) [request_id=req_abc]
255
+ ```
256
+
257
+ Tracebacks never leak from expected failures (auth errors, missing
258
+ contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
259
+ the traceback isn't swallowed — that's intentional.
260
+
261
+ ## Development
262
+
263
+ ```bash
264
+ git clone https://github.com/imprezahost/impreza-devkit.git
265
+ cd impreza-devkit/cli-python
266
+
267
+ python -m venv .venv
268
+ # Linux/macOS: source .venv/bin/activate
269
+ # Windows PowerShell: .venv\Scripts\Activate.ps1
270
+
271
+ # Install editable + test/dev/yaml extras + the SDK as a path dep
272
+ pip install -e ../sdk-python -e ".[test,dev,yaml]"
273
+
274
+ pytest # unit + Typer-runner E2E
275
+ ruff check
276
+ mypy --strict impreza_cli
277
+ ```
278
+
279
+ To run the live integration smokes (skipped silently without creds):
280
+
281
+ ```bash
282
+ export IMPREZA_API_KEY="imp_..."
283
+ export IMPREZA_API_SECRET="..."
284
+ # Optional, for `impreza domain show / dns list`:
285
+ export IMPREZA_TEST_DOMAIN="<a domain on your account>"
286
+
287
+ pytest -v -s tests/
288
+ ```
289
+
290
+ The smokes exercise the same surface as the unit tests against the
291
+ real API, so they catch contract drift between the CLI and the
292
+ server.
293
+
294
+ ## License
295
+
296
+ MIT. See [`../LICENSE`](../LICENSE) at the repository root.