majiayu000

python-project

6
1
# Install this skill:
npx skills add majiayu000/claude-arsenal --skill "python-project"

Install specific skill from multi-skill repository

# Description

Modern Python project architecture guide for 2025. Use when creating Python projects (APIs, CLI, data pipelines). Covers uv, Ruff, Pydantic, FastAPI, and async patterns.

# SKILL.md


name: python-project
description: Modern Python project architecture guide for 2025. Use when creating Python projects (APIs, CLI, data pipelines). Covers uv, Ruff, Pydantic, FastAPI, and async patterns.


Python Project Architecture

Core Principles

  • Type hints everywhere β€” Pydantic for runtime, mypy for static
  • uv for everything β€” Package management, virtualenv, Python version
  • Ruff only β€” Replace Flake8 + Black + isort with single tool
  • src layout β€” All code under src/ directory
  • pyproject.toml only β€” No setup.py, no requirements.txt
  • Async all the way β€” Once async, stay async through call chain
  • No backwards compatibility β€” Delete, don't deprecate. Change directly
  • LiteLLM for LLM APIs β€” Use LiteLLM proxy for all LLM integrations

No Backwards Compatibility

Delete unused code. Change directly. No compatibility layers.

# ❌ BAD: Deprecated decorator kept around
import warnings

def old_function():
    warnings.warn("Use new_function instead", DeprecationWarning)
    return new_function()

# ❌ BAD: Alias for renamed functions
new_name = old_name  # "for backwards compatibility"

# ❌ BAD: Unused parameters with underscore
def process(_legacy_param, data):
    ...

# ❌ BAD: Version checking for old behavior
if version < "2.0":
    # old behavior
    ...

# βœ… GOOD: Just delete and update all usages
def new_function():
    ...
# Then: Find & replace all old_function β†’ new_function

# βœ… GOOD: Remove unused parameters entirely
def process(data):
    ...

LiteLLM for LLM APIs

Use LiteLLM proxy. Don't call provider APIs directly.

# src/myapp/llm.py
from openai import AsyncOpenAI

from myapp.config import settings

# Connect to LiteLLM proxy using OpenAI SDK
client = AsyncOpenAI(
    base_url=settings.litellm_url,  # "http://localhost:4000"
    api_key=settings.litellm_api_key,
)


async def complete(prompt: str, model: str = "gpt-4o") -> str:
    """Call any LLM through LiteLLM proxy."""
    response = await client.chat.completions.create(
        model=model,  # "gpt-4o", "claude-3-opus", "gemini-pro", etc.
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content or ""

Quick Start

1. Initialize Project

# Install uv (if not installed)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create new project
uv init myapp
cd myapp

# Set Python version
echo "3.12" > .python-version

# Add dependencies
uv add fastapi uvicorn pydantic sqlalchemy httpx
uv add --dev pytest pytest-asyncio ruff mypy

2. Apply Tech Stack

Layer Recommendation
Package Manager uv
Linting + Format Ruff
Type Checking mypy
Validation Pydantic v2
Web Framework FastAPI
Database SQLAlchemy 2.0 + asyncpg
HTTP Client httpx
Testing pytest + pytest-asyncio
Logging structlog

Version Strategy

Always use latest. Never pin in templates.

[project]
dependencies = [
    "fastapi",      # uv resolves to latest
    "pydantic",
    "sqlalchemy",
]
  • uv add fetches latest compatible versions
  • uv.lock ensures reproducible builds
  • uv sync installs exact locked versions

3. Use Standard Structure (src layout)

myapp/
β”œβ”€β”€ pyproject.toml         # Single config file
β”œβ”€β”€ uv.lock                # Lock file (commit this)
β”œβ”€β”€ .python-version        # Python version for uv
β”œβ”€β”€ src/
β”‚   └── myapp/
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ __main__.py    # Entry point
β”‚       β”œβ”€β”€ main.py        # FastAPI app
β”‚       β”œβ”€β”€ config.py      # Pydantic Settings
β”‚       β”œβ”€β”€ models/        # Pydantic models
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   └── user.py
β”‚       β”œβ”€β”€ services/      # Business logic
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   └── user.py
β”‚       β”œβ”€β”€ repositories/  # Data access
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   └── user.py
β”‚       β”œβ”€β”€ api/           # HTTP layer
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   β”œβ”€β”€ deps.py    # Dependencies
β”‚       β”‚   └── routes/
β”‚       β”‚       β”œβ”€β”€ __init__.py
β”‚       β”‚       └── user.py
β”‚       └── core/          # Shared utilities
β”‚           β”œβ”€β”€ __init__.py
β”‚           β”œβ”€β”€ exceptions.py
β”‚           └── logging.py
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ conftest.py        # Fixtures
β”‚   └── test_user.py
└── Makefile

Architecture Layers

main.py β€” FastAPI Application

# src/myapp/main.py
from contextlib import asynccontextmanager

from fastapi import FastAPI

from myapp.api.routes import router
from myapp.config import settings
from myapp.core.logging import setup_logging
from myapp.db import engine


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    setup_logging()
    yield
    # Shutdown
    await engine.dispose()


app = FastAPI(
    title=settings.app_name,
    lifespan=lifespan,
)

app.include_router(router, prefix="/api/v1")


@app.get("/health")
async def health():
    return {"status": "ok"}

config.py β€” Pydantic Settings

# src/myapp/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

    app_name: str = "myapp"
    debug: bool = False

    # Database
    database_url: str = "postgresql+asyncpg://localhost/myapp"

    # LiteLLM
    litellm_url: str = "http://localhost:4000"
    litellm_api_key: str = ""


settings = Settings()

models/ β€” Pydantic Models

# src/myapp/models/user.py
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, EmailStr, Field


class UserBase(BaseModel):
    email: EmailStr
    name: str = Field(min_length=2, max_length=100)


class UserCreate(UserBase):
    pass


class UserUpdate(BaseModel):
    email: EmailStr | None = None
    name: str | None = Field(default=None, min_length=2, max_length=100)


class User(UserBase):
    id: UUID
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}

services/ β€” Business Logic

# src/myapp/services/user.py
from uuid import UUID

from myapp.core.exceptions import NotFoundError, ConflictError
from myapp.models.user import User, UserCreate, UserUpdate
from myapp.repositories.user import UserRepository


class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

    async def get(self, id: UUID) -> User:
        user = await self.repo.get(id)
        if not user:
            raise NotFoundError("user", str(id))
        return user

    async def create(self, data: UserCreate) -> User:
        existing = await self.repo.get_by_email(data.email)
        if existing:
            raise ConflictError("email already exists")
        return await self.repo.create(data)

    async def update(self, id: UUID, data: UserUpdate) -> User:
        user = await self.get(id)
        return await self.repo.update(user, data)

    async def delete(self, id: UUID) -> None:
        user = await self.get(id)
        await self.repo.delete(user)

api/routes/ β€” HTTP Handlers

# src/myapp/api/routes/user.py
from uuid import UUID

from fastapi import APIRouter, Depends, status

from myapp.api.deps import get_user_service
from myapp.models.user import User, UserCreate, UserUpdate
from myapp.services.user import UserService

router = APIRouter(prefix="/users", tags=["users"])


@router.get("/{id}", response_model=User)
async def get_user(
    id: UUID,
    service: UserService = Depends(get_user_service),
):
    return await service.get(id)


@router.post("", response_model=User, status_code=status.HTTP_201_CREATED)
async def create_user(
    data: UserCreate,
    service: UserService = Depends(get_user_service),
):
    return await service.create(data)


@router.patch("/{id}", response_model=User)
async def update_user(
    id: UUID,
    data: UserUpdate,
    service: UserService = Depends(get_user_service),
):
    return await service.update(id, data)


@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
    id: UUID,
    service: UserService = Depends(get_user_service),
):
    await service.delete(id)

core/exceptions.py β€” Custom Exceptions

# src/myapp/core/exceptions.py
from fastapi import HTTPException, status


class AppError(Exception):
    """Base application error."""

    def __init__(self, message: str, code: str):
        self.message = message
        self.code = code
        super().__init__(message)


class NotFoundError(AppError):
    def __init__(self, resource: str, id: str):
        super().__init__(f"{resource} not found: {id}", "NOT_FOUND")


class ConflictError(AppError):
    def __init__(self, message: str):
        super().__init__(message, "CONFLICT")


class ValidationError(AppError):
    def __init__(self, message: str):
        super().__init__(message, "VALIDATION_ERROR")


# FastAPI exception handler
def app_error_to_http(error: AppError) -> HTTPException:
    status_map = {
        "NOT_FOUND": status.HTTP_404_NOT_FOUND,
        "CONFLICT": status.HTTP_409_CONFLICT,
        "VALIDATION_ERROR": status.HTTP_400_BAD_REQUEST,
    }
    return HTTPException(
        status_code=status_map.get(error.code, status.HTTP_500_INTERNAL_SERVER_ERROR),
        detail={"message": error.message, "code": error.code},
    )

pyproject.toml

[project]
name = "myapp"
version = "0.1.0"
description = "My application"
requires-python = ">=3.12"
dependencies = [
    "fastapi",
    "uvicorn[standard]",
    "pydantic",
    "pydantic-settings",
    "sqlalchemy[asyncio]",
    "asyncpg",
    "httpx",
    "structlog",
]

[tool.uv]
dev-dependencies = [
    "pytest",
    "pytest-asyncio",
    "pytest-cov",
    "ruff",
    "mypy",
]

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

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "F",   # pyflakes
    "I",   # isort
    "UP",  # pyupgrade
    "B",   # flake8-bugbear
    "SIM", # flake8-simplify
]

[tool.ruff.lint.isort]
known-first-party = ["myapp"]

[tool.mypy]
strict = true
python_version = "3.12"

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

Testing

# tests/conftest.py
import pytest
from httpx import ASGITransport, AsyncClient

from myapp.main import app


@pytest.fixture
async def client():
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        yield client


# tests/test_user.py
import pytest


@pytest.mark.asyncio
async def test_create_user(client):
    response = await client.post(
        "/api/v1/users",
        json={"email": "[email protected]", "name": "Test User"},
    )
    assert response.status_code == 201
    data = response.json()
    assert data["email"] == "[email protected]"


@pytest.mark.asyncio
async def test_get_user_not_found(client):
    response = await client.get("/api/v1/users/00000000-0000-0000-0000-000000000000")
    assert response.status_code == 404

Makefile

.PHONY: dev test lint fmt check clean

# Run development server
dev:
    uv run uvicorn myapp.main:app --reload

# Run tests
test:
    uv run pytest

# Run tests with coverage
test-cov:
    uv run pytest --cov=myapp --cov-report=html

# Lint code
lint:
    uv run ruff check src tests

# Format code
fmt:
    uv run ruff format src tests
    uv run ruff check --fix src tests

# Type check
typecheck:
    uv run mypy src

# Run all checks
check: fmt lint typecheck test
    @echo "All checks passed!"

# Clean
clean:
    rm -rf .pytest_cache .mypy_cache .ruff_cache htmlcov .coverage
    find . -type d -name __pycache__ -exec rm -rf {} +

# Sync dependencies
sync:
    uv sync

# Upgrade dependencies
upgrade:
    uv lock --upgrade
    uv sync

Checklist

## Project Setup
- [ ] uv initialized with pyproject.toml
- [ ] .python-version set (3.12+)
- [ ] src/ layout structure
- [ ] Ruff configured
- [ ] mypy strict mode

## Architecture
- [ ] Pydantic models for validation
- [ ] Services for business logic
- [ ] Repositories for data access
- [ ] Custom exceptions
- [ ] Dependency injection

## Quality
- [ ] pytest with pytest-asyncio
- [ ] Type hints everywhere
- [ ] Structured logging
- [ ] Error handling middleware

## CI
- [ ] ruff check
- [ ] ruff format --check
- [ ] mypy
- [ ] pytest

See Also

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.