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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions macmanager/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions macmanager/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
12 changes: 6 additions & 6 deletions macmanager/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 39 additions & 1 deletion tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading