CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/python-project-structure

Python project structure — pyproject.toml, src layout, __init__.py, .gitignore, dependency groups, type hints, py.typed, test structure, entry points, ruff/mypy configuration

91

1.03x
Quality

87%

Does it follow best practices?

Impact

99%

1.03x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
python-project-structure
description:
Python project structure best practices — pyproject.toml, src layout, __init__.py, .gitignore, virtual environments, type hints, py.typed, test structure, entry points, dependency groups, and tool configuration. Use when starting a new Python project, restructuring an existing one, setting up a Python package, configuring dependencies, or when reviewing project organization. Triggers on: new FastAPI/Flask/Django project, CLI tool setup, Python library scaffolding, "where should I put this", monolithic files, missing pyproject.toml, setup.py migration, or dependency management questions.
keywords:
python project structure, pyproject.toml, src layout, __init__.py, .gitignore, virtual environment, venv, type hints, py.typed, pytest, entry points, dependency groups, ruff, mypy, fastapi project layout, flask project structure, python package, setup.py, python best practices, separation of concerns
license:
MIT

Python Project Structure

Practical project layout, packaging, and tooling for Python projects. Covers web APIs, CLI tools, and libraries.


Critical Pattern: Use pyproject.toml, Not setup.py

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 risk
  • pyproject.toml is the PEP 621 standard, supported by all modern tools (pip, uv, hatch, poetry)
  • All tool configuration (ruff, mypy, pytest) goes in one file instead of scattered across setup.cfg, tox.ini, .flake8, etc.

Critical Pattern: Use src Layout for Packages

Use the src/ layout to prevent accidental imports of the development version instead of the installed version.

src layout (recommended for libraries and packages)

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.md

Flat layout (acceptable for applications)

my-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.md

When to use src layout:

  • Publishing a library or package to PyPI
  • When the package name conflicts with a dependency name
  • When you want to ensure tests always run against the installed version

When flat layout is acceptable:

  • Internal web applications (FastAPI, Flask) that will never be distributed as packages
  • Simple scripts or microservices

Either way, always use a proper Python package (directory with __init__.py), never loose .py files at the project root.


Critical Pattern: Proper init.py Usage

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 package

Rules:

  • Every directory that should be importable needs __init__.py
  • Keep __init__.py files minimal — do not put business logic in them
  • Use __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"]
  • For applications (web APIs, CLIs), __init__.py should typically be empty
  • The tests/ directory also needs __init__.py for pytest to find and import test modules correctly

Critical Pattern: Proper .gitignore

Every 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.


Critical Pattern: Dependency Groups (dev/test/prod)

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.


Critical Pattern: Type Hints and py.typed Marker

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 typed

This tells mypy and other type checkers that your package ships with inline type annotations.


Critical Pattern: Test Directory Structure

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.py

conftest.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 client

Rules:

  • Name test files test_*.py (pytest default discovery pattern)
  • Name test functions test_*
  • Use conftest.py for shared fixtures — pytest discovers these automatically
  • Add __init__.py to tests/ and any subdirectories
  • Keep test structure parallel to source structure

Critical Pattern: Entry Points Configuration

For 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 --debug

Critical Pattern: Ruff and Mypy Configuration

Configure 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:

  • Ruff replaces flake8, black, isort, and pyupgrade in a single tool
  • It is 10-100x faster than the tools it replaces
  • Configuration lives in pyproject.toml alongside everything else

Why mypy strict mode:

  • Catches type errors before runtime
  • strict = true enables all optional checks (like --disallow-untyped-defs)
  • Pair with py.typed marker for libraries

Web API Project Layout (FastAPI Example)

# 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}},
        )

When NOT to Split

For a prototype or very small app, a single main.py with everything is fine. Split when:

  • The file exceeds ~300 lines
  • You have more than 3-4 route groups
  • Multiple people are working on the codebase
  • You need tests (test imports are easier with packages)

Quick Checklist

  • pyproject.toml used (not setup.py) with [build-system], [project], and requires-python
  • Code organized into a proper Python package (directory with __init__.py)
  • .gitignore includes .venv/, __pycache__/, dist/, .env, .mypy_cache/
  • Dependencies separated: production in [project.dependencies], dev/test in [project.optional-dependencies]
  • Type hints on all function signatures
  • Tests in tests/ directory with conftest.py for shared fixtures
  • Test files named test_*.py, test functions named test_*
  • Ruff configured in [tool.ruff] section of pyproject.toml
  • Mypy configured with strict = true in [tool.mypy] section
  • Entry points declared in [project.scripts] for CLI tools
  • py.typed marker present for typed libraries
  • Routes separated by resource in routes/ subdirectory
  • Database logic in db.py, not in route handlers
  • Configuration from environment using pydantic-settings, not hardcoded
  • model_config used instead of inner class Config (Pydantic v2)

Verifiers

  • pyproject-toml -- Use pyproject.toml with build-system, project metadata, and requires-python
  • gitignore-python -- Include .venv/, pycache/, dist/, .env in .gitignore
  • dependency-groups -- Separate production and dev/test dependencies using optional-dependencies
  • type-hints-and-tooling -- Type hints on functions, ruff and mypy configured in pyproject.toml
  • test-structure -- Tests in tests/ with conftest.py, init.py, and proper naming
  • project-organization -- Code in a package with init.py, routes separated, config from environment
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/python-project-structure badge