Advertisement
Open Source Projects by Phil Schwartz

Building a Recursive Directory Comparison Utility in Python

System administrators and developers across Sydney, Melbourne, and Perth regularly wrestle with the question of whether two folder trees are truly identical. A mismatch in a configuration directory can break a deployment pipeline, and a missing asset in a static site build can quietly corrupt a customer experience. Rolling a small Python script to surface those differences is often faster than wrestling with a heavyweight tool whose flags nobody on the team remembers.

Python's standard library already carries everything needed to walk a file tree, compute digests, and render readable output. The exercise below walks through the construction of a focused command-line utility that compares two paths, reports added, removed, and modified files, and exits with a status code suitable for CI pipelines.

Why a custom comparator beats off-the-shelf tools

Tools such as rsync with its dry-run flag or diff -r solve adjacent problems, yet both reveal limitations in real Australian production environments. rsync shines when one side is authoritative and you intend to sync, but it conflates transfer metadata with content differences. diff -r chokes on binary blobs and produces noise that buries meaningful signal. A purpose-built script can stage comparisons in tiers, checking size first and then hash, and produce structured output that drops cleanly into monitoring dashboards.

Local realities sharpen the requirement. Engineers maintaining mirrors of research datasets between AARNet-connected campuses in Brisbane and Adelaide routinely need to verify integrity after long transfers across vast distances. A lightweight CLI that returns a clean exit code on parity makes nightly reconciliation jobs far less brittle. The script also avoids the licensing surprises that occasionally accompany third-party utilities redistributed inside closed firmware.

Designing the command-line interface

A well-shaped CLI sets the tone for every downstream feature. argparse remains the most predictable choice for a single-binary utility, since it ships with the interpreter and offers free help text, type coercion, and exit codes. The interface below accepts two positional paths, an optional flag to ignore hidden files, and a switch that toggles coloured terminal output.

import argparse

parser = argparse.ArgumentParser(
    description="Recursively compare two directories and report differences."
)
parser.add_argument("left", help="path to the first directory")
parser.add_argument("right", help="path to the second directory")
parser.add_argument("--no-hidden", action="store_true",
                    help="skip dotfiles and dot-directories")
parser.add_argument("--no-color", action="store_true",
                    help="disable ANSI colour output")
args = parser.parse_args()

The pattern leaves room for future subcommands without breaking existing invocations. Adding --json later, for example, lets a team in a Perth-based fintech pipe the output into Slack alerts without touching the comparison engine itself.

Traversing nested directories safely

Walking both trees in parallel requires a strategy that handles missing branches without crashing. pathlib.Path.rglob offers an idiomatic iterator, but combining it with a relative-key dictionary lets the script detect paths that exist on one side and not the other. Symlinks deserve explicit attention. Following them blindly can send a walk into a recursive nightmare, while ignoring them entirely may miss legitimate shared directories.

from pathlib import Path

def index_tree(root: Path, include_hidden: bool):
    index = {}
    for path in root.rglob("*"):
        if not include_hidden and any(part.startswith(".") for part in path.parts):
            continue
        if path.is_symlink():
            continue
        key = path.relative_to(root).as_posix()
        index[key] = path
    return index

Permission errors are worth catching at the walk boundary rather than letting them abort the whole run. Australian government guidance from the Australian Cyber Security Centre stresses the principle of least privilege, and a script that fails loud and clear on a restricted file is more useful than one that silently skips protected assets.

Hashing for content-aware matching

Size mismatches are cheap to detect and resolve most cases. For files that match in bytes but may differ in content, a hash comparison provides certainty. SHA-256 has become the default in most tooling because it balances collision resistance with acceptable throughput on modern hardware. MD5 remains acceptable when speed matters and collision attacks are not a threat model, such as comparing local backup snapshots stored on a NAS in a home office.

Reading files in fixed-size chunks keeps memory usage flat regardless of file size:

import hashlib

def file_digest(path: Path, algo="sha256") -> str:
    h = hashlib.new(algo)
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

For Australian researchers, hashing becomes particularly valuable when verifying media archives destined for long-term storage at facilities such as the National Computational Infrastructure in Canberra, where petabyte-scale datasets change hands regularly.

Rendering results clearly in the terminal

Output formatting separates a script that gets used daily from one that gets abandoned after the first run. A simple table grouped by category, listing added, removed, modified, and identical files, communicates far more than a flat diff dump. ANSI colour codes highlight categories without requiring extra dependencies, and the --no-color flag respects the conventions of CI runners that lack a TTY.

def render(results, color=True):
    red = "\033[31m" if color else ""
    green = "\033[32m" if color else ""
    reset = "\033[0m" if color else ""
    for kind, paths in results.items():
        if not paths:
            continue
        label = f"{red}-- {kind} --{reset}" if kind in ("removed", "modified") \
                else f"{green}++ {kind} ++{reset}"
        print(label)
        for p in sorted(paths):
            print(f"  {p}")

Returning a non-zero exit code when differences exist lets the same command guard a deployment gate, and a structured summary printed to stderr keeps automation logs uncluttered.

Packaging and sharing with the community

A utility that lives only on the developer's laptop has limited reach. A minimal pyproject.toml lifts the script into a proper package, lets it install with pipx into an isolated environment, and gives other contributors a familiar entry point. Adding a short README with Australian-flavoured examples, such as comparing a staging tree in Sydney against production in Melbourne, helps new users see themselves in the documentation.

Publishing to PyPI under a permissive licence invites patches from the wider Australian Python community, whether that means a maintainer at PLUG Perth improving symlink handling or a contributor at a Brisbane meetup adding glob-based ignore rules. Open-source tools grow stronger when feedback loops are short, and a clean repository with thoughtful defaults lowers the barrier to that first pull request.