Site

App: Diff Reviewer — Compare Versions

Tutorial A5.0  •  AI / Learn / Applications

A5.0 What This Teaches

This tutorial builds a tool that compares two versions of a file and asks Claude to explain the changes and flag risks. It covers:

A5.1 What a Diff Shows

A unified diff is a compact representation of how one text file changed into another. Each line is prefixed to show what happened to it:
--- old.py
+++ new.py
@@ -4,7 +4,10 @@
 def load_data(filename):
-    with open(filename) as f:
-        return f.read()
+    try:
+        with open(filename, encoding="utf-8") as f:
+            return f.read()
+    except FileNotFoundError:
+        return None
The @@ line shows which line numbers changed. This format is the same one used by git diff - learning to read it here also teaches you to read git output.

A5.2 Generating the Diff with difflib

difflib.unified_diff() takes two lists of lines and produces a generator of diff lines. Join them into a single string to embed in a prompt.
# gen_diff.py - compute a unified diff between two files.
import difflib
from pathlib import Path

def compute_diff(old_path: str, new_path: str) -> str:
    old_lines = Path(old_path).read_text(encoding="utf-8").splitlines(keepends=True)
    new_lines = Path(new_path).read_text(encoding="utf-8").splitlines(keepends=True)

    diff = difflib.unified_diff(
        old_lines,
        new_lines,
        fromfile=old_path,
        tofile=new_path,
        lineterm="",
    )
    return "".join(diff)
keepends=True preserves the newline character at the end of each line, which unified_diff requires to produce correct output. lineterm="" prevents it from adding an extra newline to each diff line.

A5.3 The Review Prompt

The prompt asks three specific questions in order. Numbered questions produce numbered answers, which makes the output easier to scan.
# diff_prompt.py - build the review prompt.
def build_review_prompt(diff_text: str) -> str:
    return (
        "Review this unified diff. Explain:\n"
        "(1) What changed and why it likely changed.\n"
        "(2) Any risks introduced (bugs, security issues, breaking changes).\n"
        "(3) Whether the change looks complete or if anything is missing.\n\n"
        f"```diff\n{diff_text}\n```"
    )
The diff fence label tells the model to interpret the content as a diff rather than as source code, which improves how it reasons about the - and + prefixes.

A5.4 Complete Implementation

# diff_review.py - compare two files and review the changes with Claude.
# Usage: python diff_review.py old.py new.py

import sys
import difflib
from pathlib import Path
import anthropic

def read_file(path: str) -> list[str]:
    try:
        return Path(path).read_text(encoding="utf-8").splitlines(keepends=True)
    except FileNotFoundError:
        print(f"Error: file not found: {path}")
        sys.exit(1)

def compute_diff(old_path: str, new_path: str) -> str:
    old_lines = read_file(old_path)
    new_lines = read_file(new_path)
    return "".join(difflib.unified_diff(
        old_lines, new_lines,
        fromfile=old_path, tofile=new_path, lineterm="",
    ))

def main():
    if len(sys.argv) < 3:
        print("Usage: python diff_review.py old.py new.py")
        sys.exit(1)

    old_path, new_path = sys.argv[1], sys.argv[2]
    diff = compute_diff(old_path, new_path)

    if not diff:
        print("Files are identical.")
        sys.exit(0)

    prompt = (
        "Review this unified diff. Explain:\n"
        "(1) What changed and why it likely changed.\n"
        "(2) Any risks introduced (bugs, security issues, breaking changes).\n"
        "(3) Whether the change looks complete or if anything is missing.\n\n"
        f"```diff\n{diff}\n```"
    )

    client = anthropic.Anthropic()
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )

    print(response.content[0].text)

if __name__ == "__main__":
    main()

A5.5 Sample Output

Running the tool on a diff where a function gained a type hint and a try/except block:
$ python diff_review.py old.py new.py

(1) What changed and why it likely changed.
The load_data function gained an explicit encoding argument ("utf-8") and a
try/except block that returns None when the file is not found. This is a
defensive improvement - the original would raise an unhandled FileNotFoundError
on a missing file.

(2) Risks introduced.
Returning None on error means callers must now check the return value before
using it. Any code that does `data.split(...)` on the result will raise
AttributeError if the file is missing. The change shifts responsibility for
error handling to the caller without documenting that contract.

(3) Completeness.
The change looks complete for the stated goal of avoiding a crash. A type hint
on the return value (str | None) would make the None case explicit and would
help tools like mypy catch callers that forget to check.

A5.6 Handling Empty Diffs

When the two files are byte-for-byte identical, unified_diff returns an empty generator and "".join(...) produces an empty string. Check for this before calling the API.
# empty_diff_guard.py - avoid an unnecessary API call.
diff = compute_diff(old_path, new_path)

if not diff:
    print("Files are identical.")
    sys.exit(0)

# only reaches here if there are actual changes
response = client.messages.create(...)
Skipping the API call saves both money and latency. It also makes the tool's output clearer - "Files are identical" is more informative than a response that says "No changes were made."

A5.7 Exercise

Exercise Add a --risk flag using argparse. When present, append this sentence to the prompt:
risk_instruction = (
    "Rate the overall risk of this change on a scale of 1-5 "
    "where 1 is trivial and 5 is high-risk. "
    "Give one sentence justification."
)
After printing the full review, scan the response text for the rating and print it on its own line labeled Risk rating:. A simple approach: look for the pattern "Risk: N" or "N/5" in the response using a regular expression, or just print the last paragraph separately.
python diff_review.py old.py new.py --risk

A5.8 Common Mistakes

Sending very large diffs

Diffs over a few hundred lines push the model toward summarizing rather than reasoning carefully about each change. Quality drops noticeably around 500 lines. If a diff is large, split it by function: extract the changed functions individually and review each one in a separate call.

Not handling the case where one file does not exist

If old.py does not exist, read_text() raises FileNotFoundError. The tool wraps each read in a try/except and calls sys.exit(1) with a clear message. If you remove that guard to simplify the code, a typo in a filename produces a confusing Python traceback instead of a usable error.

Comparing binary files

difflib operates on text. Passing a compiled .pyc file, a PNG, or any other binary file causes read_text() to raise UnicodeDecodeError or produce garbage output. Add a check on the file extension, or catch UnicodeDecodeError and print an error before any diff is computed.

Key Terms

TermMeaning
unified diffA standard format showing removed lines with -, added lines with +, and unchanged context lines
difflibPython standard library module for computing differences between sequences
unified_diff()difflib function that produces a unified diff as a generator of strings
keepends=Truesplitlines() option that preserves the newline at the end of each line
lineterm=""unified_diff() option that suppresses the extra newline it would otherwise add per line
breaking changeA change that alters a public interface in a way that requires callers to update their code
UnicodeDecodeErrorException raised when read_text() encounters bytes that cannot be decoded as UTF-8 text