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) == expectedTest 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.