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