Skip to content
Happy Programming Guide
Start learning
Python

Building a Complete Python Project

Take a Python script from a single messy file to a real project: folders, a virtual environment, a requirements file, tests and a README.

Chess pieces set up on a board

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:

Python
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#

Output
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.

Python
# 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.

Python
# 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#

Python
# 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:

Terminal
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#

Terminal
python -m venv .venv

# Windows
.venv\Scripts\activate

# macOS and Linux
source .venv/bin/activate

Install whatever the project needs, then record it:

Terminal
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#

Python
# 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.

Output
# 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, explained without the jargonRead next

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *