DEV Community

William Baptist
William Baptist

Posted on

Building a Log Reader in Python

In this guide, you will build a small Python log reader that separates accepted entries from rejected ones and keeps their line numbers and original text. You will see what was read, what failed and why. It reads one saved practice file and never changes it.

This is Part 1 of a connected log-analysis series. Part 2 uses the same reader to count events by source, destination and port. Later guides can build time-window and comparison work on those foundations. The article is useful without a shop product or a live network tool.

Define the input before writing the reader

The practice format has four fields separated by a tab: time, source address, destination address and destination port. Each line represents one invented connection-log event, not a packet or a complete session. It is a teaching format created for this guide, not a claimed Wireshark, Zeek or firewall export format.

The first line must be Time, Source, Destination, Port, with actual tabs between the names. The time uses exactly YYYY-MM-DDThh:mm:ssZ; Z means Coordinated Universal Time (UTC), the shared reference time used here rather than a local clock. Fractional seconds, time differences such as +01:00 and leap-second values are not supported. The addresses use Internet Protocol version 4 (IPv4), the four-number address format such as 192.0.2.10. Each part is separated by a dot, with no extra leading zeros. Internet Protocol version 6 (IPv6), the newer address format that uses groups such as 2001:db8::1, is outside this example. Ports are whole numbers from 1 to 65535; that is this format's limit, not a claim that port zero never appears elsewhere.

Use only the invented sample. Its addresses come from the ranges reserved for documentation by RFC 5737, the published internet reference. No real traffic was captured or analysed.

Create practice.tsv in a new folder. TSV means tab-separated values. The following display uses actual tabs, not groups of spaces:

Time    Source  Destination Port
2026-09-30T09:00:00Z    192.0.2.10  198.51.100.20   443
2026-09-30T09:00:01Z    192.0.2.10  198.51.100.20   443
2026-09-30T09:00:02Z    192.0.2.11  203.0.113.30    53
2026-02-30T09:00:03Z    192.0.2.10  198.51.100.20   443
2026-09-30T09:00:04Z    192.0.2.11  203.0.113.30    70000
Enter fullscreen mode Exit fullscreen mode

If copying changes the tabs into spaces, the reader will reject the format rather than guess. Use a plain-text editor that preserves tabs. The two last rows are intentionally wrong: 30 February is not a real date, and 70000 is outside the supported port range.

The reader supports ordinary printable ASCII characters plus tabs, which is enough for this format's times, addresses and ports. It rejects other bytes, including terminal control characters. It does not support arbitrary user names, free-text descriptions or every international text encoding.

Build a reusable Python module

A Python module is a separate source file containing code another program can import and use. Save the following as LogReader.py. The file has no code comments; the guide explains it.

from dataclasses import dataclass as DataClass
from datetime import datetime as DateTime
from pathlib import Path


@DataClass
class LogEntry:
    Number: int
    Original: str
    EventTime: str = ""
    Source: str = ""
    Destination: str = ""
    Port: int = 0
    Problem: str = ""
    Accepted: bool = False


def Digits(Value, Maximum):
    if not Value:
        raise ValueError("empty number")
    if any(Character not in "0123456789" for Character in Value):
        raise ValueError("number needs ordinary digits")
    Number = int(Value)
    if Number > Maximum:
        raise ValueError("number outside supported range")
    return Number


def CheckAddress(Value):
    Parts = Value.split(".")
    if len(Parts) != 4:
        raise ValueError("expected four IPv4 address parts")
    for Part in Parts:
        if len(Part) > 3 or (len(Part) > 1 and Part.startswith("0")):
            raise ValueError("use canonical dotted IPv4 addresses")
        Digits(Part, 255)


def CheckTime(Value):
    if len(Value) != 20:
        raise ValueError("expected time YYYY-MM-DDThh:mm:ssZ")
    if any(Value[Index] != Mark for Index, Mark in
           ((4, "-"), (7, "-"), (10, "T"), (13, ":"), (16, ":"), (19, "Z"))):
        raise ValueError("expected time YYYY-MM-DDThh:mm:ssZ")
    NumberText = Value[0:4] + Value[5:7] + Value[8:10] + Value[11:13] + Value[14:16] + Value[17:19]
    if any(Character not in "0123456789" for Character in NumberText):
        raise ValueError("time needs ordinary digits")
    Year = Digits(Value[0:4], 9999)
    Month = Digits(Value[5:7], 12)
    Day = Digits(Value[8:10], 31)
    Hour = Digits(Value[11:13], 23)
    Minute = Digits(Value[14:16], 59)
    Second = Digits(Value[17:19], 59)
    try:
        DateTime(Year, Month, Day)
    except ValueError:
        raise ValueError("date does not exist") from None
    try:
        DateTime(Year, Month, Day, Hour, Minute, Second)
    except ValueError:
        raise ValueError("time does not exist") from None


def ParseEntry(Entry):
    try:
        if len(Entry.Original) > 1024:
            raise ValueError("line exceeds 1024 bytes")
        if any(Character != "\t" and not 32 <= ord(Character) <= 126
               for Character in Entry.Original):
            raise ValueError("unsupported byte in practice format")
        Fields = Entry.Original.split("\t")
        if len(Fields) != 4:
            raise ValueError("expected exactly four tab-separated fields")
        CheckTime(Fields[0])
        CheckAddress(Fields[1])
        CheckAddress(Fields[2])
        Entry.Port = Digits(Fields[3], 65535)
        if Entry.Port == 0:
            raise ValueError("port must be 1 to 65535 in this format")
        Entry.EventTime, Entry.Source, Entry.Destination = Fields[:3]
        Entry.Accepted = True
    except ValueError as Error:
        Entry.Problem = str(Error)
    return Entry


def ReadLog(FileName):
    with Path(FileName).open("rb") as Input:
        Data = Input.read(1048577)
    if len(Data) > 1048576:
        raise ValueError("input exceeds 1 MiB teaching limit")
    if not Data:
        raise ValueError("empty input")
    Lines = Data.split(b"\n")
    if Lines[-1] == b"":
        Lines.pop()
    Lines = [Line[:-1] if Line.endswith(b"\r") else Line for Line in Lines]
    if Lines[0] != b"Time\tSource\tDestination\tPort":
        raise ValueError("unsupported header")
    if len(Lines) - 1 > 1000:
        raise ValueError("more than 1000 data lines")
    return [ParseEntry(LogEntry(Number, Line.decode("latin-1")))
            for Number, Line in enumerate(Lines[1:], start=2)]
Enter fullscreen mode Exit fullscreen mode

LogEntry keeps the physical line number, original line, parsed fields, rejection reason and accepted flag. The header is line 1. The original text is kept using a one-byte-to-one-character reading format called Latin-1, excluding the line ending. That preserves rejected bytes for review; accepted fields still allow only the ASCII characters described above. This is not a byte-for-byte archival copy; keep the original file itself.

Digits allows ordinary decimal digits only and checks the value against its allowed maximum. Python integers keep these whole numbers exact without a fixed-width overflow. CheckAddress requires four supported address parts. CheckTime checks both the spelling and whether the date and clock time exist. It accepts one deliberately narrow UTC spelling rather than guessing time zones.

ParseEntry turns a wrong data line into a rejected entry with a reason. It does not abort the whole review for one malformed event. ReadLog does stop for an unsupported header, missing file or teaching-size limit: those problems mean the overall input cannot be handled as promised. The reader loads the file before displaying results, so a stopped load produces no misleading partial report.

Add a small program to display the result

Save this beside the module as ReadLogDemo.py:

import sys as Sys
from LogReader import ReadLog


def Main():
    if len(Sys.argv) != 2:
        print("Usage: python3 ReadLogDemo.py practice.tsv", file=Sys.stderr)
        return 2
    try:
        Entries = ReadLog(Sys.argv[1])
    except (OSError, ValueError) as Error:
        print(f"Input stopped: {Error}", file=Sys.stderr)
        return 2
    Good = 0
    Bad = 0
    for Entry in Entries:
        if Entry.Accepted:
            Good += 1
            print(f"Accepted line {Entry.Number}: {Entry.EventTime} "
                  f"{Entry.Source} -> {Entry.Destination}:{Entry.Port}")
        else:
            Bad += 1
            print(f"Rejected line {Entry.Number}: {Entry.Problem}")
    print(f"Accepted: {Good}; rejected: {Bad}. Original file unchanged.")
    return 1 if Bad else 0


if __name__ == "__main__":
    raise SystemExit(Main())
Enter fullscreen mode Exit fullscreen mode

Install Python 3 through the supported route for your computer. Open a command window in the folder containing both source files and run:

python3 ReadLogDemo.py practice.tsv
Enter fullscreen mode Exit fullscreen mode

No extra packages are needed. LogReader.py must be beside the program so Python can import it. The actual runs used Python 3.10.12 on Linux. Windows and macOS were not tested. The command name may differ on another installation; these are the Linux instructions used here.

All names introduced by the example use PascalCase: each word starts with a capital letter, such as ReadLog and EventTime. Python's own names, library methods and the special __name__ / __main__ spellings stay exact. Changing those would change or break Python's behaviour.

Actual output:

Accepted line 2: 2026-09-30T09:00:00Z 192.0.2.10 -> 198.51.100.20:443
Accepted line 3: 2026-09-30T09:00:01Z 192.0.2.10 -> 198.51.100.20:443
Accepted line 4: 2026-09-30T09:00:02Z 192.0.2.11 -> 203.0.113.30:53
Rejected line 5: date does not exist
Rejected line 6: number outside supported range
Accepted: 3; rejected: 2. Original file unchanged.
Enter fullscreen mode Exit fullscreen mode

The program returns 1 because some data lines were rejected. That is useful information for a script running it, not a verdict that a security incident occurred. A fully accepted input returns 0; a load failure or missing argument returns 2.

Test the boundary cases, not only the good row

The rebuilt Python reader and the Part 2 summary were run against 34 input/format cases each, plus missing-file and missing-argument cases for each program: 72 checks total. They covered exact field count, blank lines, impossible dates, unsupported time spellings, bad addresses, invalid ports, control/non-ASCII bytes, line and record limits, empty/header-only input, Windows-style line endings, no final newline and exact repeated events. Both programs left the input bytes unchanged in those runs.

The sample output was separately reproduced. A separate summary-order run also checked that six invented entries were sorted into counts 3, 2 and 1. These are local invented-file tests, not live-tool compatibility or a performance benchmark.

Keep the limits visible

The reader opens the input in binary mode and reads at most 1 MiB plus one byte. The extra byte lets it reject a file above the 1 MiB teaching limit without reading an unlimited file. It accepts at most 1,000 data lines and rejects a data line longer than 1,024 bytes after reading. This is a bounded teaching example, not a guarantee about a file somebody else changes while it is open. Use a stable practice file. It does not preserve a protected snapshot of a growing log.

The accepted flag means the line fits this format. It does not prove the event happened, that the logging system was honest, or that no other events are missing. Repeated entries stay separate. Their count is a count of log entries, not automatically a count of distinct actions.

The original rejected text remains in the in-memory result for a reviewer to inspect deliberately. The display program prints the line number and reason, not the raw rejected text, avoiding blindly echoing control bytes into the command window. Keep actual logs and review output private. If you save command-window output, choose a new report filename. Do not redirect output over the input file: the command window can empty that file before the program reads it.

Part 2 turns accepted entries into a connection summary and keeps the rejected count beside it. The next task is grouping useful evidence, not hiding the inconvenient lines.

References

Use only data you have permission to read. No live capture, scanning, attack detection or file modification is performed.

Top comments (0)