Tidy Desk Digital ยท Free guides

Building a Time-Window Log Summary in Python

Count log entries between two chosen times, then group them by source, destination and port. This makes the chosen period visible instead of hiding it inside a whole-file total.

You'll need Python 3, a plain-text editor and a command window. No extra packages are required.

1. Create the log file

Use a tab-separated text file with four columns: time, source address, destination address and destination port. The header must be Time, Source, Destination, Port, with actual tabs between the names. A .tsv file is a plain-text table that uses tabs to separate columns.

Write times as YYYY-MM-DDThh:mm:ssZ. The Z means Coordinated Universal Time (UTC), the shared reference time rather than a local clock. This reader accepts whole seconds in that exact form, not fractional seconds, local offsets such as +01:00 or leap-second values. Addresses use Internet Protocol version 4 (IPv4), four dot-separated numbers such as 192.0.2.10, without extra leading zeros. Ports are whole numbers from 1 to 65535.

The sample addresses are reserved for documentation in RFC 5737, a published document describing addresses to use in examples. Preserve the tabs when copying. This is the reader's own input format, not a direct export from a network-monitoring product.

Save this as window.tsv. The rows are out of time order, and the last has an impossible date:

Time	Source	Destination	Port
2026-09-30T09:05:00Z	192.0.2.10	198.51.100.20	443
2026-09-30T09:00:00Z	192.0.2.10	198.51.100.20	443
2026-09-30T09:10:00Z	192.0.2.11	203.0.113.30	53
2026-09-30T09:10:00Z	192.0.2.11	203.0.113.30	53
2026-09-30T09:15:00Z	192.0.2.12	203.0.113.31	22
2026-09-30T09:02:00Z	192.0.2.10	198.51.100.20	443
2026-02-30T09:03:00Z	192.0.2.10	198.51.100.20	443

2. Add the reader

The complete reader is included below so you can run this guide on its own.

Save this as LogReader.py. This reusable source file checks each row and keeps its physical line number, either with accepted values or a reason it could not be read.

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)]

The reader handles up to 1,000 data lines in a file of at most 1 MiB, roughly one million bytes, and rejects data lines longer than 1,024 bytes. Its fields use ordinary printable English-character bytes and tabs. Use a saved file that will not change while it is read.

3. Add the window counter

Save this as WindowLog.py, beside the reader:

import sys as Sys
from LogReader import ReadLog, CheckTime


def Main():
    if len(Sys.argv) != 4:
        print("Usage: python3 WindowLog.py practice.tsv START END", file=Sys.stderr)
        return 2
    try:
        CheckTime(Sys.argv[2])
        CheckTime(Sys.argv[3])
        if Sys.argv[2] >= Sys.argv[3]:
            raise ValueError("START must be earlier than END")
        Entries = ReadLog(Sys.argv[1])
    except (OSError, ValueError) as Error:
        print(f"Input stopped: {Error}", file=Sys.stderr)
        return 2
    Start, End = Sys.argv[2], Sys.argv[3]
    Groups = {}
    Inside = 0
    Outside = 0
    Bad = 0
    for Entry in Entries:
        if not Entry.Accepted:
            Bad += 1
            print(f"Rejected line {Entry.Number}: {Entry.Problem}")
        elif Start <= Entry.EventTime < End:
            Inside += 1
            Key = (Entry.Source, Entry.Destination, Entry.Port)
            Groups[Key] = Groups.get(Key, 0) + 1
        else:
            Outside += 1
    print(f"Window: {Start} up to but not including {End}")
    print("Count Source Destination Port")
    for Key, Count in sorted(Groups.items(), key=lambda Item: (-Item[1], Item[0])):
        Source, Destination, Port = Key
        print(f"{Count} {Source} {Destination} {Port}")
    print(f"Inside window: {Inside}; outside window: {Outside}; rejected: {Bad}. Counts are log entries, not sessions.")
    return 1 if Bad else 0


if __name__ == "__main__":
    raise SystemExit(Main())

The key comparison is Start <= Entry.EventTime < End: include the start, but leave out the end. This is called a half-open window. An entry at 09:10:00 belongs to a window starting there, not one ending there, so adjacent windows do not double-count that boundary.

The checked timestamps have a fixed shape with the year first, so comparing their text puts them in time order. Your start and end go through the same format check before counting. Rows can appear in any order in the file.

Each accepted row inside the window adds one to its group. Accepted rows outside and rejected rows have separate totals. Rejected rows cannot be placed in a window. Results sort by count, with source, destination and port breaking ties.

4. Run the first window

Open a command window in your folder and run:

python3 WindowLog.py window.tsv 2026-09-30T09:00:00Z 2026-09-30T09:10:00Z

Use your installation's Python 3 command if it is not named python3. The output is:

Rejected line 8: date does not exist
Window: 2026-09-30T09:00:00Z up to but not including 2026-09-30T09:10:00Z
Count Source Destination Port
3 192.0.2.10 198.51.100.20 443
Inside window: 3; outside window: 3; rejected: 1. Counts are log entries, not sessions.

The entries at 09:00:00, 09:02:00 and 09:05:00 are inside. Both 09:10:00 entries are outside because the end is excluded. The impossible date appears as a rejection.

The exit code, a small result number another script can check, is 0 for counting with no rejected rows, 1 for counting with rejections, and 2 for a command, setting or load error. This sample returns 1.

5. Move the window

Start at the time you just excluded and finish one second after the final accepted entry:

python3 WindowLog.py window.tsv 2026-09-30T09:10:00Z 2026-09-30T09:15:01Z
Rejected line 8: date does not exist
Window: 2026-09-30T09:10:00Z up to but not including 2026-09-30T09:15:01Z
Count Source Destination Port
2 192.0.2.11 203.0.113.30 53
1 192.0.2.12 203.0.113.31 22
Inside window: 3; outside window: 3; rejected: 1. Counts are log entries, not sessions.

The two 09:10:00 entries are now inside, along with the 09:15:00 entry. Changing the window changes the question, not the original file.

The first window is ten minutes long; the second is only five minutes and one second. Their equal totals do not mean equal rates. For two adjacent ten-minute windows, keep the first command and run this for the second:

python3 WindowLog.py window.tsv 2026-09-30T09:10:00Z 2026-09-30T09:20:00Z

The final totals are still Inside window: 3; outside window: 3; rejected: 1. The Window line now ends at 09:20:00Z. The first window excludes 09:10:00, and the second includes it: the two boundary entries are counted once across the pair, not twice. Both windows contain three accepted entries in ten minutes, but their groups differ. The rejected row remains visible in both reports and cannot be assigned a time.

For activity comparisons, use equal window lengths and understand the collection period. These are accepted-row counts, not distinct sessions or an attack verdict. Repeated rows count again, and missing collection can make a quiet period look quieter than it was.

The window and summary-comparison programs passed 22 command-line checks, including both boundaries, out-of-order rows, rejections and invalid settings. Tested inputs remained unchanged.

Save reports under a new filename: redirecting output over an input can empty it before Python opens it. Keep reports private when they reveal personal or work activity.

References

More free code guides