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:
| Pillar | Traditional Python | Our Stack | Why |
|---|---|---|---|
| Package management | pip + virtualenv + pip-tools | uv | One Rust binary, orders of magnitude faster |
| Linting + formatting | flake8 + black + isort + pyupgrade | ruff | 900+ rules, one config, 10β100Γ speedup |
| Type checking | mypy | ty | Same type system, 10β100Γ faster |
| Type annotations | Basic hints | Modern 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β
- Traditional Python
- Our Stack (uv)
# 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 .
# Bootstrap project
uv init
# Sync everything (creates .venv/ + uv.lock)
uv sync
# Add a package (goes into pyproject.toml, lockfile auto-updates)
uv add fastapi
# Add a dev-only package
uv add --dev pytest
# Run a command (auto-activates venv)
uv run python main.py
# Run a tool without installing globally
uvx ruff check .
Why uv Is Fasterβ
uv is written in Rust and makes three architectural choices that compound:
- Parallel downloads.
uvdownloads packages concurrently.pipdownloads sequentially. - SQLite cache.
uvcaches downloaded wheels and metadata in an SQLite database, avoiding redundant network calls across projects. - Compiled resolver. The dependency resolver is written in Rust, not Python. For a project with 200+ transitive dependencies, resolution takes milliseconds instead of seconds.
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:
| Tool | Purpose |
|---|---|
flake8 | Style and complexity linting |
black | Code formatting |
isort | Import sorting |
pyupgrade | Auto-upgrade syntax to newer Python versions |
autoflake | Remove unused imports and variables |
pydocstyle | Docstring 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β
| Prefix | Source | What it catches |
|---|---|---|
E | pycodestyle | Indentation, whitespace, line length |
F | Pyflakes | Undefined names, unused imports, syntax errors |
I | isort | Unsorted or improperly grouped imports |
N | pep8-naming | Variable and function naming conventions |
W | pycodestyle | Trailing whitespace, blank lines |
UP | pyupgrade | Outdated syntax (e.g., List[int] β list[int]) |
B | flake8-bugbear | Potential bugs (e.g., mutable default args) |
SIM | flake8-simplify | Overly complex expressions |
RUF | ruff-specific | Tool-specific best practices |
Do and Don't Examplesβ
- Do
- Don't
# 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
# Bad: Outdated typing syntax (UP006)
from typing import List
def get_users() -> List[str]:
return ["alice", "bob"]
# Bad: Overly complex conditional (SIM)
if user.is_verified:
status = "active"
else:
status = "pending"
# Bad: Mutable default argument (B006)
def process(items: list[str] = []) -> None: # B006!
for item in items:
print(item)
# Bad: Unsorted imports (I)
import requests
import os
from pathlib import Path
import sys
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 command | ty equivalent |
|---|---|
mypy . | ty check . |
mypy --strict . | ty check --strict . |
mypy src/module.py | ty check src/module.py |
mypy --ignore-missing-imports | ty 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
Anytypes unless explicitly needed - No untyped function definitions
- No missing return type annotations
- No unused
# type: ignorecomments
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β
- Python
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:
- Modern (3.9+)
- Legacy (typing module)
# 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")
# You no longer need these imports
from typing import List, Dict, Tuple
users: List[str] = ["alice", "bob"]
scores: Dict[str, int] = {"alice": 95, "bob": 87}
pair: Tuple[int, str] = (1, "first")
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β
- Modern (3.10+)
- Legacy
# Python 3.10+: Use | for unions
def find_user(id: int) -> User | None:
...
def parse(value: str) -> int | float | str:
...
# You no longer need these imports
from typing import Optional, Union
def find_user(id: int) -> Optional[User]:
...
def parse(value: str) -> Union[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:
| Feature | dataclasses | Pydantic |
|---|---|---|
| Purpose | Internal data structures | API boundaries, config, validation |
| Validation | None (trusts types) | Full runtime validation |
| Serialization | Manual (asdict()) | Built-in (.model_dump()) |
| Performance | Faster (no validation overhead) | Slight overhead for validation |
| Mutability | Configurable (frozen=True) | Configurable (frozen=True) |
| When to use | Internal domain models | Request/response models, config parsing |
- dataclass (internal)
- Pydantic (boundaries)
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}>"
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
age: int = Field(ge=0, le=150)
def __init__(self, **data):
super().__init__(**data)
# Pydantic validates at construction time
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β
| Section | Purpose |
|---|---|
[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β
| Hook | What it does | Auto-fixes? |
|---|---|---|
ruff | Lints for style, bugs, and complexity | Yes (--fix flag) |
ruff-format | Formats code consistently | Yes |
ty | Type checks staged files | No (you fix the code) |
trailing-whitespace | Strips trailing whitespace | Yes |
end-of-file-fixer | Ensures files end with newline | Yes |
check-yaml | Validates YAML syntax | No |
check-toml | Validates TOML syntax | No |
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"
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β
| Pitfall | How to avoid it |
|---|---|
uv add adds to wrong dependency group | Use uv add --dev for dev-only packages |
| ruff complains about line length | Set E501 in ignore β ruff format handles line wrapping |
ty errors on third-party packages without stubs | Add --ignore-missing-imports or install types-* packages |
| Pre-commit hooks fail on first run | Run pre-commit run --all-files manually to see and fix all issues at once |
Old requirements.txt still tracked in git | Remove it from the repository after migrating |
Key Takeawaysβ
uvreplacespip,virtualenv, andpip-tools. One tool, one config file, dramatically faster.ruffreplacesflake8,black,isort,pyupgrade, and more. 900+ rules, one binary, instant feedback.tyreplacesmypy. Same type system, same annotations, 10β100Γ faster.pyproject.tomlis the single source of truth. Dependencies, tool config, and project metadata in one file.- Modern typing matters.
Annotated,Protocol,TypeVar,Literal, andNeverare not academic β they are used in production code every day. - Pre-commit hooks catch problems early. Run
pre-commit run --all-fileswhen setting up a new repo. uv runanduvxeliminate activation friction. Run commands and tools without thinking about virtual environments.
Further Readingβ
- uv Documentation β Official uv guides and reference
- ruff Documentation β Official ruff guides and rule catalog
- ty Documentation β Official ty documentation
- Python Typing Documentation β Official typing module reference
- PEP 621 β Storing project metadata in pyproject.toml β The standard that made
pyproject.tomlthe single source of truth - Getting Started β Broader introduction for developers new to Python
- Best Practices β Team coding conventions and patterns