From 284979ebf54ec8a39dc8f6877bdd29d6dc538615 Mon Sep 17 00:00:00 2001 From: nikfot Date: Sat, 16 May 2026 09:36:12 +0300 Subject: [PATCH 1/3] Add summary statistics with --summary and --summary-json flags New flags on the visualize command print per-metric statistics (min, max, mean, p95, p99) and alert thresholds (high CPU, IO wait, swap usage) to stdout. --summary-json outputs machine-readable JSON for CI/scripting use. --- VERSIONING_TECHNICAL.md | 165 ++++++++++++++++++++++++ src/vmstat_visualizer/cli/visualizer.py | 38 +++++- src/vmstat_visualizer/summary.py | 139 ++++++++++++++++++++ 3 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 VERSIONING_TECHNICAL.md create mode 100644 src/vmstat_visualizer/summary.py diff --git a/VERSIONING_TECHNICAL.md b/VERSIONING_TECHNICAL.md new file mode 100644 index 0000000..f32e1ab --- /dev/null +++ b/VERSIONING_TECHNICAL.md @@ -0,0 +1,165 @@ +# Technical: How Versioning Works + +## Short Answer + +**Poetry does NOT read from `__version__.py`** + +- Version is **only** in `pyproject.toml` +- Poetry reads/writes version there only +- `__version__.py` is for runtime use in your application + +## The Flow + +``` +┌─────────────────┐ +│ pyproject.toml │ ← Single source of truth +│ version="0.1.0" │ ← Poetry reads/writes here +└────────┬────────┘ + │ + │ poetry build + │ + ▼ +┌─────────────────┐ +│ Package │ +│ Metadata │ ← Built into wheel/tarball +└────────┬────────┘ + │ + │ pip install + │ + ▼ +┌─────────────────┐ +│ __version__.py │ ← Reads from installed metadata +│ at runtime │ +└─────────────────┘ +``` + +## Your Fixed Setup + +### 1. `pyproject.toml` (Source of Truth) +```toml +[tool.poetry] +version = "0.1.0" # ← Poetry uses this + +[build-system] +requires = ["poetry-core>=1.0.0"] # ← Only Poetry, no setuptools +build-backend = "poetry.core.masonry.api" +``` + +### 2. `__version__.py` (Runtime Version) +```python +# Reads from package metadata automatically +try: + from importlib.metadata import version + __version__ = version("vmstat_visualizer") +except PackageNotFoundError: + __version__ = "0.0.0.dev0" # Development fallback +``` + +### 3. CLI (Shows Version to Users) +```python +@click.version_option(version=__version__) +def cli(): + pass +``` + +## How It Works + +### During Development +```bash +# Bump version (updates pyproject.toml) +poetry version patch + +# Install in editable mode +poetry install + +# __version__.py reads from package metadata +# Shows: 0.1.1 +``` + +### During Build +```bash +# Build package +poetry build + +# Creates: +# - dist/vmstat_visualizer-0.1.1-py3-none-any.whl +# - dist/vmstat_visualizer-0.1.1.tar.gz + +# Version embedded in package metadata +``` + +### After Installation +```bash +# User installs +pip install vmstat-visualizer + +# Check version +vmstat-visualizer --version +# Output: vmstat-visualizer, version 0.1.1 + +# In Python +python -c "from vmstat_visualizer.cli.__version__ import __version__; print(__version__)" +# Output: 0.1.1 +``` + +## Key Points + +✅ **One Source**: Version only in `pyproject.toml` +✅ **No Manual Sync**: `__version__.py` reads from metadata automatically +✅ **Poetry Commands**: Use `poetry version` to bump +✅ **CLI Flag**: Users can check with `--version` + +❌ **Don't Use**: `setuptools_scm` with Poetry (removed) +❌ **Don't Hardcode**: Version in `__version__.py` (dynamic instead) +❌ **Don't Mix**: Poetry + setuptools build systems + +## Commands Reference + +```bash +# Check current version +poetry version # Shows: vmstat-visualizer 0.1.0 +vmstat-visualizer --version # Shows: vmstat-visualizer, version 0.1.0 + +# Bump version (updates pyproject.toml) +poetry version patch # 0.1.0 -> 0.1.1 +poetry version minor # 0.1.0 -> 0.2.0 +poetry version major # 0.1.0 -> 1.0.0 + +# Build with new version +poetry build # Creates dist/ with version in filename + +# Check version in Python +python -c "from vmstat_visualizer.cli.__version__ import __version__; print(__version__)" +``` + +## Why This Approach? + +**Before (broken):** +- Version in `pyproject.toml`: `0.1.0` +- `setuptools_scm` configured but doesn't work with Poetry +- `__version__.py` empty or outdated +- No single source of truth + +**After (fixed):** +- Version in `pyproject.toml`: `0.1.0` ← Only place +- `__version__.py` reads from package metadata ← Always correct +- `poetry version` updates `pyproject.toml` ← One command +- CLI shows version ← `--version` flag works + +## Alternatives + +If you really want git-tag-based versioning: + +```bash +# Install plugin +poetry self add "poetry-dynamic-versioning[plugin]" +``` + +```toml +# pyproject.toml +[tool.poetry-dynamic-versioning] +enable = true +``` + +Then version comes from git tags automatically. But for simplicity, the current approach (manual version in pyproject.toml) is better. + diff --git a/src/vmstat_visualizer/cli/visualizer.py b/src/vmstat_visualizer/cli/visualizer.py index 5beb14b..58bce0f 100644 --- a/src/vmstat_visualizer/cli/visualizer.py +++ b/src/vmstat_visualizer/cli/visualizer.py @@ -4,6 +4,7 @@ """ import click from vmstat_visualizer.parser.parser import Parser +from vmstat_visualizer.summary import compute_summary, format_summary_text, format_summary_json @click.command("visualize", no_args_is_help=True) @click.argument("file", default="vmstat.log", type=click.Path(exists=True)) @@ -19,8 +20,20 @@ default="png", help="set the output extension for created files.", ) +@click.option( + "--summary", + is_flag=True, + default=False, + help="Print summary statistics (min/max/mean/p95/p99) to stdout.", +) +@click.option( + "--summary-json", + is_flag=True, + default=False, + help="Print summary statistics as JSON to stdout.", +) def visualize( - file, output_prefix, output_extension + file, output_prefix, output_extension, summary, summary_json ): """ Visualize a file or files in a directory @@ -29,10 +42,20 @@ def visualize( parser = Parser(file) parser.parse() print(f">>> Parsed {len(parser.timeseries)} time series entries.") - parser.plot( - output_file_prefix=f"{output_prefix}", - output_format=output_extension - ) + + if summary or summary_json: + plot_metrics = parser.PlotMetrics(parser.timeseries) + stats = compute_summary(plot_metrics) + if summary_json: + print(format_summary_json(stats)) + else: + print(format_summary_text(stats)) + + if not summary_json: + parser.plot( + output_file_prefix=f"{output_prefix}", + output_format=output_extension + ) @click.command("compare", no_args_is_help=True) @@ -64,11 +87,12 @@ def visualize( "free", "si", "so", "us", "sy", "id", "wa", "st", - "inact", "active"], case_sensitive=False), + "inact", "active", + "buff", "cache"], case_sensitive=False), multiple=True, help="""Specific column(s) to visualize. Can be specified multiple times. For each metric, the relevant columns are:\n - cpu: us, sy, id, wa, st\n - - memory: swpd, free, inact, active\n + - memory: swpd, free, inact, active (vmstat -a) or buff, cache (standard vmstat)\n - system_load: r, b, si, so\n Example: -c us -c sy -c id """ diff --git a/src/vmstat_visualizer/summary.py b/src/vmstat_visualizer/summary.py new file mode 100644 index 0000000..e296775 --- /dev/null +++ b/src/vmstat_visualizer/summary.py @@ -0,0 +1,139 @@ +"""Summary statistics for parsed vmstat data.""" + +import json +import math + + +def _safe_ints(values): + """Convert a list of string values to integers, skipping None.""" + return [int(v) for v in values if v is not None] + + +def _percentile(sorted_data, p): + """Calculate the p-th percentile from pre-sorted data.""" + if not sorted_data: + return 0 + k = (len(sorted_data) - 1) * (p / 100) + f = math.floor(k) + c = math.ceil(k) + if f == c: + return sorted_data[int(k)] + return sorted_data[f] * (c - k) + sorted_data[c] * (k - f) + + +def _stats_for(values): + """Return min/max/mean/p95/p99 dict for a list of string values.""" + nums = _safe_ints(values) + if not nums: + return None + s = sorted(nums) + return { + "min": s[0], + "max": s[-1], + "mean": round(sum(s) / len(s), 2), + "p95": round(_percentile(s, 95), 2), + "p99": round(_percentile(s, 99), 2), + } + + +def _duration_above(values, threshold): + """Count how many consecutive seconds values stayed above threshold.""" + nums = _safe_ints(values) + return sum(1 for v in nums if v > threshold) + + +def compute_summary(plot_metrics): + """Build a summary dict from a PlotMetrics instance.""" + summary = {} + + cpu_metrics = { + "user_cpu_percent": plot_metrics.user_cpu_percent, + "system_cpu_percent": plot_metrics.system_cpu_percent, + "idle_cpu_percent": plot_metrics.idle_cpu_percent, + "wait_cpu_percent": plot_metrics.wait_cpu_percent, + "steal_cpu_percent": plot_metrics.steal_cpu_percent, + } + summary["cpu"] = {k: _stats_for(v) for k, v in cpu_metrics.items() if _stats_for(v)} + + mem_metrics = { + "free_memory_kb": plot_metrics.free_memory_kb, + "swapped_memory_kb": plot_metrics.swapped_memory_kb, + "inactive_memory_kb": plot_metrics.inactive_memory_kb, + "active_memory_kb": plot_metrics.active_memory_kb, + } + summary["memory"] = {k: _stats_for(v) for k, v in mem_metrics.items() if _stats_for(v)} + + io_metrics = { + "blocks_in": plot_metrics.blocks_in, + "blocks_out": plot_metrics.blocks_out, + } + summary["io"] = {k: _stats_for(v) for k, v in io_metrics.items() if _stats_for(v)} + + swap_metrics = { + "swap_in_kb": plot_metrics.swap_in_kb, + "swap_out_kb": plot_metrics.swap_out_kb, + } + summary["swap"] = {k: _stats_for(v) for k, v in swap_metrics.items() if _stats_for(v)} + + load_metrics = { + "run_queue": plot_metrics.run_queue, + "blocked_processes": plot_metrics.blocked_processes, + } + summary["system_load"] = {k: _stats_for(v) for k, v in load_metrics.items() if _stats_for(v)} + + total_cpu = _safe_ints(plot_metrics.user_cpu_percent) + _safe_ints(plot_metrics.system_cpu_percent) + user_vals = _safe_ints(plot_metrics.user_cpu_percent) + sys_vals = _safe_ints(plot_metrics.system_cpu_percent) + if user_vals and sys_vals: + combined = [u + s for u, s in zip(user_vals, sys_vals)] + summary["alerts"] = { + "high_cpu_seconds": sum(1 for v in combined if v > 80), + "high_wait_seconds": _duration_above(plot_metrics.wait_cpu_percent, 20), + "high_swap_seconds": _duration_above(plot_metrics.swapped_memory_kb, 0), + } + + summary["total_samples"] = len(plot_metrics.t) + + return summary + + +def format_summary_text(summary): + """Format summary dict as a human-readable text table.""" + lines = [] + lines.append(f"{'='*60}") + lines.append(f" vmstat Summary ({summary['total_samples']} samples)") + lines.append(f"{'='*60}") + + for section in ["cpu", "memory", "io", "swap", "system_load"]: + data = summary.get(section, {}) + if not data: + continue + lines.append(f"\n {section.upper()}") + lines.append(f" {'Metric':<25} {'Min':>7} {'Max':>7} {'Mean':>7} {'P95':>7} {'P99':>7}") + lines.append(f" {'-'*55}") + for metric, stats in data.items(): + lines.append( + f" {metric:<25} {stats['min']:>7} {stats['max']:>7} " + f"{stats['mean']:>7} {stats['p95']:>7} {stats['p99']:>7}" + ) + + alerts = summary.get("alerts", {}) + if alerts: + lines.append(f"\n ALERTS") + lines.append(f" {'-'*55}") + if alerts.get("high_cpu_seconds", 0) > 0: + lines.append(f" CPU > 80% (user+sys): {alerts['high_cpu_seconds']}s") + if alerts.get("high_wait_seconds", 0) > 0: + lines.append(f" IO Wait > 20%: {alerts['high_wait_seconds']}s") + if alerts.get("high_swap_seconds", 0) > 0: + lines.append(f" Swap in use: {alerts['high_swap_seconds']}s") + if all(v == 0 for v in alerts.values()): + lines.append(f" No alert thresholds breached.") + + lines.append(f"\n{'='*60}") + return "\n".join(lines) + + +def format_summary_json(summary): + """Format summary dict as JSON.""" + return json.dumps(summary, indent=2) From 19747de0fc3353bd3a85d714cbe683582702e86a Mon Sep 17 00:00:00 2001 From: nikfot Date: Sat, 16 May 2026 09:36:18 +0300 Subject: [PATCH 2/3] Remove unrelated file accidentally included --- VERSIONING_TECHNICAL.md | 165 ---------------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 VERSIONING_TECHNICAL.md diff --git a/VERSIONING_TECHNICAL.md b/VERSIONING_TECHNICAL.md deleted file mode 100644 index f32e1ab..0000000 --- a/VERSIONING_TECHNICAL.md +++ /dev/null @@ -1,165 +0,0 @@ -# Technical: How Versioning Works - -## Short Answer - -**Poetry does NOT read from `__version__.py`** - -- Version is **only** in `pyproject.toml` -- Poetry reads/writes version there only -- `__version__.py` is for runtime use in your application - -## The Flow - -``` -┌─────────────────┐ -│ pyproject.toml │ ← Single source of truth -│ version="0.1.0" │ ← Poetry reads/writes here -└────────┬────────┘ - │ - │ poetry build - │ - ▼ -┌─────────────────┐ -│ Package │ -│ Metadata │ ← Built into wheel/tarball -└────────┬────────┘ - │ - │ pip install - │ - ▼ -┌─────────────────┐ -│ __version__.py │ ← Reads from installed metadata -│ at runtime │ -└─────────────────┘ -``` - -## Your Fixed Setup - -### 1. `pyproject.toml` (Source of Truth) -```toml -[tool.poetry] -version = "0.1.0" # ← Poetry uses this - -[build-system] -requires = ["poetry-core>=1.0.0"] # ← Only Poetry, no setuptools -build-backend = "poetry.core.masonry.api" -``` - -### 2. `__version__.py` (Runtime Version) -```python -# Reads from package metadata automatically -try: - from importlib.metadata import version - __version__ = version("vmstat_visualizer") -except PackageNotFoundError: - __version__ = "0.0.0.dev0" # Development fallback -``` - -### 3. CLI (Shows Version to Users) -```python -@click.version_option(version=__version__) -def cli(): - pass -``` - -## How It Works - -### During Development -```bash -# Bump version (updates pyproject.toml) -poetry version patch - -# Install in editable mode -poetry install - -# __version__.py reads from package metadata -# Shows: 0.1.1 -``` - -### During Build -```bash -# Build package -poetry build - -# Creates: -# - dist/vmstat_visualizer-0.1.1-py3-none-any.whl -# - dist/vmstat_visualizer-0.1.1.tar.gz - -# Version embedded in package metadata -``` - -### After Installation -```bash -# User installs -pip install vmstat-visualizer - -# Check version -vmstat-visualizer --version -# Output: vmstat-visualizer, version 0.1.1 - -# In Python -python -c "from vmstat_visualizer.cli.__version__ import __version__; print(__version__)" -# Output: 0.1.1 -``` - -## Key Points - -✅ **One Source**: Version only in `pyproject.toml` -✅ **No Manual Sync**: `__version__.py` reads from metadata automatically -✅ **Poetry Commands**: Use `poetry version` to bump -✅ **CLI Flag**: Users can check with `--version` - -❌ **Don't Use**: `setuptools_scm` with Poetry (removed) -❌ **Don't Hardcode**: Version in `__version__.py` (dynamic instead) -❌ **Don't Mix**: Poetry + setuptools build systems - -## Commands Reference - -```bash -# Check current version -poetry version # Shows: vmstat-visualizer 0.1.0 -vmstat-visualizer --version # Shows: vmstat-visualizer, version 0.1.0 - -# Bump version (updates pyproject.toml) -poetry version patch # 0.1.0 -> 0.1.1 -poetry version minor # 0.1.0 -> 0.2.0 -poetry version major # 0.1.0 -> 1.0.0 - -# Build with new version -poetry build # Creates dist/ with version in filename - -# Check version in Python -python -c "from vmstat_visualizer.cli.__version__ import __version__; print(__version__)" -``` - -## Why This Approach? - -**Before (broken):** -- Version in `pyproject.toml`: `0.1.0` -- `setuptools_scm` configured but doesn't work with Poetry -- `__version__.py` empty or outdated -- No single source of truth - -**After (fixed):** -- Version in `pyproject.toml`: `0.1.0` ← Only place -- `__version__.py` reads from package metadata ← Always correct -- `poetry version` updates `pyproject.toml` ← One command -- CLI shows version ← `--version` flag works - -## Alternatives - -If you really want git-tag-based versioning: - -```bash -# Install plugin -poetry self add "poetry-dynamic-versioning[plugin]" -``` - -```toml -# pyproject.toml -[tool.poetry-dynamic-versioning] -enable = true -``` - -Then version comes from git tags automatically. But for simplicity, the current approach (manual version in pyproject.toml) is better. - From 2ce41c3968c459237d88c896bf75a9ba11f73201 Mon Sep 17 00:00:00 2001 From: nikfot Date: Sat, 16 May 2026 10:18:38 +0300 Subject: [PATCH 3/3] Add tests for summary statistics feature --- tests/test_cli.py | 147 ++++++++++++++++++++++++--------- tests/test_summary.py | 184 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 37 deletions(-) create mode 100644 tests/test_summary.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 073361e..373dd8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,10 @@ import os import glob +import json import pytest from unittest.mock import patch from click.testing import CliRunner + from vmstat_visualizer.cli.__main__ import cli import vmstat_visualizer.cli.visualizer as viz @@ -17,24 +19,26 @@ def _register_commands(): class TestCLIHelp: def test_main_help(self): runner = CliRunner() - result = runner.invoke(cli, ['--help']) + result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 - assert 'visualize' in result.output - assert 'compare' in result.output + assert "visualize" in result.output + assert "compare" in result.output def test_visualize_help(self): runner = CliRunner() - result = runner.invoke(cli, ['visualize', '--help']) + result = runner.invoke(cli, ["visualize", "--help"]) assert result.exit_code == 0 - assert '--output-prefix' in result.output - assert '--output-extension' in result.output + assert "--output-prefix" in result.output + assert "--output-extension" in result.output + assert "--summary" in result.output + assert "--summary-json" in result.output def test_compare_help(self): runner = CliRunner() - result = runner.invoke(cli, ['compare', '--help']) + result = runner.invoke(cli, ["compare", "--help"]) assert result.exit_code == 0 - assert '--metric' in result.output - assert '--column' in result.output + assert "--metric" in result.output + assert "--column" in result.output class TestVisualizeCommand: @@ -43,11 +47,9 @@ class TestVisualizeCommand: def test_visualize_active_log(self, mock_check, active_log, tmp_path): runner = CliRunner() prefix = str(tmp_path / "viz") - result = runner.invoke(cli, [ - 'visualize', active_log, '-o', prefix, '-e', 'png' - ]) + result = runner.invoke(cli, ["visualize", active_log, "-o", prefix, "-e", "png"]) assert result.exit_code == 0 - assert '5 time series entries' in result.output + assert "5 time series entries" in result.output pngs = glob.glob(str(tmp_path / "viz_*.png")) assert len(pngs) == 5 @@ -56,19 +58,50 @@ def test_visualize_active_log(self, mock_check, active_log, tmp_path): def test_visualize_example_log(self, mock_check, example_log, tmp_path): runner = CliRunner() prefix = str(tmp_path / "ex") - result = runner.invoke(cli, [ - 'visualize', example_log, '-o', prefix - ]) + result = runner.invoke(cli, ["visualize", example_log, "-o", prefix]) assert result.exit_code == 0 - assert '30 time series entries' in result.output + assert "30 time series entries" in result.output def test_visualize_missing_file(self, tmp_path): runner = CliRunner() - result = runner.invoke(cli, [ - 'visualize', str(tmp_path / 'nonexistent.log') - ]) + result = runner.invoke(cli, ["visualize", str(tmp_path / "nonexistent.log")]) assert result.exit_code != 0 + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_text(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "summ") + result = runner.invoke( + cli, + ["visualize", active_log, "--summary", "-o", prefix, "-e", "png"], + ) + assert result.exit_code == 0 + assert "vmstat Summary" in result.output + assert "user_cpu_percent" in result.output or "CPU" in result.output + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_json_stdout(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "sj") + result = runner.invoke(cli, ["visualize", active_log, "--summary-json", "-o", prefix]) + assert result.exit_code == 0 + start = result.output.find("{") + assert start >= 0 + payload = json.loads(result.output[start:]) + assert "total_samples" in payload + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_visualize_summary_json_skips_plot_files(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "no_plot") + result = runner.invoke(cli, ["visualize", active_log, "--summary-json", "-o", prefix]) + assert result.exit_code == 0 + pngs = glob.glob(str(tmp_path / "no_plot_*.png")) + assert pngs == [] + class TestCompareCommand: @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", @@ -76,22 +109,22 @@ class TestCompareCommand: def test_compare_cpu(self, mock_check, active_log, tmp_path): runner = CliRunner() prefix = str(tmp_path / "cmp") - result = runner.invoke(cli, [ - 'compare', active_log, active_log, - '-m', 'cpu', '-o', prefix - ]) + result = runner.invoke( + cli, + ["compare", active_log, active_log, "-m", "cpu", "-o", prefix], + ) assert result.exit_code == 0 - assert 'Comparing' in result.output + assert "Comparing" in result.output @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", return_value=(True, True)) def test_compare_all(self, mock_check, active_log, tmp_path): runner = CliRunner() prefix = str(tmp_path / "all") - result = runner.invoke(cli, [ - 'compare', active_log, active_log, - '-m', 'all', '-o', prefix - ]) + result = runner.invoke( + cli, + ["compare", active_log, active_log, "-m", "all", "-o", prefix], + ) assert result.exit_code == 0 @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", @@ -99,16 +132,56 @@ def test_compare_all(self, mock_check, active_log, tmp_path): def test_compare_with_column_filter(self, mock_check, active_log, tmp_path): runner = CliRunner() prefix = str(tmp_path / "filt") - result = runner.invoke(cli, [ - 'compare', active_log, active_log, - '-m', 'cpu', '-c', 'us', '-c', 'sy', '-o', prefix - ]) + result = runner.invoke( + cli, + [ + "compare", + active_log, + active_log, + "-m", + "cpu", + "-c", + "us", + "-c", + "sy", + "-o", + prefix, + ], + ) + assert result.exit_code == 0 + assert "Filtering columns: us, sy" in result.output + + @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True)) + def test_compare_memory_with_buff_and_cache_columns( + self, mock_check, standard_log, tmp_path + ): + runner = CliRunner() + prefix = str(tmp_path / "bc") + result = runner.invoke( + cli, + [ + "compare", + standard_log, + standard_log, + "-m", + "memory", + "-c", + "buff", + "-c", + "cache", + "-o", + prefix, + ], + ) assert result.exit_code == 0 - assert 'Filtering columns: us, sy' in result.output + assert "buff" in result.output and "cache" in result.output + pngs = glob.glob(str(tmp_path / "bc_memory_comparison*.png")) + assert len(pngs) >= 1 def test_compare_invalid_metric(self, active_log): runner = CliRunner() - result = runner.invoke(cli, [ - 'compare', active_log, active_log, '-m', 'bogus' - ]) + result = runner.invoke( + cli, ["compare", active_log, active_log, "-m", "bogus"] + ) assert result.exit_code != 0 diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..da1093b --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,184 @@ +"""Unit tests for summary statistics helpers.""" + +import json +from types import SimpleNamespace + +import pytest + +from vmstat_visualizer.summary import ( + compute_summary, + format_summary_json, + format_summary_text, + _duration_above, + _percentile, + _safe_ints, + _stats_for, +) + + +class TestSafeInts: + def test_normal_values(self): + assert _safe_ints(["1", "2", "3"]) == [1, 2, 3] + + def test_skips_none(self): + assert _safe_ints([None, "5", None, "10"]) == [5, 10] + + def test_empty_list(self): + assert _safe_ints([]) == [] + + +class TestPercentile: + def test_empty_returns_zero(self): + assert _percentile([], 50) == 0 + + def test_single_element(self): + assert _percentile([42], 50) == 42 + assert _percentile([42], 99) == 42 + + def test_p50_interpolated_odd_length(self): + s = sorted([1, 2, 3, 4]) + assert _percentile(s, 50) == 2.5 + + def test_p95_known_sorted(self): + s = sorted([10, 20, 30, 40, 50]) + assert _percentile(s, 95) == pytest.approx(48.0) + + def test_p99_known_sorted(self): + s = sorted(list(range(1, 101))) + assert _percentile(s, 99) == pytest.approx(99.01) + + +class TestStatsFor: + def test_normal_list(self): + out = _stats_for(["3", None, "1", "4"]) + assert out is not None + assert out["min"] == 1 + assert out["max"] == 4 + assert out["mean"] == pytest.approx(2.67, rel=1e-2) + + def test_all_none_returns_none(self): + assert _stats_for([None, None]) is None + + def test_single_value(self): + out = _stats_for(["99"]) + assert out == {"min": 99, "max": 99, "mean": 99.0, "p95": 99.0, "p99": 99.0} + + +class TestDurationAbove: + def test_counts_above_threshold(self): + vals = ["0", None, "5", "10", None, "21"] + assert _duration_above(vals, 5) == 2 + + def test_zero_results(self): + assert _duration_above(["0", None, "-1"], 0) == 0 + + +class TestComputeSummary: + def test_known_plot_metrics_like_object(self): + pm = SimpleNamespace( + t=[0, 1, 2], + user_cpu_percent=["50", "10", "90"], + system_cpu_percent=["35", "10", "15"], + idle_cpu_percent=["15", None, "70"], + wait_cpu_percent=["5", "25", "10"], + steal_cpu_percent=["0", "0", "0"], + free_memory_kb=["1000", "2000", "3000"], + swapped_memory_kb=["0", "0", "10"], + inactive_memory_kb=["100", "200", "300"], + active_memory_kb=["500", "500", "600"], + blocks_in=["1", "2", "3"], + blocks_out=["4", "5", "6"], + swap_in_kb=["0", "1", "0"], + swap_out_kb=["0", "0", "2"], + run_queue=["0", "1", "2"], + blocked_processes=["0", "0", "1"], + ) + summary = compute_summary(pm) + + assert summary["total_samples"] == 3 + + cpu = summary["cpu"] + for key in ( + "user_cpu_percent", + "system_cpu_percent", + "idle_cpu_percent", + "wait_cpu_percent", + "steal_cpu_percent", + ): + assert key in cpu + stat = cpu[key] + for field in ("min", "max", "mean", "p95", "p99"): + assert field in stat + + assert cpu["user_cpu_percent"]["min"] == 10 + assert cpu["user_cpu_percent"]["max"] == 90 + + mem = summary["memory"] + for key in ("free_memory_kb", "swapped_memory_kb", + "inactive_memory_kb", "active_memory_kb"): + assert key in mem + + assert "blocks_in" in summary["io"] and "blocks_out" in summary["io"] + assert "swap_in_kb" in summary["swap"] and "swap_out_kb" in summary["swap"] + load = summary["system_load"] + assert "run_queue" in load and "blocked_processes" in load + + alerts = summary["alerts"] + assert alerts["high_cpu_seconds"] == 2 + assert alerts["high_wait_seconds"] == 1 + assert alerts["high_swap_seconds"] == 1 + + +class TestFormatSummaryText: + def test_contains_headers_and_metric_names(self): + pm = SimpleNamespace( + t=[0], + user_cpu_percent=["5"], + system_cpu_percent=["5"], + idle_cpu_percent=["90"], + wait_cpu_percent=["0"], + steal_cpu_percent=["0"], + free_memory_kb=["1000"], + swapped_memory_kb=["0"], + inactive_memory_kb=["0"], + active_memory_kb=["500"], + blocks_in=["1"], + blocks_out=["1"], + swap_in_kb=["0"], + swap_out_kb=["0"], + run_queue=["0"], + blocked_processes=["0"], + ) + summary = compute_summary(pm) + text = format_summary_text(summary) + assert "vmstat Summary" in text + assert "CPU" in text or "MEMORY" in text + assert "user_cpu_percent" in text + + +class TestFormatSummaryJson: + def test_valid_json_round_trip(self): + summary = compute_summary( + SimpleNamespace( + t=[0, 1], + user_cpu_percent=["1", "2"], + system_cpu_percent=["3", "4"], + idle_cpu_percent=["96", "94"], + wait_cpu_percent=["0", "0"], + steal_cpu_percent=["0", "0"], + free_memory_kb=["100", "101"], + swapped_memory_kb=["0", "0"], + inactive_memory_kb=["10", "11"], + active_memory_kb=["20", "21"], + blocks_in=["0", "0"], + blocks_out=["0", "0"], + swap_in_kb=["0", "0"], + swap_out_kb=["0", "0"], + run_queue=["0", "1"], + blocked_processes=["0", "0"], + ) + ) + dumped = format_summary_json(summary) + restored = json.loads(dumped) + assert restored["total_samples"] == summary["total_samples"] + assert restored["cpu"] == summary["cpu"]