From 31b51bff25d4d1a7738ef406c15cf8627b7321ee Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:48:53 -0300 Subject: [PATCH] =?UTF-8?q?fix(network):=20usar=20a=20rota=20default=20e?= =?UTF-8?q?=20n=C3=A3o=20cachear=20IP=20p=C3=BAblico=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit en0 hardcoded falhava em Ethernet USB-C / VPN. A interface vem de route get default. Falha no ipify não fica 5 min no cache — o watch tenta de novo no próximo ciclo. --- CHANGELOG.md | 4 ++++ COMMANDS.md | 4 ++-- macmanager/cache.py | 6 +++++- macmanager/network.py | 18 +++++++++++++++--- tests/test_cache.py | 28 ++++++++++++++++++++++++++++ tests/test_network.py | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce176f..9c25da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 net` deixa de assumir `en0` e de cachear IP público `None`.** + A interface vem da rota default (`route -n get default`); sem rota, + cai em `en0`. Falha no ipify não fica 5 minutos no cache — o watch + tenta de novo no próximo ciclo. - **`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 diff --git a/COMMANDS.md b/COMMANDS.md index d5bd42b..f8f764e 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -111,9 +111,9 @@ mm clean **What it does:** network info. **Shows:** -- Active interface (`en0`, etc.) +- Active interface (from the default route; falls back to `en0`) - Local IP -- Public IP (queries `api.ipify.org`) +- Public IP (queries `api.ipify.org`; a failed lookup is not cached) - SSID of connected Wi-Fi - Security (WPA2/WPA3) - Channel and width (e.g. 52 / 5GHz / 80MHz) diff --git a/macmanager/cache.py b/macmanager/cache.py index a7e4597..73b71aa 100644 --- a/macmanager/cache.py +++ b/macmanager/cache.py @@ -19,10 +19,12 @@ _STORE: dict[tuple, tuple[float, object]] = {} -def cached(ttl: float): +def cached(ttl: float, *, cache_none: bool = True): """Decorator that caches a function's return for `ttl` seconds. Use ttl=0 to disable the cache (useful in tests). + `cache_none=False` não guarda None — falha de rede no IP público + não pode deixar o watch em "offline" por 5 minutos. """ def deco(fn: Callable[..., T]) -> Callable[..., T]: @@ -46,6 +48,8 @@ def wrapper(*args, **kwargs) -> T: if entry and (now - entry[0]) < ttl: return entry[1] # type: ignore[return-value] value = fn(*args, **kwargs) + if value is None and not cache_none: + return value _STORE[cache_key] = (now, value) return value diff --git a/macmanager/network.py b/macmanager/network.py index 5fa0a76..34683f1 100644 --- a/macmanager/network.py +++ b/macmanager/network.py @@ -54,9 +54,21 @@ def _local_ip(iface: str = "en0") -> Optional[str]: return out or None -@cached(ttl=300) +def parse_route_interface(raw: str) -> Optional[str]: + """Extrai a interface da rota default (`route -n get default`).""" + m = re.search(r"^\s*interface:\s+(\S+)", raw, flags=re.MULTILINE) + return m.group(1) if m else None + + +def _active_interface() -> str: + """Interface da rota default. Sem rota, cai em en0.""" + parsed = parse_route_interface(_run(["route", "-n", "get", "default"], timeout=2)) + return parsed or "en0" + + +@cached(ttl=300, cache_none=False) def _public_ip() -> Optional[str]: - """Public IP rarely changes — a 5-min cache avoids hits on every refresh.""" + """IP público muda pouco — cache de 5 min. None não é cacheado.""" try: with urllib.request.urlopen("https://api.ipify.org", timeout=2) as r: return r.read().decode().strip() @@ -106,7 +118,7 @@ def _wifi_info() -> dict: @cached(ttl=10) def get_network() -> NetworkInfo: - iface = "en0" + iface = _active_interface() local = _local_ip(iface) public = _public_ip() wifi = _wifi_info() diff --git a/tests/test_cache.py b/tests/test_cache.py index bb45211..9f1cdb0 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -207,6 +207,34 @@ def two() -> int: assert is_miss(peek(two)) +class TestCacheNone: + """Falha de rede no IP público não pode ficar 5 min no cache.""" + + def test_default_still_caches_none(self) -> None: + calls = [] + + @cached(ttl=60) + def flaky() -> int | None: + calls.append(1) + return None + + assert flaky() is None + assert flaky() is None + assert len(calls) == 1 + + def test_cache_none_false_retries(self) -> None: + calls = [] + + @cached(ttl=60, cache_none=False) + def flaky() -> int | None: + calls.append(1) + return None if len(calls) == 1 else 7 + + assert flaky() is None + assert flaky() == 7 + assert len(calls) == 2 + + class TestIsMiss: def test_sentinel_is_miss(self) -> None: assert is_miss(cache_mod._MISS) diff --git a/tests/test_network.py b/tests/test_network.py index 7b43618..79c8ba8 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -13,10 +13,12 @@ from macmanager.cache import clear_all from macmanager.network import ( NetworkInfo, + _active_interface, _local_ip, _run, _signal_quality, _wifi_info, + parse_route_interface, render_network_panel, ) @@ -153,3 +155,34 @@ def test_ssid_with_closing_tag_does_not_raise(self) -> None: out = buf.getvalue() assert "My" in out assert "WiFi" in out + + +class TestParseRouteInterface: + """USB-C Ethernet / VPN não são en0. A rota default diz qual iface usar.""" + + def test_extracts_interface(self) -> None: + raw = ( + " route to: default\n" + "destination: default\n" + " gateway: 192.168.1.1\n" + " interface: en8\n" + " flags: \n" + ) + assert parse_route_interface(raw) == "en8" + + def test_empty_output_returns_none(self) -> None: + assert parse_route_interface("") is None + assert parse_route_interface("no interface here") is None + + +class TestActiveInterface: + def test_falls_back_to_en0(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("macmanager.network._run", lambda *args, **kwargs: "") + assert _active_interface() == "en0" + + def test_uses_parsed_interface(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "macmanager.network._run", + lambda *args, **kwargs: " interface: utun4\n", + ) + assert _active_interface() == "utun4"