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/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()