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,270 @@
1
+ """``impreza catalog`` subcommand surface — Phase 2.3.
2
+
3
+ Three verbs reading from :class:`impreza.resources.catalog.CatalogResource`:
4
+
5
+ * ``impreza catalog products [--group X] [--type T]``
6
+ * ``impreza catalog product-groups``
7
+ * ``impreza catalog tlds [--filter .com,.net,...]``
8
+
9
+ The catalog is reference data — values change only when staff edit
10
+ Impreza Account, not as a side effect of customer activity. These commands are
11
+ the discovery layer customers use before placing orders (Phase 3).
12
+
13
+ VPS-Cloud catalog (sizes / locations) is deliberately deferred —
14
+ the underlying Cloud backend endpoints exist but return a deeply-
15
+ nested provider-specific shape that warrants its own focused pass
16
+ once Phase 3 ordering creates real demand for it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ import typer
24
+ from impreza.exceptions import ApiError
25
+ from impreza.models.product import Product
26
+ from impreza.models.tld import TldPricing
27
+
28
+ from ..output import OutputFormat, print_table
29
+ from ..sdk import make_client_or_exit
30
+ from ..state import from_typer_context, resolve_output
31
+ from ._helpers import exit_on_api_error as _exit_on_api_error
32
+
33
+ app = typer.Typer(
34
+ name="catalog",
35
+ help="Browse the product catalog, product groups, and TLD pricing.",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+
40
+ # ── catalog products ─────────────────────────────────────────────────
41
+
42
+
43
+ def _cheapest_cycle(product: Product) -> str:
44
+ """Format the cheapest billing cycle as a single-cell table value
45
+ (``monthly: 5.00``). Returns ``"-"`` for products with no
46
+ pricing configured (free or quote-only)."""
47
+ if not product.pricing:
48
+ return "-"
49
+ cycle, price = min(
50
+ product.pricing.items(),
51
+ key=lambda kv: kv[1].price,
52
+ )
53
+ return f"{cycle}: {price.price:.2f}"
54
+
55
+
56
+ @app.command("products")
57
+ def products(
58
+ typer_ctx: typer.Context,
59
+ group: str | None = typer.Option(
60
+ None,
61
+ "--group",
62
+ "-g",
63
+ help="Filter by product group name (case-insensitive substring match upstream).",
64
+ ),
65
+ type_: str | None = typer.Option(
66
+ None,
67
+ "--type",
68
+ "-t",
69
+ help=(
70
+ "Filter by product type. One of: hostingaccount, "
71
+ "reselleraccount, server, other."
72
+ ),
73
+ ),
74
+ output: OutputFormat | None = typer.Option(
75
+ None,
76
+ "--output",
77
+ "-o",
78
+ help="Output format. Overrides the global --output flag.",
79
+ case_sensitive=False,
80
+ ),
81
+ ) -> None:
82
+ """List products in the catalog with their cheapest cycle price.
83
+
84
+ Wraps ``GET /products``. Table mode shows id / name / group /
85
+ type / currency / cheapest-cycle. JSON / YAML modes emit the full
86
+ Product model (including the per-cycle pricing dict) so callers
87
+ can pick out a specific cycle for ordering.
88
+ """
89
+ state = from_typer_context(typer_ctx)
90
+ fmt = resolve_output(state, output)
91
+
92
+ with make_client_or_exit(state) as client:
93
+ try:
94
+ items = client.catalog.products(group=group, type=type_)
95
+ except ApiError as exc:
96
+ _exit_on_api_error(exc)
97
+
98
+ if not items:
99
+ if group or type_:
100
+ filters = []
101
+ if group:
102
+ filters.append(f"group={group!r}")
103
+ if type_:
104
+ filters.append(f"type={type_!r}")
105
+ typer.echo(f"No products match the filter: {', '.join(filters)}.")
106
+ else:
107
+ typer.echo("No products in the catalog yet.")
108
+ return
109
+
110
+ if fmt is OutputFormat.TABLE:
111
+ rows: list[dict[str, Any]] = [
112
+ {
113
+ "id": p.id,
114
+ "name": p.name,
115
+ "group": p.group,
116
+ "type": p.type,
117
+ "currency": p.currency,
118
+ "cheapest": _cheapest_cycle(p),
119
+ }
120
+ for p in items
121
+ ]
122
+ print_table(
123
+ f"Products ({len(rows)})",
124
+ rows,
125
+ columns=["id", "name", "group", "type", "currency", "cheapest"],
126
+ fmt=fmt,
127
+ )
128
+ else:
129
+ # JSON / YAML: emit the full model — by_alias=False, so callers
130
+ # see Python attribute names (`register_prices` etc.). The
131
+ # Pydantic dump preserves the per-cycle pricing dict so callers
132
+ # can pick a specific cycle for ordering.
133
+ rows = [p.model_dump() for p in items]
134
+ print_table("Products", rows, fmt=fmt)
135
+
136
+
137
+ # ── catalog product-groups ───────────────────────────────────────────
138
+
139
+
140
+ @app.command("product-groups")
141
+ def product_groups(
142
+ typer_ctx: typer.Context,
143
+ output: OutputFormat | None = typer.Option(
144
+ None,
145
+ "--output",
146
+ "-o",
147
+ help="Output format. Overrides the global --output flag.",
148
+ case_sensitive=False,
149
+ ),
150
+ ) -> None:
151
+ """List product groups with the count of products in each.
152
+
153
+ Wraps ``GET /products/groups``. Useful before
154
+ ``impreza catalog products --group <name>`` to find the right
155
+ filter value.
156
+ """
157
+ state = from_typer_context(typer_ctx)
158
+ fmt = resolve_output(state, output)
159
+
160
+ with make_client_or_exit(state) as client:
161
+ try:
162
+ groups = client.catalog.product_groups()
163
+ except ApiError as exc:
164
+ _exit_on_api_error(exc)
165
+
166
+ if not groups:
167
+ typer.echo("No product groups defined yet.")
168
+ return
169
+
170
+ rows = [
171
+ {"id": g.id, "name": g.name, "product_count": g.product_count}
172
+ for g in groups
173
+ ]
174
+ print_table(
175
+ f"Product groups ({len(rows)})",
176
+ rows,
177
+ columns=["id", "name", "product_count"],
178
+ fmt=fmt,
179
+ )
180
+
181
+
182
+ # ── catalog tlds ─────────────────────────────────────────────────────
183
+
184
+
185
+ def _year_1_price(prices: dict[str, float]) -> float | None:
186
+ """Pull out the 1-year price from a ``{"1": x, "2": y}`` map.
187
+ Returns None when 1-year isn't offered (some registrars require
188
+ multi-year minimums)."""
189
+ raw = prices.get("1")
190
+ return float(raw) if raw is not None else None
191
+
192
+
193
+ @app.command("tlds")
194
+ def tlds(
195
+ typer_ctx: typer.Context,
196
+ filter_: str | None = typer.Option(
197
+ None,
198
+ "--filter",
199
+ "-f",
200
+ help=(
201
+ "Comma-separated list of TLDs (e.g. '.com,.net,.io'). "
202
+ "Without a filter, the full TLD catalog is returned."
203
+ ),
204
+ ),
205
+ output: OutputFormat | None = typer.Option(
206
+ None,
207
+ "--output",
208
+ "-o",
209
+ help="Output format. Overrides the global --output flag.",
210
+ case_sensitive=False,
211
+ ),
212
+ ) -> None:
213
+ """List domain TLD pricing.
214
+
215
+ Wraps ``GET /domains/pricing``. Table mode shows tld / currency /
216
+ 1-year register / 1-year renew / cheapest-overall. JSON / YAML
217
+ emit the full :class:`TldPricing` model with per-year pricing
218
+ dicts for both register and renew.
219
+ """
220
+ state = from_typer_context(typer_ctx)
221
+ fmt = resolve_output(state, output)
222
+
223
+ with make_client_or_exit(state) as client:
224
+ try:
225
+ items: list[TldPricing] = client.catalog.tlds(filter=filter_)
226
+ except ApiError as exc:
227
+ _exit_on_api_error(exc)
228
+
229
+ if not items:
230
+ msg = (
231
+ f"No TLDs match the filter: {filter_!r}."
232
+ if filter_
233
+ else "No TLDs in the catalog yet."
234
+ )
235
+ typer.echo(msg)
236
+ return
237
+
238
+ if fmt is OutputFormat.TABLE:
239
+ rows: list[dict[str, Any]] = []
240
+ for t in items:
241
+ reg_1y = _year_1_price(t.register_prices)
242
+ ren_1y = _year_1_price(t.renew_prices)
243
+ rows.append(
244
+ {
245
+ "tld": t.tld,
246
+ "currency": t.currency,
247
+ "register_1y": f"{reg_1y:.2f}" if reg_1y is not None else "-",
248
+ "renew_1y": f"{ren_1y:.2f}" if ren_1y is not None else "-",
249
+ "cheapest": (
250
+ f"{t.cheapest:.2f}" if t.cheapest is not None else "-"
251
+ ),
252
+ "min_years": t.min_years,
253
+ }
254
+ )
255
+ print_table(
256
+ f"TLDs ({len(rows)})",
257
+ rows,
258
+ columns=[
259
+ "tld",
260
+ "currency",
261
+ "register_1y",
262
+ "renew_1y",
263
+ "cheapest",
264
+ "min_years",
265
+ ],
266
+ fmt=fmt,
267
+ )
268
+ else:
269
+ rows = [t.model_dump(by_alias=True) for t in items]
270
+ print_table("TLDs", rows, fmt=fmt)
@@ -0,0 +1,232 @@
1
+ """``impreza context`` subcommand surface.
2
+
3
+ Five operations:
4
+
5
+ * ``impreza context create <name> --key ... --secret ... [--base-url ...]
6
+ [--default-output table|json|yaml] [--overwrite]``
7
+ * ``impreza context use <name>``
8
+ * ``impreza context list``
9
+ * ``impreza context current``
10
+ * ``impreza context delete <name> [--yes]``
11
+
12
+ The first context created becomes the default automatically. Names
13
+ must match ``[A-Za-z0-9_-]{1,50}`` (so they never need quoting in
14
+ shell commands).
15
+
16
+ The config file lives at ``$XDG_CONFIG_HOME/impreza/config.toml``
17
+ (Linux), ``~/Library/Application Support/impreza/config.toml`` (macOS),
18
+ or ``%APPDATA%\\impreza\\config.toml`` (Windows). Override with
19
+ ``IMPREZA_CONFIG=/path/to/config.toml`` for tests or alternate
20
+ layouts.
21
+
22
+ Errors render as red ``Error:`` lines on stderr and exit non-zero so
23
+ scripts can detect failure cleanly. Stdout stays reserved for the
24
+ success output.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import typer
30
+
31
+ from ..config import (
32
+ Config,
33
+ ConfigError,
34
+ ContextAlreadyExists,
35
+ ContextNotFound,
36
+ InvalidContextName,
37
+ NoActiveContext,
38
+ NoContextsConfigured,
39
+ )
40
+ from ..output import OutputFormat, error, info, print_dict, print_table, success
41
+ from ..state import from_typer_context, resolve_output
42
+
43
+ app = typer.Typer(
44
+ name="context",
45
+ help="Manage local CLI contexts (named credential sets).",
46
+ no_args_is_help=True,
47
+ )
48
+
49
+
50
+ # ── helpers ───────────────────────────────────────────────────────────
51
+
52
+
53
+ def _exit_on_config_error(exc: ConfigError) -> None:
54
+ """Print a friendly message and exit 1. Used by every command's
55
+ top-level error handler so error formatting stays consistent."""
56
+ error(str(exc))
57
+ raise typer.Exit(code=1)
58
+
59
+
60
+ def _mask_secret(value: str) -> str:
61
+ """Render a secret string as ``imp_abc...xyz`` (first/last 4 chars
62
+ of the suffix; never the middle). Used in human-readable output
63
+ to confirm "yes the right key is loaded" without leaking it."""
64
+ if len(value) <= 12:
65
+ return "[dim]<short>[/]"
66
+ return f"{value[:8]}…{value[-4:]}"
67
+
68
+
69
+ # ── commands ──────────────────────────────────────────────────────────
70
+
71
+
72
+ @app.command("create")
73
+ def create(
74
+ name: str = typer.Argument(..., help="Context name (alphanumeric, '-', '_')."),
75
+ key: str = typer.Option(..., "--key", "-k", help="API key (`imp_...`)."),
76
+ secret: str = typer.Option(
77
+ ..., "--secret", "-s", help="API secret (64 hex chars).",
78
+ ),
79
+ base_url: str | None = typer.Option(
80
+ None,
81
+ "--base-url",
82
+ help="Optional API base URL override (defaults to "
83
+ "https://api.imprezahost.com/v1).",
84
+ ),
85
+ default_output: OutputFormat | None = typer.Option(
86
+ None,
87
+ "--default-output",
88
+ help="Optional default output format for this context.",
89
+ case_sensitive=False,
90
+ ),
91
+ overwrite: bool = typer.Option(
92
+ False,
93
+ "--overwrite",
94
+ help="Replace an existing context with the same name.",
95
+ ),
96
+ ) -> None:
97
+ """Create a new context. The first one created is auto-set as
98
+ the default."""
99
+ cfg = Config.load()
100
+ try:
101
+ ctx = cfg.add_context(
102
+ name,
103
+ api_key=key,
104
+ api_secret=secret,
105
+ base_url=base_url,
106
+ default_output=default_output.value if default_output else None,
107
+ overwrite=overwrite,
108
+ )
109
+ except (ContextAlreadyExists, InvalidContextName) as exc:
110
+ _exit_on_config_error(exc)
111
+ cfg.save()
112
+
113
+ is_default = cfg.default_context == name
114
+ success(
115
+ f"Context {ctx.name!r} created"
116
+ + (" and set as default." if is_default else ".")
117
+ )
118
+
119
+
120
+ @app.command("use")
121
+ def use(
122
+ name: str = typer.Argument(..., help="Context name to switch to."),
123
+ ) -> None:
124
+ """Set the default context."""
125
+ cfg = Config.load()
126
+ try:
127
+ cfg.use_context(name)
128
+ except ContextNotFound as exc:
129
+ _exit_on_config_error(exc)
130
+ cfg.save()
131
+ success(f"Now using context {name!r}.")
132
+
133
+
134
+ @app.command("list")
135
+ def list_contexts(
136
+ typer_ctx: typer.Context,
137
+ output: OutputFormat | None = typer.Option(
138
+ None,
139
+ "--output",
140
+ "-o",
141
+ help="Output format. Overrides the global --output flag for this command.",
142
+ case_sensitive=False,
143
+ ),
144
+ ) -> None:
145
+ """List all configured contexts with their masked credentials."""
146
+ fmt = resolve_output(from_typer_context(typer_ctx), output)
147
+ cfg = Config.load()
148
+ rows = [
149
+ {
150
+ "name": ctx.name,
151
+ "default": cfg.default_context == ctx.name,
152
+ "api_key": _mask_secret(ctx.api_key)
153
+ if fmt is OutputFormat.TABLE
154
+ else ctx.api_key,
155
+ "base_url": ctx.base_url,
156
+ "default_output": ctx.default_output,
157
+ }
158
+ for ctx in (cfg.contexts[name] for name in cfg.list_contexts())
159
+ ]
160
+ if not rows:
161
+ typer.echo("No contexts configured. Run `impreza context create <name>`.")
162
+ return
163
+ print_table(
164
+ "Contexts",
165
+ rows,
166
+ columns=["name", "default", "api_key", "base_url", "default_output"],
167
+ fmt=fmt,
168
+ )
169
+
170
+
171
+ @app.command("current")
172
+ def current(
173
+ typer_ctx: typer.Context,
174
+ output: OutputFormat | None = typer.Option(
175
+ None,
176
+ "--output",
177
+ "-o",
178
+ help="Output format. Overrides the global --output flag for this command.",
179
+ case_sensitive=False,
180
+ ),
181
+ ) -> None:
182
+ """Show the active (default) context's metadata. Exits non-zero
183
+ when no contexts are configured or no default is set."""
184
+ fmt = resolve_output(from_typer_context(typer_ctx), output)
185
+ cfg = Config.load()
186
+ try:
187
+ ctx = cfg.get_context()
188
+ except (NoContextsConfigured, NoActiveContext, ContextNotFound) as exc:
189
+ _exit_on_config_error(exc)
190
+
191
+ print_dict(
192
+ "Current context",
193
+ {
194
+ "name": ctx.name,
195
+ "api_key": _mask_secret(ctx.api_key)
196
+ if fmt is OutputFormat.TABLE
197
+ else ctx.api_key,
198
+ "base_url": ctx.base_url or "<default>",
199
+ "default_output": ctx.default_output or "<inherit>",
200
+ },
201
+ fmt=fmt,
202
+ )
203
+
204
+
205
+ @app.command("delete")
206
+ def delete(
207
+ name: str = typer.Argument(..., help="Context name to delete."),
208
+ yes: bool = typer.Option(
209
+ False,
210
+ "--yes",
211
+ "-y",
212
+ help="Skip the interactive confirmation prompt.",
213
+ ),
214
+ ) -> None:
215
+ """Delete a context. Prompts for confirmation unless ``--yes``."""
216
+ cfg = Config.load()
217
+ if name not in cfg.contexts:
218
+ _exit_on_config_error(ContextNotFound(f"Context {name!r} does not exist."))
219
+
220
+ if not yes and not typer.confirm(f"Delete context {name!r}?", default=False):
221
+ typer.echo("Cancelled.")
222
+ raise typer.Exit(code=0)
223
+
224
+ was_default = cfg.default_context == name
225
+ cfg.remove_context(name)
226
+ cfg.save()
227
+
228
+ success(f"Context {name!r} deleted.")
229
+ if was_default and cfg.contexts:
230
+ info(
231
+ " (no default context now — pick one with `impreza context use <name>`.)"
232
+ )