Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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`
Expand Down
20 changes: 18 additions & 2 deletions macmanager/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)")
Expand Down
44 changes: 34 additions & 10 deletions macmanager/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import csv
import math
import os
import platform
import shutil
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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)
1 change: 1 addition & 0 deletions macmanager/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
43 changes: 43 additions & 0 deletions tests/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
LOGS_DIR,
default_logs_dir,
migrate_legacy,
parse_history_row,
resolve_logs_dir,
)

Expand Down Expand Up @@ -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
)
Loading