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` / `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
/ sem IP local — o mesmo padrão que `security._run` já usava.
- **Caching bug discovered by the new tests**: `cache.cached` and
`cache.peek` promised to fall back to a plain function call when
called with unhashable arguments (e.g. a `dict`), but only
Expand Down
34 changes: 21 additions & 13 deletions macmanager/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,27 @@ class NetworkInfo:
security: Optional[str]


def _run(cmd: list[str], timeout: int = 5) -> str:
"""Devolve o stdout. Timeout, binário ausente e erro de OS viram "".

Quem chama degrada para "desconhecido" em vez de derrubar `mm` /
`mm net` / `mm watch`. Espelha `macmanager.security._run`.
"""
try:
p = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
return p.stdout
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return ""


def _local_ip(iface: str = "en0") -> Optional[str]:
out = subprocess.run(
["ipconfig", "getifaddr", iface],
capture_output=True,
text=True,
check=False,
).stdout.strip()
out = _run(["ipconfig", "getifaddr", iface], timeout=2).strip()
return out or None


Expand All @@ -57,13 +71,7 @@ def _wifi_info() -> dict:
(no sudo)."""
data: dict = {}

out = subprocess.run(
["/usr/sbin/system_profiler", "SPAirPortDataType"],
capture_output=True,
text=True,
check=False,
timeout=5,
).stdout
out = _run(["/usr/sbin/system_profiler", "SPAirPortDataType"], timeout=5)

cur = re.search(r"Current Network Information:\s*\n\s*([^\n]+):", out)
if cur:
Expand Down
81 changes: 80 additions & 1 deletion tests/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

from __future__ import annotations

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

import pytest

from macmanager.network import _signal_quality
from macmanager.cache import clear_all
from macmanager.network import _local_ip, _run, _signal_quality, _wifi_info


class TestSignalQuality:
Expand Down Expand Up @@ -42,3 +47,77 @@ def test_positive_rssi_is_treated_as_excellent(self) -> None:
# doesn't special-case positive values — they trivially satisfy
# `>= -55`.
assert _signal_quality(0) == ("Excellent", "green")


class TestRun:
"""`_run` é a fronteira de crash de todo shell-out de rede. Timeout
ou binário ausente tem que virar string vazia — nunca uma exceção
que derrube `mm` / `mm net` / `mm watch`."""

def test_returns_stdout_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"macmanager.network.subprocess.run",
lambda *args, **kwargs: SimpleNamespace(stdout="192.168.1.10\n"),
)
assert _run(["ipconfig", "getifaddr", "en0"]) == "192.168.1.10\n"

@pytest.mark.parametrize(
"exc",
[
subprocess.TimeoutExpired(cmd="system_profiler", timeout=5),
FileNotFoundError("ipconfig"),
OSError("permission denied"),
],
)
def test_swallows_expected_failures(
self, monkeypatch: pytest.MonkeyPatch, exc: Exception
) -> None:
def boom(*args: object, **kwargs: object) -> None:
raise exc

monkeypatch.setattr("macmanager.network.subprocess.run", boom)
assert _run(["/usr/sbin/system_profiler", "SPAirPortDataType"]) == ""


class TestLocalIp:
def test_empty_run_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "")
assert _local_ip("en0") is None

def test_strips_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: " 10.0.0.4\n")
assert _local_ip("en0") == "10.0.0.4"


class TestWifiInfoDegrades:
"""`system_profiler` passa de 5s com frequência na primeira chamada.
O coletor tem que devolver dict vazio para o painel mostrar '—'
em vez de traceback."""

@pytest.fixture(autouse=True)
def _clear_wifi_cache(self) -> Iterator[None]:
clear_all()
yield
clear_all()

def test_empty_profiler_output_returns_empty_dict(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "")
assert _wifi_info() == {}

def test_parses_ssid_from_profiler_block(self, monkeypatch: pytest.MonkeyPatch) -> None:
sample = (
"Current Network Information:\n"
" HomeNet:\n"
" Security: WPA3 Personal\n"
" Channel: 36 (5GHz, 80MHz)\n"
" Signal / Noise: -55 dBm / -92 dBm\n"
" Transmit Rate: 1200\n"
)
monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: sample)
info = _wifi_info()
assert info["ssid"] == "HomeNet"
assert info["security"] == "WPA3 Personal"
assert info["rssi"] == -55
assert info["tx_rate"] == "1200"
Loading