Idiomatic, production-safe Python for LLM apps, RAG pipelines, agents, eval harnesses, model-serving APIs and training code. Each section lists the smell, why it bites in production, and the rewrite. Rule ids map to scripts/review_idioms.py.
Contents
- Secrets and configuration
- Client lifecycle
- Timeouts
- Retries and backoff
- Async correctness
- Concurrency and fan-out
- Prompt construction
- Parsing model output
- Streaming
- Batching and chunking
- Logging and observability
- Caching
- Numerics: numpy and torch
- Training and inference loops
- Review quick table
Secrets and configuration
Smell (PY036): literal keys in code, notebooks or default arguments.
# Before
client = OpenAI(api_key="sk-proj-...")
HEADERS = {"Authorization": "Bearer hf_abc..."}A key in git history is leaked even after the commit is reverted. Rotate it, then:
import os
api_key = os.environ["OPENAI_API_KEY"] # KeyError at startup is a feature: fail fastTyped settings with validation (pydantic-settings v2):
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
openai_api_key: SecretStr
model: str = "gpt-4o-mini"
request_timeout_s: float = 30.0
max_concurrency: int = 8
settings = Settings() # reads APP_OPENAI_API_KEY, APP_MODEL, ...
key = settings.openai_api_key.get_secret_value() # SecretStr hides the value in repr/logsRules:
.envin.gitignore; commit.env.examplewith placeholder values.- Never log full headers, request bodies with keys, or
repr(settings)of a non-Secret type. - Model names, temperatures and limits are configuration, not literals scattered across modules.
Client lifecycle
Smell (PY041): heavy objects created at import time.
# Before: runs on import (tests, CLI --help, every forked worker)
client = OpenAI()
embedder = SentenceTransformer("all-MiniLM-L6-v2") # downloads weights, allocates GPUProblems: slow imports, network access in unit tests, settings cannot be overridden, GPU memory allocated in processes that never use it, and fork-unsafe objects copied into workers.
Lazy, cached factory (scripts, libraries):
from functools import cache
@cache
def get_embedder() -> "SentenceTransformer":
from sentence_transformers import SentenceTransformer # defer heavy import too
return SentenceTransformer(settings.embed_model, device=settings.device)FastAPI lifespan + dependency injection (services):
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from openai import AsyncOpenAI
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.llm = AsyncOpenAI(timeout=30.0, max_retries=2)
yield
await app.state.llm.close()
app = FastAPI(lifespan=lifespan)
def get_llm(request: Request) -> AsyncOpenAI:
return request.app.state.llm
@app.post("/answer")
async def answer(q: Question, llm: AsyncOpenAI = Depends(get_llm)):
...Reuse one client (one connection pool) per process; do not construct a client per request.
Timeouts
Smells (PY039, PY042): requests without timeout=; SDK clients on default timeouts.
| Library | Default | Idiomatic |
|---|---|---|
requests | none: waits forever | requests.post(url, json=b, timeout=(5, 60)) (connect, read) |
httpx | 5 s for each phase | httpx.Client(timeout=httpx.Timeout(30.0, connect=5.0)) |
urllib.request.urlopen | socket default (none) | urlopen(req, timeout=30) |
openai SDK | 600 s, 2 retries | OpenAI(timeout=30.0, max_retries=3); per call client.with_options(timeout=5) |
anthropic SDK | 600 s, 2 retries | Anthropic(timeout=30.0, max_retries=3) |
asyncio code | none | async with asyncio.timeout(10): (3.11+) or await asyncio.wait_for(coro, 10) |
import asyncio
async def answer_with_deadline(question: str) -> str:
async with asyncio.timeout(20): # 3.11+: cancels everything inside on expiry
ctx = await retrieve(question)
return await generate(question, ctx)Set an overall deadline per user request and smaller per-call timeouts beneath it.
Retries and backoff
Smell (PY040): while True retry loops.
# Before: 401 Unauthorized or a 400 bad request retries forever
while True:
try:
return client.chat.completions.create(**kw)
except Exception:
time.sleep(1)Rules for retries:
- Bound the number of attempts (and ideally total elapsed time).
- Retry only retryable failures: 408, 409, 429, 5xx, connection errors, timeouts. Never 400/401/403/404/422.
- Exponential backoff with jitter; honour
Retry-Afterwhen present. - Make the operation idempotent (or pass an idempotency key) before retrying writes.
- Know what the SDK already does: OpenAI/Anthropic SDKs retry twice by default, so wrapping them in another 5-attempt loop gives up to 15 calls.
import random
import time
RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
def post_json(session, url: str, payload: dict, *, attempts: int = 5, timeout=(5, 60)) -> dict:
for attempt in range(attempts):
resp = session.post(url, json=payload, timeout=timeout)
if resp.status_code not in RETRYABLE_STATUS:
resp.raise_for_status()
return resp.json()
if attempt == attempts - 1:
resp.raise_for_status()
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after and retry_after.isdigit() else min(30.0, 2 ** attempt)
time.sleep(delay + random.uniform(0, 1))
raise AssertionError("unreachable")With tenacity (declarative, async-aware):
from openai import APIConnectionError, APITimeoutError, RateLimitError
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential
@retry(
retry=retry_if_exception_type((RateLimitError, APIConnectionError, APITimeoutError)),
wait=wait_random_exponential(min=1, max=30),
stop=stop_after_attempt(5),
reraise=True,
)
async def complete(llm, messages):
return await llm.chat.completions.create(model=settings.model, messages=messages)Async correctness
Smells (PY037, PY038): blocking calls inside async def.
One blocking call inside a coroutine freezes the whole event loop: every in-flight request in a FastAPI worker waits.
| Blocking | Async replacement |
|---|---|
time.sleep(s) | await asyncio.sleep(s) |
requests.get(...) | await client.get(...) with httpx.AsyncClient / aiohttp |
OpenAI().chat.completions.create | await AsyncOpenAI().chat.completions.create |
subprocess.run([...]) | await asyncio.create_subprocess_exec(...) |
| File I/O on large files | await asyncio.to_thread(path.read_text, encoding="utf-8") or aiofiles |
| CPU-bound (tokenising, embedding on CPU, numpy) | await asyncio.to_thread(fn, ...) (releases GIL in C code) or a process pool |
async def embed_texts(texts: list[str]) -> list[list[float]]:
model = get_embedder()
return await asyncio.to_thread(model.encode, texts, batch_size=64)Other async rules:
- Never call
asyncio.run()inside a running loop (notebooks, FastAPI handlers);awaitinstead. - Every coroutine must be awaited or scheduled; a bare
fetch(url)call does nothing (and warns). - Keep references to tasks created with
asyncio.create_task, or they can be garbage collected mid-flight; preferTaskGroup(3.11+). - In FastAPI, a plain
defendpoint runs in a threadpool and may block safely; anasync defendpoint must not block.
Concurrency and fan-out
Smell (PY044): sequential awaits.
# Before: 100 documents = 100 round-trips, one after another
results = []
for doc in docs:
results.append(await summarize(doc))# After: bounded concurrency
async def summarize_all(docs, limit: int = 8):
sem = asyncio.Semaphore(limit)
async def one(doc):
async with sem:
return await summarize(doc)
return await asyncio.gather(*(one(d) for d in docs))Structured concurrency (3.11+): failures cancel siblings and surface as an ExceptionGroup.
async def summarize_all(docs, limit: int = 8):
sem = asyncio.Semaphore(limit)
results: list[str | None] = [None] * len(docs)
async def one(i, doc):
async with sem:
results[i] = await summarize(doc)
async with asyncio.TaskGroup() as tg:
for i, doc in enumerate(docs):
tg.create_task(one(i, doc))
return resultsPartial failure tolerated: await asyncio.gather(*coros, return_exceptions=True) then split results with isinstance(r, BaseException).
Choose the limit from the provider's rate limits (requests/min and tokens/min), not from CPU count. Threads (concurrent.futures.ThreadPoolExecutor) are the sync equivalent for I/O-bound SDK calls.
Prompt construction
Smell (PY043): prompts glued together with + across a function.
# Before
prompt = "You are a helpful assistant.\n" + "Context: " + context + "\n" + "Q: " + q# After: one template constant with named fields
RAG_TEMPLATE = """\
You are a support assistant. Answer only from the context.
If the answer is not in the context, say "I don't know".
<context>
{context}
</context>
Question: {question}"""
def render_rag_prompt(question: str, chunks: list[Chunk]) -> str:
context = "\n\n".join(f"[{i}] {c.text}" for i, c in enumerate(chunks, start=1))
return RAG_TEMPLATE.format(context=context, question=question)- Keep templates as module constants or files (versionable, testable, diffable).
str.formattreats{and}specially: double them ({{}}) when the template contains JSON examples, or usestring.Template($context) for JSON-heavy prompts.- Build chat messages as data, not as one string:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
*history,
{"role": "user", "content": render_rag_prompt(question, chunks)},
]- Delimit untrusted content (retrieved documents, user input) with clear tags; never concatenate user input into the system prompt.
- Count tokens with the model's tokenizer, not
len(text), when trimming context.
Parsing model output
Smell (PY004): eval() / exec() on model output.
# Before
args = eval(reply) # prompt injection -> code execution# After: JSON + schema validation
from pydantic import BaseModel, ValidationError
class SearchArgs(BaseModel):
query: str
top_k: int = 5
try:
args = SearchArgs.model_validate_json(reply)
except ValidationError as err:
raise InvalidToolArgs(reply[:500]) from err- Prefer provider structured outputs / tool calling with JSON schema over regex-scraping prose.
- Extract fenced JSON with a regex and the walrus operator, then validate:
import re
FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.S)
def extract_json(reply: str) -> dict:
if m := FENCE.search(reply):
return json.loads(m.group(1))
return json.loads(reply)ast.literal_evalonly for Python literals you trust the shape of; it is safe from code execution but can still exhaust memory on huge inputs.
Streaming
from openai import AsyncOpenAI
async def stream_answer(llm: AsyncOpenAI, messages: list[dict]):
stream = await llm.chat.completions.create(model=settings.model, messages=messages, stream=True)
async for event in stream:
if delta := event.choices[0].delta.content:
yield deltaFastAPI server-sent events:
from fastapi.responses import StreamingResponse
@app.post("/chat/stream")
async def chat_stream(req: ChatRequest, llm: AsyncOpenAI = Depends(get_llm)):
async def events():
async for piece in stream_answer(llm, req.messages):
yield f"data: {json.dumps({'delta': piece})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(events(), media_type="text/event-stream")Accumulate streamed text with a list and "".join(parts) at the end, not text += delta (PY018).
Batching and chunking
from itertools import batched # 3.12+
def embed_corpus(texts: list[str], batch_size: int = 128) -> list[list[float]]:
vectors: list[list[float]] = []
for batch in batched(texts, batch_size):
resp = client.embeddings.create(model=EMBED_MODEL, input=list(batch))
vectors.extend(d.embedding for d in resp.data)
return vectorsChunker with overlap, written with ranges instead of manual index arithmetic:
def chunk_tokens(tokens: list[int], size: int = 512, overlap: int = 64) -> list[list[int]]:
if not 0 <= overlap < size:
raise ValueError("overlap must be in [0, size)")
step = size - overlap
return [tokens[i:i + size] for i in range(0, max(len(tokens) - overlap, 1), step)]Deduplicate before embedding (dict.fromkeys(texts)), and keep ids aligned with vectors by zipping, never by parallel index counters.
Logging and observability
Smells (PY034, PY035): print in pipelines; f-strings in log calls.
import logging
logger = logging.getLogger(__name__)
logger.info("retrieved %d chunks in %.0f ms", len(chunks), elapsed_ms)
logger.debug("prompt tokens=%d model=%s", n_tokens, model)
try:
reply = await complete(llm, messages)
except Exception:
logger.exception("completion failed request_id=%s", request_id)
raise- Configure once in the entry point:
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")). - Attach request ids / trace ids with
logging.LoggerAdapterorcontextvars, or use structlog. - Never log raw prompts or completions at INFO in production: they contain user data; log sizes, ids, token counts and latencies instead.
- Record token usage from responses (
resp.usage) for cost tracking.
Caching
from functools import lru_cache
@lru_cache(maxsize=10_000)
def embed_query(text: str) -> tuple[float, ...]:
vec = client.embeddings.create(model=EMBED_MODEL, input=text).data[0].embedding
return tuple(vec) # immutable: callers cannot corrupt the cached valuelru_cachekeys must be hashable; convert lists to tuples.- It does not work on
async def(it caches the coroutine object). Use a dict keyed by input plus anasyncio.Lock, or a library such asaiocache. - On methods,
lru_cachekeepsselfalive; prefer module functions orfunctools.cached_propertyfor per-instance values.
Numerics: numpy and torch
| Smell | Idiom |
|---|---|
if embedding: | if embedding is not None and len(embedding): / arr.size |
mask == True | mask (already boolean) |
| Python loop over vectors for cosine | normed = X / np.linalg.norm(X, axis=1, keepdims=True); sims = normed @ q |
sorted(range(len(s)), key=lambda i: -s[i])[:k] | np.argpartition(-s, k)[:k] then sort those k |
x == np.nan | np.isnan(x) |
torch.tensor(existing_tensor) | existing.clone().detach() |
.cpu().numpy() inside the hot loop | Accumulate on device, convert once |
import numpy as np
def top_k_cosine(query: np.ndarray, matrix: np.ndarray, k: int = 5) -> list[tuple[int, float]]:
q = query / np.linalg.norm(query)
m = matrix / np.linalg.norm(matrix, axis=1, keepdims=True)
sims = m @ q
idx = np.argpartition(-sims, min(k, len(sims) - 1))[:k]
idx = idx[np.argsort(-sims[idx])]
return [(int(i), float(sims[i])) for i in idx]Training and inference loops
import torch
def evaluate(model, loader, device) -> float:
model.eval()
correct = total = 0
with torch.inference_mode(): # no autograd bookkeeping
for inputs, labels in loader: # unpack, don't index batch[0], batch[1]
inputs, labels = inputs.to(device), labels.to(device)
preds = model(inputs).argmax(dim=-1)
correct += (preds == labels).sum().item()
total += labels.numel()
return correct / max(total, 1)model.train()/model.eval()explicitly at the start of each phase.torch.inference_mode()(orno_grad) for eval and serving.optimizer.zero_grad(set_to_none=True).- Seed once from config (
random,numpy,torch), not scattered. - Checkpoint with
pathlibpaths and atomic writes; savestate_dict(), not the module object. - Use
tqdm/ logging for progress, notprintevery step. - Hyperparameters in a frozen dataclass or pydantic model, not module globals mutated with
global.
Review quick table
| Rule | Look for | Severity |
|---|---|---|
| PY036 | sk-, hf_, AKIA, ghp_ literals; api_key="..." | high |
| PY037 | time.sleep in async def | high |
| PY038 | requests, subprocess, sync SDKs in async def | high |
| PY040 | while True + except + sleep | high |
| PY004 | eval / exec | high |
| PY039 | HTTP without timeout= | medium |
| PY041 | Clients/models at module level | medium |
| PY042 | SDK client without timeout= | low |
| PY043 | prompt = ... + ... | low |
| PY044 | await inside for | low |
| PY034 | print in library functions | low |
| PY035 | logger.info(f"...") | low |
| — | Sync SDK method called in async def (not detectable by name) | high, manual |
| — | Unbounded asyncio.gather over thousands of items | medium, manual |
| — | Raw prompts/PII logged at INFO | medium, manual |
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.
python-exception-handling-designer
Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.
python-type-hints-annotator
Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.