diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c80b47..3ad2349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ 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` e `mm watch` não morrem inteiros** quando um único coletor + falha. Cada painel passa por `safe_panel`: a exceção vira um fallback + ("Não foi possível coletar estes dados") e os outros painéis seguem. - **`mm` / `mm net` / `mm watch` não crasham mais** quando `system_profiler SPAirPortDataType` passa do timeout de 5s, ou quando `ipconfig` não existe. A coleta de rede degrada para Wi-Fi vazio diff --git a/macmanager/cli.py b/macmanager/cli.py index a4c6383..f7540a9 100644 --- a/macmanager/cli.py +++ b/macmanager/cli.py @@ -50,12 +50,12 @@ def cmd_status(args=None) -> None: from macmanager.disk import render_disk_panel from macmanager.network import render_network_panel from macmanager.system import render_system_panel - from macmanager.ui import console + from macmanager.ui import console, safe_panel - console.print(render_battery_panel()) - console.print(render_system_panel()) - console.print(render_disk_panel(full=False)) - console.print(render_network_panel()) + console.print(safe_panel("Battery", render_battery_panel, "cyan")) + console.print(safe_panel("System", render_system_panel, "magenta")) + console.print(safe_panel("Disk", lambda: render_disk_panel(full=False), "green")) + console.print(safe_panel("Network", render_network_panel, "blue")) def main(argv: list[str] | None = None) -> int: diff --git a/macmanager/ui.py b/macmanager/ui.py index a42fa3c..5099360 100644 --- a/macmanager/ui.py +++ b/macmanager/ui.py @@ -2,11 +2,35 @@ from __future__ import annotations +from collections.abc import Callable + from rich.console import Console +from rich.panel import Panel console = Console() +def safe_panel( + title: str, + render: Callable[[], Panel], + border_style: str = "red", +) -> Panel: + """Roda o renderer e, se ele explodir, devolve um painel de fallback. + + Assim um coletor morto (timeout, MarkupError, psutil) não derruba o + `mm` status nem o `mm watch` inteiro. `KeyboardInterrupt` e + `SystemExit` passam — são `BaseException`, não `Exception`. + """ + try: + return render() + except Exception: + return Panel( + "[dim]Não foi possível coletar estes dados.[/]", + title=f"[bold]{title}[/]", + border_style=border_style, + ) + + def fmt_bytes(num: float) -> str: """Format bytes in KB/MB/GB/TB in a readable way.""" for unit in ("B", "KB", "MB", "GB", "TB"): diff --git a/macmanager/watch.py b/macmanager/watch.py index f106f21..ccef6d0 100644 --- a/macmanager/watch.py +++ b/macmanager/watch.py @@ -33,7 +33,7 @@ from macmanager.network import render_network_panel from macmanager.security import check_macos_updates, run_all as run_security from macmanager.system import render_system_panel -from macmanager.ui import console, health_color +from macmanager.ui import console, health_color, safe_panel def _build_layout() -> Layout: @@ -122,11 +122,11 @@ def _render(layout: Layout) -> Layout: border_style="cyan", ) layout["header"].update(header) - layout["battery"].update(render_battery_panel()) - layout["system"].update(render_system_panel()) - layout["disk"].update(render_disk_panel(full=False)) - layout["network"].update(render_network_panel()) - layout["health"].update(_render_health_bar()) + layout["battery"].update(safe_panel("Battery", render_battery_panel, "cyan")) + layout["system"].update(safe_panel("System", render_system_panel, "magenta")) + layout["disk"].update(safe_panel("Disk", lambda: render_disk_panel(full=False), "green")) + layout["network"].update(safe_panel("Network", render_network_panel, "blue")) + layout["health"].update(safe_panel("Overall health", _render_health_bar, "yellow")) layout["footer"].update( Align.center(Text("Press Ctrl+C to exit · health bar refreshes every ~5min", style="dim")) ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..538e7dc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,49 @@ +"""Testes do dispatcher e do status isolado. + +Não exercitamos coletores reais (ioreg/psutil) — o CI roda em Linux. +Aqui o contrato é: um painel morto não aborta o `mm` status. +""" + +from __future__ import annotations + +from io import StringIO + +import pytest +from rich.console import Console +from rich.panel import Panel + +from macmanager.cli import cmd_status + + +class TestCmdStatusIsolation: + def test_survives_when_one_panel_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.battery.render_battery_panel", + lambda: Panel("bat", title="Battery"), + ) + monkeypatch.setattr( + "macmanager.system.render_system_panel", + lambda: Panel("sys", title="System"), + ) + monkeypatch.setattr( + "macmanager.disk.render_disk_panel", + lambda full=False: Panel("disk", title="Disk"), + ) + + def boom() -> Panel: + raise TimeoutError("system_profiler") + + monkeypatch.setattr("macmanager.network.render_network_panel", boom) + + buf = StringIO() + monkeypatch.setattr( + "macmanager.ui.console", + Console(file=buf, force_terminal=True, width=80), + ) + + cmd_status() + out = buf.getvalue() + assert "bat" in out + assert "sys" in out + assert "disk" in out + assert "Não foi possível coletar estes dados" in out diff --git a/tests/test_ui.py b/tests/test_ui.py index 444e3f6..437d41a 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -7,8 +7,9 @@ from __future__ import annotations import pytest +from rich.panel import Panel -from macmanager.ui import bar, fmt_bytes, fmt_seconds, health_color, usage_color +from macmanager.ui import bar, fmt_bytes, fmt_seconds, health_color, safe_panel, usage_color # --------------------------------------------------------------------------- # fmt_bytes @@ -175,3 +176,40 @@ def test_total_width_is_respected(self) -> None: # Remove leading [color] segment. glyphs = glyphs.split("]", 1)[1] assert len(glyphs) == 20 + + +# --------------------------------------------------------------------------- +# safe_panel +# --------------------------------------------------------------------------- + + +class TestSafePanel: + """Um coletor morto não pode derrubar o status/watch. O helper devolve + o painel real no sucesso e um fallback estático em qualquer Exception.""" + + def test_returns_renderer_panel_on_success(self) -> None: + expected = Panel("ok", title="Battery") + assert safe_panel("Battery", lambda: expected) is expected + + def test_returns_fallback_when_renderer_raises(self) -> None: + def boom() -> Panel: + raise TimeoutError("system_profiler") + + panel = safe_panel("Network", boom, border_style="blue") + assert isinstance(panel, Panel) + assert "Não foi possível coletar estes dados" in panel.renderable # type: ignore[operator] + assert "Network" in str(panel.title) + + def test_keyboard_interrupt_propagates(self) -> None: + def boom() -> Panel: + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + safe_panel("Battery", boom) + + def test_system_exit_propagates(self) -> None: + def boom() -> Panel: + raise SystemExit(1) + + with pytest.raises(SystemExit): + safe_panel("Battery", boom)