Python Best Practices
This article is the definitive reference for how we write Python on this team. If you've read the Python for Node.js Developers guide, you already understand the mental model. Now you need to know how to write code that's maintainable, testable, and production-ready.
Every section below contains actionable guidelines grounded in our tooling stack: uv (package management), ruff (linting and formatting), ty (type checking), pytest (testing), and pre-commit (automated quality gates).
What Makes Python "Production-Grade"β
Production-grade Python on our team means code that satisfies five properties:
| Property | How We Enforce It |
|---|---|
| Consistent style | ruff format enforces a single, opinionated code style β no style debates in PRs |
| No obvious bugs | ruff check catches anti-patterns, unused imports, bug-prone constructs |
| Type-safe interfaces | ty validates type hints at commit time, catching mismatched contracts |
| Tested behavior | pytest runs on every change, with fixtures and parametrization |
| Automated gates | pre-commit hooks run before every git commit β nothing bad lands on main |
The goal is not perfection. The goal is frictionless quality: the tooling should make the right thing the easy thing.
Project Structure with uvβ
Recommended Directory Layoutβ
For an AI engineering project, we follow a src layout with a top-level tests/ directory. This keeps the import namespace clean and makes packaging straightforward.
my_ai_project/
βββ .gitignore
βββ .python-version
βββ .pre-commit-config.yaml
βββ pyproject.toml
βββ uv.lock
βββ src/
β βββ my_ai_project/
β βββ __init__.py
β βββ main.py
β βββ agents/
β β βββ __init__.py
β β βββ base.py
β β βββ research_agent.py
β βββ tools/
β β βββ __init__.py
β β βββ search.py
β β βββ calculator.py
β βββ prompts/
β β βββ __init__.py
β β βββ templates.py
β βββ models/
β β βββ __init__.py
β β βββ schemas.py
β βββ utils/
β βββ __init__.py
β βββ logging.py
βββ tests/
βββ __init__.py
βββ conftest.py
βββ test_agents/
β βββ __init__.py
β βββ test_research_agent.py
βββ test_tools/
β βββ __init__.py
β βββ test_search.py
βββ test_integration/
βββ __init__.py
βββ test_pipeline.py
src/ layout?The src/ layout prevents a common class of bugs: importing from the source tree instead of the installed package. When you run python main.py from the project root without a src/ layout, Python adds the project root to sys.path, and you might accidentally import a stale local copy instead of the installed version. The src/ layout eliminates this entirely.
pyproject.toml Configurationβ
The pyproject.toml is the single source of truth for your project. It holds metadata, dependencies, and tool configuration.
[project]
name = "my-ai-project"
version = "0.1.0"
description = "AI research agent with tool use"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"langchain>=0.3.0",
"langchain-openai>=0.2.0",
"pydantic>=2.0",
"httpx>=0.27.0",
]
[dependency-groups]
dev = [
"ruff>=0.8.0",
"ty>=0.0.1",
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-mock>=3.14.0",
"pre-commit>=4.0.0",
]
[project.scripts]
run = "my_ai_project.main:main"
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"]
ignore = [
"E501", # Line length β handled by ruff format
]
[tool.ruff.lint.isort]
force-single-line = false
force-sort-within-sections = true
[tool.ty]
python-version = "3.12"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
asyncio_mode = "auto"
[tool.uv]
dev-dependencies = []
uv supports [dependency-groups] (PEP 735) as the modern way to declare development dependencies. The older [tool.uv.dev-dependencies] still works but is being phased out. Use [dependency-groups] for new projects.
Using uv Workspaces for Monoreposβ
If your team maintains multiple related packages (e.g., a shared prompt library, a tool registry, and an agent framework), uv workspaces let you manage them as a single unit.
# Root pyproject.toml
[project]
name = "ai-workspace"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = ["packages/*"]
Each member package has its own pyproject.toml:
# packages/prompt-lib/pyproject.toml
[project]
name = "prompt-lib"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0",
]
# packages/tool-registry/pyproject.toml
[project]
name = "tool-registry"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"prompt-lib", # Cross-package dependency
"httpx>=0.27.0",
]
Key workspace commands:
# Sync all workspace members
uv sync
# Add a dependency to a specific member
uv add httpx --package tool-registry
# Run a command in a specific member's environment
uv run --package prompt-lib python -c "import prompt_lib"
Managing Environmentsβ
uv manages virtual environments automatically. Here are the essential commands:
| Command | What It Does |
|---|---|
uv sync | Creates/updates .venv/ and uv.lock from pyproject.toml |
uv add <pkg> | Adds a dependency, updates lockfile, syncs environment |
uv remove <pkg> | Removes a dependency, updates lockfile, syncs environment |
uv run <cmd> | Runs a command inside the virtual environment |
uvx <tool> | Runs a tool without installing it (like npx) |
uv tree | Shows the dependency tree |
.venv/Your .gitignore must include .venv/ and uv.lock should be committed. The lockfile ensures reproducible builds across machines and CI.
Code Quality with ruffβ
Our ruff Configurationβ
ruff replaces Flake8, isort, Black, pydocstyle, pyupgrade, autoflake, and more β all in a single binary that runs in milliseconds. Our baseline configuration:
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"I", # isort β import ordering
"N", # pep8-naming
"W", # pycodestyle warnings
"UP", # pyupgrade β modern Python syntax
"B", # flake8-bugbear β bug-prone patterns
"SIM", # flake8-simplify β code simplifications
"RUF", # ruff-specific rules
]
ignore = [
"E501", # Line length β ruff format handles this
]
Which Rules We Enforce and Whyβ
| Rule Set | Prefix | What It Catches | Why It Matters |
|---|---|---|---|
| Pyflakes | F | Undefined names, unused imports, star imports | Catches typos and dead code |
| pycodestyle | E, W | Indentation, whitespace, line length | Consistent formatting |
| isort | I | Import ordering and grouping | Predictable import structure |
| pep8-naming | N | Wrong function/class/variable naming | Follows Python naming conventions |
| pyupgrade | UP | Outdated Python syntax | Keeps code modern (e.g., dict instead of Dict) |
| flake8-bugbear | B | Bug-prone patterns (mutable defaults, etc.) | Prevents subtle runtime bugs |
| flake8-simplify | SIM | Overly complex code | Encourages readable patterns |
| ruff-specific | RUF | Tool-specific improvements | Catches issues other linters miss |
# Check for lint violations
uvx ruff check .
# Auto-fix what can be fixed
uvx ruff check --fix .
# Format code (replaces Black)
uvx ruff format .
# Check formatting without modifying files
uvx ruff format --check .
# Combined: fix lint + format
uvx ruff check --fix . && uvx ruff format .
Common ruff Rules and What They Catchβ
F401 β Unused imports:
# β Triggers F401
import os
import json
def load_config(path: str) -> dict:
with open(path) as f:
return json.load(f)
# β
Fixed
import json
def load_config(path: str) -> dict:
with open(path) as f:
return json.load(f)
B006 β Do not use mutable default arguments:
# β Triggers B006 β the list is shared across calls
def add_tool(name: str, tools: list[str] = []) -> list[str]:
tools.append(name)
return tools
# β
Fixed
def add_tool(name: str, tools: list[str] | None = None) -> list[str]:
if tools is None:
tools = []
tools.append(name)
return tools
SIM910 β Unnecessary list comprehension:
# β Triggers SIM910
unique_tools = list({tool for tool in tools})
# β
Fixed
unique_tools = {tool for tool in tools}
UP035 β Deprecated typing imports:
# β Triggers UP035
from typing import List, Dict, Optional
# β
Fixed β use built-in generics (Python 3.9+)
# No import needed β list, dict, and X | None are built-in
Type Safety with tyβ
When and How to Use Type Hintsβ
Type hints are optional at runtime but mandatory for our code quality gates. ty checks them at commit time.
Always type:
- Function signatures (parameters and return types)
- Class attributes
- Module-level variables
Prefer explicit over implicit:
# β Avoid β ty infers `Any` for unannotated parameters
def process_response(response):
return response.json()
# β
Explicit β ty validates the contract
from httpx import Response
def process_response(response: Response) -> dict:
return response.json()
Running tyβ
# Type check the entire project
uvx ty check
# Type check a specific file
uvx ty check src/my_ai_project/agents/research_agent.py
# Type check with verbose output
uvx ty check --verbose
Common Type Patterns in AI Engineering Codeβ
Generic agent return types:
from typing import TypeVar
T = TypeVar("T")
class AgentResult:
def __init__(self, data: T, metadata: dict[str, str]) -> None:
self.data = data
self.metadata = metadata
Protocol-based duck typing for tool interfaces:
from typing import Protocol
class Tool(Protocol):
name: str
description: str
def run(self, **kwargs: object) -> str: ...
Union types for LLM response handling:
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
ChatMessage = AIMessage | HumanMessage | ToolMessage
def summarize_conversation(messages: list[ChatMessage]) -> str:
"""Summarize a conversation thread."""
content = "\n".join(msg.content for msg in messages)
return content[:500]
Pydantic models for structured output:
from pydantic import BaseModel, Field
class SearchResult(BaseModel):
title: str
url: str
snippet: str = Field(description="Brief summary of the page content")
relevance_score: float = Field(ge=0.0, le=1.0)
def search(query: str, max_results: int = 5) -> list[SearchResult]:
"""Search the web and return structured results."""
...
ty vs mypyty is 10β100Γ faster than mypy because it's written in Rust. For most projects, the difference is seconds vs. milliseconds. Use ty for everyday development and CI. If you need mypy-specific features (e.g., reveal_type), you can still install mypy as a dev dependency.
Testing with pytestβ
Test Organization and Naming Conventionsβ
pytest discovers tests automatically. Follow these conventions:
tests/
βββ conftest.py # Shared fixtures for the entire test suite
βββ test_agents/
β βββ __init__.py
β βββ conftest.py # Agent-specific fixtures
β βββ test_research_agent.py
βββ test_tools/
β βββ __init__.py
β βββ test_search.py
βββ test_integration/
βββ __init__.py
βββ test_pipeline.py
Naming conventions:
| Element | Convention | Example |
|---|---|---|
| Test directory | test_<module>/ | test_agents/ |
| Test file | test_<unit>.py | test_research_agent.py |
| Test function | test_<unit>_<scenario>_<expected> | test_agent_handles_empty_response() |
| Fixture | Descriptive noun | mock_llm_client, sample_messages |
Fixturesβ
Fixtures are pytest's dependency injection system. They replace manual setup/teardown.
# tests/conftest.py
import pytest
from pydantic import BaseModel
class MockToolResponse(BaseModel):
content: str
is_error: bool = False
@pytest.fixture
def mock_tool_response() -> MockToolResponse:
"""Return a default mock tool response."""
return MockToolResponse(content="Search results for query")
@pytest.fixture
def sample_messages():
"""Return a list of sample chat messages."""
from langchain_core.messages import HumanMessage, AIMessage
return [
HumanMessage(content="What is the capital of France?"),
AIMessage(content="The capital of France is Paris."),
]
Use fixtures in tests:
# tests/test_tools/test_search.py
import pytest
def test_search_returns_results(mock_tool_response):
"""Search tool returns structured results on success."""
assert mock_tool_response.content == "Search results for query"
assert not mock_tool_response.is_error
def test_search_error_response(mock_tool_response):
"""Search tool marks errors in the response."""
mock_tool_response.is_error = True
assert mock_tool_response.is_error is True
Parametrizationβ
Use @pytest.mark.parametrize to test multiple inputs without duplicating test logic:
import pytest
@pytest.mark.parametrize(
"query,expected_count",
[
("Python", 5),
("LangChain", 3),
("", 0),
("a" * 1000, 0), # Too long β should be rejected
],
)
def test_search_query_validation(query: str, expected_count: int) -> None:
"""Search validates query length and content."""
results = search(query)
assert len(results) == expected_count
Testing Async Codeβ
AI engineering code is heavily async. pytest handles it with pytest-asyncio:
# tests/test_agents/test_research_agent.py
import pytest
from my_ai_project.agents.research_agent import ResearchAgent
@pytest.fixture
def agent():
"""Create a research agent for testing."""
return ResearchAgent(model_name="gpt-4o-mini")
@pytest.mark.asyncio
async def test_agent_researches_topic(agent) -> None:
"""Agent returns research results for a valid topic."""
result = await agent.research("Python async patterns")
assert result is not None
assert len(result.findings) > 0
@pytest.mark.asyncio
async def test_agent_handles_timeout(agent) -> None:
"""Agent raises TimeoutError when LLM call exceeds deadline."""
with pytest.raises(TimeoutError):
await agent.research("complex topic", timeout=0.001)
Mocking LLM Callsβ
LLM calls are expensive, non-deterministic, and slow. Always mock them in tests:
from unittest.mock import AsyncMock, patch
import pytest
@pytest.mark.asyncio
async def test_agent_uses_tools_correctly() -> None:
"""Agent calls search tool when asked about current information."""
with patch(
"my_ai_project.agents.research_agent.ChatOpenAI"
) as MockChatOpenAI:
mock_llm = AsyncMock()
mock_llm.ainvoke.return_value.content = "I should search the web."
MockChatOpenAI.return_value = mock_llm
agent = ResearchAgent(model_name="gpt-4o-mini")
result = await agent.research("latest Python version")
# Verify the LLM was called with the expected prompt
mock_llm.ainvoke.assert_called()
Test the orchestration layer, not the LLM output. You cannot assert on what the LLM says, but you can assert that your code:
- Calls the LLM with the correct prompt template
- Parses the response correctly
- Handles errors and retries
- Routes to the right tools
Pre-commit Hooksβ
Our Configurationβ
.pre-commit-config.yaml at the project root:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=500"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/astral-sh/ty
rev: v0.0.33
hooks:
- id: ty
- repo: local
hooks:
- id: pytest
name: pytest
entry: uv run pytest tests/ -x --tb=short
language: system
pass_filenames: false
always_run: true
stages: [push]
How to Set Upβ
# Add pre-commit as a dev dependency
uv add --group dev pre-commit
# Install the git hooks
uv run pre-commit install
# Run all hooks against all files (useful on first setup)
uv run pre-commit run --all-files
What Happens on Every Commitβ
When you run git commit, pre-commit executes hooks in order:
git commit -m "Add research agent"
[INFO] Running pre-commit hooks...
trim trailing whitespace.........................Passed
fix end of files................................Passed
check yaml......................................Passed
ruff............................................Passed
ruff-format.....................................Passed
ty..............................................Passed
If any hook fails, the commit is rejected:
git commit -m "Add research agent"
ruff............................................Failed
- hook id: ruff
- exit code: 1
F401 `os` imported but unused
--> src/my_ai_project/agents/research_agent.py:2:8
|
2 | import os
| ^^
pre-commit only checks files that are staged (git add). This keeps commits fast. If you want to check the entire codebase, run uv run pre-commit run --all-files manually.
Running the full test suite on every commit slows down the development workflow. We configure pytest to run at the push stage instead β it still prevents bad code from reaching the remote, but local commits are fast.
Async Patternsβ
When to Use Async vs. Syncβ
| Scenario | Use | Reason |
|---|---|---|
| LLM API calls | async | Network-bound, high latency, benefits from concurrency |
| File I/O | async | Disk-bound, non-blocking I/O available |
| Database queries | async | Connection pooling, concurrent queries |
| CPU-bound computation | sync | Async doesn't help with CPU; use concurrent.futures instead |
| Simple utility functions | sync | No I/O, no benefit from async overhead |
Common Async Patterns with LangChain/LangGraphβ
Parallel tool execution:
import asyncio
from my_ai_project.tools.search import search_web
from my_ai_project.tools.calculator import calculate
async def research_and_compute(
query: str, expression: str
) -> tuple[str, str]:
"""Run search and calculation in parallel."""
search_result, calc_result = await asyncio.gather(
search_web(query),
calculate(expression),
)
return search_result, calc_result
Sequential agent pipeline with error handling:
import asyncio
from my_ai_project.agents.base import AgentError
async def run_agent_pipeline(
agent: Agent,
prompt: str,
max_retries: int = 3,
) -> str:
"""Run an agent with retry logic."""
for attempt in range(max_retries):
try:
result = await agent.run(prompt)
return result
except AgentError as exc:
if attempt == max_retries - 1:
raise
wait_time = 2 ** attempt # Exponential backoff
await asyncio.sleep(wait_time)
raise RuntimeError("Pipeline exhausted retries")
Streaming responses:
from collections.abc import AsyncIterator
async def stream_agent_response(
agent: Agent,
prompt: str,
) -> AsyncIterator[str]:
"""Stream an agent's response token by token."""
async for token in agent.stream(prompt):
yield token
Error Handling in Async Codeβ
import asyncio
import httpx
class AIRequestError(Exception):
"""Base exception for AI request failures."""
class LLMTimeoutError(AIRequestError):
"""Raised when an LLM call times out."""
class LLMRateLimitError(AIRequestError):
"""Raised when rate-limited by the LLM provider."""
async def call_llm(
prompt: str,
timeout: float = 30.0,
) -> str:
"""Call the LLM with proper error handling."""
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
json={"model": "gpt-4o", "messages": [{"role": "user", "content": prompt}]},
headers={"Authorization": f"Bearer {API_KEY}"},
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
except asyncio.TimeoutError:
raise LLMTimeoutError(f"LLM call timed out after {timeout}s")
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 429:
raise LLMRateLimitError("Rate limited by provider")
raise AIRequestError(f"HTTP error: {exc.response.status_code}")
A common mistake is using bare except: in async handlers, which masks KeyboardInterrupt and SystemExit. Always catch specific exceptions. In async code, swallowed exceptions also prevent the event loop from cleaning up properly.
Quick Referenceβ
Command Cheat Sheetβ
# ββ Project management ββ
uv init # Create new project
uv add <package> # Add dependency
uv add --group dev <package> # Add dev dependency
uv sync # Sync environment from lockfile
uv run <command> # Run command in virtual environment
uvx <tool> # Run tool without installing
# ββ Code quality ββ
uvx ruff check . # Lint
uvx ruff check --fix . # Lint + auto-fix
uvx ruff format . # Format
uvx ruff format --check . # Check formatting
uvx ty check # Type check
# ββ Testing ββ
uv run pytest # Run all tests
uv run pytest -x # Stop on first failure
uv run pytest -v # Verbose output
uv run pytest tests/test_agents/ # Run specific test directory
uv run pytest -k "timeout" # Run tests matching keyword
# ββ Pre-commit ββ
uv run pre-commit install # Install git hooks
uv run pre-commit run --all-files # Run all hooks on all files
uv run pre-commit autoupdate # Update hook versions
pyproject.toml Templateβ
Copy this as a starting point for new projects:
[project]
name = "my-project"
version = "0.1.0"
description = "Project description"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = [
"ruff>=0.8.0",
"ty>=0.0.1",
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-mock>=3.14.0",
"pre-commit>=4.0.0",
]
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "RUF"]
ignore = ["E501"]
[tool.ty]
python-version = "3.12"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.uv]
dev-dependencies = []
Key Takeawaysβ
pyproject.tomlis your single source of truth β dependencies, tool config, and metadata live together.ruff check+ruff formatreplace a dozen separate linting and formatting tools.tygives you fast type checking that fits into your commit workflow.- pytest fixtures and parametrization keep tests DRY and expressive.
- pre-commit hooks enforce quality automatically β never merge bad code by accident.
- Async is the default for I/O-bound AI workloads β use
asyncio.gatherfor parallel calls and always handle errors explicitly. - Test orchestration, not LLM output β mock the LLM, assert on your code's behavior.