After building several Python projects, I've settled on a structure that scales from scripts to packages.
The Layout
project/
├── src/
│ └── project/
│ ├── __init__.py
│ ├── main.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── test_main.py
│ └── test_utils.py
├── pyproject.toml
├── Makefile
└── README.md
The src/ layout prevents accidental imports from the project root. You have to install the package to use it, which catches issues early.
pyproject.toml
Everything in one file:
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"click>=8.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.4",
"mypy>=1.10",
]
[project.scripts]
project = "project.main:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.mypy]
python_version = "3.11"
strict = trueNo setup.py, no setup.cfg, no requirements.txt. Just pyproject.toml.
Makefile
Common commands I run:
.PHONY: setup dev test lint check clean
setup:
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
test:
.venv/bin/pytest tests/ -v
lint:
.venv/bin/ruff check src/ tests/
.venv/bin/ruff format --check src/ tests/
check: lint test
clean:
rm -rf .venv __pycache__ .pytest_cacheType make setup and you're ready to code.
Dependencies
Runtime deps go in [project.dependencies]. Keep this minimal.
Dev deps go in [project.optional-dependencies.dev]. Testing, linting, type checking.
I pin minimum versions (>=) not exact versions (==). Exact pins cause dependency hell.
For reproducible builds, generate a lockfile:
pip freeze > requirements.lockTesting
pytest with minimal config:
# tests/test_main.py
from project.main import process
def test_process_returns_expected():
result = process("input")
assert result == "expected"Run with pytest tests/ -v. No test runners, no complex setup.
For fixtures:
# tests/conftest.py
import pytest
@pytest.fixture
def sample_data():
return {"key": "value"}Linting and Formatting
Ruff does everything:
- Linting (replaces flake8, isort, pyupgrade)
- Formatting (replaces black)
One tool, fast, consistent. Run ruff check . and ruff format ..
Type Checking
mypy in strict mode:
mypy src/Start strict from day one. Adding types to existing code is painful.
Type hints in function signatures:
def process(data: str, count: int = 1) -> list[str]:
...Package Initialization
Keep __init__.py minimal:
# src/project/__init__.py
from project.main import process, cli
__all__ = ["process", "cli"]
__version__ = "0.1.0"Export the public API. Hide implementation details.
CLI Entry Point
Define in pyproject.toml:
[project.scripts]
project = "project.main:cli"Then pip install -e . gives you the project command.
Development Workflow
make setup— create venv, install deps- Edit code
make check— lint and test- Commit
That's it. No virtualenv activation commands, no remembering which Python version.
What I Skip
Separate requirements files. pyproject.toml handles everything.
tox. Overkill for most projects. CI can run tests on multiple Python versions.
Coverage thresholds. I write tests for important code, not to hit a number.
Pre-commit hooks. I run make check manually. Hooks slow down commits.
Scaling Up
For larger projects, add:
src/project/
├── api/ # HTTP/CLI interfaces
├── core/ # Business logic
├── adapters/ # External service integrations
└── models/ # Data structures
But start simple. Add structure when you need it, not before.