Refactor a Real Python Project

Apply safe refactoring techniques to a complete Python project, transforming messy code into clean, maintainable modules step by step.

8 min read

This article applies the safe refactoring techniques from refactor Python code safely to a complete project. You will start with a single messy script that works but is hard to change, and transform it into clean, tested modules through small, safe steps.

The example is a command-line tool that reads a sales report CSV, calculates commissions, and writes a summary. The original code works but mixes file I/O, business logic, and output formatting in one function with hardcoded paths.

The starting code

Here is the script as it might look after a developer wrote it quickly to solve an immediate problem. It works, but every change requires touching the same monolithic block. The rates are hardcoded, the file paths are hardcoded, and testing requires a real CSV file on disk.

The first half reads the CSV and calculates commissions using a hardcoded rate table:

pythonpython
import csv
import sys
 
def main():
    rates = {"A": 0.10, "B": 0.08, "C": 0.05}
    results = []
    with open("sales.csv") as fh:
        reader = csv.DictReader(fh)
        for row in reader:
            name = row["salesperson"]
            amount = float(row["amount"])
            tier = row["tier"]
            if tier not in rates:
                print(f"Warning: unknown tier {tier} for {name}")
                continue
            commission = amount * rates[tier]
            results.append({"name": name, "commission": commission})

The second half sorts the results and writes them to a CSV file, then prints a summary to the console:

pythonpython
    results.sort(key=lambda r: r["commission"], reverse=True)
    with open("commissions.csv", "w") as fh:
        writer = csv.DictWriter(fh, fieldnames=["name", "commission"])
        writer.writeheader()
        writer.writerows(results)
    for result in results:
        print(f"{result['name']}: ${result['commission']:.2f}")
 
if __name__ == "__main__":
    main()

This is real code that solves a real problem. It is also a maintenance burden.

The input path is hardcoded. The commission rates are hardcoded. Error handling is a print statement. Testing requires creating a real CSV file on disk.

Every change forces you to touch this single function. Adding a new tier means editing the rates dictionary. Changing the output format means editing the write block.

Switching to a database means rewriting the entire function. This is exactly the kind of code that safe refactoring transforms.

Step 1: write characterization tests

Before changing anything, capture the current behavior. A characterization test runs the script with known input and checks the output. It documents what the code does, not what it should do:

pythonpython
import io
from unittest.mock import patch
 
def test_main_writes_correct_commissions():
    input_csv = "salesperson,tier,amount\nAlice,A,1000\nBob,B,2000\n"
    output = io.StringIO()
    output.close = lambda: None  # keep the buffer readable after main() closes it
    with patch("builtins.open", side_effect=[io.StringIO(input_csv), output]):
        main()
    assert "Alice,100.0" in output.getvalue()
    assert "Bob,160.0" in output.getvalue()

Run the test. It passes. Now you have a safety net.

The assertions matter here. A characterization test that only calls main() without checking its output would pass even if the commission math were completely wrong, giving you false confidence instead of a real safety net.

With this test in place, any refactoring that breaks commission calculation fails immediately, giving you fast feedback on every change. The confidence this single test provides is what makes safe refactoring possible.

Step 2: extract the loading function

The first responsibility to extract is reading the CSV. A function that takes a file path and returns a list of dictionaries isolates the I/O from the business logic:

pythonpython
def load_sales(path):
    with open(path) as fh:
        return list(csv.DictReader(fh))

Replace the inline file reading with a call to this function. Run the test. It passes. Commit.

One small, safe change done. The main function is one step closer to being a clean orchestrator.

Step 3: extract business logic

The commission calculation is the core business logic. Extract it into a pure function that takes data in and returns data out, with no file I/O:

pythonpython
def calculate_commissions(sales, rates):
    results = []
    for row in sales:
        name = row["salesperson"]
        amount = float(row["amount"])
        tier = row["tier"]
        if tier not in rates:
            raise ValueError(f"Unknown tier: {tier} for {name}")
        commission = amount * rates[tier]
        results.append({"name": name, "commission": commission})
    return results

Pure functions are easy to test because they have no side effects. Pass in controlled data and assert the output. No mocking required. This test will catch any change to the commission formula:

pythonpython
def test_calculate_commissions():
    sales = [{"salesperson": "Alice", "amount": "1000", "tier": "A"}]
    rates = {"A": 0.10}
    result = calculate_commissions(sales, rates)
    assert result == [{"name": "Alice", "commission": 100.0}]

Step 4: make paths configurable

Hardcoded file paths prevent testing and limit reuse. Pass them as parameters with sensible defaults. The main function signature now declares its dependencies:

pythonpython
def main(input_path="sales.csv", output_path="commissions.csv"):
    rates = {"A": 0.10, "B": 0.08, "C": 0.05}
    sales = load_sales(input_path)
    results = calculate_commissions(sales, rates)
    results.sort(key=lambda r: r["commission"], reverse=True)
    write_results(results, output_path)
    print_summary(results)

Tests can now pass StringIO objects or temporary files. The production code uses the default paths. Configuration is explicit rather than buried in the function body. This simple change makes the entire script testable without touching the filesystem.

Step 5: split into modules

The final step moves each responsibility into its own module. A project structure emerges:

texttext
commission_tool/
├── commission_tool/
│   ├── __init__.py
│   ├── loader.py       # load_sales
│   ├── calculator.py   # calculate_commissions
│   ├── writer.py       # write_results, print_summary
│   └── cli.py          # main entry point
├── tests/
│   ├── test_loader.py
│   ├── test_calculator.py
│   └── test_writer.py
└── pyproject.toml

Each module has a clear purpose. Each test file tests one module. The entry point imports and composes them. This is the module design pattern from design reusable Python modules applied to a real project.

The refactored result

After all steps, the main function is a short, readable script that tells the story in five lines. Every piece is independently testable and replaceable:

pythonpython
def main(input_path="sales.csv", output_path="commissions.csv"):
    rates = {"A": 0.10, "B": 0.08, "C": 0.05}
    sales = load_sales(input_path)
    results = calculate_commissions(sales, rates)
    results.sort(key=lambda r: r["commission"], reverse=True)
    write_results(results, output_path)
    print_summary(results)

The transformation took five small steps, each verified by tests. The code was never broken for more than a few minutes. This is the essence of safe refactoring: small steps, constant verification, and the discipline to never move forward from a red state.

The same process applies to any Python project, regardless of size. Start with a test that captures current behavior. Extract one responsibility at a time into a named function.

Make configuration explicit. Split into modules when a single file grows unwieldy. Commit after every green test run. After enough small steps, clean code emerges without the risk of a big rewrite.

Rune AI

Rune AI

Key Insights

  • Start with characterization tests that capture the current behavior before changing anything.
  • Extract functions by responsibility: loading, validating, transforming, and writing.
  • Replace hardcoded paths and values with configuration parameters.
  • Split a monolithic script into focused modules with clear public APIs.
  • Commit after every green test run; never continue refactoring from a red state.
RunePowered by Rune AI

Frequently Asked Questions

How long should a refactoring session take?

A safe refactoring session should produce working, tested code every few minutes. If you have gone more than ten minutes without the tests passing, you have taken too big a step. Revert to the last commit and try a smaller change.

Should I refactor the entire project at once?

No. Refactor the parts you are about to change. The boy scout rule applies: leave the code cleaner than you found it, but do not embark on a grand rewrite. Incremental improvement over months produces better results than a big-bang refactor.

Conclusion

Refactoring a real project is the same process as refactoring a single function, repeated. Write tests, make one change, run tests, commit. After enough small steps, the codebase transforms without ever being broken. The discipline is more important than the technique.