Overview

Modern Python is Python 3.12+, a pyproject.toml, uv for package and environment management, ruff for lint and format, pyright in strict mode for types, and pytest for tests. Everything below assumes those choices. Read general-principles first for the language-agnostic rules this page builds on.

Use pyproject.toml and uv

Every project ships a pyproject.toml. No requirements.txt, no setup.py, no Pipfile.

[project]
name = "myproj"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "httpx>=0.27",
  "pydantic>=2.7",
]
 
[tool.uv]
dev-dependencies = [
  "pytest>=8",
  "ruff>=0.5",
  "pyright>=1.1.370",
]

Use uv for installs, virtualenvs, and lockfiles. uv sync creates a .venv and installs from uv.lock. uv add httpx adds a dependency and updates both files in one step. Poetry is the fallback if a team already standardizes on it; do not mix the two in one repo.

One virtualenv per project, never global

Activate the project’s .venv before running anything. Global pip install is a foot-gun: it pollutes the system Python, masks dependency conflicts, and breaks the next project. uv run <cmd> runs inside the project venv without an explicit activate step.

Ruff for lint and format

Ruff replaces flake8, isort, black, pyupgrade, and a long tail of plugins. Configure it once in pyproject.toml:

[tool.ruff]
line-length = 100
target-version = "py312"
 
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
ignore = ["E501"]

Run ruff check --fix and ruff format on every commit. Wire both into a pre-commit hook and into CI. A diff that mixes formatting with logic changes is unreviewable; format first, commit, then change.

Pyright in strict mode

Use pyright (or mypy --strict) and make it part of CI. Strict mode forces explicit types, catches Optional mistakes, and refuses untyped function definitions.

[tool.pyright]
include = ["src", "tests"]
strict = ["src"]
pythonVersion = "3.12"

Type hints are mandatory on every public function. Internal helpers can rely on inference, but the moment a function crosses a module boundary, annotate the parameters and return type. See general-principles on naming; the same rule applies to type aliases.

Pytest with fixtures, not setUp

pytest is the only test runner worth using. Avoid unittest.TestCase. Use fixtures for shared setup, parametrize for input matrices, and tmp_path for filesystem tests.

import pytest
 
@pytest.fixture
def client(tmp_path):
    return Client(db_path=tmp_path / "app.db")
 
@pytest.mark.parametrize("n, expected", [(0, 1), (1, 1), (5, 120)])
def test_factorial(n: int, expected: int) -> None:
    assert factorial(n) == expected

Test the cases from general-principles: golden path, empty, boundary, error. Run with pytest -x --ff locally to fail fast on the previously failing test.

Async with asyncio.TaskGroup

For concurrent I/O in Python 3.11+, use asyncio.TaskGroup. It handles cancellation, exception aggregation, and structured cleanup automatically.

import asyncio
import httpx
 
async def fetch_all(urls: list[str]) -> list[bytes]:
    async with httpx.AsyncClient() as client, asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(client.get(u)) for u in urls]
    return [t.result().content for t in tasks]

Avoid bare asyncio.gather(*tasks). If one task raises, gather cancels the others without surfacing the partial state; TaskGroup cancels and re-raises an ExceptionGroup you can pattern-match on.

For HTTP, use httpx (sync and async in one library). For async Postgres, use asyncpg or psycopg[async]. See fastapi for the request-handler patterns.

No print inside libraries

A library function that prints corrupts every caller’s stdout. Use logging with a module-level logger:

import logging
logger = logging.getLogger(__name__)
 
def parse(payload: str) -> Invoice:
    logger.debug("parsing payload of %d bytes", len(payload))
    ...

The application entry point configures handlers and levels. Library code only emits records. print is fine in scripts, CLIs at the entry point, and notebooks; nowhere else.