Skip to main content

Python for Python Developers

You already know Python. You've used venv, pip, flake8, black, isort, and mypy. You know list comprehensions, decorators, and context managers.

This guide is not a Python tutorial. It is a delta document β€” it explains what is different about how our team works, why we made each tool choice, and what you need to change in your daily workflow.

By the end, you'll understand the four pillars of our stack: uv, ruff, ty, and modern Python typing β€” and you'll be productive on day one.


The Four Pillars​

Our tooling stack is built around four decisions that compound into a faster, more consistent developer experience:

PillarTraditional PythonOur StackWhy
Package managementpip + virtualenv + pip-toolsuvOne Rust binary, orders of magnitude faster
Linting + formattingflake8 + black + isort + pyupgraderuff900+ rules, one config, 10–100Γ— speedup
Type checkingmypytySame type system, 10–100Γ— faster
Type annotationsBasic hintsModern typing (Annotated, Protocol, TypeVar)Production-grade abstractions

See also: Getting Started for a broader introduction, and Best Practices for coding conventions.


Virtual Environments: venv vs uv​

The Traditional Workflow​

If you are coming from a standard Python setup, your workflow looks something like this:

# 1. Create a virtual environment
python -m venv .venv
source .venv/bin/activate

# 2. Install dependencies from a requirements file
pip install -r requirements.txt

# 3. Add a new dependency
pip install new-package
pip freeze > requirements.txt # update the file manually

# 4. Run your code
python main.py

This works, but it has friction: slow installs, manual lockfile management, and no built-in way to pin Python versions.

Our Workflow with uv​

The same workflow with uv:

# 1. Bootstrap the project (creates pyproject.toml + .python-version)
uv init

# 2. Sync dependencies (creates .venv/ and uv.lock automatically)
uv sync

# 3. Add a new dependency
uv add new-package

# 4. Run your code (auto-activates the venv)
uv run python main.py

Side-by-Side Comparison​

# Create venv
python -m venv .venv
source .venv/bin/activate

# Install from requirements.txt
pip install -r requirements.txt

# Add a package
pip install fastapi
pip freeze > requirements.txt

# Add a dev-only package
pip install pytest
# (no way to separate dev vs prod deps in requirements.txt)

# Run a command
python main.py

# Run a tool without installing globally
pip install ruff --user
ruff check .

Why uv Is Faster​

uv is written in Rust and makes three architectural choices that compound:

  1. Parallel downloads. uv downloads packages concurrently. pip downloads sequentially.
  2. SQLite cache. uv caches downloaded wheels and metadata in an SQLite database, avoiding redundant network calls across projects.
  3. Compiled resolver. The dependency resolver is written in Rust, not Python. For a project with 200+ transitive dependencies, resolution takes milliseconds instead of seconds.
uv run β€” Like npx for Python

If you come from Node.js, uv run is the closest thing to npx for running commands inside a virtual environment without manually activating it:

# Runs pytest inside the project's .venv without "source .venv/bin/activate"
uv run pytest tests/

# Runs a one-off script with transient dependencies
uv run --with httpx curl --help

uv tool install β€” Global Tools​

For tools you want available everywhere (not tied to a project):

# Install a tool globally (like npm install -g)
uv tool install ruff
uv tool install ty
uv tool install pre-commit

# List installed tools
uv tool list

# Upgrade a tool
uv tool upgrade ruff

Linting and Formatting: flake8/black/isort β†’ ruff​

The Problem with the Traditional Stack​

A typical Python project historically needed a constellation of tools:

ToolPurpose
flake8Style and complexity linting
blackCode formatting
isortImport sorting
pyupgradeAuto-upgrade syntax to newer Python versions
autoflakeRemove unused imports and variables
pydocstyleDocstring style checking

Each has its own config file, its own plugin ecosystem, and its own performance characteristics. Running all of them on a medium-sized project takes seconds.

ruff Replaces Them All​

ruff is a single Rust binary that does everything above β€” and more β€” at 10–100Γ— the speed of flake8. It processes approximately 1 million lines per second compared to flake8's ~10,000.

Key commands:

# Lint the project
ruff check .

# Auto-fix fixable issues
ruff check --fix .

# Format the project
ruff format .

# Lint + format in one go
ruff check --fix . && ruff format .

Our ruff Configuration​

Our pyproject.toml includes a [tool.ruff] section:

[tool.ruff]
target-version = "py312"
line-length = 88

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"I", # isort
"N", # pep8-naming
"W", # pycodestyle warnings
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
]
ignore = [
"E501", # Line too long (handled by ruff format)
"F401", # Unused imports (we allow them for __init__.py re-exports)
]

[tool.ruff.lint.isort]
force-single-line = false
combine-as-imports = true

[tool.ruff.lint.pydocstyle]
convention = "google"

Common Rules We Enforce​

PrefixSourceWhat it catches
EpycodestyleIndentation, whitespace, line length
FPyflakesUndefined names, unused imports, syntax errors
IisortUnsorted or improperly grouped imports
Npep8-namingVariable and function naming conventions
WpycodestyleTrailing whitespace, blank lines
UPpyupgradeOutdated syntax (e.g., List[int] β†’ list[int])
Bflake8-bugbearPotential bugs (e.g., mutable default args)
SIMflake8-simplifyOverly complex expressions
RUFruff-specificTool-specific best practices

Do and Don't Examples​

# Good: Use modern built-in generics (UP006)
def get_users() -> list[str]:
return ["alice", "bob"]

# Good: Simple conditional (SIM)
status = "active" if user.is_verified else "pending"

# Good: Immutable default argument (B006)
def process(items: list[str] | None = None) -> None:
for item in items or []:
print(item)

# Good: Import sorting (I)
import os
import sys

from pathlib import Path

import requests

Type Checking: mypy β†’ ty​

What Changed​

ty is a drop-in replacement for mypy. The type system is the same. The annotations are the same. The only difference is speed β€” ty is 10–100Γ— faster because it is written in Rust with an incremental checking engine.

Command Mapping​

mypy commandty equivalent
mypy .ty check .
mypy --strict .ty check --strict .
mypy src/module.pyty check src/module.py
mypy --ignore-missing-importsty check --ignore-missing-imports

Configuration​

In pyproject.toml:

[tool.ty]
python-version = "3.12"

That is often all you need. ty reads the same type annotations that mypy understands.

Strict Mode​

We recommend running ty in strict mode for new code:

ty check --strict .

Strict mode enables additional checks:

  • No Any types unless explicitly needed
  • No untyped function definitions
  • No missing return type annotations
  • No unused # type: ignore comments
Incremental Checking in CI

In CI pipelines, ty caches type-checking results between runs. On a 50k-line codebase, a full check takes ~2 seconds, and an incremental check (after a small change) takes under 200ms. With mypy, those same numbers are ~20s and ~2s respectively.


Modern Python Type System​

You know basic type hints. This section covers what we actually use in production β€” the features that make Python typing powerful enough for complex AI engineering code.

Basic Type Hints​

def greet(name: str, times: int = 1) -> str:
return "\n".join(f"Hello, {name}!" for _ in range(times))

Built-in Generic Types (Python 3.9+)​

Since Python 3.9, you can use built-in types directly instead of importing from typing:

# Use these directly β€” no imports needed
users: list[str] = ["alice", "bob"]
scores: dict[str, int] = {"alice": 95, "bob": 87}
pair: tuple[int, str] = (1, "first")
ruff will auto-upgrade these

The UP rule set in ruff automatically converts List[str] β†’ list[str] via ruff check --fix .. You never need to do it manually.

Optional and Union Types​

# Python 3.10+: Use | for unions
def find_user(id: int) -> User | None:
...

def parse(value: str) -> int | float | str:
...

typing.Annotated β€” Metadata on Types​

Annotated lets you attach metadata to a type without changing its runtime behavior. This is used heavily in LangGraph state definitions:

from typing import Annotated
import operator

# Accumulator pattern: each step appends to the message list
class AgentState(TypedDict):
messages: Annotated[list[str], operator.add]
total_tokens: Annotated[int, operator.add]
user_id: str

The operator.add reducer tells LangGraph how to merge state across steps. Without Annotated, you would need custom merge functions.

typing.Protocol β€” Structural Typing​

Protocol gives you Go-style interfaces β€” you define the shape, and any class matching that shape is compatible:

from typing import Protocol

class Stringifyable(Protocol):
def to_string(self) -> str: ...

class User:
def to_string(self) -> str:
return f"User({self.name})"

class Product:
def to_string(self) -> str:
return f"Product({self.sku})"

def process(item: Stringifyable) -> str:
return item.to_string()

# Both work β€” no inheritance needed
process(User()) # OK
process(Product()) # OK

This is especially useful in AI engineering where you want to abstract over different model providers or tool implementations without a shared base class.

typing.TypeVar β€” Generics for Reusable Abstractions​

TypeVar lets you write functions and classes that preserve type information:

from typing import TypeVar

T = TypeVar("T")

# Generic function that preserves the input type
def first(items: list[T]) -> T | None:
return items[0] if items else None

result = first([1, 2, 3]) # ty knows result is int | None
result = first(["a", "b"]) # ty knows result is str | None

# Generic class
class Cache(dict[str, T]):
def get_or_compute(self, key: str, fn: () -> T) -> T:
if key not in self:
self[key] = fn()
return self[key] # type: ignore[return-value]

typing.Never and typing.Literal​

Never and Literal help you express narrow, precise types:

from typing import Literal, Never

# Literal narrows a type to specific values
def set_mode(mode: Literal["train", "eval", "predict"]) -> None:
...

set_mode("train") # OK
set_mode("debug") # ty error: "debug" not in Literal

# Never means "this function never returns normally"
def raise_error(message: str) -> Never:
raise RuntimeError(message)

# Never is useful for exhaustive match checking
LogLevel = Literal["debug", "info", "warning", "error"]

def handle_level(level: LogLevel) -> None:
match level:
case "debug":
pass
case "info":
pass
case "warning":
pass
case "error":
pass
case _:
# ty will flag this branch as unreachable if all cases are covered
raise ValueError(f"Unknown level: {level}")

dataclasses vs Pydantic​

Both are used in our codebase, but for different purposes:

FeaturedataclassesPydantic
PurposeInternal data structuresAPI boundaries, config, validation
ValidationNone (trusts types)Full runtime validation
SerializationManual (asdict())Built-in (.model_dump())
PerformanceFaster (no validation overhead)Slight overhead for validation
MutabilityConfigurable (frozen=True)Configurable (frozen=True)
When to useInternal domain modelsRequest/response models, config parsing
from dataclasses import dataclass


@dataclass(frozen=True)
class UserProfile:
user_id: int
name: str
email: str

def display_name(self) -> str:
return f"{self.name} <{self.email}>"
Rule of Thumb

Use dataclasses for internal structures where you control the data. Use Pydantic at boundaries β€” HTTP request bodies, configuration files, serialization layers β€” where you need runtime validation.


pyproject.toml Deep Dive​

pyproject.toml is the single source of truth for your project. It replaces setup.py, requirements.txt, .flake8, .pylintrc, setup.cfg, and more.

Full Example​

[project]
name = "my-project"
version = "0.1.0"
description = "An AI engineering project"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110.0",
"pydantic>=2.6.0",
"langgraph>=0.2.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.4.0",
"ty>=0.0.1",
"pre-commit>=3.7.0",
]
docs = [
"mkdocs>=1.6.0",
"mkdocs-material>=9.5.0",
]

[tool.uv]
dev-dependencies = [
"ipython>=8.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", "F401"]

[tool.ruff.lint.isort]
force-single-line = false
combine-as-imports = true

[tool.ty]
python-version = "3.12"

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

Section Breakdown​

SectionPurpose
[project]PEP 621 metadata: name, version, description, Python version, dependencies
[project.optional-dependencies]Named dependency groups (uv sync --extra dev)
[tool.uv]uv-specific configuration (dev dependencies, Python version resolution)
[tool.ruff]Linter and formatter configuration
[tool.ty]Type checker configuration
[tool.pytest.ini_options]Test runner configuration

Declaring Dependencies​

Unlike requirements.txt, which is a flat list, pyproject.toml supports:

# Exact version
"requests==2.31.0"

# Minimum version (recommended default)
"requests>=2.31.0"

# Version range
"requests>=2.31.0,<3.0.0"

# Specific Python version constraint
"pywin32>=306; sys_platform == 'win32'"

uv lock resolves these constraints and writes a deterministic uv.lock file. uv sync installs exactly what the lockfile says β€” no surprises.


Pre-commit Hooks​

Pre-commit hooks enforce quality gates at commit time. They run automatically when you run git commit, catching issues before they reach the branch.

Configuration​

Our .pre-commit-config.yaml:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: https://github.com/astral-sh/ty
rev: v0.0.33
hooks:
- id: ty

- 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-toml

What Runs on Each Commit​

HookWhat it doesAuto-fixes?
ruffLints for style, bugs, and complexityYes (--fix flag)
ruff-formatFormats code consistentlyYes
tyType checks staged filesNo (you fix the code)
trailing-whitespaceStrips trailing whitespaceYes
end-of-file-fixerEnsures files end with newlineYes
check-yamlValidates YAML syntaxNo
check-tomlValidates TOML syntaxNo

Installation​

# Install pre-commit as a global tool
uv tool install pre-commit

# Install hooks into the repository
pre-commit install

After installation, hooks run automatically on every git commit. The first run may take a few seconds as hooks download and cache.

Bypassing Hooks​

Sometimes you need to commit before everything is green (e.g., work-in-progress, debugging). You can bypass hooks with:

git commit --no-verify -m "WIP: debugging auth flow"
Bypassing Hooks

Use --no-verify sparingly and only for legitimate reasons. Never bypass hooks to skip type checks or linting on production code. If a hook is blocking you, the right fix is usually to address the underlying issue, not to skip the gate.


Migration Checklist​

Coming from a traditional Python setup? Follow this checklist to transition smoothly.

Step 1: Replace requirements.txt with pyproject.toml​

# Create pyproject.toml from your existing requirements.txt
uv init
# Then migrate dependencies one group at a time:
uv add fastapi pydantic langgraph
uv add --dev pytest ruff ty pre-commit

Step 2: Replace venv + pip with uv​

# Delete the old venv
rm -rf .venv

# Sync with uv (creates .venv/ and uv.lock)
uv sync

Step 3: Replace flake8/black/isort with ruff​

# Remove old tool configs
rm -f .flake8 .pylintrc .isort.cfg

# Add ruff config to pyproject.toml (see the Deep Dive section above)

# Run ruff to see what it catches
ruff check .
ruff check --fix . # auto-fix what it can
ruff format . # format everything

Step 4: Replace mypy with ty​

# Replace mypy config with ty config in pyproject.toml
# Then run:
ty check .

Step 5: Add pre-commit hooks​

uv tool install pre-commit
pre-commit install
pre-commit run --all-files # verify everything passes

Common Pitfalls​

PitfallHow to avoid it
uv add adds to wrong dependency groupUse uv add --dev for dev-only packages
ruff complains about line lengthSet E501 in ignore β€” ruff format handles line wrapping
ty errors on third-party packages without stubsAdd --ignore-missing-imports or install types-* packages
Pre-commit hooks fail on first runRun pre-commit run --all-files manually to see and fix all issues at once
Old requirements.txt still tracked in gitRemove it from the repository after migrating

Key Takeaways​

Summary
  • uv replaces pip, virtualenv, and pip-tools. One tool, one config file, dramatically faster.
  • ruff replaces flake8, black, isort, pyupgrade, and more. 900+ rules, one binary, instant feedback.
  • ty replaces mypy. Same type system, same annotations, 10–100Γ— faster.
  • pyproject.toml is the single source of truth. Dependencies, tool config, and project metadata in one file.
  • Modern typing matters. Annotated, Protocol, TypeVar, Literal, and Never are not academic β€” they are used in production code every day.
  • Pre-commit hooks catch problems early. Run pre-commit run --all-files when setting up a new repo.
  • uv run and uvx eliminate activation friction. Run commands and tools without thinking about virtual environments.

Further Reading​