diff --git a/README.md b/README.md index 337f9c1..273227e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,35 @@ combines ERROR and CRITICAL messages, sorts by descending frequency, and breaks ties by first occurrence. Entries are indented by two spaces; it shows ` (none)` if there are no error messages. The skipped count covers all unparseable input lines, not filtered-out records. +JSON summary: add `--summary-format json` to print the summary as a JSON object. + +```sh +logslice app.log --summary --summary-format json +``` + +For the three example log lines above, this prints: + +```json +{ + "levels": { + "DEBUG": 0, + "INFO": 1, + "WARNING": 1, + "ERROR": 1, + "CRITICAL": 0 + }, + "top_errors": [ + { + "count": 1, + "message": "Disk full" + } + ], + "skipped_lines": 0 +} +``` + +The object has the keys `levels`, `top_errors` and `skipped_lines`. + Input is processed line by line as UTF-8, replacing invalid bytes. LF and CRLF line endings are supported; output uses UTF-8. Summary mode keeps per-level and per-distinct-error-message counters rather than loading all records into memory. @@ -149,6 +178,7 @@ have been written when a later file fails. | `--grep PATTERN` | Keep only records whose message matches the Python regular expression `PATTERN` (searched in the message only). | | `-i`, `--ignore-case` | Make `--grep` case-insensitive. | | `--summary` | Print level counts, the top 5 ERROR/CRITICAL messages and the skipped line count instead of records. | +| `--summary-format {text,json}` | Output format of `--summary`, default `text`; `json` prints the level counts, the top 5 error messages and the skipped line count as a JSON object. | | `-h`, `--help` | Show help. | ## Development diff --git a/src/logslice/cli.py b/src/logslice/cli.py index 69690b4..9c8642e 100644 --- a/src/logslice/cli.py +++ b/src/logslice/cli.py @@ -51,6 +51,10 @@ def build_parser() -> argparse.ArgumentParser: "--summary", action="store_true", help="print level counts, top 5 ERROR/CRITICAL messages, and skipped lines", ) + parser.add_argument( + "--summary-format", choices=["text", "json"], default="text", + help="output format of --summary: text or json (default: text)", + ) return parser @@ -66,6 +70,9 @@ def main(argv: list[str] | None = None) -> int: for stream in (sys.stdin, sys.stdout, sys.stderr): _configure_utf8(stream) args = build_parser().parse_args(argv) + if args.summary_format == "json" and not args.summary: + print("logslice: --summary-format requires --summary", file=sys.stderr) + return 2 pattern = None if args.grep is not None: try: @@ -105,5 +112,5 @@ def main(argv: list[str] | None = None) -> int: print(f"logslice: cannot read {path!r}: {error}", file=sys.stderr) return 2 if args.summary: - print(summary.render()) + print(summary.render_json() if args.summary_format == "json" else summary.render()) return 0 diff --git a/src/logslice/summary.py b/src/logslice/summary.py index 9d16549..7e5d1fe 100644 --- a/src/logslice/summary.py +++ b/src/logslice/summary.py @@ -1,5 +1,6 @@ """Accumulate filtered records without retaining the input lines.""" +import json from collections import Counter from dataclasses import dataclass, field @@ -32,3 +33,15 @@ def render(self) -> str: lines.append(" (none)") lines.append(f"Skipped lines: {self.skipped_lines}") return "\n".join(lines) + + def render_json(self) -> str: + """Return level counts, up to five error messages, and skipped count as JSON.""" + payload = { + "levels": {level.name: self.level_counts[level] for level in Level}, + "top_errors": [ + {"count": count, "message": message} + for message, count in self.error_messages.most_common(5) + ], + "skipped_lines": self.skipped_lines, + } + return json.dumps(payload, ensure_ascii=False, indent=2) diff --git a/tests/test_cli.py b/tests/test_cli.py index baee592..518636d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """End-to-end CLI tests using the module entry point and real UTF-8 streams.""" import importlib +import json import os import subprocess import sys @@ -48,7 +49,7 @@ def test_help_documents_inputs_options_and_accepted_forms(): "--since", "--until", "--level", "--summary", "stdin", "'-'", "YYYY-MM-DD", "HH:MM:SS", "fraction", "DEBUG", "INFO", "WARNING", "WARN", "ERROR", "CRITICAL", "case-insensitive", - "--grep", "--ignore-case", + "--grep", "--ignore-case", "--summary-format", ): assert text in result.stdout @@ -317,3 +318,67 @@ def test_ignore_case_alone_does_not_change_output(): with_flag = run_cli("-i", input_text=README_LOG) assert with_flag.returncode == plain.returncode == 0 assert with_flag.stdout == plain.stdout == README_LOG + + +TEXT_SUMMARY = ( + "Level counts:\nDEBUG: 0\nINFO: 1\nWARNING: 1\nERROR: 1\nCRITICAL: 0\n" + "Top 5 error messages:\n 1 Disk full\nSkipped lines: 0\n" +) + + +def test_summary_json_with_level_and_grep_filters(): + result = run_cli( + "--summary", "--summary-format", "json", + "--level", "warning", "--grep", "Disk", input_text=README_LOG, + ) + assert result.returncode == 0 + assert result.stderr == "" + assert result.stdout.endswith("}\n") + assert not result.stdout.endswith("\n\n") + assert json.loads(result.stdout) == { + "levels": {"DEBUG": 0, "INFO": 0, "WARNING": 0, "ERROR": 1, "CRITICAL": 0}, + "top_errors": [{"count": 1, "message": "Disk full"}], + "skipped_lines": 0, + } + + +def test_summary_json_unfiltered_counts_levels(): + result = run_cli("--summary", "--summary-format", "json", input_text=README_LOG) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert list(data) == ["levels", "top_errors", "skipped_lines"] + assert data["levels"]["INFO"] == 1 + assert data["levels"]["WARNING"] == 1 + + +@pytest.mark.parametrize("extra", [(), ("--summary-format", "text")]) +def test_summary_text_is_default_and_explicit(extra): + result = run_cli("--summary", *extra, input_text=README_LOG) + assert result.returncode == 0 + assert result.stderr == "" + assert result.stdout == TEXT_SUMMARY + + +@pytest.mark.parametrize( + "extra", + [(), ("/nonexistent/missing.log",), ("--grep", "[")], +) +def test_summary_format_json_requires_summary(extra): + result = run_cli("--summary-format", "json", *extra, input_text=README_LOG) + assert result.returncode == 2 + assert result.stdout == "" + assert "logslice: --summary-format requires --summary" in result.stderr + + +def test_summary_format_text_without_summary_is_ignored(): + result = run_cli("--summary-format", "text", input_text=README_LOG) + assert result.returncode == 0 + assert result.stderr == "" + assert result.stdout == README_LOG + + +def test_summary_format_invalid_choice_is_usage_error(): + result = run_cli("--summary", "--summary-format", "xml", input_text=README_LOG) + assert result.returncode == 2 + assert result.stdout == "" + assert "usage:" in result.stderr diff --git a/tests/test_summary.py b/tests/test_summary.py index 638ace1..4b96a93 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -1,5 +1,6 @@ """Tests for the streaming summary accumulator and its rendered output.""" +import json from datetime import datetime import pytest @@ -156,3 +157,114 @@ def test_summary_instances_do_not_share_counters(): assert not empty.level_counts assert not empty.error_messages assert empty.skipped_lines == 0 + + +README_JSON = ( + "{\n" + ' "levels": {\n' + ' "DEBUG": 0,\n' + ' "INFO": 1,\n' + ' "WARNING": 1,\n' + ' "ERROR": 1,\n' + ' "CRITICAL": 0\n' + " },\n" + ' "top_errors": [\n' + " {\n" + ' "count": 1,\n' + ' "message": "Disk full"\n' + " }\n" + " ],\n" + ' "skipped_lines": 0\n' + "}" +) + + +def test_render_json_readme_example_exact_string_and_key_order(): + summary = Summary() + summary.add(make_record(Level.INFO, "Started")) + summary.add(make_record(Level.WARNING, "Slow response")) + summary.add(make_record(Level.ERROR, "Disk full")) + + result = summary.render_json() + + assert result == README_JSON + data = json.loads(result) + assert list(data) == ["levels", "top_errors", "skipped_lines"] + assert list(data["levels"]) == [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL", + ] + + +def test_render_json_without_records_has_zero_counts_and_empty_errors(): + summary = Summary() + summary.skipped_lines = 3 + + result = summary.render_json() + + assert '"top_errors": []' in result + data = json.loads(result) + assert data["levels"] == { + "DEBUG": 0, + "INFO": 0, + "WARNING": 0, + "ERROR": 0, + "CRITICAL": 0, + } + assert data["top_errors"] == [] + assert data["skipped_lines"] == 3 + + +def test_render_json_counts_several_levels(): + summary = Summary() + for level in (Level.DEBUG, Level.DEBUG, Level.ERROR, Level.CRITICAL): + summary.add(make_record(level, "boom")) + + data = json.loads(summary.render_json()) + + assert data["levels"] == { + "DEBUG": 2, + "INFO": 0, + "WARNING": 0, + "ERROR": 1, + "CRITICAL": 1, + } + assert data["top_errors"] == [{"count": 2, "message": "boom"}] + + +def test_render_json_ties_keep_first_occurrence(): + summary = Summary() + for message in ("z", "y", "x"): + summary.add(make_record(Level.ERROR, message)) + + data = json.loads(summary.render_json()) + + assert [e["message"] for e in data["top_errors"]] == ["z", "y", "x"] + + +def test_render_json_keeps_non_ascii_characters(): + summary = Summary() + summary.add(make_record(Level.ERROR, "caf\u00e9 \u2603 down")) + + result = summary.render_json() + + assert "\u00e9" in result + assert "\u2603" in result + assert "\\u" not in result + assert json.loads(result)["top_errors"][0]["message"] == "caf\u00e9 \u2603 down" + + +def test_render_json_lists_only_five_of_eight_distinct_errors(): + summary = Summary() + for index in range(8): + summary.add(make_record(Level.ERROR, f"error-{index}")) + + data = json.loads(summary.render_json()) + + assert [e["message"] for e in data["top_errors"]] == [ + f"error-{index}" for index in range(5) + ] + assert data["levels"]["ERROR"] == 8