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,427 @@
1
+ """``impreza doctor`` health-check command — Phase 4.1.
2
+
3
+ A single-command first-line support tool. Runs a sequence of
4
+ checks against the resolved context and reports the state of
5
+ each, so a user troubleshooting "I just installed and nothing
6
+ works" gets a copy-pasteable diagnostic instead of digging
7
+ through eight different verbs.
8
+
9
+ Checks (in order, stopping early on hard failures):
10
+
11
+ 1. **Config file resolved** — does ``~/.config/impreza/config.toml``
12
+ exist and parse? (Implicit via ``make_client_or_exit``; failures
13
+ land as the standard config-error stderr.)
14
+ 2. **Active context** — which context resolved? Show its label
15
+ and the API key prefix.
16
+ 3. **API reachable** — round-trip ``GET /account/api-keys/self``.
17
+ Network errors and auth errors both surface here.
18
+ 4. **Key status** — the returned ``KeyIdentity.status`` field.
19
+ 5. **IP whitelist** — does the server-observed ``request_ip`` match
20
+ one of the whitelist entries? Mismatch is the single most
21
+ common "everything is 403" cause.
22
+ 6. **Account profile + balance** — round-trip ``GET /account``.
23
+ Sanity check that the key has actual scopes beyond
24
+ ``/account/api-keys/self``.
25
+
26
+ Each check renders as ``[OK] / [FAIL] / [WARN]`` (ASCII-only —
27
+ no Unicode glyphs, per the Phase 1.6 cp1252 lesson). The exit
28
+ code is 0 if every check passed, 1 otherwise. Output mode
29
+ ``--output json`` emits a structured array suitable for piping
30
+ into monitoring scripts.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import sys
37
+ import time
38
+ from typing import Any
39
+
40
+ import typer
41
+ from impreza.exceptions import (
42
+ ApiError,
43
+ AuthError,
44
+ IpNotWhitelisted,
45
+ NetworkError,
46
+ PermissionDenied,
47
+ )
48
+
49
+ from ..output import OutputFormat, error, success, warning
50
+ from ..sdk import make_client_or_exit
51
+ from ..state import from_typer_context, resolve_output
52
+
53
+ app = typer.Typer(
54
+ name="doctor",
55
+ help="Run a health check against the active context.",
56
+ invoke_without_command=True,
57
+ no_args_is_help=False,
58
+ )
59
+
60
+
61
+ # ── data model ──────────────────────────────────────────────────────
62
+
63
+
64
+ class _CheckResult:
65
+ """One row in the doctor report. Mutable so callers can build it
66
+ in-place across the check body.
67
+
68
+ ``ok`` is True/False/None — None means "skipped" (typically
69
+ because an earlier check failed and this one depends on it).
70
+ """
71
+
72
+ __slots__ = ("name", "ok", "summary", "detail")
73
+
74
+ def __init__(self, name: str) -> None:
75
+ self.name: str = name
76
+ self.ok: bool | None = False
77
+ self.summary: str = ""
78
+ self.detail: str = ""
79
+
80
+ def passed(self, summary: str, detail: str = "") -> None:
81
+ self.ok = True
82
+ self.summary = summary
83
+ self.detail = detail
84
+
85
+ def failed(self, summary: str, detail: str = "") -> None:
86
+ self.ok = False
87
+ self.summary = summary
88
+ self.detail = detail
89
+
90
+ def warned(self, summary: str, detail: str = "") -> None:
91
+ # WARN renders distinctly but still counts as "passed" for
92
+ # the overall exit code. Used for cosmetic mismatches that
93
+ # don't actually break functionality.
94
+ self.ok = True
95
+ self.summary = "WARN: " + summary
96
+ self.detail = detail
97
+
98
+ def skipped(self, summary: str) -> None:
99
+ self.ok = None
100
+ self.summary = summary
101
+ self.detail = ""
102
+
103
+ def as_dict(self) -> dict[str, Any]:
104
+ return {
105
+ "name": self.name,
106
+ "ok": self.ok,
107
+ "summary": self.summary,
108
+ "detail": self.detail,
109
+ }
110
+
111
+
112
+ # ── individual checks ───────────────────────────────────────────────
113
+
114
+
115
+ def _check_context(state: Any) -> _CheckResult:
116
+ """Report which context resolved. Doesn't actually verify the
117
+ API key — that's the next check's job. The ``make_client_or_exit``
118
+ call in the parent already failed-fast on missing-config errors,
119
+ so reaching this check at all means there's a usable context."""
120
+ r = _CheckResult("active-context")
121
+ override = state.context_override
122
+ if override:
123
+ r.passed(f"Context override: {override!r}")
124
+ else:
125
+ r.passed("Default context")
126
+ return r
127
+
128
+
129
+ def _check_api_reachable(client: Any, key_holder: dict[str, Any]) -> _CheckResult:
130
+ """Round-trip GET /account/api-keys/self. Captures elapsed time
131
+ so the report shows latency, and stashes the result in
132
+ ``key_holder`` so later checks reuse it without a second HTTP."""
133
+ r = _CheckResult("api-reachable")
134
+ t0 = time.monotonic()
135
+ try:
136
+ key = client.account.api_key_self()
137
+ except NetworkError as exc:
138
+ r.failed(
139
+ "Could not reach api.imprezahost.com",
140
+ f"Network error: {exc}. Check connectivity / DNS / proxy. "
141
+ "If using Tor, confirm the SOCKS proxy is up.",
142
+ )
143
+ return r
144
+ except AuthError as exc:
145
+ r.failed(
146
+ "Authentication failed (HTTP 401)",
147
+ f"{exc.message}. The API key or secret is invalid. "
148
+ "Rotate via Impreza Account, update the local context "
149
+ "with `impreza context create / use`.",
150
+ )
151
+ return r
152
+ except IpNotWhitelisted as exc:
153
+ r.failed(
154
+ "IP not whitelisted (HTTP 403)",
155
+ f"{exc.message}. Add the calling IP to the API key's "
156
+ "whitelist via Impreza Account, or use a different "
157
+ "key whose whitelist already covers this IP.",
158
+ )
159
+ return r
160
+ except PermissionDenied as exc:
161
+ r.failed(
162
+ "Permission denied (HTTP 403)",
163
+ f"{exc.message}.",
164
+ )
165
+ return r
166
+ except ApiError as exc:
167
+ r.failed(
168
+ f"API error: {exc.message}",
169
+ f"code={exc.code or '?'}, status={exc.status_code or '?'}",
170
+ )
171
+ return r
172
+ elapsed_ms = (time.monotonic() - t0) * 1000.0
173
+ key_holder["key"] = key
174
+ r.passed(
175
+ f"GET /account/api-keys/self OK ({elapsed_ms:.0f}ms)",
176
+ f"key prefix={key.prefix!r}, label={key.label or '(unnamed)'!r}",
177
+ )
178
+ return r
179
+
180
+
181
+ def _check_key_status(key_holder: dict[str, Any]) -> _CheckResult:
182
+ """Inspect the KeyIdentity.status field returned by api_key_self.
183
+ Active keys pass; anything else (paused, revoked, etc.) flags as
184
+ a hard fail — even though the call succeeded, the key may stop
185
+ working at any time."""
186
+ r = _CheckResult("key-status")
187
+ key = key_holder.get("key")
188
+ if key is None:
189
+ r.skipped("api-reachable check failed; cannot inspect key")
190
+ return r
191
+ status = (key.status or "").lower()
192
+ if status == "active":
193
+ r.passed(f"status={status!r}")
194
+ else:
195
+ r.failed(
196
+ f"status={key.status!r} (not active)",
197
+ "Pending, paused, or revoked keys will start returning "
198
+ "401 unpredictably. Rotate via Impreza Account.",
199
+ )
200
+ return r
201
+
202
+
203
+ def _check_ip_whitelist(key_holder: dict[str, Any]) -> _CheckResult:
204
+ """Compare ``request_ip`` (what the server saw the call coming
205
+ from) to ``ip_whitelist`` (the entries the server would accept)."""
206
+ r = _CheckResult("ip-whitelist")
207
+ key = key_holder.get("key")
208
+ if key is None:
209
+ r.skipped("api-reachable check failed; cannot inspect whitelist")
210
+ return r
211
+
212
+ request_ip = key.request_ip or ""
213
+ entries = list(key.ip_whitelist or [])
214
+ if not request_ip:
215
+ r.warned(
216
+ "server did not echo request_ip in this response",
217
+ "Whitelist check is unavailable; the call already "
218
+ "succeeded so the IP must be allowed, but the report "
219
+ "cannot prove it.",
220
+ )
221
+ return r
222
+ if not entries:
223
+ r.warned(
224
+ f"request_ip {request_ip} reached the API but the key "
225
+ "has no whitelist entries",
226
+ "Either the key has whitelist enforcement disabled "
227
+ "(unusual) or the server is letting it through anyway. "
228
+ "Inspect via Impreza Account to be sure.",
229
+ )
230
+ return r
231
+
232
+ match = next((e for e in entries if e.ip_address == request_ip), None)
233
+ if match is None:
234
+ labels = ", ".join(
235
+ f"{e.ip_address!r}{f' ({e.label!r})' if e.label else ''}"
236
+ for e in entries
237
+ )
238
+ r.failed(
239
+ f"request_ip {request_ip} not in whitelist "
240
+ f"({len(entries)} entr{'y' if len(entries) == 1 else 'ies'})",
241
+ f"Whitelist: {labels}. Add the calling IP via your Impreza "
242
+ "Account, or switch to a context whose key already "
243
+ "allows this IP.",
244
+ )
245
+ return r
246
+
247
+ label = f" ({match.label!r})" if match.label else ""
248
+ r.passed(f"request_ip {request_ip} matches entry{label}")
249
+ return r
250
+
251
+
252
+ def _check_account_profile(client: Any) -> _CheckResult:
253
+ """Round-trip GET /account. The api_key_self endpoint sometimes
254
+ bypasses scope checks; calling /account exercises a regular
255
+ read-scope so the doctor catches scope-limited keys early."""
256
+ r = _CheckResult("account-profile")
257
+ try:
258
+ acc = client.account.get()
259
+ except ApiError as exc:
260
+ r.failed(
261
+ f"GET /account failed: {exc.message}",
262
+ f"code={exc.code or '?'}, status={exc.status_code or '?'}. "
263
+ "If api-reachable passed but this didn't, the key may "
264
+ "lack the basic read scope. Contact support.",
265
+ )
266
+ return r
267
+ name = f"{acc.first_name} {acc.last_name}".strip()
268
+ if acc.company:
269
+ name = f"{name} ({acc.company})"
270
+ r.passed(
271
+ f"{name} <{acc.email}>, balance {acc.balance:.2f} {acc.currency}",
272
+ f"registered {acc.registered_at}",
273
+ )
274
+ return r
275
+
276
+
277
+ # ── renderers ───────────────────────────────────────────────────────
278
+
279
+
280
+ _LABEL_COLOR = {
281
+ True: typer.colors.GREEN,
282
+ False: typer.colors.RED,
283
+ None: typer.colors.YELLOW,
284
+ }
285
+ _LABEL_TEXT = {True: "[OK] ", False: "[FAIL]", None: "[SKIP]"}
286
+
287
+
288
+ def _is_warn(result: _CheckResult) -> bool:
289
+ return result.ok is True and result.summary.startswith("WARN:")
290
+
291
+
292
+ def _print_text_report(results: list[_CheckResult]) -> None:
293
+ """Write the human-friendly report to stdout. Failures and warns
294
+ get their detail indented under the summary line so a copy-paste
295
+ of the whole report preserves the full context."""
296
+ sys.stdout.write("\n")
297
+ sys.stdout.write("impreza doctor\n")
298
+ sys.stdout.write("-" * 40 + "\n")
299
+ for r in results:
300
+ if _is_warn(r):
301
+ label = "[WARN]"
302
+ color = typer.colors.YELLOW
303
+ else:
304
+ label = _LABEL_TEXT[r.ok]
305
+ color = _LABEL_COLOR[r.ok]
306
+ typer.secho(label, fg=color, bold=True, nl=False)
307
+ sys.stdout.write(f" {r.name}: {r.summary}\n")
308
+ if r.detail:
309
+ for line in r.detail.splitlines():
310
+ sys.stdout.write(f" {line}\n")
311
+ sys.stdout.write("-" * 40 + "\n")
312
+
313
+
314
+ def _print_summary(results: list[_CheckResult]) -> int:
315
+ """Bottom-line summary. Returns the exit code (0 if all pass).
316
+
317
+ Uses the :mod:`commands.output` palette helpers (4.3) so the
318
+ summary line matches the colour conventions every other CLI
319
+ command will adopt going forward: success = green, warning =
320
+ yellow, error = red.
321
+ """
322
+ total = len(results)
323
+ failed = [r for r in results if r.ok is False]
324
+ skipped = [r for r in results if r.ok is None]
325
+ warned = [r for r in results if _is_warn(r)]
326
+ passed = total - len(failed) - len(skipped)
327
+ if failed:
328
+ error(f"{len(failed)} of {total} checks failed.")
329
+ return 1
330
+ if warned or skipped:
331
+ msg_parts = [f"{passed} of {total} checks passed"]
332
+ if warned:
333
+ msg_parts.append(f"{len(warned)} warning(s)")
334
+ if skipped:
335
+ msg_parts.append(f"{len(skipped)} skipped")
336
+ warning(", ".join(msg_parts) + ".")
337
+ return 0
338
+ success(f"All checks passed. {passed}/{total}.")
339
+ return 0
340
+
341
+
342
+ # ── entry point ─────────────────────────────────────────────────────
343
+
344
+
345
+ @app.callback(invoke_without_command=True)
346
+ def doctor(
347
+ typer_ctx: typer.Context,
348
+ output: OutputFormat | None = typer.Option(
349
+ None,
350
+ "--output",
351
+ "-o",
352
+ help=(
353
+ "Output format. 'json' emits the check array structured "
354
+ "for monitoring scripts (each entry has name / ok / "
355
+ "summary / detail). Default: human-friendly text report."
356
+ ),
357
+ case_sensitive=False,
358
+ ),
359
+ ) -> None:
360
+ """Run a health check against the active context.
361
+
362
+ Reports each step (config / context / API reachable / key
363
+ status / IP whitelist / account) and exits 0 only if every
364
+ check passed. ``--output json`` produces a structured report
365
+ suitable for piping into monitoring scripts.
366
+
367
+ Common failure modes the doctor catches:
368
+
369
+ * Config file missing or empty → first-time setup hint.
370
+ * API key invalid → rotate via your Impreza Account.
371
+ * IP not whitelisted → add the IP or switch context.
372
+ * Key paused / revoked → rotate.
373
+ * Account-scope mismatch → contact support.
374
+ """
375
+ # If a subcommand was invoked, do nothing — Typer routes there.
376
+ if typer_ctx.invoked_subcommand is not None:
377
+ return
378
+
379
+ state = from_typer_context(typer_ctx)
380
+ fmt = resolve_output(state, output)
381
+
382
+ results: list[_CheckResult] = []
383
+
384
+ # Check 1: config + context (failures here exit before we can
385
+ # build a Client; the parent helper renders its own error).
386
+ results.append(_check_context(state))
387
+
388
+ # Build the client. make_client_or_exit handles config errors
389
+ # itself with a friendly stderr + Exit(1), so reaching this point
390
+ # means we have a valid client.
391
+ with make_client_or_exit(state) as client:
392
+ key_holder: dict[str, Any] = {}
393
+ results.append(_check_api_reachable(client, key_holder))
394
+ results.append(_check_key_status(key_holder))
395
+ results.append(_check_ip_whitelist(key_holder))
396
+ # Account profile only makes sense if API was reachable.
397
+ if key_holder.get("key") is not None:
398
+ results.append(_check_account_profile(client))
399
+ else:
400
+ r = _CheckResult("account-profile")
401
+ r.skipped("api-reachable failed; cannot test other endpoints")
402
+ results.append(r)
403
+
404
+ if fmt is OutputFormat.JSON:
405
+ sys.stdout.write(
406
+ json.dumps([r.as_dict() for r in results], indent=2, ensure_ascii=False)
407
+ )
408
+ sys.stdout.write("\n")
409
+ # JSON consumers want exit-code-as-signal too.
410
+ if any(r.ok is False for r in results):
411
+ raise typer.Exit(code=1)
412
+ return
413
+
414
+ _print_text_report(results)
415
+ exit_code = _print_summary(results)
416
+ if exit_code != 0:
417
+ # Don't suppress the report — raise after printing so the
418
+ # user sees the full diagnostic AND gets the non-zero exit.
419
+ # typer.Exit raises a SystemExit(exit_code) which click /
420
+ # CliRunner translates to result.exit_code.
421
+ raise typer.Exit(code=exit_code)
422
+ # Some terminals expect a trailing newline before the prompt.
423
+ # The summary printer already emitted one via secho.
424
+
425
+ # Note: we don't use `error()` for failed checks because the
426
+ # report itself contains the [FAIL] markers in red — adding a
427
+ # stderr "Error:" line on top would just duplicate the signal.