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 = true

No 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_cache

Type 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.lock

Testing

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

  1. make setup — create venv, install deps
  2. Edit code
  3. make check — lint and test
  4. 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.

React to this post: