Structuring a Python project
Everything in this module comes together here: how to lay out a project so that imports work, dependencies are recorded, and somebody else can run it without asking you questions.
A layout that works
expense-tracker/
.venv/ not committed
.gitignore
README.md
requirements.txt
pyproject.toml
src/
expense_tracker/
__init__.py
main.py
storage.py
reporting.py
models.py
tests/
test_storage.py
test_reporting.py
data/
.gitkeep
That is more structure than a script needs and about right for anything you intend to keep. Taking it piece by piece.
Naming
The folder can be anything readable — expense-tracker, with hyphens, is
conventional.
The package cannot. expense_tracker uses underscores, because it is an
import name and hyphens are not legal in identifiers. import expense-tracker
is a syntax error.
So: hyphens for the repository, underscores for the package. That mismatch looks odd and is standard.
Module names are lowercase with underscores, and short. storage.py, not
StorageManager.py.
Why src/
Putting the package inside src/ rather than at the top level is called the src
layout, and it exists for one reason.
Without it, your package sits in the folder you run commands from, so import expense_tracker works because the current directory is on the path — not
because the package is properly installed. Your tests then pass against the
local files while the installed version might be broken or missing a file, and
you would not find out until somebody else installed it.
With src/, the only way to import your package is to install it:
python -m pip install -e .
Now you are testing what you would actually ship.
For a small script, the src layout is overkill and a flat layout is fine. For anything you will package or share, use it.
What goes where
main.py — the entry point, and as thin as you can make it. Parse
arguments, call into the real code, handle top-level errors.
def main() -> None:
args = parse_args()
...
if __name__ == "__main__":
main()
The guard from the imports lesson, doing its job.
models.py — the shapes your data takes. Dataclasses once you reach module
9.
storage.py — reading and writing. All the file handling in one place.
reporting.py — calculations and formatting.
The principle is one file per responsibility, and a name that says which. When you cannot decide where something belongs, that usually means the responsibility is not clear yet.
Two signs a file needs splitting: it has stopped fitting in your head, or its name has acquired an "and".
tests/
Mirror the package structure and prefix files with test_, which is what pytest
looks for. Module 10 covers writing them; the structure is worth setting up now,
because a project with nowhere obvious to put tests tends not to get any.
README.md
The most undervalued file in any project. At minimum:
- What it does, in a sentence
- How to install it
- How to run it
- How to run the tests
# Expense Tracker
Track expenses from the command line, stored as JSON.
## Setup
python -m venv .venv
source .venv/bin/activate
python -m pip install -e ".[dev]"
## Usage
expense-tracker add 250 "lunch"
expense-tracker report --month september
## Tests
pytest
Write it early, while you still remember which steps were not obvious. A README written months later always omits the thing that catches everyone.
.gitignore
.venv/
__pycache__/
*.pyc
.env
data/*.json
.pytest_cache/
.DS_Store
.env matters most. It is where secrets live, and committing one is among the
easier ways to leak an API key — public repositories are scanned for exactly
this within minutes.
Note data/*.json with a committed .gitkeep: the folder's existence is
tracked, its contents are not. That is how you ship a place for data without
shipping data.
Configuration and secrets
Never hard-code a secret. Read it from the environment, as the standard library lesson showed:
import os
API_KEY = os.environ.get("API_KEY")
if not API_KEY:
raise RuntimeError("API_KEY is not set")
Failing loudly at startup beats failing mysteriously later.
Locally, keep them in a .env file that is gitignored, and commit a
.env.example listing the names with no values — so a new developer knows what
to set.
A worked minimum
For a small project, this is enough and genuinely fine:
my-tool/
.gitignore
README.md
requirements.txt
my_tool.py
test_my_tool.py
Do not build a package structure for a hundred-line script. The point is to recognise when a project has outgrown one file — usually when you start scrolling to find things, or when one change means editing three unrelated parts of the same file.
Grow into structure rather than starting with it. A tidy small project beats an over-engineered one.
Starting a project properly
The sequence, start to finish:
mkdir expense-tracker && cd expense-tracker
git init
python -m venv .venv
source .venv/bin/activate
printf '.venv/\n__pycache__/\n*.pyc\n.env\n' > .gitignore
python -m pip install pytest ruff
python -m pip freeze > requirements.txt
git add -A && git commit -m "Initial project structure"
Two minutes, and you have an isolated environment, version control, and dependencies recorded before there is anything to get wrong.
Doing this first is a habit worth forming. Retrofitting it onto a project that already has forty files and no environment is considerably less pleasant.
Practice
- Create a project folder with a
.venv,.gitignore,README.mdandrequirements.txt. - Build the
src/layout with a package containing three modules. Import between them. - Write a
pyproject.tomland install with-e .. Confirm you can import your package from a Python session started anywhere. - Move the package out of
src/to the top level. Note that imports work without installing — then explain why that is a disadvantage. - Write a README covering setup, usage and tests for something you built earlier in this course.
- Read an environment variable with a clear error when it is missing. Run the program with and without it set.
- Create
.envand.env.example, and confirmgit statusignores the first and not the second. - Take your guessing game or receipt program and give it a proper structure:
main.pyplus at least one other module, a README, and a.gitignore. - Run the full "starting a project" sequence from scratch and time it.
That is module eight. Your code can be split across files that find each other, your projects are isolated from one another, your dependencies are recorded, and a new person can run your work without asking you anything.
Next module: object-oriented Python — classes, what problem they actually solve, and when a function is still the better answer.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship