testsAndMisc/linux_configuration/tests/test_usage_report_atop_pipeline.py
Krzysztof kuhy Rudnicki b5dbe1e67a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
usage_report: split _usage_report_parsing.py 425 -> 243 + 206
Lifts the atop subprocess machinery into _usage_report_atop.py: running
atop, streaming its parseable output, building/caching the atop_agg C
helper, and reading its TSV. What stays is record parsing, epoch windows
and per-day merging.

Verified by the 100%-coverage tests from the previous commit. An AST
comparison of all 16 functions against HEAD reports 16 identical, 0
mismatches.

_usage_report_render.py imported _run from the parsing module; it now
imports from the module that owns it. That is the same re-export barrel
the last split shipped and had to fix in a follow-up - caught in the same
commit this time. usage_report.py needed no edit at all.

Both modules hold 100% line+branch (94/28 and 130/34, 0 missed). 179
tests before and after; the five new test files collect 8+6+4+14+5 = 37,
which is last commit's 36 plus the one re-homed out of
test_usage_report_modes.py, where it referenced a constant that had
moved out from under it.

The two test files added last commit were themselves over cap (373, 272)
so they are split five ways here - the DropZone lesson from earlier
today, where a lint fix silently added an over-cap file.

Ran the program: a real report from today's logs, through the native
atop_agg path, which is precisely the code this commit moved.

Over-cap 39 -> 38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-17 12:12:11 +02:00

136 lines
4.0 KiB
Python

"""Tests for the `atop | atop_agg` pipeline: how usage_report reads the native
helper's TSV rows and copes with a producer or consumer that has no stdout.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import _usage_report_atop as parsing
from _usage_report_types import _Progress
from typing_extensions import Self
if TYPE_CHECKING:
import pytest
def _progress() -> _Progress:
"""A progress reporter that never draws, so stderr stays clean."""
return _Progress(enabled=False, total_stages=1)
# --------------------------------------------------------------------------- #
# _aggregate_atop_native
# --------------------------------------------------------------------------- #
class _ClosableLines(list[str]):
"""A line list that also answers .close(), as a real pipe would."""
def close(self) -> None:
"""Called on the producer's stdout by the pipeline code."""
class _FakePipeline:
"""Two-process context manager mimicking the atop | atop_agg pipeline."""
def __init__(self, lines: list[str] | None) -> None:
self.stdout = None if lines is None else _ClosableLines(lines)
def __enter__(self) -> Self:
return self
def __exit__(self, *_exc: object) -> None:
"""Never suppresses an exception."""
def _fake_popen_factory(
rows: list[str] | None,
) -> object:
"""Build a Popen replacement: first call is atop, second is atop_agg."""
calls: list[int] = []
def fake_popen(*_a: object, **_k: object) -> _FakePipeline:
calls.append(1)
# The first Popen is atop (its stdout is only closed by the caller);
# the second is atop_agg, whose stdout the loop reads.
return _FakePipeline(["x"] if len(calls) == 1 else rows)
return fake_popen
def test_aggregate_atop_native_reads_n_and_w_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""N rows become aggregates and a W row becomes the window."""
rows = [
"N\tfirefox\t900\t4096\t2048\t5\t2\n",
"W\t100\t3700\t6\t600\n",
]
monkeypatch.setattr(parsing.subprocess, "Popen", _fake_popen_factory(rows))
agg, window = parsing._aggregate_atop_native(
Path("log"),
_progress(),
Path("/bin/atop_agg"),
)
assert agg["firefox"].cpu_ticks == 900
assert window.distinct_samples == 6
assert window.interval_s == 600
def test_aggregate_atop_native_ignores_malformed_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rows with the wrong field count are skipped, not fatal."""
rows = ["N\ttoo\tshort\n", "W\tbad\n", "Z\tunknown\ttag\n"]
monkeypatch.setattr(parsing.subprocess, "Popen", _fake_popen_factory(rows))
agg, window = parsing._aggregate_atop_native(
Path("log"),
_progress(),
Path("/bin/atop_agg"),
)
assert agg == {}
assert window.distinct_samples == 0
def test_aggregate_atop_native_handles_no_stdout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A helper with no stdout pipe returns empty results rather than raising."""
monkeypatch.setattr(parsing.subprocess, "Popen", _fake_popen_factory(None))
agg, window = parsing._aggregate_atop_native(
Path("log"),
_progress(),
Path("/bin/atop_agg"),
)
assert agg == {}
assert window.distinct_samples == 0
def test_aggregate_atop_native_tolerates_producer_without_stdout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When atop exposes no stdout there is nothing to close; the read proceeds."""
calls: list[int] = []
def fake_popen(*_a: object, **_k: object) -> _FakePipeline:
calls.append(1)
# First Popen is atop: give it no stdout, so the close() is skipped.
return _FakePipeline(None if len(calls) == 1 else ["W\t0\t0\t0\t0\n"])
monkeypatch.setattr(parsing.subprocess, "Popen", fake_popen)
agg, window = parsing._aggregate_atop_native(
Path("log"),
_progress(),
Path("/bin/atop_agg"),
)
assert agg == {}
assert window.distinct_samples == 0