From 5eaf19b3a55bfcc1be7c1f81716595ad9aa41d5e Mon Sep 17 00:00:00 2001 From: Gulliby-development <20404543+Gulliby@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:32:24 +0200 Subject: [PATCH] Add --count option to print only the number of matching records --- README.md | 14 ++++ src/logslice/cli.py | 21 ++++- tests/test_cli.py | 2 +- tests/test_count.py | 200 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 tests/test_count.py diff --git a/README.md b/README.md index b8969f9..3e28e59 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,19 @@ Top 2 error messages: Skipped lines: 0 ``` +Count: add `--count` (`-c`) to print only the number of matching records, as a decimal number +and a newline, instead of the records. With the `errors.log` above, `logslice errors.log --count` +prints: + +```text +4 +``` + +and `logslice errors.log --level critical --count` prints `1`. `--count` works with the time bounds, +`--level`, `--grep`, `-i` and `-v`, adds up all input files, and prints `0` when nothing matches. +It cannot be combined with `--summary` (an error, exit status `2`). When an input file cannot be +read, no count is printed. + 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. @@ -221,6 +234,7 @@ have been written when a later file fails. | `--summary` | Print level counts, up to the top 5 ERROR/CRITICAL messages by default, and the skipped line count instead of records. | | `--summary-format {text,json}` | Output format of `--summary`, default `text`; `json` prints the level counts, up to the top 5 error messages by default, and the skipped line count as a JSON object. | | `--top N` | With `--summary`, list the N most frequent ERROR/CRITICAL messages instead of 5; `N` is 1 to 100. An error without `--summary`. | +| `-c`, `--count` | Print only the number of matching records; an error with `--summary`. | | `-h`, `--help` | Show help. | ## Development diff --git a/src/logslice/cli.py b/src/logslice/cli.py index ecfb31d..1adbea9 100644 --- a/src/logslice/cli.py +++ b/src/logslice/cli.py @@ -51,6 +51,10 @@ def build_parser() -> argparse.ArgumentParser: "-v", "--invert-match", action="store_true", help="keep only records whose message does not match --grep", ) + parser.add_argument( + "-c", "--count", action="store_true", + help="print only the number of matching records", + ) parser.add_argument( "--summary", action="store_true", help="print level counts, top 5 ERROR/CRITICAL messages, and skipped lines", @@ -66,11 +70,14 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _configure_utf8(stream: TextIO) -> None: +def _configure_utf8(stream: TextIO, *, newline: str | None = None) -> None: """Configure real standard streams, leaving in-memory test streams alone.""" reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: - reconfigure(encoding="utf-8", errors="replace") + if newline is None: + reconfigure(encoding="utf-8", errors="replace") + else: + reconfigure(encoding="utf-8", errors="replace", newline=newline) def main(argv: list[str] | None = None) -> int: @@ -87,6 +94,9 @@ def main(argv: list[str] | None = None) -> int: if args.invert_match and args.grep is None: print("logslice: --invert-match requires --grep", file=sys.stderr) return 2 + if args.count and args.summary: + print("logslice: --count conflicts with --summary", file=sys.stderr) + return 2 pattern = None if args.grep is not None: try: @@ -98,6 +108,7 @@ def main(argv: list[str] | None = None) -> int: ) return 2 summary = Summary() + matched = 0 for path in args.files or ["-"]: try: with ( @@ -119,13 +130,17 @@ def main(argv: list[str] | None = None) -> int: invert_match=args.invert_match, ): continue + matched += 1 if args.summary: summary.add(record) - else: + elif not args.count: print(record.raw) except OSError as error: print(f"logslice: cannot read {path!r}: {error}", file=sys.stderr) return 2 + if args.count: + _configure_utf8(sys.stdout, newline="\n") + print(matched) if args.summary: top = 5 if args.top is None else args.top print( diff --git a/tests/test_cli.py b/tests/test_cli.py index a6a5573..fe98361 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -49,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", "--summary-format", "--top", + "--grep", "--ignore-case", "--summary-format", "--top", "-c", "--count", ): assert text in result.stdout diff --git a/tests/test_count.py b/tests/test_count.py new file mode 100644 index 0000000..77bde59 --- /dev/null +++ b/tests/test_count.py @@ -0,0 +1,200 @@ +"""Tests for count output through the logslice CLI.""" + +import io +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from logslice.cli import main + +SAMPLE_LOG = ( + "2026-10-10 12:00:00 INFO Started\n" + "2026-10-10T12:00:00.123 warn Slow response\n" + "not a record\n" + "2026-10-10 12:01:00 ERROR Disk full\n" + "2026-10-10 12:02:00 ERROR Timeout\n" +) + + +README_LOG = ( + "2026-10-10 12:00:00 ERROR Disk full\n" + "2026-10-10 12:00:05 ERROR Timeout\n" + "2026-10-10 12:00:09 ERROR Disk full\n" + "2026-10-10 12:01:00 CRITICAL Out of memory\n" +) + + +def write_log(tmp_path, content, name="app.log"): + path = tmp_path / name + path.write_text(content, encoding="utf-8") + return path + + +def run_cli(capsys, *args): + exit_code = main([str(arg) for arg in args]) + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + +@pytest.mark.parametrize( + ("filters", "expected"), + [ + ((), "4\n"), + (("--level", "warning"), "3\n"), + (("--level", "error"), "2\n"), + (("--level", "critical"), "0\n"), + (("--since", "2026-10-10 12:01:00"), "2\n"), + (("--until", "2026-10-10T12:00:00.123"), "2\n"), + (("--grep", "Disk"), "1\n"), + (("--grep", "disk"), "0\n"), + (("--grep", "disk", "-i"), "1\n"), + (("--grep", "Disk", "-v"), "3\n"), + ], +) +def test_count_matches_filter_examples(tmp_path, capsys, filters, expected): + path = write_log(tmp_path, SAMPLE_LOG) + + exit_code, stdout, stderr = run_cli(capsys, path, *filters, "--count") + + assert exit_code == 0 + assert stdout == expected + assert stderr == "" + + +def test_count_matches_readme_errors_example(tmp_path, capsys): + path = write_log(tmp_path, README_LOG) + + exit_code, stdout, stderr = run_cli(capsys, path, "--count") + + assert exit_code == 0 + assert stdout == "4\n" + assert stderr == "" + + +def test_short_count_option_works_before_or_after_file(tmp_path, capsys): + path = write_log(tmp_path, SAMPLE_LOG) + + before_exit, before_stdout, before_stderr = run_cli(capsys, "-c", path) + after_exit, after_stdout, after_stderr = run_cli(capsys, path, "-c") + + assert (before_exit, before_stdout, before_stderr) == (0, "4\n", "") + assert (after_exit, after_stdout, after_stderr) == (0, "4\n", "") + + +def test_count_totals_records_across_multiple_files(tmp_path, capsys): + first = write_log(tmp_path, README_LOG, "first.log") + second = write_log( + tmp_path, + "2026-10-10 12:05:00 INFO Extra record\nnot a record\n", + "second.log", + ) + + exit_code, stdout, stderr = run_cli(capsys, "--count", first, second) + + assert exit_code == 0 + assert stdout == "5\n" + assert stderr == "" + + +def test_count_reads_standard_input(tmp_path, capsys, monkeypatch): + monkeypatch.setattr(sys, "stdin", io.StringIO(SAMPLE_LOG)) + + exit_code, stdout, stderr = run_cli(capsys, "--count") + + assert exit_code == 0 + assert stdout == "4\n" + assert stderr == "" + + +def test_count_subprocess_emits_lf_bytes(): + root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + filter(None, (str(root / "src"), env.get("PYTHONPATH"))) + ) + result = subprocess.run( + [sys.executable, "-B", "-m", "logslice", "--count"], + input=b"", + capture_output=True, + cwd=root, + env=env, + timeout=30, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == b"0\n" + assert result.stderr == b"" + + +def test_count_on_empty_file_prints_zero(tmp_path, capsys): + path = write_log(tmp_path, "") + + exit_code, stdout, stderr = run_cli(capsys, path, "--count") + + assert exit_code == 0 + assert stdout == "0\n" + assert stderr == "" + + +def test_records_still_print_without_count(tmp_path, capsys): + contents = ( + "2026-10-10 12:00:00 INFO Started \n" + "not a record\n" + "2026-10-10T12:00:00.123 WARN Slow response" + ) + path = write_log(tmp_path, contents) + + exit_code, stdout, stderr = run_cli(capsys, path) + + assert exit_code == 0 + assert stdout == ( + "2026-10-10 12:00:00 INFO Started \n" + "2026-10-10T12:00:00.123 WARN Slow response\n" + ) + assert stderr == "" + + +def test_count_conflicts_with_summary_before_grep_compilation(tmp_path, capsys): + path = write_log(tmp_path, SAMPLE_LOG) + invocations = [ + ("--count", "--summary"), + ("--count", "--summary", "--top", "2"), + ("--count", "--summary", "--grep", "["), + ] + + for args in invocations: + exit_code, stdout, stderr = run_cli(capsys, path, *args) + assert exit_code == 2 + assert stdout == "" + assert stderr == "logslice: --count conflicts with --summary\n" + + +def test_existing_option_errors_precede_count_summary_conflict(capsys): + invocations = [ + (("--count", "--summary-format", "json"), + "logslice: --summary-format requires --summary\n"), + (("--count", "--top", "2"), "logslice: --top requires --summary\n"), + (("--count", "--summary", "-v"), + "logslice: --invert-match requires --grep\n"), + ] + + for args, expected_error in invocations: + exit_code, stdout, stderr = run_cli(capsys, *args) + assert exit_code == 2 + assert stdout == "" + assert stderr == expected_error + + +def test_unreadable_file_does_not_print_partial_count(tmp_path, capsys): + readable = write_log(tmp_path, README_LOG, "readable.log") + missing = tmp_path / "missing.log" + + exit_code, stdout, stderr = run_cli(capsys, "--count", readable, missing) + + assert exit_code == 2 + assert stdout == "" + assert stderr.startswith("logslice: cannot read ")