diff --git a/src/vmstat_visualizer/cli/__main__.py b/src/vmstat_visualizer/cli/__main__.py index 11f7094..4621b9f 100644 --- a/src/vmstat_visualizer/cli/__main__.py +++ b/src/vmstat_visualizer/cli/__main__.py @@ -1,9 +1,10 @@ """ -This is the entrypoint to netcdfella app. +Entrypoint for the vmstat-visualizer CLI. """ import click import vmstat_visualizer.cli.visualizer as viz +from vmstat_visualizer.cli.export import export @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(export) cli(prog_name="vmstat-visualizer") diff --git a/src/vmstat_visualizer/cli/export.py b/src/vmstat_visualizer/cli/export.py new file mode 100644 index 0000000..e862c65 --- /dev/null +++ b/src/vmstat_visualizer/cli/export.py @@ -0,0 +1,42 @@ +"""CLI command for exporting parsed vmstat data to JSON/CSV.""" + +import click +from vmstat_visualizer.parser.parser import Parser +from vmstat_visualizer.exporters import export_ndjson, export_csv + + +@click.command("export", no_args_is_help=True) +@click.argument("file", type=click.Path(exists=True)) +@click.option( + "--to", + "fmt", + type=click.Choice(["json", "csv"], case_sensitive=False), + required=True, + help="Output format: json (NDJSON) or csv.", +) +@click.option( + "-o", + "--output-file", + default=None, + help="Write output to a file instead of stdout.", +) +def export(file, fmt, output_file): + """Export parsed vmstat data to JSON or CSV format.""" + parser = Parser(file) + parser.parse() + + if not parser.timeseries: + click.echo("No data found in file.", err=True) + raise SystemExit(1) + + if fmt == "json": + result = export_ndjson(parser.timeseries) + else: + result = export_csv(parser.timeseries) + + if output_file: + with open(output_file, "w") as f: + f.write(result) + click.echo(f">>> Exported {len(parser.timeseries)} entries to {output_file}") + else: + click.echo(result) diff --git a/src/vmstat_visualizer/exporters.py b/src/vmstat_visualizer/exporters.py new file mode 100644 index 0000000..de64f5b --- /dev/null +++ b/src/vmstat_visualizer/exporters.py @@ -0,0 +1,64 @@ +"""Export parsed vmstat data to JSON and CSV formats.""" + +import csv +import io +import json + + +FIELD_ORDER = [ + ("time", "time"), + ("run_queue", "r"), + ("blocked_processes", "b"), + ("swapped_memory_kb", "swpd"), + ("free_memory_kb", "free"), + ("inactive_memory_kb", "inact"), + ("active_memory_kb", "active"), + ("swap_in_kb", "si"), + ("swap_out_kb", "so"), + ("blocks_in", "bi"), + ("blocks_out", "bo"), + ("interrupts", "in"), + ("context_switches", "cs"), + ("user_cpu_percent", "us"), + ("system_cpu_percent", "sy"), + ("idle_cpu_percent", "id"), + ("wait_cpu_percent", "wa"), + ("steal_cpu_percent", "st"), + ("guest_cpu_percent", "gu"), +] + + +def _entry_to_dict(ts_entry): + """Convert a Timeseries entry to a flat dict, omitting None values.""" + row = {} + for attr, short_name in FIELD_ORDER: + val = getattr(ts_entry, attr, None) + if val is not None: + row[short_name] = val + return row + + +def export_ndjson(timeseries): + """Export timeseries as newline-delimited JSON (NDJSON).""" + lines = [] + for entry in timeseries: + lines.append(json.dumps(_entry_to_dict(entry))) + return "\n".join(lines) + + +def export_csv(timeseries): + """Export timeseries as CSV.""" + if not timeseries: + return "" + + all_rows = [_entry_to_dict(entry) for entry in timeseries] + fieldnames = list(dict.fromkeys( + key for row in all_rows for key in row.keys() + )) + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + for row in all_rows: + writer.writerow(row) + return output.getvalue() diff --git a/tests/test_cli.py b/tests/test_cli.py index 073361e..3dca2b6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,15 @@ -import os +import csv import glob +import io +import json + 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.export import export @pytest.fixture(autouse=True) @@ -12,103 +17,213 @@ def _register_commands(): """Ensure commands are registered before each test.""" cli.add_command(viz.visualize) cli.add_command(viz.compare) + cli.add_command(export) 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 "export" 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 + + +def _json_lines(cli_output): + """NDJSON rows only (parser prints status lines before JSON).""" + return [ln for ln in cli_output.splitlines() if ln.strip().startswith("{")] + + +def _csv_text_from_output(cli_output): + """CSV starts at header row ``time,...`` after optional parser preamble.""" + lines = cli_output.splitlines() + start = next((i for i, ln in enumerate(lines) if ln.startswith("time,")), None) + assert start is not None, "expected CSV header in output" + return "\n".join(lines[start:]) + + +class TestExportCommand: + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_export_json_stdout(self, mock_check, standard_log): + runner = CliRunner() + result = runner.invoke(cli, ["export", standard_log, "--to", "json"]) + assert result.exit_code == 0 + jl = _json_lines(result.output) + assert len(jl) == 5 + first = json.loads(jl[0]) + assert first["time"] == "2025-09-15 14:00:01" + assert first["r"] == "2" + assert "free" in first + + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_export_csv_stdout(self, mock_check, standard_log): + runner = CliRunner() + result = runner.invoke(cli, ["export", standard_log, "--to", "csv"]) + assert result.exit_code == 0 + csv_blob = _csv_text_from_output(result.output) + reader = csv.DictReader(io.StringIO(csv_blob)) + rows = list(reader) + assert len(rows) == 5 + assert rows[0]["time"] == "2025-09-15 14:00:01" + assert rows[0]["r"] == "2" + + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_export_json_to_file(self, mock_check, standard_log, tmp_path): + runner = CliRunner() + out = tmp_path / "out.ndjson" + result = runner.invoke( + cli, ["export", standard_log, "--to", "json", "-o", str(out)] + ) + assert result.exit_code == 0 + text = out.read_text() + assert text.startswith('{"time"') + assert "Exported 5 entries" in result.output + lines = [ln for ln in text.splitlines() if ln.strip()] + assert len(lines) == 5 + json.loads(lines[0]) + + def test_export_help_mentions_formats(self): + runner = CliRunner() + result = runner.invoke(cli, ["export", "--help"]) + assert result.exit_code == 0 + low = result.output.lower() + assert "json" in low and "csv" in low + + @patch( + "vmstat_visualizer.parser.parser.check_vmstat_columns", + return_value=(True, True), + ) + def test_export_empty_file_error(self, mock_check, empty_log): + runner = CliRunner() + result = runner.invoke(cli, ["export", empty_log, "--to", "json"]) + assert result.exit_code == 1 + assert "No data found" in result.output + + def test_export_missing_file_error(self, tmp_path): + runner = CliRunner() + bad = str(tmp_path / "nope.vmstat") + result = runner.invoke(cli, ["export", bad, "--to", "json"]) + assert result.exit_code == 2 + assert "does not exist" in result.output + diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 0000000..80189dd --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,102 @@ +"""Unit tests for JSON/CSV export helpers.""" + +import csv +import io +import json + +from vmstat_visualizer.exporters import ( + FIELD_ORDER, + _entry_to_dict, + export_csv, + export_ndjson, +) +from vmstat_visualizer.parser.timeseries import Timeseries + + +class TestEntryToDict: + def test_short_names_and_known_values(self): + ts = Timeseries() + ts.time = "2025-09-15 14:00:01" + ts.run_queue = "2" + ts.blocked_processes = "0" + ts.user_cpu_percent = "5" + d = _entry_to_dict(ts) + assert d["time"] == "2025-09-15 14:00:01" + assert d["r"] == "2" + assert d["b"] == "0" + assert d["us"] == "5" + + def test_omits_none_attributes(self): + ts = Timeseries() + ts.time = "2025-01-01 00:00:00" + ts.run_queue = "1" + # All other attrs remain None by default + d = _entry_to_dict(ts) + assert set(d.keys()) == {"time", "r"} + assert "b" not in d + assert "swpd" not in d + + def test_field_order_covers_exporter_mapping(self): + """Sanity check: FIELD_ORDER attrs exist on Timeseries.""" + attrs = [a for a, _ in FIELD_ORDER] + ts = Timeseries() + missing = [a for a in attrs if not hasattr(ts, a)] + assert missing == [], f"Timeseries missing attrs: {missing}" + + +class TestExportNdjson: + def test_each_line_valid_json_and_fields(self): + ts1 = Timeseries() + ts1.time = "t1" + ts1.run_queue = "1" + ts2 = Timeseries() + ts2.time = "t2" + ts2.blocks_in = "10" + out = export_ndjson([ts1, ts2]) + lines = out.splitlines() + assert len(lines) == 2 + d1 = json.loads(lines[0]) + d2 = json.loads(lines[1]) + assert d1 == {"time": "t1", "r": "1"} + assert d2 == {"time": "t2", "bi": "10"} + + def test_empty_timeseries(self): + assert export_ndjson([]) == "" + + +class TestExportCsv: + def test_header_and_rows(self): + ts1 = Timeseries() + ts1.time = "2025-09-15 14:00:01" + ts1.run_queue = "2" + ts1.user_cpu_percent = "5" + + ts2 = Timeseries() + ts2.time = "2025-09-15 14:00:02" + ts2.run_queue = "1" + ts2.user_cpu_percent = "3" + + csv_text = export_csv([ts1, ts2]) + reader = csv.DictReader(io.StringIO(csv_text)) + rows = list(reader) + assert reader.fieldnames is not None + assert rows[0]["time"] == "2025-09-15 14:00:01" + assert rows[0]["r"] == "2" + assert rows[0]["us"] == "5" + assert rows[1]["time"] == "2025-09-15 14:00:02" + assert rows[1]["r"] == "1" + assert rows[1]["us"] == "3" + + def test_multiple_rows(self): + entries = [] + for i in range(3): + ts = Timeseries() + ts.time = f"t{i}" + ts.idle_cpu_percent = str(90 + i) + entries.append(ts) + csv_text = export_csv(entries) + body_lines = [ln for ln in csv_text.strip().split("\n")] + assert len(body_lines) == 4 # header + 3 data rows + + def test_empty_timeseries(self): + assert export_csv([]) == ""