Skip to content

Commit 346a9f6

Browse files
olivermeyerclaude
andcommitted
fix(system): replace uptime with psutil
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3a77e30 commit 346a9f6

4 files changed

Lines changed: 120 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ dependencies = [
8787
"pyyaml>=6.0.3,<7",
8888
"sentry-sdk>=2.47.0,<3",
8989
"typer>=0.20.0,<1",
90-
"uptime>=3.0.1,<4",
9190
# Custom
9291
"boto3>=1.42.4,<2",
9392
"certifi>=2025.11.12",

src/aignostics/system/_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""System service."""
22

33
import asyncio
4+
import datetime
45
import json
56
import os
67
import platform
78
import re
89
import ssl
910
import sys
11+
import time
1012
import typing as t
1113
from http import HTTPStatus
1214
from pathlib import Path
@@ -309,9 +311,9 @@ async def info(include_environ: bool = False, mask_secrets: bool = True) -> dict
309311
dict[str, Any]: Service configuration.
310312
"""
311313
import psutil # noqa: PLC0415
312-
from uptime import boottime, uptime # noqa: PLC0415
313314

314-
bootdatetime = boottime()
315+
boot_ts = psutil.boot_time()
316+
bootdatetime = datetime.datetime.fromtimestamp(boot_ts, tz=datetime.UTC)
315317
vmem = psutil.virtual_memory()
316318
swap = psutil.swap_memory()
317319
psutil.cpu_percent(interval=None) # prime the counter
@@ -378,8 +380,8 @@ async def info(include_environ: bool = False, mask_secrets: bool = True) -> dict
378380
"ssl_default_verify_paths": ssl.get_default_verify_paths()._asdict(),
379381
},
380382
"uptime": {
381-
"seconds": uptime(),
382-
"boottime": bootdatetime.isoformat() if bootdatetime else None,
383+
"seconds": time.time() - boot_ts,
384+
"boottime": bootdatetime.isoformat(),
383385
},
384386
},
385387
"python": {

tests/aignostics/system/service_test.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,72 @@
88

99
from aignostics.system._service import Service
1010

11+
# ---------------------------------------------------------------------------
12+
# Helpers shared by uptime tests
13+
# ---------------------------------------------------------------------------
14+
15+
FIXED_BOOT_TIME = 1_000_000.0 # arbitrary fixed epoch seconds
16+
17+
18+
def _make_mock_process() -> mock.MagicMock:
19+
proc = mock.MagicMock()
20+
proc.username.return_value = "testuser"
21+
return proc
22+
23+
24+
def _patch_info_dependencies(boot_time: float = FIXED_BOOT_TIME):
25+
"""Return a context manager stack that patches all external I/O for Service.info()."""
26+
import contextlib
27+
28+
now = boot_time + 3600.0 # pretend system has been up 1 hour
29+
30+
vmem = mock.MagicMock()
31+
vmem.percent = 50.0
32+
vmem.total = 8_000_000_000
33+
vmem.available = 4_000_000_000
34+
vmem.used = 3_500_000_000
35+
vmem.free = 500_000_000
36+
37+
swap = mock.MagicMock()
38+
swap.percent = 10.0
39+
swap.total = 2_000_000_000
40+
swap.used = 200_000_000
41+
swap.free = 1_800_000_000
42+
43+
cpu_times = mock.MagicMock()
44+
cpu_times.user = 20.0
45+
cpu_times.system = 10.0
46+
cpu_times.idle = 70.0
47+
48+
from aignostics.utils._process import ParentProcessInfo, ProcessInfo
49+
50+
mock_process_info = ProcessInfo(
51+
project_root="/fake/root",
52+
pid=1234,
53+
parent=ParentProcessInfo(name="pytest", pid=1),
54+
)
55+
56+
@contextlib.contextmanager
57+
def _ctx():
58+
with (
59+
mock.patch("psutil.boot_time", return_value=boot_time),
60+
mock.patch("psutil.virtual_memory", return_value=vmem),
61+
mock.patch("psutil.swap_memory", return_value=swap),
62+
mock.patch("psutil.cpu_percent", return_value=15.0),
63+
mock.patch("psutil.cpu_times_percent", return_value=cpu_times),
64+
mock.patch("psutil.getloadavg", return_value=(1.0, 1.0, 1.0)),
65+
mock.patch("psutil.Process", return_value=_make_mock_process()),
66+
mock.patch("aignostics.system._service.get_process_info", return_value=mock_process_info),
67+
mock.patch("asyncio.sleep"),
68+
mock.patch.object(Service, "_get_public_ipv4", return_value=None),
69+
mock.patch.object(Service, "_collect_all_settings", return_value={}),
70+
mock.patch("aignostics.system._service.locate_subclasses", return_value=[]),
71+
mock.patch("time.time", return_value=now),
72+
):
73+
yield
74+
75+
return _ctx()
76+
1177

1278
@pytest.mark.unit
1379
def test_get_cpu_freq_info_returns_dict_with_expected_keys() -> None:
@@ -397,3 +463,51 @@ def test_is_secret_key_real_world_examples(record_property) -> None:
397463

398464
for key in non_secret_examples:
399465
assert not Service._is_secret_key(key), f"Expected '{key}' to NOT be identified as a secret key"
466+
467+
468+
# ---------------------------------------------------------------------------
469+
# Uptime tests — verify psutil-based implementation
470+
# ---------------------------------------------------------------------------
471+
472+
473+
@pytest.mark.unit
474+
@pytest.mark.asyncio
475+
async def test_info_uptime_keys_present() -> None:
476+
"""info() uptime dict contains both 'seconds' and 'boottime' keys with non-None values."""
477+
with _patch_info_dependencies():
478+
result = await Service.info()
479+
480+
uptime = result["runtime"]["host"]["uptime"]
481+
assert "seconds" in uptime
482+
assert "boottime" in uptime
483+
assert uptime["seconds"] is not None
484+
assert uptime["boottime"] is not None
485+
486+
487+
@pytest.mark.unit
488+
@pytest.mark.asyncio
489+
async def test_info_uptime_seconds_positive() -> None:
490+
"""info() uptime seconds is a positive number (time since boot)."""
491+
with _patch_info_dependencies():
492+
result = await Service.info()
493+
494+
seconds = result["runtime"]["host"]["uptime"]["seconds"]
495+
assert isinstance(seconds, float)
496+
assert seconds > 0
497+
498+
499+
@pytest.mark.unit
500+
@pytest.mark.asyncio
501+
async def test_info_uptime_boottime_is_iso_string() -> None:
502+
"""info() uptime boottime is a non-empty ISO 8601 string."""
503+
import datetime
504+
505+
with _patch_info_dependencies():
506+
result = await Service.info()
507+
508+
boottime_str = result["runtime"]["host"]["uptime"]["boottime"]
509+
assert isinstance(boottime_str, str)
510+
assert len(boottime_str) > 0
511+
# Must be parseable as an ISO 8601 datetime
512+
parsed = datetime.datetime.fromisoformat(boottime_str)
513+
assert parsed.tzinfo is not None # timezone-aware (UTC)

uv.lock

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)