Skip to main content

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:

PropertyHow We Enforce It
Consistent styleruff format enforces a single, opinionated code style β€” no style debates in PRs
No obvious bugsruff check catches anti-patterns, unused imports, bug-prone constructs
Type-safe interfacesty validates type hints at commit time, catching mismatched contracts
Tested behaviorpytest runs on every change, with fixtures and parametrization
Automated gatespre-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​

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
Why 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 = []
Dependency Groups vs. 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:

CommandWhat It Does
uv syncCreates/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 treeShows the dependency tree
Never commit .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 SetPrefixWhat It CatchesWhy It Matters
PyflakesFUndefined names, unused imports, star importsCatches typos and dead code
pycodestyleE, WIndentation, whitespace, line lengthConsistent formatting
isortIImport ordering and groupingPredictable import structure
pep8-namingNWrong function/class/variable namingFollows Python naming conventions
pyupgradeUPOutdated Python syntaxKeeps code modern (e.g., dict instead of Dict)
flake8-bugbearBBug-prone patterns (mutable defaults, etc.)Prevents subtle runtime bugs
flake8-simplifySIMOverly complex codeEncourages readable patterns
ruff-specificRUFTool-specific improvementsCatches issues other linters miss
Running ruff
# 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 mypy

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

ElementConventionExample
Test directorytest_<module>/test_agents/
Test filetest_<unit>.pytest_research_agent.py
Test functiontest_<unit>_<scenario>_<expected>test_agent_handles_empty_response()
FixtureDescriptive nounmock_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()
Testing Strategy for AI Code

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
| ^^
Hooks run on staged files only

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.

Why pytest runs on push, not commit

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​

ScenarioUseReason
LLM API callsasyncNetwork-bound, high latency, benefits from concurrency
File I/OasyncDisk-bound, non-blocking I/O available
Database queriesasyncConnection pooling, concurrent queries
CPU-bound computationsyncAsync doesn't help with CPU; use concurrent.futures instead
Simple utility functionssyncNo 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}")
Never swallow exceptions in async 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​

Summary
  • pyproject.toml is your single source of truth β€” dependencies, tool config, and metadata live together.
  • ruff check + ruff format replace a dozen separate linting and formatting tools.
  • ty gives 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.gather for parallel calls and always handle errors explicitly.
  • Test orchestration, not LLM output β€” mock the LLM, assert on your code's behavior.