A complete Python project is a script that has grown up. Same code, but now it lives in a sensible folder structure, declares what it needs to run, has a couple of tests to prove it still works, and comes with a README so that a stranger — including you in six months — can get it running. This guide walks through that whole journey with one small project.
The project we are building#
We will build a small command-line tool that reads a CSV of expenses and prints a summary by category. It is deliberately simple, because the point of this guide is the structure around the code, not the code itself.
Here is the whole thing as most people first write it — one file, everything inline:
import csv
totals = {}
with open("expenses.csv") as f:
for row in csv.DictReader(f):
cat = row["category"]
totals[cat] = totals.get(cat, 0) + float(row["amount"])
for cat, amount in sorted(totals.items()):
print(cat, round(amount, 2))
That works. It is also the version that becomes unmaintainable the moment you add a second feature.
Step 1: give the project a folder#
expense-summary/
expense_summary/
__init__.py
parser.py
report.py
cli.py
tests/
test_parser.py
data/
example.csv
requirements.txt
README.md
.gitignore
The outer folder is the project. The inner expense_summary/ folder is the package — the importable code. Underscores in the package name, hyphens in the project name, is the usual Python convention.
The __init__.py file can be empty. Its presence is what tells Python “this folder is a package you can import from”.
Step 2: split the script into functions#
Before splitting across files, split into functions. Each function should do one nameable thing.
# expense_summary/parser.py
import csv
def read_expenses(path):
"""Read a CSV and return a list of dictionaries."""
with open(path, newline="") as f:
return list(csv.DictReader(f))
def totals_by_category(rows):
"""Sum the amount column, grouped by category."""
totals = {}
for row in rows:
category = row["category"].strip().lower()
totals[category] = totals.get(category, 0) + float(row["amount"])
return totals
Notice that neither function prints anything. That is deliberate — functions that return values are easy to test, and functions that print are not.
# expense_summary/report.py
def format_report(totals):
"""Turn a dictionary of totals into printable lines."""
if not totals:
return ["No expenses found."]
width = max(len(name) for name in totals)
lines = []
for name in sorted(totals):
lines.append(name.ljust(width) + " " + format(totals[name], ".2f"))
lines.append("-" * (width + 10))
lines.append("total".ljust(width) + " " + format(sum(totals.values()), ".2f"))
return lines
Step 3: one entry point#
# expense_summary/cli.py
import argparse
from expense_summary.parser import read_expenses, totals_by_category
from expense_summary.report import format_report
def main():
ap = argparse.ArgumentParser(description="Summarise expenses by category.")
ap.add_argument("csv_file", help="path to the expenses CSV")
args = ap.parse_args()
rows = read_expenses(args.csv_file)
for line in format_report(totals_by_category(rows)):
print(line)
if __name__ == "__main__":
main()
Run it from the project root:
python -m expense_summary.cli data/example.csv
The -m flag runs the module as a script while keeping the project root on the import path, which is why the import lines at the top of the file resolve.
Step 4: a virtual environment and a requirements file#
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS and Linux
source .venv/bin/activate
Install whatever the project needs, then record it:
pip install pytest
pip freeze > requirements.txt
Anyone who clones the project can now recreate your environment with pip install -r requirements.txt. Add .venv/ to .gitignore — the environment is rebuilt, never committed.
Step 5: write two tests#
# tests/test_parser.py
from expense_summary.parser import totals_by_category
def test_groups_by_category():
rows = [
{"category": "Food", "amount": "10.50"},
{"category": "food", "amount": "4.50"},
{"category": "Travel", "amount": "20"},
]
totals = totals_by_category(rows)
assert totals["food"] == 15.0
assert totals["travel"] == 20.0
def test_empty_input():
assert totals_by_category([]) == {}
Run them from the project root with python -m pytest. The first test is doing real work: it proves that “Food” and “food” are treated as one category. That behaviour came from a single .lower() call, and a test is how you find out when someone deletes it.
Step 6: the README#
A README answers four questions, in this order: what is this, how do I install it, how do I run it, how do I run the tests. Anything else is a bonus.
# Expense Summary
Reads a CSV of expenses and prints totals by category.
## Install
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
## Use
python -m expense_summary.cli data/example.csv
## Tests
python -m pytest
## CSV format
The file needs a header row with "category" and "amount" columns.
What to leave out for now#
Real projects eventually grow a pyproject.toml, type hints, a linter, continuous integration and packaging. All of that is useful and none of it is urgent. Add each piece the first time you feel the pain it solves, not before — a beginner project with more configuration than code is harder to learn from, not easier.
Questions people ask#
Do I still need __init__.py in modern Python?
Technically no — Python 3.3 and later support namespace packages without it. In practice, include it. It makes your intent explicit, it avoids surprising behaviour when two folders share a name, and some tools still expect it.
Should the tests folder have one too?
Usually not. Pytest discovers test files without it, and leaving it out keeps the tests from being importable as a package, which is normally what you want.
How big should a file get before I split it?
There is no magic number, but a useful signal is when you can no longer describe the file in one short sentence. “Reads and parses expense data” is one file. “Reads expense data, formats reports and handles command-line arguments” is three.
What is the difference between a script and a package?
A script is a single file you run. A package is a folder of modules you import from. The moment a second file needs to reuse code from the first, you want a package.
Where to go next#
- Python virtual environments — what the
.venvfolder actually is and why activation matters. - Python file handling — the reading and writing patterns behind the parser module.
- Git and GitHub explained — the next thing to add once the folder structure settles.