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
- **SSID, processo, serial e paths com `[` / `]` não crasham mais a UI.**
Texto que vem do sistema passa por `safe_text` (escape do Rich) antes
de entrar em markup — um Wi-Fi `My[/Home]WiFi` ou um processo
`foo[/]bar` deixam de gerar `MarkupError`.
- **`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.
Expand Down
6 changes: 3 additions & 3 deletions macmanager/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from rich.table import Table

from macmanager.cache import cached
from macmanager.ui import bar, console, fmt_seconds, health_color, usage_color
from macmanager.ui import bar, console, fmt_seconds, health_color, safe_text, usage_color


@dataclass
Expand Down Expand Up @@ -168,7 +168,7 @@ def render_battery_panel(info: Optional[BatteryInfo] = None) -> Panel:
t.add_row(
"Charge", f"[{pct_color}]{info.percent:.1f}%[/] {bar(info.percent, color=pct_color)}"
)
t.add_row("State", f"{state} · source: [bold]{info.power_source}[/]")
t.add_row("State", f"{state} · source: [bold]{safe_text(info.power_source)}[/]")
if (
info.time_remaining_sec is not None
and info.time_remaining_sec > 0
Expand All @@ -190,7 +190,7 @@ def render_battery_panel(info: Optional[BatteryInfo] = None) -> Panel:
f"[{usage_color(info.temperature_c, warn=35, crit=40)}]{info.temperature_c:.1f}°C[/]",
)
if info.serial:
t.add_row("Serial", f"[dim]{info.serial}[/]")
t.add_row("Serial", f"[dim]{safe_text(info.serial)}[/]")

return Panel(t, title="[bold]Battery[/]", border_style="cyan")

Expand Down
6 changes: 3 additions & 3 deletions macmanager/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from rich.panel import Panel
from rich.table import Table

from macmanager.ui import console
from macmanager.ui import console, safe_text


@dataclass
Expand Down Expand Up @@ -249,7 +249,7 @@ def render_dev_panels(
summary.add_row("Detected", f"[green]{total_found}[/] of {total} known tools")
git_id = _git_identity()
if git_id:
summary.add_row("Git identity", git_id)
summary.add_row("Git identity", safe_text(git_id))

panels.append(Panel(summary, title="[bold]Summary[/]", border_style="cyan"))

Expand All @@ -266,7 +266,7 @@ def render_dev_panels(
t.add_column(style="dim")

for tool in found:
t.add_row(tool.name, tool.version or "?", tool.path or "")
t.add_row(tool.name, safe_text(tool.version or "?"), safe_text(tool.path or ""))

if show_missing and missing:
t.add_row("", "", "")
Expand Down
4 changes: 2 additions & 2 deletions macmanager/disk.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from rich.table import Table

from macmanager.cache import cached
from macmanager.ui import bar, console, fmt_bytes, usage_color
from macmanager.ui import bar, console, fmt_bytes, safe_text, usage_color

CLEAN_TARGETS = [
"~/Library/Caches",
Expand Down Expand Up @@ -119,7 +119,7 @@ def render_disk_panel(info: Optional[DiskInfo] = None, full: bool = True) -> Pan
total = 0
for path, size in info.cleanup:
total += size
t.add_row(f"[dim]{path}[/]", f"{fmt_bytes(size)}")
t.add_row(f"[dim]{safe_text(path)}[/]", f"{fmt_bytes(size)}")
t.add_row("", f"[bold cyan]Total recoverable: {fmt_bytes(total)}[/]")
t.add_row(
"",
Expand Down
16 changes: 8 additions & 8 deletions macmanager/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from rich.table import Table

from macmanager.cache import cached
from macmanager.ui import console
from macmanager.ui import console, safe_text


@dataclass
Expand Down Expand Up @@ -144,18 +144,18 @@ def render_network_panel(info: Optional[NetworkInfo] = None) -> Panel:
t.add_column(justify="right", style="dim")
t.add_column()

t.add_row("Interface", info.interface)
t.add_row("Local IP", info.local_ip or "[dim]—[/]")
t.add_row("Public IP", info.public_ip or "[dim]offline[/]")
t.add_row("Interface", safe_text(info.interface))
t.add_row("Local IP", safe_text(info.local_ip) or "[dim]—[/]")
t.add_row("Public IP", safe_text(info.public_ip) or "[dim]offline[/]")
if info.ssid:
t.add_row("", "")
t.add_row("Wi-Fi", f"[bold]{info.ssid}[/]")
t.add_row("Wi-Fi", f"[bold]{safe_text(info.ssid)}[/]")
if info.security:
t.add_row("Security", info.security)
t.add_row("Security", safe_text(info.security))
if info.channel:
t.add_row("Channel", info.channel)
t.add_row("Channel", safe_text(info.channel))
if info.tx_rate:
t.add_row("TX Rate", f"{info.tx_rate} Mbps")
t.add_row("TX Rate", f"{safe_text(info.tx_rate)} Mbps")
if info.rssi is not None:
label, color = _signal_quality(info.rssi)
t.add_row("Signal", f"[{color}]{info.rssi} dBm · {label}[/]")
Expand Down
6 changes: 3 additions & 3 deletions macmanager/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from rich.table import Table

from macmanager.cache import cached
from macmanager.ui import console, fmt_seconds, health_color
from macmanager.ui import console, fmt_seconds, health_color, safe_text

OK, WARN, FAIL, INFO = "OK", "WARN", "FAIL", "INFO"

Expand Down Expand Up @@ -320,9 +320,9 @@ def render_security_panel(checks: Optional[list[Check]] = None) -> Panel:
t.add_column()

for c in checks:
line = c.detail
line = safe_text(c.detail)
if c.tip:
line += f" [dim]· {c.tip}[/]"
line += f" [dim]· {safe_text(c.tip)}[/]"
t.add_row(STATUS_STYLE.get(c.status, c.status), c.name, line)

return Panel(
Expand Down
4 changes: 2 additions & 2 deletions macmanager/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from rich.table import Table

from macmanager.cache import cached
from macmanager.ui import bar, console, fmt_bytes, fmt_seconds, usage_color
from macmanager.ui import bar, console, fmt_bytes, fmt_seconds, safe_text, usage_color


@dataclass
Expand Down Expand Up @@ -148,7 +148,7 @@ def render_system_panel(info: Optional[SystemInfo] = None) -> Panel:
t.add_row("[bold]Top processes[/]", "")
for p in info.top_processes:
t.add_row(
f"[dim]{p['pid']:>5}[/] {p['name'][:25]}",
f"[dim]{p['pid']:>5}[/] {safe_text(p['name'][:25])}",
f"CPU [bold]{p['cpu']:5.1f}%[/] MEM [bold]{fmt_bytes(p['mem'])}[/]",
)

Expand Down
12 changes: 12 additions & 0 deletions macmanager/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import Callable

from rich.console import Console
from rich.markup import escape
from rich.panel import Panel

console = Console()
Expand All @@ -31,6 +32,17 @@ def safe_panel(
)


def safe_text(value: object) -> str:
"""Escapa markup Rich em texto que veio do sistema (SSID, processo, path).

Sem isso, um SSID `My[/Home]WiFi` ou um processo `foo[/]bar` vira
`MarkupError` e derruba o painel.
"""
if value is None:
return ""
return escape(str(value))


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
4 changes: 2 additions & 2 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, safe_panel
from macmanager.ui import console, health_color, safe_panel, safe_text


def _build_layout() -> Layout:
Expand Down Expand Up @@ -99,7 +99,7 @@ def _render_health_bar() -> Panel:
count = upd.detail.split(" ")[0]
upd_str = f"[yellow]{count} pending[/]"
else:
upd_str = f"[dim]{upd.detail}[/]"
upd_str = f"[dim]{safe_text(upd.detail)}[/]"
except Exception:
upd_str = "[dim]?[/]"

Expand Down
34 changes: 33 additions & 1 deletion tests/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@

import subprocess
from collections.abc import Iterator
from io import StringIO
from types import SimpleNamespace

import pytest
from rich.console import Console

from macmanager.cache import clear_all
from macmanager.network import _local_ip, _run, _signal_quality, _wifi_info
from macmanager.network import (
NetworkInfo,
_local_ip,
_run,
_signal_quality,
_wifi_info,
render_network_panel,
)


class TestSignalQuality:
Expand Down Expand Up @@ -121,3 +130,26 @@ def test_parses_ssid_from_profiler_block(self, monkeypatch: pytest.MonkeyPatch)
assert info["security"] == "WPA3 Personal"
assert info["rssi"] == -55
assert info["tx_rate"] == "1200"


class TestRenderNetworkPanel:
"""SSID com markup Rich não pode derrubar `mm net` / o painel de status."""

def test_ssid_with_closing_tag_does_not_raise(self) -> None:
info = NetworkInfo(
interface="en0",
local_ip="192.168.1.2",
public_ip="1.2.3.4",
ssid="My[/Home]WiFi",
bssid=None,
rssi=-50,
noise=None,
channel="36",
tx_rate="1200",
security="WPA3",
)
buf = StringIO()
Console(file=buf, force_terminal=True, width=80).print(render_network_panel(info))
out = buf.getvalue()
assert "My" in out
assert "WiFi" in out
29 changes: 28 additions & 1 deletion tests/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

from io import StringIO

import pytest
from rich.console import Console

from macmanager.system import _pressure_color
from macmanager.system import SystemInfo, _pressure_color, render_system_panel


class TestPressureColor:
Expand All @@ -28,3 +31,27 @@ def test_known_states(self, state: str, color: str) -> None:
def test_unknown_states_fall_back_to_dim(self, state: str) -> None:
# Case-sensitive on purpose — the source dict is keyed on exact strings.
assert _pressure_color(state) == "dim"


class TestRenderSystemPanel:
"""Nome de processo com `[/]` não pode derrubar `mm health`."""

def test_process_name_with_markup_does_not_raise(self) -> None:
info = SystemInfo(
cpu_percent=10.0,
load_avg_1=1.0,
load_avg_5=1.0,
load_avg_15=1.0,
cpu_count=8,
memory_total=16 * 1024**3,
memory_used=8 * 1024**3,
memory_percent=50.0,
swap_used=0,
swap_total=0,
memory_pressure="Normal",
uptime_sec=1000,
top_processes=[{"pid": 1, "name": "foo[/]bar", "cpu": 10.0, "mem": 1000}],
)
buf = StringIO()
Console(file=buf, force_terminal=True, width=80).print(render_system_panel(info))
assert "foo" in buf.getvalue()
32 changes: 31 additions & 1 deletion tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@
from __future__ import annotations

import pytest
from rich.markup import escape
from rich.panel import Panel

from macmanager.ui import bar, fmt_bytes, fmt_seconds, health_color, safe_panel, usage_color
from macmanager.ui import (
bar,
fmt_bytes,
fmt_seconds,
health_color,
safe_panel,
safe_text,
usage_color,
)

# ---------------------------------------------------------------------------
# fmt_bytes
Expand Down Expand Up @@ -213,3 +222,24 @@ def boom() -> Panel:

with pytest.raises(SystemExit):
safe_panel("Battery", boom)


# ---------------------------------------------------------------------------
# safe_text
# ---------------------------------------------------------------------------


class TestSafeText:
"""Texto do sistema (SSID, processo, path) não pode entrar cru no
markup do Rich — `[/]` fecha tag e vira MarkupError."""

def test_escapes_rich_markup(self) -> None:
raw = "My[/Home]WiFi"
assert safe_text(raw) == escape(raw)
assert safe_text(raw).startswith("My\\[")

def test_none_becomes_empty_string(self) -> None:
assert safe_text(None) == ""

def test_plain_text_is_unchanged(self) -> None:
assert safe_text("en0") == "en0"
Loading