From e17075042dfad0a8a09d3db7d57ca6ba2ee992a8 Mon Sep 17 00:00:00 2001 From: nikfot Date: Sat, 16 May 2026 09:41:14 +0300 Subject: [PATCH 1/2] Add compare-multi command for N-file comparison New 'compare-multi' subcommand accepts any number of vmstat log files and overlays them on a single chart per metric. Each file gets a distinct color and line style for visual differentiation. Usage: vmstat-visualizer compare-multi f1.log f2.log f3.log -m cpu vmstat-visualizer compare-multi *.log -m all --- src/vmstat_visualizer/cli/__main__.py | 2 + src/vmstat_visualizer/cli/multi_compare.py | 49 ++++++++ src/vmstat_visualizer/multi_compare.py | 136 +++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/vmstat_visualizer/cli/multi_compare.py create mode 100644 src/vmstat_visualizer/multi_compare.py diff --git a/src/vmstat_visualizer/cli/__main__.py b/src/vmstat_visualizer/cli/__main__.py index 11f7094..7c0e6d7 100644 --- a/src/vmstat_visualizer/cli/__main__.py +++ b/src/vmstat_visualizer/cli/__main__.py @@ -4,6 +4,7 @@ import click import vmstat_visualizer.cli.visualizer as viz +from vmstat_visualizer.cli.multi_compare import compare_multi @click.group(no_args_is_help=True) @@ -17,6 +18,7 @@ def main(): "main is the entrypoint of vmstat-visualizer" cli.add_command(viz.visualize) cli.add_command(viz.compare) + cli.add_command(compare_multi) cli(prog_name="vmstat-visualizer") diff --git a/src/vmstat_visualizer/cli/multi_compare.py b/src/vmstat_visualizer/cli/multi_compare.py new file mode 100644 index 0000000..3facd2b --- /dev/null +++ b/src/vmstat_visualizer/cli/multi_compare.py @@ -0,0 +1,49 @@ +"""CLI command for multi-file comparison.""" + +import click +from vmstat_visualizer.parser.parser import Parser +from vmstat_visualizer.multi_compare import plot_multi_compare + + +@click.command("compare-multi", no_args_is_help=True) +@click.argument("files", nargs=-1, type=click.Path(exists=True), required=True) +@click.option( + "-m", + "--metric", + type=click.Choice(["cpu", "memory", "system_load", "swap", "io", "all"], + case_sensitive=False), + required=True, + help="Metric to compare.", +) +@click.option( + "-o", + "--output-prefix", + default="vmstat", + help="Output filename prefix.", +) +@click.option( + "-e", + "--output-extension", + default="png", + help="Output format: png or svg.", +) +def compare_multi(files, metric, output_prefix, output_extension): + """Compare N vmstat log files on a given metric. + + Pass two or more log files as arguments. + """ + if len(files) < 2: + raise click.UsageError("At least 2 files are required for comparison.") + + parsers = [] + for f in files: + p = Parser(f) + p.parse() + print(f">>> Parsed {len(p.timeseries)} entries from {f}") + parsers.append(p) + + plot_multi_compare( + parsers, metric, + output_file_prefix=output_prefix, + output_format=output_extension, + ) diff --git a/src/vmstat_visualizer/multi_compare.py b/src/vmstat_visualizer/multi_compare.py new file mode 100644 index 0000000..7564208 --- /dev/null +++ b/src/vmstat_visualizer/multi_compare.py @@ -0,0 +1,136 @@ +"""Multi-file comparison plotting for N vmstat log files.""" + +import datetime +import os +import time +import matplotlib.pyplot as plt + + +LINE_STYLES = ['-', '--', '-.', ':', (0, (3, 1, 1, 1))] +COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', + '#1abc9c', '#e67e22', '#34495e', '#16a085', '#c0392b'] + + +def _style_for(idx): + """Return (color, linestyle) for file index.""" + color = COLORS[idx % len(COLORS)] + style = LINE_STYLES[idx % len(LINE_STYLES)] + return color, style + + +def plot_multi_compare(parsers, metric, output_file_prefix='vmstat', + output_format='png'): + """Compare N parsed files on a given metric. + + Args: + parsers: list of Parser instances (already parsed) + metric: one of 'cpu', 'memory', 'system_load', 'swap', 'io', 'all' + output_file_prefix: filename prefix + output_format: 'png' or 'svg' + """ + if metric == 'all': + for m in ['cpu', 'memory', 'system_load', 'swap', 'io']: + plot_multi_compare(parsers, m, output_file_prefix, output_format) + return + + from vmstat_visualizer.parser.parser import Parser + + all_metrics = [] + filenames = [] + for p in parsers: + pm = Parser.PlotMetrics(p.timeseries, relative_time=True) + all_metrics.append(pm) + filenames.append(os.path.basename(p.filename)) + + max_len = max(len(pm.t) for pm in all_metrics) + t = list(range(max_len)) + + now_unix = int(time.mktime( + datetime.datetime.now().replace(second=0, microsecond=0).timetuple() + )) + + metric_configs = { + 'cpu': { + 'title': 'CPU Usage (%) - Multi Comparison', + 'ylabel': 'Percent', + 'series': [ + ('User CPU', 'user_cpu_percent'), + ('System CPU', 'system_cpu_percent'), + ('Idle CPU', 'idle_cpu_percent'), + ('Wait CPU', 'wait_cpu_percent'), + ('Steal CPU', 'steal_cpu_percent'), + ] + }, + 'memory': { + 'title': 'Memory Usage (KB) - Multi Comparison', + 'ylabel': 'Memory (KB)', + 'series': [ + ('Free', 'free_memory_kb'), + ('Inactive', 'inactive_memory_kb'), + ('Active', 'active_memory_kb'), + ('Swapped', 'swapped_memory_kb'), + ] + }, + 'system_load': { + 'title': 'System Load - Multi Comparison', + 'ylabel': 'Processes', + 'series': [ + ('Run Queue', 'run_queue'), + ('Blocked', 'blocked_processes'), + ] + }, + 'swap': { + 'title': 'Swap Usage - Multi Comparison', + 'ylabel': 'KB', + 'series': [ + ('Swap In', 'swap_in_kb'), + ('Swap Out', 'swap_out_kb'), + ('Swapped', 'swapped_memory_kb'), + ] + }, + 'io': { + 'title': 'Block I/O - Multi Comparison', + 'ylabel': 'Blocks', + 'series': [ + ('Blocks In', 'blocks_in'), + ('Blocks Out', 'blocks_out'), + ] + }, + } + + config = metric_configs[metric] + fig, ax = plt.subplots(figsize=(12, 5)) + + for file_idx, (pm, fname) in enumerate(zip(all_metrics, filenames)): + color, style = _style_for(file_idx) + for series_name, attr in config['series']: + data = getattr(pm, attr, []) + if not data or all(v is None for v in data): + continue + values = [int(v) for v in data] + ax.plot( + t[:len(values)], values, + label=f'{series_name} ({fname})', + color=color, linestyle=style, linewidth=1.5, + alpha=0.8, + ) + + ax.set_title(config['title']) + ax.set_xlabel('Relative Time (seconds)') + ax.set_ylabel(config['ylabel']) + ax.set_ylim(bottom=0) + ax.legend(loc='best', fontsize=7, ncol=2) + plt.tight_layout() + + starts = [] + for p in parsers: + if p.timeseries: + starts.append(f"{os.path.basename(p.filename)}: {p.timeseries[0].time}") + start_text = " | ".join(starts) + plt.figtext(0.99, 0.01, f"Start times: {start_text}", + horizontalalignment='right', fontsize=6, color='gray') + + outfile = f'{output_file_prefix}_{metric}_multi_{now_unix}.{output_format}' + plt.savefig(outfile, bbox_inches='tight') + plt.close() + print(f">>> Saved {outfile}") From 6a4c78eb8ffb5c5acd54cc21f24cc787613ab570 Mon Sep 17 00:00:00 2001 From: nikfot Date: Sat, 16 May 2026 10:24:35 +0300 Subject: [PATCH 2/2] Add tests for multi-file comparison feature --- tests/test_cli.py | 190 ++++++++++++++++++++++++++---------- tests/test_multi_compare.py | 125 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 50 deletions(-) create mode 100644 tests/test_multi_compare.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 073361e..0a32878 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,12 @@ -import os import glob + import pytest -from unittest.mock import patch from click.testing import CliRunner -from vmstat_visualizer.cli.__main__ import cli +from unittest.mock import patch + import vmstat_visualizer.cli.visualizer as viz +from vmstat_visualizer.cli.__main__ import cli +from vmstat_visualizer.cli.multi_compare import compare_multi @pytest.fixture(autouse=True) @@ -12,103 +14,191 @@ def _register_commands(): """Ensure commands are registered before each test.""" cli.add_command(viz.visualize) cli.add_command(viz.compare) + cli.add_command(compare_multi) 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 + assert "compare-multi" 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 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: - @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", - return_value=(True, True)) + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) 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 - @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", - return_value=(True, True)) + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) 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 class TestCompareCommand: - @patch("vmstat_visualizer.parser.parser.check_vmstat_columns", - return_value=(True, True)) + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) 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)) + @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", - return_value=(True, True)) + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) 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 + assert "Filtering columns: us, sy" in result.output 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 + + +class TestCompareMultiCommand: + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_compare_multi_cpu_png(self, mock_check, active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "multic") + result = runner.invoke( + cli, + [ + "compare-multi", + active_log, + active_log, + "-m", + "cpu", + "-o", + prefix, + ], + ) + assert result.exit_code == 0 + pngs = glob.glob(str(tmp_path / "multic_cpu_multi_*.png")) + assert len(pngs) == 1 + + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_compare_multi_all_five_outputs(self, mock_check, + active_log, tmp_path): + runner = CliRunner() + prefix = str(tmp_path / "fall") + result = runner.invoke( + cli, + [ + "compare-multi", + active_log, + active_log, + "-m", + "all", + "-o", + prefix, + ], + ) + assert result.exit_code == 0 + pngs = glob.glob(str(tmp_path / "fall_*_multi_*.png")) + assert len(pngs) == 5 + + def test_compare_multi_help_lists_metrics(self): + runner = CliRunner() + result = runner.invoke(cli, ["compare-multi", "--help"]) + assert result.exit_code == 0 + assert "cpu|memory|" in result.output or "[cpu|" in result.output + assert "system_load" in result.output + + def test_compare_multi_single_file_fails(self, active_log): + runner = CliRunner() + result = runner.invoke( + cli, + ["compare-multi", active_log, "-m", "cpu"], + ) + assert result.exit_code != 0 + assert "At least 2 files" in result.output + diff --git a/tests/test_multi_compare.py b/tests/test_multi_compare.py new file mode 100644 index 0000000..978ebe9 --- /dev/null +++ b/tests/test_multi_compare.py @@ -0,0 +1,125 @@ +import glob +import os + +import matplotlib + +matplotlib.use("Agg") + +import pytest +from unittest.mock import patch + +from vmstat_visualizer.multi_compare import ( + COLORS, + LINE_STYLES, + _style_for, + plot_multi_compare, +) +from vmstat_visualizer.parser.parser import Parser + + +class TestStyleFor: + def test_returns_first_palette_entry(self): + c, s = _style_for(0) + assert c == COLORS[0] + assert s == LINE_STYLES[0] + + def test_colors_wrap_when_index_exceeds_length(self): + idx = len(COLORS) + 7 + c, _ = _style_for(idx) + assert c == COLORS[idx % len(COLORS)] + + def test_line_styles_wrap_when_index_exceeds_length(self): + idx = len(LINE_STYLES) + 3 + _, s = _style_for(idx) + assert s == LINE_STYLES[idx % len(LINE_STYLES)] + + def test_combo_wrap_large_index(self): + idx = len(COLORS) * len(LINE_STYLES) + 42 + c, s = _style_for(idx) + assert c == COLORS[idx % len(COLORS)] + assert s == LINE_STYLES[idx % len(LINE_STYLES)] + + +@pytest.fixture +def two_parsers(active_log): + with patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ): + p1 = Parser(active_log) + p1.parse() + p2 = Parser(active_log) + p2.parse() + return [p1, p2] + + +@pytest.fixture +def three_parsers(active_log, standard_log): + with patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ): + parsers = [] + for path in (active_log, standard_log, active_log): + p = Parser(path) + p.parse() + parsers.append(p) + return parsers + + +class TestPlotMultiCompare: + def test_plot_cpu_naming_two_parsers(self, two_parsers, tmp_path): + prefix = str(tmp_path / "pfx") + plot_multi_compare(two_parsers, "cpu", output_file_prefix=prefix) + matches = glob.glob(str(tmp_path / "pfx_cpu_multi_*.png")) + assert len(matches) == 1 + basename = os.path.basename(matches[0]) + assert basename.startswith("pfx_cpu_multi_") + assert basename.endswith(".png") + + def test_plot_all_metric_five_files(self, two_parsers, tmp_path): + prefix = str(tmp_path / "mall") + plot_multi_compare(two_parsers, "all", output_file_prefix=prefix) + pngs = glob.glob(str(tmp_path / "mall_*_multi_*.png")) + assert len(pngs) == 5 + basenames = {os.path.basename(f) for f in pngs} + for metric in ("cpu", "memory", "system_load", "swap", "io"): + assert any( + name.startswith(f"mall_{metric}_multi_") + and name.endswith(".png") + for name in basenames + ) + + @pytest.mark.parametrize( + "metric", + ["memory", "system_load", "swap", "io"], + ) + def test_plot_individual_metric_output( + self, two_parsers, tmp_path, metric + ): + prefix = str(tmp_path / metric) + plot_multi_compare( + two_parsers, + metric, + output_file_prefix=prefix, + ) + hits = glob.glob(str(tmp_path / f"{metric}_{metric}_multi_*.png")) + assert len(hits) == 1 + + def test_svg_output_format(self, two_parsers, tmp_path): + prefix = str(tmp_path / "sv") + plot_multi_compare( + two_parsers, + "cpu", + output_file_prefix=prefix, + output_format="svg", + ) + svgs = glob.glob(str(tmp_path / "sv_cpu_multi_*.svg")) + assert len(svgs) == 1 + + def test_three_parsers(self, three_parsers, tmp_path): + prefix = str(tmp_path / "t3") + plot_multi_compare(three_parsers, "swap", output_file_prefix=prefix) + assert ( + len(glob.glob(str(tmp_path / "t3_swap_multi_*.png"))) == 1 + )