Project structure
A Python project that starts as a single script will eventually need a test suite, configuration, and shared utilities. Adding those without a plan creates a tangle that slows every change. Starting with the right structure means later growth follows a clear path.
This guide walks through a reference project called pricemon, a command-line tool that reads a product catalog from a JSON file and reports items below a target price. The complete directory tree and all source files appear at the end.
When a single file is enough
Not every Python program needs a package. A script that does one clear task belongs in a single file. Keep it short enough to read in one sitting — under 300 lines is a useful guideline. When the file grows beyond that, or when you need to share logic across scripts or write a test suite, convert it to a package.
For simple scripts, the following layout is sufficient:
pricemon/
├── pricemon.py
├── requirements.txt
└── README.md
The src layout
For a package that includes tests, a command-line entry point, or distribution via pip, the src layout is the recommended standard. Place all source code under a src/ directory:
pricemon/
├── src/
│ └── pricemon/
│ ├── __init__.py
│ ├── __main__.py
│ ├── catalog.py
│ └── report.py
├── tests/
│ ├── conftest.py
│ ├── test_catalog.py
│ └── test_report.py
├── pyproject.toml
├── .env.example
└── README.md
The src/ wrapper prevents the package from being importable before installation. Without it, Python’s import system can find the local source directory and silently shadow an installed version, creating subtle differences between development and production.
What each file does
The following files are standard in every package:
__init__.py: Marks the directory as a Python package. Keep it minimal — define the public API or leave it empty__main__.py: Makes the package runnable withpython -m pricemon. Put yourmain()call here- Module files (
catalog.py,report.py): The actual logic, split by responsibility tests/conftest.py: Shared pytest fixtures, loaded automaticallypyproject.toml: Project metadata, dependencies, and tool configuration
pyproject.toml
pyproject.toml is the single configuration file for modern Python projects. It replaces setup.py, setup.cfg, and scattered .ini files. The following is a complete example for pricemon:
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "pricemon"
version = "0.1.0"
description = "Monitor product prices from a catalog file"
requires-python = ">=3.11"
dependencies = [
"rich>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"mypy>=1.9",
"ruff>=0.4",
]
[project.scripts]
pricemon = "pricemon.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"slow: marks tests as slow (deselect with -m 'not slow')",
]
[tool.mypy]
strict = true
python_version = "3.11"
[tool.ruff.lint]
select = ["E", "F", "I"]
The key sections serve distinct purposes:
[project]: The package name, version, Python version requirement, and runtime dependencies. Therequires-pythonfield prevents accidental installation on an incompatible interpreter[project.optional-dependencies]: Development tools installed withpip install -e ".[dev]"— kept separate from runtime dependencies[project.scripts]: Registers thepricemonshell command. After installation, runningpricemonin the terminal callspricemon.__main__:main[tool.setuptools.packages.find]: Tells setuptools to look for packages insidesrc/rather than the project root
Manage dependencies
Python’s built-in tools are sufficient for most projects. The following workflow covers the full lifecycle.
Create and activate a virtual environment
Every project gets its own isolated environment. Run the following from the project root:
python3 -m venv .venv
source .venv/bin/activate
Install in editable mode
Install the project and its dependencies in one step:
pip install -e ".[dev]"
The -e flag installs in editable mode: changes to source files take effect immediately without reinstalling. The [dev] extra installs the development tools defined in pyproject.toml.
Pin dependencies for production
For applications deployed to a server or container, pin exact versions so every deployment is identical. Generate a locked requirements file:
pip freeze > requirements.lock
Install from the lock file in CI or production:
pip install -r requirements.lock
Libraries published to PyPI should not pin their dependencies. Only pin in applications and services.
Configuration
Applications need configuration: file paths, API keys, and environment-specific settings. Store configuration in environment variables, not in source files. Source files belong in version control; secrets do not.
Read from environment variables
Read configuration at startup with os.environ:
import os
from pathlib import Path
def load_config() -> dict[str, str | Path]:
catalog_path = os.environ.get("PRICEMON_CATALOG", "catalog.json")
return {
"catalog_path": Path(catalog_path),
"currency": os.environ.get("PRICEMON_CURRENCY", "USD"),
}
.env files for local development
In development, load environment variables from a .env file instead of setting them in the shell. Install python-dotenv:
pip install python-dotenv
Load it at the entry point before anything else runs:
from dotenv import load_dotenv
load_dotenv()
Create a .env.example file in the project root and commit it to version control. List every required variable with placeholder values so new contributors know what to configure:
PRICEMON_CATALOG=catalog.json
PRICEMON_CURRENCY=USD
Add .env itself to .gitignore. The real values stay on the developer’s machine.
Full example
The following shows all source files for the complete pricemon project.
src/pricemon/__init__.py
The __init__.py file declares the package’s public API:
from pricemon.catalog import load_catalog, Item
from pricemon.report import find_below_price
__all__ = ["load_catalog", "Item", "find_below_price"]
src/pricemon/catalog.py
catalog.py owns all data loading and validation:
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Item:
name: str
price: float
category: str
def load_catalog(path: Path) -> list[Item]:
"""Load items from a JSON catalog file.
Args:
path: Path to the catalog JSON file.
Returns:
A list of Item objects.
Raises:
FileNotFoundError: If path does not exist.
ValueError: If the file contains invalid JSON or missing fields.
"""
if not path.exists():
raise FileNotFoundError(f"Catalog not found: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
return [
Item(name=entry["name"], price=entry["price"], category=entry["category"])
for entry in raw
]
src/pricemon/report.py
report.py contains pure filtering logic with no I/O:
from pricemon.catalog import Item
def find_below_price(items: list[Item], threshold: float) -> list[Item]:
"""Return items whose price is strictly below threshold, sorted by price.
Args:
items: The catalog to search.
threshold: The maximum price, exclusive.
Returns:
A filtered list sorted by price ascending.
"""
return sorted(
[item for item in items if item.price < threshold],
key=lambda item: item.price,
)
src/pricemon/__main__.py
__main__.py is the entry point. It wires the CLI together but contains no business logic:
import argparse
import sys
from pathlib import Path
from pricemon.catalog import load_catalog
from pricemon.report import find_below_price
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Find catalog items below a price threshold."
)
parser.add_argument("catalog", type=Path, help="Path to the JSON catalog file")
parser.add_argument("--below", type=float, required=True, help="Price threshold")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
items = load_catalog(args.catalog)
results = find_below_price(items, args.below)
if not results:
print(f"No items below {args.below}")
return 0
for item in results:
print(f"{item.name:<30} {item.price:>8.2f} {item.category}")
return 0
if __name__ == "__main__":
sys.exit(main())
Notice the structure:
load_catalogandfind_below_priceare pure functions, testable without running the CLImain()accepts an optionalargvparameter so tests can callmain(["catalog.json", "--below", "50"])without spawning a subprocessmain()returns an integer exit code passed tosys.exit(), the only place the process exits
tests/conftest.py
conftest.py defines shared fixtures for all tests:
import json
import pytest
from pathlib import Path
from pricemon.catalog import Item
@pytest.fixture
def sample_items() -> list[Item]:
return [
Item(name="Espresso machine", price=149.99, category="Kitchen"),
Item(name="Notebook", price= 2.50, category="Office"),
Item(name="Desk lamp", price= 34.95, category="Office"),
]
@pytest.fixture
def catalog_file(tmp_path: Path) -> Path:
data = [
{"name": "Espresso machine", "price": 149.99, "category": "Kitchen"},
{"name": "Notebook", "price": 2.50, "category": "Office"},
]
path = tmp_path / "catalog.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path
tests/test_catalog.py
Tests for loading and validation:
import pytest
from pathlib import Path
from pricemon.catalog import load_catalog
def test_load_catalog_returns_items(catalog_file: Path):
items = load_catalog(catalog_file)
assert len(items) == 2
assert items[0].name == "Espresso machine"
def test_load_catalog_raises_for_missing_file(tmp_path: Path):
with pytest.raises(FileNotFoundError):
load_catalog(tmp_path / "missing.json")
def test_load_catalog_raises_for_invalid_json(tmp_path: Path):
bad = tmp_path / "bad.json"
bad.write_text("not json", encoding="utf-8")
with pytest.raises(ValueError, match="Invalid JSON"):
load_catalog(bad)
tests/test_report.py
Tests for the filtering logic:
from pricemon.catalog import Item
from pricemon.report import find_below_price
def test_find_below_price_filters_correctly(sample_items: list[Item]):
results = find_below_price(sample_items, 50.00)
names = [item.name for item in results]
assert "Notebook" in names
assert "Desk lamp" in names
assert "Espresso machine" not in names
def test_find_below_price_returns_sorted(sample_items: list[Item]):
results = find_below_price(sample_items, 200.00)
prices = [item.price for item in results]
assert prices == sorted(prices)
def test_find_below_price_returns_empty_when_none_match(sample_items: list[Item]):
results = find_below_price(sample_items, 1.00)
assert results == []