kuma-stats 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.
kuma_stats/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """kuma-stats: CLI for viewing Uptime Kuma monitor heartbeat data, uptime, and status."""
2
+
3
+ __version__ = "0.3.0"
kuma_stats/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m kuma_stats`."""
2
+
3
+ from kuma_stats.cli import main
4
+
5
+ main()
kuma_stats/cli.py ADDED
@@ -0,0 +1,328 @@
1
+ """kuma-stats command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Callable, NoReturn, Optional, TypeVar, cast
7
+
8
+ import click
9
+ from rich.table import Table
10
+
11
+ from . import __version__
12
+ from .core.adapter import password_login
13
+ from .core.config import Config, Format
14
+ from .core.errors import KumaStatsError
15
+ from .output import console, emit, emit_error, progress
16
+ from .services import monitors
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ def _config(ctx: click.Context) -> Config:
22
+ return ctx.obj["config"]
23
+
24
+
25
+ def _percentage(value: object) -> str:
26
+ if value is None:
27
+ return "N/A"
28
+ if isinstance(value, (int, float)):
29
+ return f"{value * 100:.1f}%"
30
+ return str(value)
31
+
32
+
33
+ def _table(title: str, data: object) -> Table:
34
+ table = Table(title=title, title_style="bold cyan")
35
+ if isinstance(data, dict) and "beats" in data:
36
+ beats = data["beats"]
37
+ table.title = f"Heartbeat History: {data['name']} (last {data['hours']}h)"
38
+ table.add_column("Time")
39
+ table.add_column("Status")
40
+ table.add_column("Ping")
41
+ table.add_column("Message")
42
+ for beat in beats:
43
+ ping = beat.get("ping")
44
+ table.add_row(
45
+ beat["time"], beat["status"], "N/A" if ping is None else f"{ping}ms", beat["msg"]
46
+ )
47
+ total = data["total_beats"]
48
+ if total:
49
+ up = sum(beat["status"] == "UP" for beat in beats)
50
+ table.caption = f"{up}/{total} UP ({up / total * 100:.1f}% uptime in this window)"
51
+ return table
52
+ if not isinstance(data, list):
53
+ return table
54
+ if title == "Uptime Kuma Monitors":
55
+ columns = ("ID", "Monitor", "Status", "24h Uptime", "30d Uptime", "Avg Ping", "Interval")
56
+ for column in columns:
57
+ table.add_column(column)
58
+ for monitor in data:
59
+ ping = monitor["avg_ping_ms"]
60
+ table.add_row(
61
+ str(monitor["id"]),
62
+ monitor["name"],
63
+ monitor["status"],
64
+ _percentage(monitor["uptime_24h"]),
65
+ _percentage(monitor["uptime_30d"]),
66
+ "N/A" if ping is None else f"{ping:.0f}ms",
67
+ f"{monitor['interval_seconds']}s",
68
+ )
69
+ return table
70
+ if title == "Uptime Kuma Uptime":
71
+ for column in ("ID", "Monitor", "24h", "30d", "1y"):
72
+ table.add_column(column)
73
+ for monitor in data:
74
+ table.add_row(
75
+ str(monitor["id"]),
76
+ monitor["name"],
77
+ _percentage(monitor["uptime_24h"]),
78
+ _percentage(monitor["uptime_30d"]),
79
+ _percentage(monitor["uptime_1y"]),
80
+ )
81
+ return table
82
+ table.add_column("Monitor")
83
+ table.add_column("Status")
84
+ table.add_column("24h")
85
+ table.add_column("30d")
86
+ table.add_column("1y")
87
+ table.add_column("Recent heartbeats")
88
+ for monitor in data:
89
+ uptime = monitor["uptime"]
90
+ table.add_row(
91
+ f"{monitor['name']} ({monitor['id']})",
92
+ monitor["status"],
93
+ _percentage(uptime.get("24")),
94
+ _percentage(uptime.get("720")),
95
+ _percentage(uptime.get("1y")),
96
+ str(len(monitor["recent_heartbeats"])),
97
+ )
98
+ return table
99
+
100
+
101
+ def _output_options(command):
102
+ command = click.option(
103
+ "--format", "output_format", type=click.Choice(["table", "json", "yaml"]), default=None
104
+ )(command)
105
+ return click.option("--json", "json_output", is_flag=True, help="Shortcut for --format json.")(
106
+ command
107
+ )
108
+
109
+
110
+ def _command_config(ctx: click.Context, output_format: Optional[str], json_output: bool) -> Config:
111
+ return _config(ctx).with_overrides(output_format="json" if json_output else output_format)
112
+
113
+
114
+ def _store_token(token: str, path: Path, force: bool) -> None:
115
+ from dotenv import dotenv_values, set_key
116
+
117
+ old = dotenv_values(path).get("KUMA_TOKEN") if path.exists() else None
118
+ if old and old != token and not force:
119
+ raise click.ClickException(f"{path} already has a KUMA_TOKEN; use --force to replace it.")
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ set_key(str(path), "KUMA_TOKEN", token, quote_mode="never")
122
+ try:
123
+ path.chmod(0o600)
124
+ except OSError:
125
+ pass
126
+
127
+
128
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
129
+ @click.option("--url", envvar="KUMA_URL", help="Uptime Kuma server URL")
130
+ @click.option("--username", envvar="KUMA_USERNAME", help="Login username")
131
+ @click.option(
132
+ "--password", envvar="KUMA_PASSWORD", help="Login password (prefer KUMA_PASSWORD in automation)"
133
+ )
134
+ @click.option("--token", envvar="KUMA_TOKEN", help="JWT token (prefer KUMA_TOKEN in automation)")
135
+ @click.option(
136
+ "--insecure", is_flag=True, default=None, help="Disable TLS certificate verification."
137
+ )
138
+ @click.option(
139
+ "--format", "output_format", type=click.Choice(["table", "json", "yaml"]), default=None
140
+ )
141
+ @click.option("--json", "json_output", is_flag=True, help="Shortcut for --format json.")
142
+ @click.option(
143
+ "--timeout",
144
+ type=click.FloatRange(min=0.1),
145
+ default=None,
146
+ help="Connection/retry budget in seconds.",
147
+ )
148
+ @click.option(
149
+ "--retries",
150
+ type=click.IntRange(min=0),
151
+ default=None,
152
+ help="Retries for transient network failures.",
153
+ )
154
+ @click.version_option(__version__)
155
+ @click.pass_context
156
+ def cli(
157
+ ctx: click.Context,
158
+ url: Optional[str],
159
+ username: Optional[str],
160
+ password: Optional[str],
161
+ token: Optional[str],
162
+ insecure: Optional[bool],
163
+ output_format: Optional[str],
164
+ json_output: bool,
165
+ timeout: Optional[float],
166
+ retries: Optional[int],
167
+ ) -> None:
168
+ """View Uptime Kuma monitor status, uptime, and heartbeat data."""
169
+ try:
170
+ base = Config.load()
171
+ except KumaStatsError as error:
172
+ selected_format = cast(Format, "json" if json_output else output_format or "table")
173
+ emit_error(error, Config(output_format=selected_format))
174
+ ctx.exit(error.exit_code)
175
+ ctx.obj = {
176
+ "config": base.with_overrides(
177
+ url=url,
178
+ username=username,
179
+ password=password,
180
+ token=token,
181
+ insecure=insecure,
182
+ output_format="json" if json_output else output_format,
183
+ timeout_seconds=timeout,
184
+ max_retries=retries,
185
+ )
186
+ }
187
+
188
+
189
+ @cli.command()
190
+ @click.option("--store-token", is_flag=True, help="Save JWT to an owner-only dotenv file.")
191
+ @click.option("--store-keyring", is_flag=True, help="Save JWT to the operating-system keyring.")
192
+ @click.option(
193
+ "--env-file", type=click.Path(path_type=Path), default=Path(".env"), show_default=True
194
+ )
195
+ @click.option("--force", is_flag=True)
196
+ @click.pass_context
197
+ def login(
198
+ ctx: click.Context, store_token: bool, store_keyring: bool, env_file: Path, force: bool
199
+ ) -> None:
200
+ """Authenticate using username/password and print or save a JWT."""
201
+ if force and not store_token:
202
+ raise click.UsageError("--force requires --store-token.")
203
+ if store_token and store_keyring:
204
+ raise click.UsageError("Choose only one token storage backend.")
205
+ config = _config(ctx)
206
+ response = _execute(
207
+ config, "Authenticating with Uptime Kuma...", lambda: password_login(config)
208
+ )
209
+ token = response.get("token") if isinstance(response, dict) else None
210
+ if not isinstance(token, str) or not token:
211
+ _raise_error(
212
+ config, KumaStatsError("Uptime Kuma login succeeded but returned no JWT token.")
213
+ )
214
+ if store_token:
215
+ _store_token(token, env_file, force)
216
+ console.print(f"Saved KUMA_TOKEN to {env_file}; token value not printed.")
217
+ elif store_keyring:
218
+ from .core.session import save_token
219
+
220
+ try:
221
+ save_token(token)
222
+ except RuntimeError as error:
223
+ _raise_error(config, KumaStatsError(str(error)))
224
+ console.print("Saved JWT to the operating-system keyring; token value not printed.")
225
+ else:
226
+ click.echo(token)
227
+
228
+
229
+ def _raise_error(config: Config, error: KumaStatsError) -> NoReturn:
230
+ emit_error(error, config)
231
+ raise click.exceptions.Exit(error.exit_code)
232
+
233
+
234
+ def _execute(config: Config, message: str, operation: Callable[[], T]) -> T:
235
+ try:
236
+ with progress(message, enabled=config.output_format == "table"):
237
+ return operation()
238
+ except KumaStatsError as error:
239
+ _raise_error(config, error)
240
+
241
+
242
+ def _report(config: Config, title: str, data: object) -> None:
243
+ emit(data, config, _table(title, data))
244
+
245
+
246
+ @cli.command("list")
247
+ @_output_options
248
+ @click.pass_context
249
+ def list_monitors(ctx: click.Context, output_format: Optional[str], json_output: bool) -> None:
250
+ """List all monitors and their current telemetry."""
251
+ config = _command_config(ctx, output_format, json_output)
252
+ _report(
253
+ config,
254
+ "Uptime Kuma Monitors",
255
+ _execute(config, "Fetching monitor telemetry...", lambda: monitors.list_monitors(config)),
256
+ )
257
+
258
+
259
+ @cli.command("status")
260
+ @click.argument("monitor_ids", type=int, nargs=-1)
261
+ @_output_options
262
+ @click.pass_context
263
+ def status(
264
+ ctx: click.Context,
265
+ monitor_ids: tuple[int, ...],
266
+ output_format: Optional[str],
267
+ json_output: bool,
268
+ ) -> None:
269
+ """Show details for one or more monitor IDs."""
270
+ config = _command_config(ctx, output_format, json_output)
271
+ _report(
272
+ config,
273
+ "Monitor Status",
274
+ _execute(
275
+ config,
276
+ "Fetching monitor status...",
277
+ lambda: monitors.status_monitors(config, monitor_ids),
278
+ ),
279
+ )
280
+
281
+
282
+ @cli.command("beats")
283
+ @click.argument("monitor_id", type=int)
284
+ @click.option("--hours", type=click.IntRange(min=1), default=24, show_default=True)
285
+ @_output_options
286
+ @click.pass_context
287
+ def monitor_beats(
288
+ ctx: click.Context,
289
+ monitor_id: int,
290
+ hours: int,
291
+ output_format: Optional[str],
292
+ json_output: bool,
293
+ ) -> None:
294
+ """Show heartbeat history for a monitor."""
295
+ config = _command_config(ctx, output_format, json_output)
296
+ _report(
297
+ config,
298
+ "Heartbeat History",
299
+ _execute(
300
+ config,
301
+ "Fetching heartbeat history...",
302
+ lambda: monitors.beats(config, monitor_id, hours),
303
+ ),
304
+ )
305
+
306
+
307
+ @cli.command("uptime")
308
+ @_output_options
309
+ @click.pass_context
310
+ def uptime(ctx: click.Context, output_format: Optional[str], json_output: bool) -> None:
311
+ """Show 24-hour, 30-day, and one-year uptime."""
312
+ config = _command_config(ctx, output_format, json_output)
313
+ _report(
314
+ config,
315
+ "Uptime Kuma Uptime",
316
+ _execute(config, "Fetching uptime report...", lambda: monitors.uptime_report(config)),
317
+ )
318
+
319
+
320
+ def main() -> None:
321
+ try:
322
+ cli(obj={})
323
+ except KeyboardInterrupt:
324
+ raise SystemExit(130)
325
+
326
+
327
+ if __name__ == "__main__":
328
+ main()
@@ -0,0 +1 @@
1
+ """Configuration, error, retry, and vendor-adapter infrastructure."""
@@ -0,0 +1,103 @@
1
+ """Boundary around uptime-kuma-api, keeping vendor specifics out of commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+
8
+ from socketio.exceptions import SocketIOError
9
+ from uptime_kuma_api import UptimeKumaApi
10
+ from uptime_kuma_api.exceptions import Timeout as UptimeKumaTimeout
11
+ from uptime_kuma_api.exceptions import UptimeKumaException
12
+
13
+ from .config import Config
14
+ from .errors import ApiError, AuthenticationError, NetworkError
15
+ from .retry import with_retry
16
+
17
+
18
+ def _disconnect(api: UptimeKumaApi | None) -> None:
19
+ if api is not None:
20
+ api.disconnect()
21
+
22
+
23
+ def _is_connection_error(error: UptimeKumaException) -> bool:
24
+ return isinstance(error, UptimeKumaTimeout) or str(error) == "unable to connect"
25
+
26
+
27
+ def _authenticate(config: Config) -> UptimeKumaApi:
28
+ """Construct and authenticate a client in one retryable operation."""
29
+ api: UptimeKumaApi | None = None
30
+ try:
31
+ api = UptimeKumaApi(
32
+ config.url, timeout=config.timeout_seconds, ssl_verify=not config.insecure
33
+ )
34
+ if config.token:
35
+ api.login_by_token(config.token)
36
+ else:
37
+ api.login(config.username, config.password) # type: ignore[arg-type]
38
+ return api
39
+ except UptimeKumaException as error:
40
+ _disconnect(api)
41
+ if _is_connection_error(error):
42
+ raise
43
+ raise AuthenticationError(f"Unable to authenticate with Uptime Kuma: {error}") from error
44
+ except Exception:
45
+ _disconnect(api)
46
+ raise
47
+
48
+
49
+ @contextmanager
50
+ def authenticated_api(config: Config) -> Iterator[UptimeKumaApi]:
51
+ if not config.url:
52
+ raise AuthenticationError("Uptime Kuma URL is required. Set KUMA_URL or --url.")
53
+ if not config.token and (not config.username or not config.password):
54
+ raise AuthenticationError(
55
+ "Credentials required. Set KUMA_TOKEN or KUMA_USERNAME and KUMA_PASSWORD."
56
+ )
57
+ api = with_retry(
58
+ lambda: _authenticate(config),
59
+ attempts=config.max_retries,
60
+ timeout_seconds=config.timeout_seconds,
61
+ )
62
+ try:
63
+ yield api
64
+ except UptimeKumaException as error:
65
+ raise ApiError(f"Uptime Kuma returned an error: {error}") from error
66
+ except SocketIOError as error:
67
+ raise NetworkError(f"Connection to Uptime Kuma failed: {error}") from error
68
+ finally:
69
+ api.disconnect()
70
+
71
+
72
+ def password_login(config: Config) -> dict:
73
+ if not config.url or not config.username or not config.password:
74
+ raise AuthenticationError(
75
+ "Login requires KUMA_URL, KUMA_USERNAME, and KUMA_PASSWORD (or their flags)."
76
+ )
77
+ api, response = with_retry(
78
+ lambda: _password_login(config),
79
+ attempts=config.max_retries,
80
+ timeout_seconds=config.timeout_seconds,
81
+ )
82
+ try:
83
+ return response
84
+ finally:
85
+ api.disconnect()
86
+
87
+
88
+ def _password_login(config: Config) -> tuple[UptimeKumaApi, dict]:
89
+ api: UptimeKumaApi | None = None
90
+ try:
91
+ api = UptimeKumaApi(
92
+ config.url, timeout=config.timeout_seconds, ssl_verify=not config.insecure
93
+ )
94
+ response = api.login(config.username, config.password)
95
+ return api, response
96
+ except UptimeKumaException as error:
97
+ _disconnect(api)
98
+ if _is_connection_error(error):
99
+ raise
100
+ raise AuthenticationError(f"Unable to authenticate with Uptime Kuma: {error}") from error
101
+ except Exception:
102
+ _disconnect(api)
103
+ raise
@@ -0,0 +1,94 @@
1
+ """Layered runtime configuration: CLI flags > environment > dotenv defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ try: # Python 3.11+
11
+ import tomllib
12
+ except ModuleNotFoundError: # Python 3.10
13
+ import tomli as tomllib
14
+
15
+ from platformdirs import user_config_dir
16
+
17
+ from .errors import ConfigurationError
18
+
19
+ Format = Literal["table", "json", "yaml"]
20
+ _FORMATS = {"table", "json", "yaml"}
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Config:
25
+ url: str | None = None
26
+ username: str | None = None
27
+ password: str | None = None
28
+ token: str | None = None
29
+ insecure: bool = False
30
+ output_format: Format = "table"
31
+ timeout_seconds: float = 30.0
32
+ max_retries: int = 2
33
+ color: bool = True
34
+
35
+ @classmethod
36
+ def load(cls) -> "Config":
37
+ # Precedence: defaults < user TOML < dotenv/environment < CLI options.
38
+ path = Path(
39
+ os.environ.get("KUMA_CONFIG_FILE", Path(user_config_dir("kuma-stats")) / "config.toml")
40
+ )
41
+ try:
42
+ with path.open("rb") as handle:
43
+ file_config: dict[str, Any] = tomllib.load(handle)
44
+ except FileNotFoundError:
45
+ file_config = {}
46
+ except tomllib.TOMLDecodeError as error:
47
+ raise ConfigurationError(f"Invalid TOML configuration in {path}: {error}") from error
48
+ except OSError as error:
49
+ raise ConfigurationError(
50
+ f"Unable to read configuration file {path}: {error}"
51
+ ) from error
52
+ from dotenv import load_dotenv
53
+
54
+ load_dotenv(dotenv_path=Path.cwd() / ".env")
55
+
56
+ def value(name: str, default: Any = None) -> Any:
57
+ return os.environ.get(f"KUMA_{name.upper()}", file_config.get(name, default))
58
+
59
+ def number(name: str, default: float, converter: type[float] | type[int]) -> float | int:
60
+ try:
61
+ return converter(value(name, default))
62
+ except (TypeError, ValueError) as error:
63
+ raise ConfigurationError(f"KUMA_{name.upper()} must be a number.") from error
64
+
65
+ output_format = str(value("format", "table")).lower()
66
+ if output_format not in _FORMATS:
67
+ raise ConfigurationError("KUMA_FORMAT must be one of: table, json, yaml.")
68
+ timeout = number("timeout", 30, float)
69
+ retries = number("max_retries", 2, int)
70
+ if timeout <= 0:
71
+ raise ConfigurationError("KUMA_TIMEOUT must be greater than zero.")
72
+ if retries < 0:
73
+ raise ConfigurationError("KUMA_MAX_RETRIES cannot be negative.")
74
+
75
+ no_color = bool(os.environ.get("NO_COLOR"))
76
+ token = value("token")
77
+ if not token:
78
+ from .session import get_token
79
+
80
+ token = get_token()
81
+ return cls(
82
+ url=value("url"),
83
+ username=value("username"),
84
+ password=value("password"),
85
+ token=token,
86
+ insecure=str(value("insecure", False)).lower() in {"1", "true", "yes"},
87
+ output_format=output_format, # type: ignore[arg-type]
88
+ timeout_seconds=float(timeout),
89
+ max_retries=int(retries),
90
+ color=not no_color and not bool(value("no_color", False)),
91
+ )
92
+
93
+ def with_overrides(self, **kwargs: object) -> "Config":
94
+ return replace(self, **{key: value for key, value in kwargs.items() if value is not None}) # type: ignore[arg-type]
@@ -0,0 +1,44 @@
1
+ """Consistent errors and exit codes for kuma-stats."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class KumaStatsError(Exception):
10
+ message: str
11
+ code: str = "API_ERROR"
12
+ exit_code: int = 1
13
+ details: dict[str, Any] = field(default_factory=dict)
14
+
15
+ def __str__(self) -> str:
16
+ return self.message
17
+
18
+ def to_dict(self) -> dict[str, Any]:
19
+ return {"error": True, "code": self.code, "message": self.message, "details": self.details}
20
+
21
+
22
+ class ConfigurationError(KumaStatsError):
23
+ def __init__(self, message: str):
24
+ super().__init__(message, "INVALID_CONFIGURATION", 2)
25
+
26
+
27
+ class AuthenticationError(KumaStatsError):
28
+ def __init__(self, message: str):
29
+ super().__init__(message, "AUTH_REQUIRED", 2)
30
+
31
+
32
+ class NotFoundError(KumaStatsError):
33
+ def __init__(self, message: str):
34
+ super().__init__(message, "NOT_FOUND", 1)
35
+
36
+
37
+ class NetworkError(KumaStatsError):
38
+ def __init__(self, message: str):
39
+ super().__init__(message, "NETWORK_ERROR", 1)
40
+
41
+
42
+ class ApiError(KumaStatsError):
43
+ def __init__(self, message: str):
44
+ super().__init__(message, "API_ERROR", 1)
@@ -0,0 +1,51 @@
1
+ """Small synchronous retry helper for transient Kuma connection failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import TypeVar
9
+
10
+ from socketio.exceptions import SocketIOError
11
+ from uptime_kuma_api.exceptions import Timeout as UptimeKumaTimeout
12
+ from uptime_kuma_api.exceptions import UptimeKumaException
13
+
14
+ from .errors import NetworkError
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ def _is_transient(error: Exception) -> bool:
20
+ return (
21
+ not isinstance(error, UptimeKumaException)
22
+ or isinstance(error, UptimeKumaTimeout)
23
+ or str(error) == "unable to connect"
24
+ )
25
+
26
+
27
+ def with_retry(operation: Callable[[], T], *, attempts: int, timeout_seconds: float) -> T:
28
+ """Retry transient Socket.IO and network failures within a backoff budget."""
29
+ started = time.monotonic()
30
+ last: Exception | None = None
31
+ for attempt in range(attempts + 1):
32
+ try:
33
+ return operation()
34
+ except (
35
+ OSError,
36
+ ConnectionError,
37
+ TimeoutError,
38
+ SocketIOError,
39
+ UptimeKumaException,
40
+ ) as error:
41
+ if not _is_transient(error):
42
+ raise
43
+ last = error
44
+ remaining = timeout_seconds - (time.monotonic() - started)
45
+ if attempt == attempts or remaining <= 0:
46
+ break
47
+ delay = min(0.25 * 2**attempt * (0.75 + random.random() * 0.5), 2.0, remaining)
48
+ time.sleep(delay)
49
+ raise NetworkError(
50
+ f"Unable to contact Uptime Kuma after {attempts + 1} attempt(s): {last}"
51
+ ) from last
@@ -0,0 +1,21 @@
1
+ """Secure optional JWT storage in the operating-system keyring."""
2
+
3
+ from __future__ import annotations
4
+ import keyring
5
+
6
+ SERVICE = "kuma-stats"
7
+ ACCOUNT = "jwt"
8
+
9
+
10
+ def get_token() -> str | None:
11
+ try:
12
+ return keyring.get_password(SERVICE, ACCOUNT)
13
+ except Exception:
14
+ return None
15
+
16
+
17
+ def save_token(token: str) -> None:
18
+ try:
19
+ keyring.set_password(SERVICE, ACCOUNT, token)
20
+ except Exception as error:
21
+ raise RuntimeError(f"Unable to store token in OS keyring: {error}") from error
@@ -0,0 +1,54 @@
1
+ """Output and progress helpers; data always stays on stdout."""
2
+
3
+ from __future__ import annotations
4
+ import json, sys
5
+ from contextlib import contextmanager
6
+ from collections.abc import Iterator
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+ from ..core.config import Config
10
+ from ..core.errors import KumaStatsError
11
+
12
+ console = Console(color_system="auto")
13
+ err_console = Console(stderr=True, color_system="auto")
14
+
15
+
16
+ def emit(data: object, config: Config, table: object | None = None) -> None:
17
+ if config.output_format == "json":
18
+ print(json.dumps(data, indent=2, default=str))
19
+ elif config.output_format == "yaml":
20
+ import yaml
21
+
22
+ print(yaml.safe_dump(data, default_flow_style=False, sort_keys=False))
23
+ elif table is not None:
24
+ console.print(table)
25
+
26
+
27
+ def emit_error(error: KumaStatsError, config: Config) -> None:
28
+ if config.output_format == "json":
29
+ print(json.dumps(error.to_dict()), file=sys.stderr)
30
+ elif config.output_format == "yaml":
31
+ import yaml
32
+
33
+ print(yaml.safe_dump(error.to_dict(), sort_keys=False), file=sys.stderr, end="")
34
+ else:
35
+ err_console.print(f"[red]Error:[/] {error.message}")
36
+
37
+
38
+ @contextmanager
39
+ def progress(message: str, *, enabled: bool = True) -> Iterator[None]:
40
+ if not enabled:
41
+ yield
42
+ return
43
+ if not sys.stderr.isatty():
44
+ print(message, file=sys.stderr)
45
+ yield
46
+ return
47
+ with Progress(
48
+ SpinnerColumn(),
49
+ TextColumn("[progress.description]{task.description}"),
50
+ console=err_console,
51
+ transient=True,
52
+ ) as display:
53
+ display.add_task(message, total=None)
54
+ yield
kuma_stats/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Application services for querying monitor telemetry."""
@@ -0,0 +1,111 @@
1
+ """Fetch and normalize Uptime Kuma monitoring telemetry."""
2
+
3
+ from __future__ import annotations
4
+ from uptime_kuma_api import MonitorStatus
5
+ from ..core.adapter import authenticated_api
6
+ from ..core.config import Config
7
+ from ..core.errors import NotFoundError
8
+
9
+
10
+ def status_value(value: MonitorStatus) -> str:
11
+ return value.name if isinstance(value, MonitorStatus) else str(value)
12
+
13
+
14
+ def monitor_map(monitors: list[dict]) -> dict[int, dict]:
15
+ return {int(m["id"]): m for m in monitors}
16
+
17
+
18
+ def list_monitors(config: Config) -> list[dict]:
19
+ with authenticated_api(config) as api:
20
+ monitors = monitor_map(api.get_monitors())
21
+ uptime, pings = api.uptime(), api.avg_ping()
22
+ statuses = {mid: api.get_monitor_status(mid) for mid in monitors}
23
+ return [
24
+ {
25
+ "id": mid,
26
+ "name": m.get("name", ""),
27
+ "url": m.get("url", ""),
28
+ "status": status_value(statuses[mid]),
29
+ "uptime_24h": uptime.get(mid, {}).get(24),
30
+ "uptime_30d": uptime.get(mid, {}).get(720),
31
+ "avg_ping_ms": pings.get(mid),
32
+ "interval_seconds": m.get("interval"),
33
+ }
34
+ for mid, m in sorted(monitors.items())
35
+ ]
36
+
37
+
38
+ def status_monitors(config: Config, ids: tuple[int, ...]) -> list[dict]:
39
+ with authenticated_api(config) as api:
40
+ monitors = monitor_map(api.get_monitors())
41
+ selected = ids or tuple(sorted(monitors))
42
+ missing = [str(mid) for mid in selected if mid not in monitors]
43
+ if missing:
44
+ raise NotFoundError(f"Monitor IDs not found: {', '.join(missing)}")
45
+ uptime, pings, heartbeats = api.uptime(), api.avg_ping(), api.get_heartbeats()
46
+ result = []
47
+ for mid in selected:
48
+ m, beats, u = (
49
+ monitors[mid],
50
+ sorted(heartbeats.get(mid, []), key=lambda h: h["time"], reverse=True),
51
+ uptime.get(mid, {}),
52
+ )
53
+ result.append(
54
+ {
55
+ "id": mid,
56
+ "name": m.get("name", ""),
57
+ "url": m.get("url", ""),
58
+ "type": m.get("type", ""),
59
+ "status": status_value(api.get_monitor_status(mid)),
60
+ "interval": m.get("interval"),
61
+ "avg_ping_ms": pings.get(mid),
62
+ "uptime": {str(k): v for k, v in u.items()},
63
+ "recent_heartbeats": [
64
+ {
65
+ "time": b["time"],
66
+ "status": status_value(b["status"]),
67
+ "ping": b.get("ping"),
68
+ }
69
+ for b in beats[:10]
70
+ ],
71
+ }
72
+ )
73
+ return result
74
+
75
+
76
+ def beats(config: Config, monitor_id: int, hours: int) -> dict:
77
+ with authenticated_api(config) as api:
78
+ monitors = monitor_map(api.get_monitors())
79
+ if monitor_id not in monitors:
80
+ raise NotFoundError(f"Monitor {monitor_id} not found")
81
+ raw = api.get_monitor_beats(monitor_id, hours)
82
+ return {
83
+ "monitor_id": monitor_id,
84
+ "name": monitors[monitor_id].get("name", f"Monitor {monitor_id}"),
85
+ "hours": hours,
86
+ "total_beats": len(raw),
87
+ "beats": [
88
+ {
89
+ "time": b["time"],
90
+ "status": status_value(b["status"]),
91
+ "ping": b.get("ping"),
92
+ "msg": b.get("msg", ""),
93
+ }
94
+ for b in raw
95
+ ],
96
+ }
97
+
98
+
99
+ def uptime_report(config: Config) -> list[dict]:
100
+ with authenticated_api(config) as api:
101
+ monitors, data = monitor_map(api.get_monitors()), api.uptime()
102
+ return [
103
+ {
104
+ "id": mid,
105
+ "name": m.get("name", ""),
106
+ "uptime_24h": data.get(mid, data.get(str(mid), {})).get(24),
107
+ "uptime_30d": data.get(mid, data.get(str(mid), {})).get(720),
108
+ "uptime_1y": data.get(mid, data.get(str(mid), {})).get("1y"),
109
+ }
110
+ for mid, m in sorted(monitors.items())
111
+ ]
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: kuma-stats
3
+ Version: 0.3.0
4
+ Summary: Read-only CLI for Uptime Kuma monitor status, uptime, and heartbeat data
5
+ Project-URL: Homepage, https://github.com/crcatala/kuma-stats
6
+ Project-URL: Repository, https://github.com/crcatala/kuma-stats
7
+ Project-URL: Issues, https://github.com/crcatala/kuma-stats/issues
8
+ Project-URL: Security, https://github.com/crcatala/kuma-stats/security/policy
9
+ Author-email: Christian Catalan <crcatala@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,monitoring,status,uptime,uptime-kuma
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: click>=8.0
26
+ Requires-Dist: keyring>=24.0
27
+ Requires-Dist: platformdirs>=4.0
28
+ Requires-Dist: python-dotenv>=1.0
29
+ Requires-Dist: pyyaml>=6.0
30
+ Requires-Dist: rich>=13.0
31
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
32
+ Requires-Dist: uptime-kuma-api>=1.2.1
33
+ Provides-Extra: dev
34
+ Requires-Dist: build>=1.2; extra == 'dev'
35
+ Requires-Dist: mypy>=1.13; extra == 'dev'
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.8; extra == 'dev'
38
+ Requires-Dist: twine>=5.0; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # kuma-stats
42
+
43
+ [![CI](https://github.com/crcatala/kuma-stats/actions/workflows/ci.yml/badge.svg)](https://github.com/crcatala/kuma-stats/actions/workflows/ci.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/kuma-stats)](https://pypi.org/project/kuma-stats/)
45
+ [![Python](https://img.shields.io/pypi/pyversions/kuma-stats)](https://pypi.org/project/kuma-stats/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
47
+
48
+ A read-only terminal dashboard for [Uptime Kuma](https://github.com/louislam/uptime-kuma): current monitor state, uptime percentages, response time, and heartbeat history.
49
+
50
+ > `kuma-stats` is an independent project and is not affiliated with or endorsed by Uptime Kuma.
51
+
52
+ ## Install
53
+
54
+ Requires Python 3.10 or later.
55
+
56
+ ```bash
57
+ pip install kuma-stats
58
+ # or install as an isolated command-line tool
59
+ uv tool install kuma-stats
60
+ # pipx install kuma-stats
61
+ ```
62
+
63
+ Verify the installation:
64
+
65
+ ```bash
66
+ kuma-stats --version
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ Set the Uptime Kuma URL and a server-issued JWT token. Environment variables are preferred for automation; do not pass passwords or tokens as command-line arguments because they can be exposed in shell history or process listings.
72
+
73
+ ```bash
74
+ export KUMA_URL=https://kuma.example.com
75
+ export KUMA_TOKEN=your-server-issued-jwt
76
+ kuma-stats list
77
+ kuma-stats --format json uptime
78
+ ```
79
+
80
+ Alternatively, create a `.env` file in the directory where you run the command:
81
+
82
+ ```dotenv
83
+ KUMA_URL=https://kuma.example.com
84
+ KUMA_TOKEN=your-server-issued-jwt
85
+ ```
86
+
87
+ `kuma-stats` loads this working-directory `.env` without overwriting environment variables. Keep it out of version control.
88
+
89
+ To create a token with username/password credentials already supplied through environment variables, use:
90
+
91
+ ```bash
92
+ export KUMA_USERNAME=your-username
93
+ export KUMA_PASSWORD=your-password
94
+ kuma-stats login --store-keyring
95
+ ```
96
+
97
+ The keyring is the preferred token store. `login --store-token` writes an owner-only `.env` file; use it only when the OS keyring is unavailable.
98
+
99
+ ## Commands
100
+
101
+ | Command | Description |
102
+ | --- | --- |
103
+ | `login [--store-token \| --store-keyring]` | Authenticate and print or securely save a server-issued JWT |
104
+ | `list` | List all monitors and current telemetry |
105
+ | `status [ID…]` | Show details and recent heartbeats; omit IDs for all monitors |
106
+ | `beats ID [--hours N]` | Show heartbeat history for one monitor |
107
+ | `uptime` | Show 24-hour, 30-day, and one-year uptime |
108
+
109
+ Examples:
110
+
111
+ ```bash
112
+ kuma-stats list
113
+ kuma-stats status 1 2
114
+ kuma-stats beats 1 --hours 48
115
+ kuma-stats --format yaml uptime
116
+ kuma-stats --format json list
117
+ ```
118
+
119
+ Table output is the default. Use `--format json` or `--format yaml` for structured output. JSON and YAML errors are written to stderr with a stable code and message.
120
+
121
+ ## Configuration
122
+
123
+ Values are resolved in this order, from highest to lowest precedence:
124
+
125
+ 1. Command-line options
126
+ 2. Environment variables
127
+ 3. A `.env` file in the working directory
128
+ 4. User configuration: `$XDG_CONFIG_HOME/kuma-stats/config.toml` on Linux
129
+ 5. An OS-keyring token
130
+
131
+ Use `KUMA_CONFIG_FILE` to select a different TOML file. Example:
132
+
133
+ ```toml
134
+ url = "https://kuma.example.com"
135
+ format = "table" # table, json, or yaml
136
+ timeout = 30
137
+ max_retries = 2
138
+ insecure = false
139
+ ```
140
+
141
+ | Variable | Purpose |
142
+ | --- | --- |
143
+ | `KUMA_URL` | Uptime Kuma server URL |
144
+ | `KUMA_TOKEN` | Server-issued JWT token |
145
+ | `KUMA_USERNAME`, `KUMA_PASSWORD` | Login credentials; required for `login` without a token |
146
+ | `KUMA_FORMAT` | Default output format: `table`, `json`, or `yaml` |
147
+ | `KUMA_TIMEOUT` | Per-connection timeout and overall retry budget in seconds |
148
+ | `KUMA_MAX_RETRIES` | Retry count for transient connection failures |
149
+ | `KUMA_INSECURE` | Set to `true` only for a trusted self-signed server |
150
+ | `NO_COLOR` | Disable terminal colour |
151
+
152
+ TLS certificate verification is enabled by default. Use `--insecure` only for a trusted self-signed server. Invalid or unreadable TOML configuration files fail with an `INVALID_CONFIGURATION` error instead of being ignored.
153
+
154
+ ## Compatibility
155
+
156
+ `kuma-stats` supports Python 3.10–3.12 and uses the maintained [`uptime-kuma-api`](https://github.com/lucasheld/uptime-kuma-api) client for Uptime Kuma communication. CI smoke-tests token authentication, password login, failed authentication, and `list` telemetry against a disposable [Uptime Kuma 1.23.16](https://hub.docker.com/layers/louislam/uptime-kuma/1.23.16/images/sha256-431fee3be822b04861cf0e35daf4beef6b7cb37391c5f26c3ad6e12ce280fe18) Docker container. It uses no external server or credentials.
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ make setup
162
+ make verify
163
+ make live-test # requires Docker
164
+ ```
165
+
166
+ `make verify` runs linting, type checks, tests, builds wheel/sdist artifacts, and validates their metadata. `make live-test` runs the separately gated Docker compatibility smoke test; it creates disposable credentials and state and never contacts a production server.
167
+
168
+ ### Releasing
169
+
170
+ Releases are intentionally manual. Draft notes from the commits since the last release:
171
+
172
+ ```bash
173
+ make release-prep
174
+ ```
175
+
176
+ Use the printed prompt with an LLM if useful, review its suggested Keep a Changelog entry, then update `CHANGELOG.md` and `src/kuma_stats/__init__.py`. Preview and create the GitHub Release:
177
+
178
+ ```bash
179
+ make release-dry
180
+ make release
181
+ ```
182
+
183
+ `make release` requires a clean `main` worktree, a new versioned changelog section, and confirmation. It validates the project, creates and pushes an annotated `vX.Y.Z` tag, then attaches the validated wheel and sdist to a GitHub Release. PyPI publishing is a separate deliberate step:
184
+
185
+ ```bash
186
+ uv run twine upload dist/*
187
+ ```
188
+
189
+ See [docs/RELEASING.md](docs/RELEASING.md) for PyPI/TestPyPI account setup, local token storage, testing, and troubleshooting.
190
+
191
+ ## Security and support
192
+
193
+ See [SECURITY.md](SECURITY.md) for private vulnerability reporting, [CONTRIBUTING.md](CONTRIBUTING.md) for the maintenance policy, and [CHANGELOG.md](CHANGELOG.md) for release history.
194
+
195
+ ## License
196
+
197
+ Distributed under the [MIT License](LICENSE).
@@ -0,0 +1,18 @@
1
+ kuma_stats/__init__.py,sha256=HhA_IKuu9VjT92OgpgYFPib6hJOR1Si_LS8N2-hcR_I,113
2
+ kuma_stats/__main__.py,sha256=-du2PrU1IKC-4dAYUBrSoIyVX8OH-wV_E7gm-REdtnw,88
3
+ kuma_stats/cli.py,sha256=dNHBX5jxVbleqGdZRWvYR9SR9zCa1YNHwWxnmBB--dI,10778
4
+ kuma_stats/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ kuma_stats/core/__init__.py,sha256=D1zj7PHtddjEbUGXvyJkjfXWz-NtQ8ozHSkH4el39GM,70
6
+ kuma_stats/core/adapter.py,sha256=c543Fj1odw21b8cXBvAqwK84I0jJoolt_our19ooTLo,3508
7
+ kuma_stats/core/config.py,sha256=WNJajMKrlQyRfQzkKAvr_tuw3mBGqr3IvRxma1A1t7c,3454
8
+ kuma_stats/core/errors.py,sha256=4bAda9oJ7tv0_wiUuDHmRxLMBlfPCfTyeFNI76IMsUM,1188
9
+ kuma_stats/core/retry.py,sha256=sK25AA8gZbJnDZTUmlW3BtRgH7j2EE4_meIpmMW0qqg,1615
10
+ kuma_stats/core/session.py,sha256=ER2MYkf-vDHzMOyKBfQkbPvsyOFX5iDYqMuI8l8FG9s,515
11
+ kuma_stats/output/__init__.py,sha256=OpHFk4d3qesrLwiT3QzmVt33VQZ_3-FZRDoypEmQovI,1692
12
+ kuma_stats/services/__init__.py,sha256=SCrPMM7bgbAhQpuG_TFJSeEc49iUEZvBXllrfo2i7ms,59
13
+ kuma_stats/services/monitors.py,sha256=gPVXAOKt2ZgQetxOkiE4g3xmiZUA3DWkSBAsDdc5N4Y,4045
14
+ kuma_stats-0.3.0.dist-info/METADATA,sha256=b2gWw4NFisjCRdXKj6nwXnuN2nIKTwd3o8BMRsMiPqw,7595
15
+ kuma_stats-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ kuma_stats-0.3.0.dist-info/entry_points.txt,sha256=oYDhrGgZvxWNbuj-z8UZYuPUvmIMM8B0QDkjAjETFOU,51
17
+ kuma_stats-0.3.0.dist-info/licenses/LICENSE,sha256=vpJeqfvQ02IDIT2tVzjQ01DtkG2WL37D7FopDvDKn4o,1074
18
+ kuma_stats-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kuma-stats = kuma_stats.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Catalan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.