Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/logslice/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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
13 changes: 13 additions & 0 deletions src/logslice/summary.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Accumulate filtered records without retaining the input lines."""

import json
from collections import Counter
from dataclasses import dataclass, field

Expand Down Expand Up @@ -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)
67 changes: 66 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
112 changes: 112 additions & 0 deletions tests/test_summary.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the streaming summary accumulator and its rendered output."""

import json
from datetime import datetime

import pytest
Expand Down Expand Up @@ -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
Loading