refactor(metrics): MetricsProvider abstraction with Android /proc backend
Build Android APK / build-android (push) Failing after 1m39s
Lint & Test / test (push) Successful in 4m20s

Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.

Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
This commit is contained in:
2026-04-14 13:34:32 +03:00
parent 488df98996
commit 546b24d015
9 changed files with 570 additions and 91 deletions
+183
View File
@@ -0,0 +1,183 @@
"""Tests for the metrics provider abstraction."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock, mock_open, patch
import pytest
from ledgrab.utils.metrics import (
AndroidMetricsProvider,
MemorySnapshot,
NullMetricsProvider,
ProcessSnapshot,
PsutilMetricsProvider,
get_metrics_provider,
reset_metrics_provider,
)
from ledgrab.utils.metrics import android_provider as android_mod
@pytest.fixture(autouse=True)
def _reset_provider_cache():
reset_metrics_provider()
yield
reset_metrics_provider()
def test_null_provider_returns_zero_metrics() -> None:
p = NullMetricsProvider()
assert p.available is False
assert p.cpu_percent() == 0.0
assert p.cpu_count() == 1
assert p.virtual_memory() == MemorySnapshot(0, 0, 0.0)
assert p.process_snapshot() == ProcessSnapshot(0.0, 0)
def test_psutil_provider_normalizes_process_cpu() -> None:
psutil_mock = MagicMock()
psutil_mock.cpu_percent.return_value = 42.5
psutil_mock.cpu_count.return_value = 8
mem = MagicMock(used=2_000_000, total=8_000_000, percent=25.0)
psutil_mock.virtual_memory.return_value = mem
proc_mock = MagicMock()
# Per-core 0N*100% — 800% means all 8 cores fully busy → 100% normalized.
proc_mock.cpu_percent.return_value = 800.0
proc_mock.memory_info.return_value = MagicMock(rss=1_500_000)
psutil_mock.Process.return_value = proc_mock
provider = PsutilMetricsProvider(psutil_mock)
# Two priming calls expected at construction (host + process counters).
assert psutil_mock.cpu_percent.call_count == 1
assert proc_mock.cpu_percent.call_count == 1
assert provider.available is True
assert provider.cpu_percent() == 42.5
assert provider.cpu_count() == 8
snap = provider.virtual_memory()
assert snap.used_bytes == 2_000_000
assert snap.total_bytes == 8_000_000
assert snap.percent == 25.0
proc = provider.process_snapshot()
assert proc.cpu_percent == 100.0 # 800% / 8 cores
assert proc.rss_bytes == 1_500_000
def test_psutil_provider_handles_unknown_cpu_count() -> None:
psutil_mock = MagicMock()
psutil_mock.cpu_count.return_value = None # psutil sometimes returns None
psutil_mock.Process.return_value = MagicMock()
provider = PsutilMetricsProvider(psutil_mock)
assert provider.cpu_count() == 1 # falls back to 1 to avoid div-by-zero
def test_factory_returns_psutil_provider_when_available() -> None:
pytest.importorskip("psutil")
provider = get_metrics_provider()
assert isinstance(provider, PsutilMetricsProvider)
assert provider.available is True
# Same instance on subsequent calls — provider is cached.
assert get_metrics_provider() is provider
def test_factory_falls_back_to_null_when_psutil_missing(monkeypatch) -> None:
# Hide psutil from the import system for this test.
monkeypatch.setitem(sys.modules, "psutil", None)
provider = get_metrics_provider()
assert isinstance(provider, NullMetricsProvider)
assert provider.available is False
# ── Android provider ────────────────────────────────────────────────
def test_android_meminfo_parses_kb_values(monkeypatch) -> None:
sample = (
"MemTotal: 2000000 kB\n"
"MemFree: 500000 kB\n"
"MemAvailable: 1500000 kB\n"
)
with patch("builtins.open", mock_open(read_data=sample)):
snap = android_mod._read_meminfo()
assert snap.total_bytes == 2000000 * 1024
# used = total - available
assert snap.used_bytes == 500000 * 1024
assert snap.percent == 25.0
def test_android_meminfo_returns_zero_on_missing_file(monkeypatch) -> None:
def _raise(*args, **kwargs):
raise OSError("simulated")
monkeypatch.setattr("builtins.open", _raise)
snap = android_mod._read_meminfo()
assert snap == MemorySnapshot(0, 0, 0.0)
def test_android_proc_self_stat_parses_with_paren_in_comm() -> None:
# Process name "(weird) name" — embedded parens are the classic /proc trap.
fields = ["S", "1", "1", "1", "0", "-1", "0", "0", "0", "0", "0", "150", "75"]
raw = b"42 ((weird) name) " + " ".join(fields).encode() + b"\n"
m = mock_open(read_data=raw)
with patch("builtins.open", m):
jiffies = android_mod._read_proc_self_stat_jiffies()
assert jiffies == 150 + 75
def test_android_provider_cpu_percent_uses_delta() -> None:
# First sample: total=1000, busy=200. Second sample: total=2000, busy=900.
# Delta busy/total = 700/1000 = 70%.
samples = iter(
[
android_mod._CpuSample(total=1000, busy=200),
android_mod._CpuSample(total=2000, busy=900),
]
)
with patch.object(android_mod, "_read_proc_stat", lambda: next(samples)):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: 0):
provider = AndroidMetricsProvider()
assert provider.cpu_percent() == 70.0
def test_android_provider_process_cpu_normalized_across_cores() -> None:
# Process consumed 400 jiffies while host clock advanced 1000 jiffies
# across all cores → 40% of one CPU's worth of work.
host_samples = iter(
[
android_mod._CpuSample(total=1000, busy=500),
android_mod._CpuSample(total=2000, busy=1500),
]
)
proc_samples = iter([100, 500])
with patch.object(android_mod, "_read_proc_stat", lambda: next(host_samples)):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: next(proc_samples)):
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 12345):
with patch.object(android_mod.os, "cpu_count", lambda: 4):
provider = AndroidMetricsProvider()
snap = provider.process_snapshot()
assert snap.cpu_percent == 40.0
assert snap.rss_bytes == 12345
def test_android_provider_handles_missing_proc_files() -> None:
with patch.object(android_mod, "_read_proc_stat", lambda: None):
with patch.object(android_mod, "_read_proc_self_stat_jiffies", lambda: None):
with patch.object(android_mod, "_read_self_rss_bytes", lambda: 0):
provider = AndroidMetricsProvider()
# No samples available → 0.0, not an exception.
assert provider.cpu_percent() == 0.0
snap = provider.process_snapshot()
assert snap == ProcessSnapshot(0.0, 0)
def test_factory_prefers_android_when_running_on_android(monkeypatch) -> None:
monkeypatch.setattr("ledgrab.utils.metrics.is_android", lambda: True)
monkeypatch.setattr("ledgrab.utils.metrics._android_supported", lambda: True)
provider = get_metrics_provider()
assert isinstance(provider, AndroidMetricsProvider)