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:
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:
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:
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:
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:
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 resultsPure 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:
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:
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:
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.tomlEach 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:
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
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.
Frequently Asked Questions
How long should a refactoring session take?
Should I refactor the entire project at once?
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.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.