Python project structure — pyproject.toml, src layout, __init__.py, .gitignore, dependency groups, type hints, py.typed, test structure, entry points, ruff/mypy configuration
91
87%
Does it follow best practices?
Impact
99%
1.03xAverage score across 5 eval scenarios
Passed
No known issues
Practical project layout, packaging, and tooling for Python projects. Covers web APIs, CLI tools, and libraries.
Every new Python project must use pyproject.toml as the single source of project metadata, dependencies, and tool configuration. Do not create setup.py or setup.cfg for new projects.
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-project"
version = "0.1.0"
description = "A short description of the project"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"pydantic>=2.0",
"pydantic-settings>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"httpx>=0.27",
"ruff>=0.8",
"mypy>=1.13",
]
[project.scripts]
my-project = "my_project.cli:main"Why not setup.py:
setup.py is legacy — it executes arbitrary code during install, which is a security riskpyproject.toml is the PEP 621 standard, supported by all modern tools (pip, uv, hatch, poetry)Use the src/ layout to prevent accidental imports of the development version instead of the installed version.
my-project/
src/
my_project/
__init__.py
main.py
routes/
__init__.py
users.py
items.py
models.py
db.py
config.py
errors.py
tests/
__init__.py
conftest.py
test_users.py
test_items.py
pyproject.toml
.gitignore
README.mdmy-project/
my_project/
__init__.py
main.py
routes/
__init__.py
users.py
items.py
models.py
db.py
config.py
errors.py
tests/
__init__.py
conftest.py
test_users.py
test_items.py
pyproject.toml
.gitignore
README.mdWhen to use src layout:
When flat layout is acceptable:
Either way, always use a proper Python package (directory with __init__.py), never loose .py files at the project root.
Every Python package directory must have an __init__.py file. It can be empty, but it must exist.
# src/my_project/__init__.py
# Can be empty — its presence makes this directory a Python package# src/my_project/routes/__init__.py
# Can be empty — makes routes/ importable as a packageRules:
__init__.py__init__.py files minimal — do not put business logic in them__init__.py for public API re-exports in libraries:# src/my_project/__init__.py (for a library)
from my_project.client import Client
from my_project.exceptions import MyProjectError
__all__ = ["Client", "MyProjectError"]__init__.py should typically be emptytests/ directory also needs __init__.py for pytest to find and import test modules correctlyEvery Python project must have a .gitignore that excludes virtual environments, bytecode, build artifacts, and environment files.
# Virtual environments — NEVER commit these
.venv/
venv/
env/
.env/
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
*.pyo
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/settings.json
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.coverage
htmlcov/
.pytest_cache/
# Type checking
.mypy_cache/
.ruff_cache/The most common mistake is committing the virtual environment directory. A .venv/ directory contains hundreds of megabytes of installed packages and is platform-specific. It must always be gitignored.
Separate dependencies into production, development, and test groups using [project.optional-dependencies] in pyproject.toml.
[project]
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"sqlalchemy>=2.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.8",
"mypy>=1.13",
"pre-commit>=4.0",
]
test = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-cov>=6.0",
"httpx>=0.27",
]Install commands:
# Production only
pip install .
# Development (includes dev tools)
pip install -e ".[dev]"
# Testing
pip install -e ".[test]"
# Everything
pip install -e ".[dev,test]"The -e flag installs in editable mode, so code changes are reflected without reinstalling.
Use type hints on all function signatures. For libraries, include a py.typed marker file.
# GOOD — fully typed function signatures
from typing import Literal
def create_order(
customer_name: str,
items: list[dict[str, int]],
status: Literal["pending", "confirmed"] = "pending",
) -> dict[str, str | int]:
...
# BAD — no type hints
def create_order(customer_name, items, status="pending"):
...For libraries and packages that others will import, include a py.typed marker:
src/my_project/py.typed # Empty file — its presence tells type checkers this package is typedThis tells mypy and other type checkers that your package ships with inline type annotations.
Tests live in a top-level tests/ directory that mirrors the source package structure.
tests/
__init__.py
conftest.py # Shared fixtures (test client, database setup, etc.)
test_users.py # Tests for user-related functionality
test_items.py # Tests for item-related functionality
routes/
__init__.py
test_user_routes.py
test_item_routes.pyconftest.py is the standard pytest file for shared fixtures:
# tests/conftest.py
import pytest
from httpx import ASGITransport, AsyncClient
from my_project.main import create_app
@pytest.fixture
def app():
return create_app()
@pytest.fixture
async def client(app):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield clientRules:
test_*.py (pytest default discovery pattern)test_*conftest.py for shared fixtures — pytest discovers these automatically__init__.py to tests/ and any subdirectoriesFor CLI tools or scripts, use [project.scripts] in pyproject.toml instead of custom if __name__ == "__main__" entrypoints.
# pyproject.toml
[project.scripts]
my-cli = "my_project.cli:main"# src/my_project/cli.py
import argparse
def main() -> None:
parser = argparse.ArgumentParser(description="My CLI tool")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
# ... application logic
# Still useful for direct execution during development
if __name__ == "__main__":
main()After pip install -e ., the my-cli command is available system-wide (within the virtualenv).
For web applications, use standard framework commands rather than entry points:
# FastAPI
uvicorn my_project.main:app --reload
# Flask
flask --app my_project.main run --debugConfigure ruff (linter + formatter) and mypy (type checker) in pyproject.toml.
# pyproject.toml
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
]
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"Why ruff over flake8/black/isort:
Why mypy strict mode:
strict = true enables all optional checks (like --disallow-untyped-defs)py.typed marker for libraries# src/my_project/main.py — App factory
from fastapi import FastAPI
from my_project.routes import users, items
from my_project.errors import register_error_handlers
from my_project.db import init_db
def create_app() -> FastAPI:
app = FastAPI(title="My API")
register_error_handlers(app)
app.include_router(users.router, prefix="/api")
app.include_router(items.router, prefix="/api")
@app.on_event("startup")
async def startup() -> None:
init_db()
return app
app = create_app()# src/my_project/config.py — Settings from environment
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///data.db"
allowed_origins: str = "http://localhost:5173"
debug: bool = False
model_config = {"env_file": ".env"}
settings = Settings()# src/my_project/models.py — Pydantic schemas
from pydantic import BaseModel, Field
from typing import Literal
class Item(BaseModel):
id: int
name: str
description: str
price_cents: int = Field(gt=0, description="Price in cents")
category: str
class CreateItemRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(max_length=1000)
price_cents: int = Field(gt=0)
category: str
ItemStatus = Literal["active", "discontinued", "out_of_stock"]# src/my_project/routes/items.py — Route module
from fastapi import APIRouter
from my_project.models import CreateItemRequest
from my_project.errors import NotFoundError
from my_project import db
router = APIRouter(tags=["items"])
@router.post("/items", status_code=201)
async def create_item(body: CreateItemRequest) -> dict:
item = db.create_item(body)
return {"data": item}
@router.get("/items/{item_id}")
async def get_item(item_id: int) -> dict:
item = db.get_item(item_id)
if not item:
raise NotFoundError("Item", str(item_id))
return {"data": item}# src/my_project/errors.py — Custom exceptions with handlers
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, message: str, status_code: int = 400) -> None:
self.message = message
self.status_code = status_code
class NotFoundError(AppError):
def __init__(self, resource: str, resource_id: str) -> None:
super().__init__(f"{resource} {resource_id} not found", status_code=404)
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": {"message": exc.message}},
)For a prototype or very small app, a single main.py with everything is fine. Split when:
pyproject.toml used (not setup.py) with [build-system], [project], and requires-python__init__.py).gitignore includes .venv/, __pycache__/, dist/, .env, .mypy_cache/[project.dependencies], dev/test in [project.optional-dependencies]tests/ directory with conftest.py for shared fixturestest_*.py, test functions named test_*[tool.ruff] section of pyproject.tomlstrict = true in [tool.mypy] section[project.scripts] for CLI toolspy.typed marker present for typed librariesroutes/ subdirectorydb.py, not in route handlersmodel_config used instead of inner class Config (Pydantic v2)evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
python-project-structure
verifiers