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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Messages may be empty. Timestamps are timezone-naive: `Z` and UTC offsets are no
supported. Invalid lines, including blank lines and stack-trace continuations,
are skipped.

The fractional seconds may follow a dot or a comma, so the default output of Python's `logging` module (`2026-10-10 12:00:00,123 ERROR Disk full`) is read too. A matching line is printed exactly as it was written, comma included. The `--since` and `--until` values still use a dot.

Read one or more files in argument order:

```sh
Expand Down
6 changes: 3 additions & 3 deletions src/logslice/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,23 @@ class Record:

_LINE_RE = re.compile(
r"(?P<timestamp>[0-9]{4}-[0-9]{2}-[0-9]{2}[ T]"
r"[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?)"
r"[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.,][0-9]+)?)"
r"[ \t]+(?P<level>[A-Za-z]+)(?:[ \t]+(?P<message>.*))?"
)


def parse_line(line: str) -> Record | None:
"""Return a record, or None for invalid dates, levels, or line formats.

Accept space or T timestamp separators and optional fractional seconds.
Accept space or T timestamp separators and optional fractional seconds after a dot or comma.
Levels are case-insensitive. Timezone suffixes are not accepted.
"""
raw = line.rstrip("\r\n")
match = _LINE_RE.fullmatch(raw)
if match is None:
return None
try:
timestamp = datetime.fromisoformat(match["timestamp"])
timestamp = datetime.fromisoformat(match["timestamp"].replace(",", "."))
level = Level[match["level"].upper()]
except (ValueError, KeyError):
return None
Expand Down
106 changes: 106 additions & 0 deletions tests/test_comma_fraction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Tests for comma fractional seconds in log timestamps."""

from datetime import datetime

import pytest

from logslice.cli import main
from logslice.parser import Level, Record, parse_line


@pytest.mark.parametrize("separator", [" ", "T"])
def test_comma_fraction_is_read_like_a_dot(separator):
line = f"2026-10-10{separator}12:00:00,123 ERROR Disk full"

assert parse_line(line) == Record(
datetime(2026, 10, 10, 12, 0, 0, 123000), Level.ERROR, "Disk full", raw=line
)


def test_comma_and_dot_give_the_same_timestamp():
comma_record = parse_line("2026-10-10 12:00:00,5 ERROR Disk full")
dot_record = parse_line("2026-10-10 12:00:00.5 ERROR Disk full")

assert comma_record is not None
assert dot_record is not None
assert comma_record.timestamp == dot_record.timestamp
assert comma_record.timestamp.microsecond == 500000


@pytest.mark.parametrize(
"line",
[
"2026-10-10 12:00:00, INFO x",
"2026-10-10 12:00:00,,5 INFO x",
"2026-10-10 12:00:00,5,6 INFO x",
"2026-10-10,12:00:00 INFO x",
"2026-10-10 12:00,00 INFO x",
"2026-10-10 12:00:00,5Z INFO x",
],
)
def test_other_commas_are_still_rejected(line):
assert parse_line(line) is None


def test_the_original_text_keeps_its_comma():
line = "2026-10-10 12:00:00,123\tWARN\tSlow\r\n"

record = parse_line(line)

assert record is not None
assert record.raw == "2026-10-10 12:00:00,123\tWARN\tSlow"
assert record.level == Level.WARNING


def test_cli_filters_and_prints_comma_lines(tmp_path, capsys):
lines = (
"2026-10-10 12:00:00,100 INFO Started\n"
"2026-10-10 12:00:00,900 ERROR Disk full\n"
"2026-10-10 12:00:01,000 ERROR Timeout\n"
)
path = tmp_path / "app.log"
path.write_text(lines, encoding="utf-8")

exit_code = main([str(path), "--since", "2026-10-10 12:00:00.5", "--level", "error"])
captured = capsys.readouterr()

assert exit_code == 0
assert captured.out == (
"2026-10-10 12:00:00,900 ERROR Disk full\n2026-10-10 12:00:01,000 ERROR Timeout\n"
)
assert captured.err == ""


def test_cli_summary_counts_comma_lines_and_skips_bad_ones(tmp_path, capsys):
contents = (
"2026-10-10 12:00:00,100 ERROR Disk full\n"
"2026-10-10 12:00:00,900 ERROR Disk full\n"
"2026-10-10 12:00:00, ERROR Broken\n"
)
path = tmp_path / "app.log"
path.write_text(contents, encoding="utf-8")

exit_code = main([str(path), "--summary"])
captured = capsys.readouterr()

assert exit_code == 0
assert "ERROR: 2\n" in captured.out
assert " 2 Disk full\n" in captured.out
assert captured.out.endswith("Skipped lines: 1\n")
assert captured.err == ""


def test_since_and_until_still_need_a_dot(tmp_path, capsys):
path = tmp_path / "app.log"
path.write_text("", encoding="utf-8")

with pytest.raises(SystemExit) as exc_info:
main([str(path), "--since", "2026-10-10 12:00:00,5"])
captured = capsys.readouterr()

assert exc_info.value.code == 2
assert captured.out == ""
assert (
"argument --since: invalid datetime '2026-10-10 12:00:00,5': expected "
"YYYY-MM-DD or YYYY-MM-DD[ T]HH:MM:SS[.fraction] without a timezone"
) in captured.err
Loading