Python Development Guidelines
Philosophy
Strong preferences, pragmatic adaptation.
- On new projects: Apply these patterns fully
- On existing projects: Respect existing conventions, introduce patterns gradually
When to Use
- Writing new Python code
- Writing or reviewing tests
- Setting up new Python projects
- Debugging import, exception, or testing issues
Tooling
| Tool | Purpose | |------|---------| | uv | Package management, virtual environments | | ruff | Linting + formatting (replaces flake8, isort, black) | | mypy | Type checking (standard mode) | | alembic | Database migrations (if existing in project) | | pytest | Testing framework | | pytest-asyncio | Async test support |
Code Style
| Category | Standard | |----------|----------| | Line length | 119 characters | | Import order | isort-style (project first, then third-party) | | Type annotations | Full coverage ideal, gradual pragmatically | | Naming | PEP8 (ClassNames, snake_case_functions) | | Docstrings | Google-style on all public functions | | Complexity | Max 12, extract ruthlessly into small functions |
Import Order
# Project imports first
from app.api.v1.users.repository import UserRepository
from app.api.v1.users.exceptions import UserNotFoundError
from app.database import get_session
# Third-party imports
import structlog
from fastapi import HTTPException
from pydantic import BaseModel
Project Structure
Vertical slices mirroring API routes:
app/
├── api/
│ └── v1/
│ ├── users/
│ │ ├── __init__.py
│ │ ├── router.py # API routes
│ │ ├── service.py # Business logic
│ │ ├── repository.py # Data access
│ │ ├── models.py # SQLAlchemy/Pydantic models
│ │ └── exceptions.py # Domain exceptions
│ └── orders/
│ └── ... # Same structure
├── database.py # Cross-cutting: DB connection
├── config.py # Cross-cutting: Settings
├── logging.py # Cross-cutting: Logging setup
└── main.py
Error Handling
For new projects: Error as Value pattern - functions return (result, error) tuples.
For existing projects: Respect existing try/except patterns.
Why Error as Value?
- Explicit handling at every call site (Go-style discipline)
- Type system forces callers to handle errors
- No hidden control flow from exceptions
Domain Exceptions
Flat hierarchy in dedicated exceptions.py:
class UserNotFoundError(Exception):
"""Raised when user lookup returns no results."""
pass
class ValidationError(Exception):
"""Raised when input validation fails."""
pass
class DatabaseConnectionError(Exception):
"""Raised when database connection fails."""
pass
Repository Layer
from app.api.v1.users.exceptions import DatabaseConnectionError, DatabaseQueryError
async def get_by_id(
session: AsyncSession,
user_id: str,
) -> tuple[User | None, DatabaseConnectionError | DatabaseQueryError | None]:
try:
result = await session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none(), None
except OperationalError as e:
return None, DatabaseConnectionError(f"Connection failed: {e}")
except SQLAlchemyError as e:
return None, DatabaseQueryError(f"Query failed: {e}")
Service Layer
async def get_user(
session: AsyncSession,
user_id: str,
) -> tuple[User | None, ServiceError | None]:
user, error = await repository.get_by_id(session, user_id)
match error:
case DatabaseConnectionError() | DatabaseQueryError():
return None, ServiceError(f"Failed to fetch user {user_id}: {error}")
case None if user is None:
return None, ServiceError(f"User {user_id} not found")
case None:
return user, None
Route Layer
@router.get("/users/{user_id}")
async def get_user(user_id: str, session: AsyncSession = Depends(get_session)):
user, error = await service.get_user(session, user_id)
match error:
case ServiceError() if "not found" in str(error):
raise HTTPException(status_code=404, detail=str(error))
case ServiceError():
raise HTTPException(status_code=500, detail=str(error))
case None:
return user
Testing
See python-testing-guidelines skill for full details.
Key principles:
- New code ships with tests - When implementing new functionality (validators, methods, classes), always add corresponding tests in the same task. Don't wait to be asked.
- Never use mocks - No
Mock,MagicMock,AsyncMock, orpatch() - Dummy classes only - Inherit from real classes, track state for assertions
- Test structure mirrors source -
app/api/v1/users/→tests/api/v1/users/
Configuration
New projects: Pydantic Settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
api_key: str
debug: bool = False
class Config:
env_file = ".env"
settings = Settings()
Existing projects: Adapt to existing pattern.
Logging
| Context | Approach |
|---------|----------|
| Services, APIs | Structured logging (structlog) |
| Scripts, tools | print() or basic logging |
Async
| Context | Approach |
|---------|----------|
| Services, APIs | async/await |
| Scripts, CLI tools | Sync |
Dependency Injection
Prefer explicit injection:
# Good - explicit
async def get_user(user_id: str, repository: UserRepository):
return await repository.get_by_id(user_id)
# Acceptable - module singleton for breaking circular deps
_session_factory = None
def get_session_factory():
global _session_factory
if _session_factory is None:
_session_factory = create_session_factory()
return _session_factory