Frontend Labs
⋯

23. Terminal project: pygrep, a tested grep clone

Goal: search files recursively for a regex, with -i, -n, -c, --glob, and correct exit codes (0 = match found, 1 = no match, 2 = error, like real grep). It combines generators, pathlib, argparse, dataclasses, and a pytest suite.

# src/pygrep/core.py
import re
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from pathlib import Path

@dataclass(frozen=True, slots=True)
class Match:
    path: Path
    lineno: int
    line: str

def iter_files(roots: Iterable[Path], glob: str = "*") -> Iterator[Path]:
    for root in roots:
        if root.is_file():
            yield root
        elif root.is_dir():
            for p in sorted(root.rglob(glob)):
                if p.is_file() and not any(part.startswith(".") for part in p.parts):
                    yield p

def search(pattern: re.Pattern[str], files: Iterable[Path]) -> Iterator[Match]:
    for path in files:
        try:
            with path.open(encoding="utf-8", errors="strict") as f:
                for lineno, line in enumerate(f, 1):
                    if pattern.search(line):
                        yield Match(path, lineno, line.rstrip("\n"))
        except UnicodeDecodeError:
            continue                            # binary file: skip, like grep -I
# src/pygrep/cli.py
import argparse
import re
import sys
from pathlib import Path
from .core import iter_files, search

def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="pygrep")
    p.add_argument("pattern")
    p.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
    p.add_argument("-i", "--ignore-case", action="store_true")
    p.add_argument("-n", "--line-number", action="store_true")
    p.add_argument("-c", "--count", action="store_true")
    p.add_argument("--glob", default="*")
    a = p.parse_args(argv)

    try:
        rx = re.compile(a.pattern, re.IGNORECASE if a.ignore_case else 0)
    except re.error as e:
        print(f"pygrep: bad pattern: {e}", file=sys.stderr)
        return 2

    found = 0
    for m in search(rx, iter_files(a.paths, a.glob)):
        found += 1
        if not a.count:
            prefix = f"{m.path}:{m.lineno}:" if a.line_number else f"{m.path}:"
            print(prefix + m.line)
    if a.count:
        print(found)
    return 0 if found else 1
# tests/test_pygrep.py
import re
from pygrep.cli import main
from pygrep.core import iter_files, search

def make_tree(tmp_path):
    (tmp_path / "a.py").write_text("import os\nTODO: fix\n")
    (tmp_path / "sub").mkdir()
    (tmp_path / "sub" / "b.txt").write_text("todo later\n")
    (tmp_path / ".git").mkdir()
    (tmp_path / ".git" / "c").write_text("TODO hidden\n")
    (tmp_path / "bin.dat").write_bytes(b"\xff\xfeTODO")
    return tmp_path

def test_search_skips_hidden_and_binary(tmp_path):
    root = make_tree(tmp_path)
    hits = list(search(re.compile("TODO", re.I), iter_files([root])))
    assert [(h.path.name, h.lineno) for h in hits] == [("a.py", 2), ("b.txt", 1)]

def test_exit_codes(tmp_path, capsys):
    root = make_tree(tmp_path)
    assert main(["TODO", str(root), "-c"]) == 0
    assert capsys.readouterr().out.strip() == "1"
    assert main(["nomatch", str(root)]) == 1
    assert main(["(", str(root)]) == 2

Extend it: add -v (invert), --max-count, coloured output with rich; parallelize over files with a thread pool and measure whether it helps (Ch 27 explains the answer: file I/O releases the GIL, regex matching does not).


Generated from real-life-python-guide.md · 8 Oct 2026 16:36