Coverage for src/secchi/diagnostics.py: 91%
56 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Readable, structured diagnostics shared by CLI and dashboard surfaces."""
3from __future__ import annotations
5from collections.abc import Callable
6from dataclasses import dataclass
7from datetime import UTC, datetime
8from enum import StrEnum
9from pathlib import Path
10from threading import Lock
13class DiagnosticStatus(StrEnum):
14 SUCCESS = "SUCCESS"
15 WARN = "WARN"
16 FAILURE = "FAILURE"
19@dataclass(frozen=True)
20class DiagnosticEvent:
21 """One user-facing process or HTTP diagnostic event."""
23 timestamp: datetime
24 status: DiagnosticStatus
25 source: str
26 message: str
27 url: str = ""
28 status_code: int | None = None
30 def format(self) -> str:
31 time = self.timestamp.astimezone(UTC).strftime("%H:%M:%S")
32 suffix = f" -> {self.status_code}" if self.status_code is not None else ""
33 target = f" {self.url}" if self.url else ""
34 return f"{time} {self.status.value:<7} [{self.source}] {self.message}{target}{suffix}"
37class DiagnosticLog:
38 """Bounded session log with optional human-readable file output."""
40 def __init__(
41 self,
42 *,
43 path: Path | None = None,
44 max_events: int = 1000,
45 clock: Callable[[], datetime] | None = None,
46 ) -> None:
47 self.path = path
48 self.max_events = max_events
49 self.clock = clock or (lambda: datetime.now(UTC))
50 self._events: list[DiagnosticEvent] = []
51 self._lock = Lock()
52 if path is not None:
53 path.parent.mkdir(parents=True, exist_ok=True)
54 path.write_text("")
56 def record(
57 self,
58 status: DiagnosticStatus,
59 source: str,
60 message: str,
61 *,
62 url: str = "",
63 status_code: int | None = None,
64 ) -> DiagnosticEvent:
65 event = DiagnosticEvent(
66 timestamp=self.clock(),
67 status=status,
68 source=source,
69 message=message,
70 url=url,
71 status_code=status_code,
72 )
73 with self._lock:
74 self._events.append(event)
75 del self._events[: max(0, len(self._events) - self.max_events)]
76 if self.path is not None:
77 with self.path.open("a") as output:
78 output.write(event.format() + "\n")
79 return event
81 def snapshot(self) -> list[DiagnosticEvent]:
82 with self._lock:
83 return list(self._events)
85 def has_status(self, *statuses: DiagnosticStatus) -> bool:
86 wanted = set(statuses)
87 return any(event.status in wanted for event in self.snapshot())
90def diagnostic_for_http_error(exc: Exception) -> str:
91 """Return a concise, stable message without a traceback."""
92 if hasattr(exc, "response"):
93 response = exc.response
94 reason = response.reason_phrase or "HTTP error"
95 return f"HTTP {response.status_code} {reason}"
96 message = str(exc).strip()
97 return message or exc.__class__.__name__