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/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
impreza_cli/config.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Multi-context configuration store for the CLI.
|
|
2
|
+
|
|
3
|
+
The CLI reads/writes a single TOML file holding any number of named
|
|
4
|
+
contexts (sets of credentials + per-context defaults). This module
|
|
5
|
+
hides the on-disk format from the rest of the CLI and exposes a
|
|
6
|
+
small, fully-typed surface:
|
|
7
|
+
|
|
8
|
+
* :class:`Config` — load, save, mutate.
|
|
9
|
+
* :func:`default_config_path` — platform-appropriate location.
|
|
10
|
+
* :class:`Context` — an individual context's resolved values.
|
|
11
|
+
* :class:`ConfigError` (and friends) — typed error hierarchy that
|
|
12
|
+
the command layer maps to user-facing exit codes / messages.
|
|
13
|
+
|
|
14
|
+
On-disk format (``config.toml``)::
|
|
15
|
+
|
|
16
|
+
default_context = "personal"
|
|
17
|
+
|
|
18
|
+
[contexts.personal]
|
|
19
|
+
api_key = "imp_..."
|
|
20
|
+
api_secret = "..."
|
|
21
|
+
base_url = "https://api.imprezahost.com/v1" # optional override
|
|
22
|
+
default_output = "table" # optional
|
|
23
|
+
|
|
24
|
+
[contexts.work]
|
|
25
|
+
api_key = "imp_..."
|
|
26
|
+
api_secret = "..."
|
|
27
|
+
|
|
28
|
+
[settings]
|
|
29
|
+
poll_interval = 2.0 # optional CLI default
|
|
30
|
+
use_tor = false # optional CLI default
|
|
31
|
+
|
|
32
|
+
The path is selected by :func:`default_config_path`, which delegates
|
|
33
|
+
to ``click.get_app_dir("impreza")``. That follows XDG on Linux,
|
|
34
|
+
``%APPDATA%`` on Windows, and ``~/Library/Application Support`` on
|
|
35
|
+
macOS (with a fallback to ``~/.config/impreza`` on Linux when
|
|
36
|
+
``XDG_CONFIG_HOME`` is not set, matching what most CLIs do).
|
|
37
|
+
|
|
38
|
+
Permissions: on POSIX, when the file is created the mode is set to
|
|
39
|
+
0600 so only the owner can read the credentials. Windows ACLs are
|
|
40
|
+
left to the OS default — there is no portable way to lock down a
|
|
41
|
+
file to a single user from Python's stdlib without third-party
|
|
42
|
+
packages.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import contextlib
|
|
48
|
+
import os
|
|
49
|
+
import sys
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
import click
|
|
55
|
+
import tomli_w
|
|
56
|
+
|
|
57
|
+
if sys.version_info >= (3, 11):
|
|
58
|
+
import tomllib
|
|
59
|
+
else: # pragma: no cover - exercised on 3.10 runners
|
|
60
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"Config",
|
|
65
|
+
"ConfigError",
|
|
66
|
+
"Context",
|
|
67
|
+
"ContextAlreadyExists",
|
|
68
|
+
"ContextNotFound",
|
|
69
|
+
"InvalidContextName",
|
|
70
|
+
"NoActiveContext",
|
|
71
|
+
"NoContextsConfigured",
|
|
72
|
+
"default_config_path",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── path resolution ───────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def default_config_path() -> Path:
|
|
80
|
+
"""Return the platform-appropriate config file path.
|
|
81
|
+
|
|
82
|
+
``click.get_app_dir`` follows the OS conventions:
|
|
83
|
+
|
|
84
|
+
* **Linux**: ``$XDG_CONFIG_HOME/impreza/`` (defaults to
|
|
85
|
+
``~/.config/impreza/``)
|
|
86
|
+
* **macOS**: ``~/Library/Application Support/impreza/``
|
|
87
|
+
* **Windows**: ``%APPDATA%\\impreza\\``
|
|
88
|
+
|
|
89
|
+
Override with ``IMPREZA_CONFIG`` for tests or non-standard
|
|
90
|
+
layouts. The override is treated as an absolute file path,
|
|
91
|
+
not a directory.
|
|
92
|
+
"""
|
|
93
|
+
override = os.environ.get("IMPREZA_CONFIG")
|
|
94
|
+
if override:
|
|
95
|
+
return Path(override).expanduser().resolve()
|
|
96
|
+
return Path(click.get_app_dir("impreza")) / "config.toml"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── exceptions ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConfigError(Exception):
|
|
103
|
+
"""Base for config-level errors. The CLI command layer maps these
|
|
104
|
+
to non-zero exit codes with friendly messages."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ContextNotFound(ConfigError):
|
|
108
|
+
"""Requested context does not exist in the config file."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ContextAlreadyExists(ConfigError):
|
|
112
|
+
"""``context create`` was called with an existing name without
|
|
113
|
+
``--overwrite``."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class InvalidContextName(ConfigError):
|
|
117
|
+
"""Context name fails the format check (alphanumeric + ``-`` /
|
|
118
|
+
``_`` only, 1-50 chars). Hyphens and underscores allowed so
|
|
119
|
+
"my-personal" / "ci_runner" both work; spaces and special
|
|
120
|
+
characters rejected so the name never needs quoting in shell
|
|
121
|
+
commands."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class NoContextsConfigured(ConfigError):
|
|
125
|
+
"""No contexts exist yet. ``impreza context create`` is the
|
|
126
|
+
bootstrap step."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class NoActiveContext(ConfigError):
|
|
130
|
+
"""``default_context`` is unset and no override was provided."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── data classes ──────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Context:
|
|
138
|
+
"""Resolved view of a single context.
|
|
139
|
+
|
|
140
|
+
``name`` is the lookup key; the rest are inputs to the SDK
|
|
141
|
+
:class:`impreza.Client`.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
name: str
|
|
145
|
+
api_key: str
|
|
146
|
+
api_secret: str
|
|
147
|
+
base_url: str | None = None
|
|
148
|
+
default_output: str | None = None
|
|
149
|
+
|
|
150
|
+
def to_toml_dict(self) -> dict[str, Any]:
|
|
151
|
+
"""Render to the on-disk dict shape (only set keys are
|
|
152
|
+
emitted, so the file stays minimal and round-trip-friendly)."""
|
|
153
|
+
body: dict[str, Any] = {
|
|
154
|
+
"api_key": self.api_key,
|
|
155
|
+
"api_secret": self.api_secret,
|
|
156
|
+
}
|
|
157
|
+
if self.base_url is not None:
|
|
158
|
+
body["base_url"] = self.base_url
|
|
159
|
+
if self.default_output is not None:
|
|
160
|
+
body["default_output"] = self.default_output
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class _Settings:
|
|
166
|
+
"""Per-config (not per-context) defaults. Optional in the file."""
|
|
167
|
+
|
|
168
|
+
poll_interval: float | None = None
|
|
169
|
+
use_tor: bool | None = None
|
|
170
|
+
|
|
171
|
+
def to_toml_dict(self) -> dict[str, Any]:
|
|
172
|
+
body: dict[str, Any] = {}
|
|
173
|
+
if self.poll_interval is not None:
|
|
174
|
+
body["poll_interval"] = self.poll_interval
|
|
175
|
+
if self.use_tor is not None:
|
|
176
|
+
body["use_tor"] = self.use_tor
|
|
177
|
+
return body
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── name validation ───────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_NAME_MAX = 50
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _validate_context_name(name: str) -> None:
|
|
187
|
+
if not name or len(name) > _NAME_MAX:
|
|
188
|
+
raise InvalidContextName(
|
|
189
|
+
f"Context name must be 1-{_NAME_MAX} chars; got: {name!r}"
|
|
190
|
+
)
|
|
191
|
+
for ch in name:
|
|
192
|
+
if not (ch.isalnum() or ch in ("-", "_")):
|
|
193
|
+
raise InvalidContextName(
|
|
194
|
+
f"Context name {name!r} contains illegal character {ch!r}; "
|
|
195
|
+
"only alphanumerics, '-', and '_' are allowed."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── Config ────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class Config:
|
|
204
|
+
"""In-memory representation of ``config.toml``.
|
|
205
|
+
|
|
206
|
+
Use :meth:`load` to read from disk (or get an empty config if the
|
|
207
|
+
file does not exist), :meth:`save` to write back, and the various
|
|
208
|
+
mutators below to add / remove / select contexts. The mutators
|
|
209
|
+
raise :class:`ConfigError` subclasses on bad input — they never
|
|
210
|
+
silently swallow.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
path: Path
|
|
214
|
+
default_context: str | None = None
|
|
215
|
+
contexts: dict[str, Context] = field(default_factory=dict)
|
|
216
|
+
settings: _Settings = field(default_factory=_Settings)
|
|
217
|
+
|
|
218
|
+
# ── persistence ────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
222
|
+
"""Load the config from ``path`` (or the default location).
|
|
223
|
+
|
|
224
|
+
If the file does not exist, return an empty :class:`Config`
|
|
225
|
+
rooted at that path so a subsequent :meth:`save` writes
|
|
226
|
+
there. This is the bootstrap path — first-time users have
|
|
227
|
+
nothing on disk yet.
|
|
228
|
+
"""
|
|
229
|
+
resolved = path or default_config_path()
|
|
230
|
+
if not resolved.is_file():
|
|
231
|
+
return cls(path=resolved)
|
|
232
|
+
|
|
233
|
+
with resolved.open("rb") as fh:
|
|
234
|
+
raw = tomllib.load(fh)
|
|
235
|
+
|
|
236
|
+
contexts_raw = raw.get("contexts", {})
|
|
237
|
+
contexts: dict[str, Context] = {}
|
|
238
|
+
if isinstance(contexts_raw, dict):
|
|
239
|
+
for name, body in contexts_raw.items():
|
|
240
|
+
if not isinstance(body, dict):
|
|
241
|
+
continue
|
|
242
|
+
# Skip rather than raise on individual malformed
|
|
243
|
+
# entries — the user should still be able to repair
|
|
244
|
+
# one broken context without losing the others.
|
|
245
|
+
api_key = body.get("api_key")
|
|
246
|
+
api_secret = body.get("api_secret")
|
|
247
|
+
if not isinstance(api_key, str) or not isinstance(api_secret, str):
|
|
248
|
+
continue
|
|
249
|
+
base_url = body.get("base_url")
|
|
250
|
+
default_output = body.get("default_output")
|
|
251
|
+
contexts[str(name)] = Context(
|
|
252
|
+
name=str(name),
|
|
253
|
+
api_key=api_key,
|
|
254
|
+
api_secret=api_secret,
|
|
255
|
+
base_url=base_url if isinstance(base_url, str) else None,
|
|
256
|
+
default_output=(
|
|
257
|
+
default_output if isinstance(default_output, str) else None
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
settings_raw = raw.get("settings", {})
|
|
262
|
+
settings = _Settings()
|
|
263
|
+
if isinstance(settings_raw, dict):
|
|
264
|
+
poll = settings_raw.get("poll_interval")
|
|
265
|
+
if isinstance(poll, (int, float)):
|
|
266
|
+
settings.poll_interval = float(poll)
|
|
267
|
+
tor = settings_raw.get("use_tor")
|
|
268
|
+
if isinstance(tor, bool):
|
|
269
|
+
settings.use_tor = tor
|
|
270
|
+
|
|
271
|
+
default_context = raw.get("default_context")
|
|
272
|
+
if not isinstance(default_context, str):
|
|
273
|
+
default_context = None
|
|
274
|
+
|
|
275
|
+
return cls(
|
|
276
|
+
path=resolved,
|
|
277
|
+
default_context=default_context,
|
|
278
|
+
contexts=contexts,
|
|
279
|
+
settings=settings,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def save(self) -> None:
|
|
283
|
+
"""Write the config back to disk atomically.
|
|
284
|
+
|
|
285
|
+
Creates the parent directory if missing. On POSIX, sets the
|
|
286
|
+
file mode to ``0600`` after write so that only the owning
|
|
287
|
+
user can read the credentials.
|
|
288
|
+
"""
|
|
289
|
+
body: dict[str, Any] = {}
|
|
290
|
+
if self.default_context is not None:
|
|
291
|
+
body["default_context"] = self.default_context
|
|
292
|
+
if self.contexts:
|
|
293
|
+
body["contexts"] = {
|
|
294
|
+
name: ctx.to_toml_dict() for name, ctx in self.contexts.items()
|
|
295
|
+
}
|
|
296
|
+
settings_body = self.settings.to_toml_dict()
|
|
297
|
+
if settings_body:
|
|
298
|
+
body["settings"] = settings_body
|
|
299
|
+
|
|
300
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
|
|
302
|
+
# Write to a sibling temp file then rename so a crash mid-
|
|
303
|
+
# write can't corrupt the live config.
|
|
304
|
+
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
305
|
+
with tmp.open("wb") as fh:
|
|
306
|
+
tomli_w.dump(body, fh)
|
|
307
|
+
os.replace(tmp, self.path)
|
|
308
|
+
|
|
309
|
+
# Lock down on POSIX. Windows ACLs are best-effort via OS
|
|
310
|
+
# default — locking down portably needs a third-party lib.
|
|
311
|
+
# Best-effort chmod: if it fails (network FS, exotic mount)
|
|
312
|
+
# we don't want to hide a successful write behind a
|
|
313
|
+
# permission warning.
|
|
314
|
+
if os.name == "posix":
|
|
315
|
+
with contextlib.suppress(OSError):
|
|
316
|
+
os.chmod(self.path, 0o600)
|
|
317
|
+
|
|
318
|
+
# ── mutators ───────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def add_context(
|
|
321
|
+
self,
|
|
322
|
+
name: str,
|
|
323
|
+
*,
|
|
324
|
+
api_key: str,
|
|
325
|
+
api_secret: str,
|
|
326
|
+
base_url: str | None = None,
|
|
327
|
+
default_output: str | None = None,
|
|
328
|
+
overwrite: bool = False,
|
|
329
|
+
) -> Context:
|
|
330
|
+
"""Add (or replace, with ``overwrite=True``) a context.
|
|
331
|
+
|
|
332
|
+
If this is the first context, it is also set as the
|
|
333
|
+
``default_context`` automatically. Returns the new Context
|
|
334
|
+
object.
|
|
335
|
+
|
|
336
|
+
Raises:
|
|
337
|
+
InvalidContextName: name fails format check.
|
|
338
|
+
ContextAlreadyExists: name is taken and ``overwrite=False``.
|
|
339
|
+
"""
|
|
340
|
+
_validate_context_name(name)
|
|
341
|
+
if name in self.contexts and not overwrite:
|
|
342
|
+
raise ContextAlreadyExists(
|
|
343
|
+
f"Context {name!r} already exists. Pass overwrite=True to replace it."
|
|
344
|
+
)
|
|
345
|
+
ctx = Context(
|
|
346
|
+
name=name,
|
|
347
|
+
api_key=api_key,
|
|
348
|
+
api_secret=api_secret,
|
|
349
|
+
base_url=base_url,
|
|
350
|
+
default_output=default_output,
|
|
351
|
+
)
|
|
352
|
+
first_context = not self.contexts
|
|
353
|
+
self.contexts[name] = ctx
|
|
354
|
+
if first_context or self.default_context is None:
|
|
355
|
+
self.default_context = name
|
|
356
|
+
return ctx
|
|
357
|
+
|
|
358
|
+
def remove_context(self, name: str) -> None:
|
|
359
|
+
"""Delete a context. If it was the default, clear the default
|
|
360
|
+
(the user is expected to pick a new one explicitly via
|
|
361
|
+
``context use``)."""
|
|
362
|
+
if name not in self.contexts:
|
|
363
|
+
raise ContextNotFound(f"Context {name!r} does not exist.")
|
|
364
|
+
del self.contexts[name]
|
|
365
|
+
if self.default_context == name:
|
|
366
|
+
self.default_context = None
|
|
367
|
+
|
|
368
|
+
def use_context(self, name: str) -> None:
|
|
369
|
+
"""Mark a context as the default."""
|
|
370
|
+
if name not in self.contexts:
|
|
371
|
+
raise ContextNotFound(f"Context {name!r} does not exist.")
|
|
372
|
+
self.default_context = name
|
|
373
|
+
|
|
374
|
+
# ── readers ────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def list_contexts(self) -> list[str]:
|
|
377
|
+
"""Return all context names, sorted alphabetically."""
|
|
378
|
+
return sorted(self.contexts.keys())
|
|
379
|
+
|
|
380
|
+
def get_context(self, name: str | None = None) -> Context:
|
|
381
|
+
"""Resolve a context. If ``name`` is None, return the
|
|
382
|
+
``default_context``.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
NoContextsConfigured: no contexts defined at all.
|
|
386
|
+
NoActiveContext: no name provided and no default set.
|
|
387
|
+
ContextNotFound: name provided but no match.
|
|
388
|
+
"""
|
|
389
|
+
if not self.contexts:
|
|
390
|
+
raise NoContextsConfigured(
|
|
391
|
+
"No contexts configured yet. Run "
|
|
392
|
+
"`impreza context create <name> --key ... --secret ...` "
|
|
393
|
+
"to add one."
|
|
394
|
+
)
|
|
395
|
+
if name is None:
|
|
396
|
+
if self.default_context is None:
|
|
397
|
+
raise NoActiveContext(
|
|
398
|
+
"No default context set. Run "
|
|
399
|
+
"`impreza context use <name>` to pick one."
|
|
400
|
+
)
|
|
401
|
+
name = self.default_context
|
|
402
|
+
ctx = self.contexts.get(name)
|
|
403
|
+
if ctx is None:
|
|
404
|
+
raise ContextNotFound(f"Context {name!r} does not exist.")
|
|
405
|
+
return ctx
|
impreza_cli/main.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Entry point — `impreza` console script.
|
|
2
|
+
|
|
3
|
+
Mounts every subcommand module from :mod:`impreza_cli.commands` and
|
|
4
|
+
exposes the global flags (``--version``, ``--context``, ``--output``)
|
|
5
|
+
on the root callback. Each subcommand pulls the global state off
|
|
6
|
+
``ctx.obj`` (see :mod:`impreza_cli.state`).
|
|
7
|
+
|
|
8
|
+
Phase 2.1 shipped the ``context`` subcommand. Phase 2.2 added
|
|
9
|
+
``account`` and the global ``--context`` / ``--output`` flags.
|
|
10
|
+
Subsequent fases (2.3+) mount more resource groups via the
|
|
11
|
+
``app.add_typer(...)`` block at the bottom of this file.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .commands import account as account_cmd
|
|
20
|
+
from .commands import catalog as catalog_cmd
|
|
21
|
+
from .commands import context as context_cmd
|
|
22
|
+
from .commands import doctor as doctor_cmd
|
|
23
|
+
from .commands import domain as domain_cmd
|
|
24
|
+
from .commands import invoice as invoice_cmd
|
|
25
|
+
from .commands import key as key_cmd
|
|
26
|
+
from .commands import orders as orders_cmd
|
|
27
|
+
from .commands import services as services_cmd
|
|
28
|
+
from .commands import vps as vps_cmd
|
|
29
|
+
from .commands import webhooks as webhooks_cmd
|
|
30
|
+
from .output import OutputFormat
|
|
31
|
+
from .state import GlobalState
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="impreza",
|
|
35
|
+
help="Official CLI for the Impreza Host public REST API.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
rich_markup_mode="rich",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_callback(value: bool) -> None:
|
|
42
|
+
if value:
|
|
43
|
+
typer.echo(f"impreza-cli {__version__}")
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def _root(
|
|
49
|
+
ctx: typer.Context,
|
|
50
|
+
version: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--version",
|
|
53
|
+
callback=_version_callback,
|
|
54
|
+
is_eager=True,
|
|
55
|
+
help="Show the installed CLI version and exit.",
|
|
56
|
+
),
|
|
57
|
+
context: str | None = typer.Option(
|
|
58
|
+
None,
|
|
59
|
+
"--context",
|
|
60
|
+
"-c",
|
|
61
|
+
help="Override the default context for this invocation.",
|
|
62
|
+
),
|
|
63
|
+
output: OutputFormat | None = typer.Option(
|
|
64
|
+
None,
|
|
65
|
+
"--output",
|
|
66
|
+
"-o",
|
|
67
|
+
help=(
|
|
68
|
+
"Default output format. Per-command --output flags override "
|
|
69
|
+
"this. Falls back to 'table' when neither is set."
|
|
70
|
+
),
|
|
71
|
+
case_sensitive=False,
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Run ``impreza --help`` for the full command tree.
|
|
75
|
+
|
|
76
|
+
Global flags ``--context`` and ``--output`` apply to every
|
|
77
|
+
subcommand and are inherited via Typer's context object.
|
|
78
|
+
"""
|
|
79
|
+
ctx.obj = GlobalState(context_override=context, output=output)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Subcommand mounts ────────────────────────────────────────────────
|
|
83
|
+
#
|
|
84
|
+
# New resource groups land here. Keep them alphabetised so the
|
|
85
|
+
# ``impreza --help`` output reads predictably.
|
|
86
|
+
app.add_typer(account_cmd.app, name="account")
|
|
87
|
+
app.add_typer(catalog_cmd.app, name="catalog")
|
|
88
|
+
app.add_typer(context_cmd.app, name="context")
|
|
89
|
+
app.add_typer(doctor_cmd.app, name="doctor")
|
|
90
|
+
app.add_typer(domain_cmd.app, name="domain")
|
|
91
|
+
app.add_typer(invoice_cmd.app, name="invoice")
|
|
92
|
+
app.add_typer(key_cmd.app, name="key")
|
|
93
|
+
app.add_typer(orders_cmd.app, name="order")
|
|
94
|
+
app.add_typer(services_cmd.app, name="service")
|
|
95
|
+
app.add_typer(vps_cmd.app, name="vps")
|
|
96
|
+
app.add_typer(webhooks_cmd.app, name="webhook")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
app()
|