Log analyser CLI
A typer-based CLI with dataclasses, regex parsing, pathlib, and packaging via uv — end-to-end fundamentals.
Goal
Build a fast command-line tool that parses, filters, and summarises log files. Output as table, JSON, or CSV. Distribute via PyPI.
This project lets you exercise idiomatic Python end-to-end: regex parsing, dataclasses, generators, pathlib, typer CLI, packaging with uv, full pytest suite, and CI.
Why this project
Recruiters give weight to "CLI on PyPI" because it shows: you can publish, structure, test, and document. Logs are ubiquitous; the tool is genuinely useful.
User experience
powershell
logq parse access.log
# Shows a table of (timestamp, method, path, status, latency_ms)
logq parse access.log --since 1h --status 500 --top 10
# Last hour, only 5xx, top 10 by latency
logq summary access.log --by status
# Counts grouped by status code
logq parse access.log --format json | jq .
cat access.log | logq parse - --format csvTech stack
typer(CLI) +rich(tables) +pydantic(config + model).re+ dataclasses for log lines.pytest+hypothesisfor tests.ruff+mypy --strict+pre-commit.uvfor builds; GitHub Actions for CI + release; PyPI for publishing.
Architecture
src/logq/
├── __init__.py
├── __main__.py # entry for `python -m logq`
├── cli.py # typer app
├── models.py # LogRecord dataclass
├── parsers/
│ ├── __init__.py
│ ├── base.py # Parser ABC
│ ├── nginx.py # nginx combined log format
│ └── json_lines.py # JSON-line logs
├── filters.py # since, level, status, regex
├── formatters/
│ ├── table.py # rich.Table
│ ├── json_out.py
│ └── csv_out.py
└── utils.py
tests/
├── conftest.py # fixtures: sample logs
├── test_parsers.py
├── test_filters.py
├── test_cli.py # typer.testing.CliRunner
└── test_properties.py # hypothesisSpec
LogRecord
python
from dataclasses import dataclass
from datetime import datetime
@dataclass(slots=True, frozen=True)
class LogRecord:
ts: datetime
method: str
path: str
status: int
latency_ms: float | None
bytes_sent: int | None
raw: strParser interface
python
from typing import Protocol, Iterator
class Parser(Protocol):
def parse(self, lines: Iterator[str]) -> Iterator[LogRecord]: ...Nginx combined format
127.0.0.1 - - [10/Oct/2026:13:55:36 -0700] "GET /index.html HTTP/1.1" 200 2326 "-" "Mozilla/5.0"Filters
--since <duration>(e.g.1h,30m,2d).--until <duration>.--status <code>(multiple OK).--method GET,POST.--path-regex <pattern>.--min-latency <ms>.
Output formats
table(default): rich table with colour by status (2xx green, 4xx yellow, 5xx red).json: array of records.csv: standard CSV.
Summary mode (logq summary)
--by status→ counts per status.--by path --top 10→ top 10 paths by request count.--by hour→ requests per hour bucket.
Acceptance criteria
logq --helpshows commands;logq parse --helpshows options.- Parses 100k nginx lines in under 1s on a modern laptop.
- Stream-parses stdin (no full in-memory buffering).
- Test coverage > 90%.
- mypy --strict passes.
- Published to TestPyPI; install via
pip install -i https://test.pypi.org/simple/ logqworks. - CI runs on Linux/macOS/Windows × Python 3.10–3.13.
Stretch goals
logq tail -fmode (watch file).- Plugin system: third-party packages can register their own parsers via entry points.
--cacheflag: parse once, store in SQLite, query fast.- Export to ClickHouse / DuckDB for ad-hoc analytics.
- HTML report mode (Jinja2 + chart.js).
- Anomaly detection on latency / status (z-score).
Key implementation hints
Streaming parser
python
def parse_file(path: Path | None) -> Iterator[LogRecord]:
if path is None:
source = sys.stdin
else:
source = path.open(encoding="utf-8", errors="replace")
with source as f:
yield from parser.parse(f)Duration parsing
python
import re
from datetime import timedelta
_DUR = re.compile(r"^(\d+)\s*(s|m|h|d|w)$")
_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
def parse_duration(s: str) -> timedelta:
m = _DUR.match(s.strip())
if not m:
raise ValueError(f"invalid duration: {s!r}")
return timedelta(**{_UNITS[m.group(2)]: int(m.group(1))})Typer command
python
import typer
from pathlib import Path
app = typer.Typer(help="Parse and summarise logs.")
@app.command()
def parse(
file: Path = typer.Argument(..., exists=True, allow_dash=True),
since: str | None = typer.Option(None),
status: list[int] = typer.Option(None),
format: str = typer.Option("table"),
) -> None:
records = parse_file(None if str(file) == "-" else file)
if since:
cutoff = datetime.now() - parse_duration(since)
records = (r for r in records if r.ts >= cutoff)
if status:
records = (r for r in records if r.status in status)
write(records, fmt=format)
if __name__ == "__main__":
app()Property-based tests
python
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=50))
def test_parser_doesnt_crash(line: str) -> None:
list(NginxParser().parse(iter([line]))) # never raisesDeliverables
- GitHub repo with README badges (CI, coverage, PyPI version).
- Tagged release on PyPI.
- 5-minute Loom demo (optional but recruiter-friendly).
- Blog post explaining a non-trivial design decision (e.g., "why streaming over list[LogRecord]").
Lessons exercised
- 01_fundamentals (all)
- 02_oop (dataclasses, protocols)
- 03_advanced (decorators, generators)
- 04_stdlib_and_modern (regex, stdlib, asyncio for tail)
- 06_testing (pytest, hypothesis, mypy)
- 09_packaging (all)
Total estimated time: 6–10 hours for the core spec, 15–25 hours with stretch goals.