From c86ebdf9a8b2b52718608c1e266911543ea82dd0 Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:38:26 -0300 Subject: [PATCH] fix(cli): rejeitar intervalo/n negativos e CSV quebrado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watch -i -1 crashava no sleep; history -n -5 fatiava o arquivo ao contrário. Flags passam a exigir >= 1 e linhas inválidas do CSV são ignoradas em vez de derrubar o comando. --- CHANGELOG.md | 4 ++++ COMMANDS.md | 4 ++++ macmanager/cli.py | 20 ++++++++++++++++++-- macmanager/logger.py | 44 ++++++++++++++++++++++++++++++++++---------- macmanager/watch.py | 1 + tests/test_cli.py | 26 +++++++++++++++++++++++++- tests/test_logger.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9643628..1ce176f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **`mm watch -i` e `mm history -n` rejeitam 0 e negativo.** Antes + `watch -i -1` crashava no `sleep` e `history -n -5` fatiava o CSV + ao contrário. Linhas quebradas do CSV são ignoradas em vez de + derrubar `mm history`. - **Histórico e cooldown dos alertas saem da árvore do pacote.** `mm log` / `mm history` / `mm alerts` gravam em `~/Library/Application Support/mac-manager/` (no Linux do CI, diff --git a/COMMANDS.md b/COMMANDS.md index 6956225..d5bd42b 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -145,6 +145,8 @@ mm watch -i 5 # refresh every 5s mm watch --interval 10 ``` +`-i` / `--interval` must be >= 1. + > Full-screen works best in Terminal.app, iTerm2, Warp or Ghostty. In the embedded terminal in Cursor it may break visually. --- @@ -196,6 +198,8 @@ mm history -n 30 # last 30 mm history -n 200 # last 200 ``` +`-n` must be >= 1. Broken CSV rows are skipped instead of crashing the command. + --- ### `mm alerts` diff --git a/macmanager/cli.py b/macmanager/cli.py index f7540a9..7f8dd17 100644 --- a/macmanager/cli.py +++ b/macmanager/cli.py @@ -8,6 +8,14 @@ from macmanager import __version__ +def _positive_int(value: str) -> int: + """Rejeita 0 e negativo — `watch -i -1` crashava no sleep.""" + parsed = int(value) + if parsed < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return parsed + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="mm", @@ -25,10 +33,18 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("log", help="Records a battery snapshot to the CSV") history = sub.add_parser("history", help="Shows the last N measurements") - history.add_argument("-n", type=int, default=10, help="how many lines to show (default 10)") + history.add_argument( + "-n", type=_positive_int, default=10, help="how many lines to show (default 10)" + ) watch = sub.add_parser("watch", help="Live dashboard (Ctrl+C to exit)") - watch.add_argument("-i", "--interval", type=int, default=2, help="refresh interval in seconds") + watch.add_argument( + "-i", + "--interval", + type=_positive_int, + default=2, + help="refresh interval in seconds (minimum 1)", + ) sub.add_parser("alerts", help="Evaluates and fires alerts (called by launchd)") sub.add_parser("status", help="Quick summary (battery + system + disk)") diff --git a/macmanager/logger.py b/macmanager/logger.py index 5824134..fc0f734 100644 --- a/macmanager/logger.py +++ b/macmanager/logger.py @@ -3,6 +3,7 @@ from __future__ import annotations import csv +import math import os import platform import shutil @@ -117,6 +118,30 @@ def cmd_log(args=None) -> None: ) +def parse_history_row(row: dict) -> dict[str, str] | None: + """Formata uma linha do CSV. None se o registro estiver quebrado. + + Linha truncada ou percent vazio não pode derrubar `mm history`. + """ + try: + ts = (row.get("timestamp") or "").replace("T", " ").strip() + percent = float(row["percent"]) + health = float(row["health_percent"]) + if not ts or math.isnan(percent) or math.isnan(health): + return None + temp_raw = row.get("temperature_c") or "" + return { + "when": ts, + "charge": f"{percent:.0f}%", + "health": f"{health:.1f}%", + "cycles": str(row.get("cycle_count") or "—"), + "temp": f"{temp_raw}°C" if temp_raw else "—", + "source": "AC" if row.get("is_charging") == "1" else "Bat", + } + except (TypeError, ValueError, KeyError): + return None + + def cmd_history(args=None) -> None: """Shows the last N entries from the CSV.""" from rich.table import Table @@ -127,8 +152,14 @@ def cmd_history(args=None) -> None: return n = getattr(args, "n", 10) if args else 10 - rows = list(csv.DictReader(BATTERY_CSV.open())) - rows = rows[-n:] + n = max(1, int(n)) + with BATTERY_CSV.open(newline="", encoding="utf-8") as fh: + raw_rows = list(csv.DictReader(fh)) + rows = [] + for raw in raw_rows[-n:]: + parsed = parse_history_row(raw) + if parsed: + rows.append(parsed) t = Table(title=f"Last {len(rows)} measurements", border_style="cyan") t.add_column("When", style="dim") @@ -139,12 +170,5 @@ def cmd_history(args=None) -> None: t.add_column("Source") for r in rows: - t.add_row( - r["timestamp"].replace("T", " "), - f"{float(r['percent']):.0f}%", - f"{float(r['health_percent']):.1f}%", - r["cycle_count"], - f"{r['temperature_c']}°C" if r["temperature_c"] else "—", - "AC" if r["is_charging"] == "1" else "Bat", - ) + t.add_row(r["when"], r["charge"], r["health"], r["cycles"], r["temp"], r["source"]) console.print(t) diff --git a/macmanager/watch.py b/macmanager/watch.py index 49d39e8..158d39a 100644 --- a/macmanager/watch.py +++ b/macmanager/watch.py @@ -150,6 +150,7 @@ def _safe(fn): def cmd_watch(args=None) -> None: interval = getattr(args, "interval", 2) if args else 2 + interval = max(1, int(interval)) _warm_cache_background() layout = _build_layout() try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 538e7dc..bb7501f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,7 @@ from rich.console import Console from rich.panel import Panel -from macmanager.cli import cmd_status +from macmanager.cli import build_parser, cmd_status class TestCmdStatusIsolation: @@ -47,3 +47,27 @@ def boom() -> Panel: assert "sys" in out assert "disk" in out assert "Não foi possível coletar estes dados" in out + + +class TestPositiveIntFlags: + """`watch -i -1` crashava no sleep; `history -n -5` fatiava o CSV ao contrário.""" + + def test_watch_rejects_non_positive_interval(self) -> None: + parser = build_parser() + for value in ("0", "-1"): + with pytest.raises(SystemExit): + parser.parse_args(["watch", "-i", value]) + + def test_watch_accepts_interval_one(self) -> None: + args = build_parser().parse_args(["watch", "-i", "1"]) + assert args.interval == 1 + + def test_history_rejects_non_positive_n(self) -> None: + parser = build_parser() + for value in ("0", "-5"): + with pytest.raises(SystemExit): + parser.parse_args(["history", "-n", value]) + + def test_history_accepts_n_one(self) -> None: + args = build_parser().parse_args(["history", "-n", "1"]) + assert args.n == 1 diff --git a/tests/test_logger.py b/tests/test_logger.py index ce97f43..29c5964 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -17,6 +17,7 @@ LOGS_DIR, default_logs_dir, migrate_legacy, + parse_history_row, resolve_logs_dir, ) @@ -124,3 +125,45 @@ def test_does_not_overwrite_existing_dest(self, tmp_path: Path) -> None: def test_same_dir_is_a_noop(self, tmp_path: Path) -> None: (tmp_path / "battery.csv").write_text("x\n", encoding="utf-8") assert migrate_legacy(tmp_path, tmp_path) == [] + + +class TestParseHistoryRow: + """Linha truncada ou percent vazio não pode derrubar `mm history`.""" + + def test_valid_row(self) -> None: + parsed = parse_history_row( + { + "timestamp": "2026-01-01T09:00:00", + "percent": "82.4", + "is_charging": "1", + "health_percent": "99.3", + "cycle_count": "12", + "temperature_c": "30.5", + } + ) + assert parsed == { + "when": "2026-01-01 09:00:00", + "charge": "82%", + "health": "99.3%", + "cycles": "12", + "temp": "30.5°C", + "source": "AC", + } + + def test_empty_percent_is_skipped(self) -> None: + assert parse_history_row({"timestamp": "2026-01-01T00:00:00", "percent": ""}) is None + + def test_truncated_row_is_skipped(self) -> None: + assert parse_history_row({"timestamp": "2026-01-01T00:00:00", "percent": "80"}) is None + + def test_nan_percent_is_skipped(self) -> None: + assert ( + parse_history_row( + { + "timestamp": "2026-01-01T00:00:00", + "percent": "NaN", + "health_percent": "99", + } + ) + is None + )