Before/after pairs for idiomatic Python, numbered for easy citation in reviews ("see idiom 14"). Examples come from LLM, RAG and ML code but every idiom applies to general Python. Baseline is Python 3.9; newer features carry a version tag. Rule ids (PYxxx) map to scripts/review_idioms.py.
Contents
- Iteration (1–14)
- Unpacking and assignment (15–21)
- Comparisons and truthiness (22–29)
- Strings (30–37)
- Dicts and sets (38–46)
- Functions (47–54)
- Exceptions (55–61)
- Classes and data (62–67)
- Files and resources (68–72)
- Module hygiene (73–76)
Iteration
1. Iterate directly (PY012)
# Before
for i in range(len(chunks)):
embed(chunks[i])
# After
for chunk in chunks:
embed(chunk)2. Need the index: enumerate (PY012, PY013)
# Before
i = 0
for doc in docs:
print(i, doc.title)
i += 1
# After
for i, doc in enumerate(docs):
print(i, doc.title)
for rank, hit in enumerate(hits, start=1): # 1-based ranks
print(f"{rank}. {hit.doc_id}")3. Parallel sequences: zip
# Before
for i in range(len(questions)):
score(questions[i], answers[i])
# After
for question, answer in zip(questions, answers):
score(question, answer)
# 3.10+: raise if lengths differ instead of silently truncating
for question, answer in zip(questions, answers, strict=True):
score(question, answer)4. Comprehension instead of append loop (PY015)
# Before
texts = []
for doc in docs:
if doc.text.strip():
texts.append(doc.text.strip())
# After
texts = [doc.text.strip() for doc in docs if doc.text.strip()]
# 3.8+: avoid calling strip twice
texts = [t for doc in docs if (t := doc.text.strip())]5. Dict and set comprehensions
by_id = {doc.id: doc for doc in docs}
vocab = {tok for text in corpus for tok in text.split()}
inverted = {v: k for k, v in label2id.items()}6. Generator expressions for aggregates (no intermediate list)
total_tokens = sum(len(enc.encode(t)) for t in texts)
longest = max(docs, key=lambda d: len(d.text))7. any / all instead of flag loops (PY016)
# Before
def has_pii(text):
for pattern in PII_PATTERNS:
if pattern.search(text):
return True
return False
# After
def has_pii(text):
return any(p.search(text) for p in PII_PATTERNS)
all_valid = all(0.0 <= s <= 1.0 for s in scores)8. Dictionary iteration (PY014)
# Before
for key in config.keys():
print(key, config[key])
# After
for key, value in config.items():
print(key, value)
if "temperature" in config: ... # not: in config.keys()9. Reverse and sorted iteration
for msg in reversed(history): ...
for doc in sorted(docs, key=lambda d: d.score, reverse=True): ...10. Loop with else for "not found"
for model in candidates:
if model in available:
chosen = model
break
else:
raise LookupError("no candidate model is available")Use sparingly; next((m for m in candidates if m in available), None) is often clearer.
11. First match: next() with default
tool = next((t for t in tools if t.name == call.name), None)
if tool is None:
raise KeyError(call.name)12. Batching (3.12+ itertools.batched)
from itertools import batched # 3.12+
for batch in batched(texts, 64):
vectors.extend(model.encode(list(batch)))
# 3.9–3.11 equivalent
def batched(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i + n]13. Sliding windows / pairs
from itertools import pairwise # 3.10+
gaps = [b.start - a.end for a, b in pairwise(spans)]
# overlapping chunk windows
def windows(tokens, size, stride):
return [tokens[i:i + size] for i in range(0, max(len(tokens) - size, 0) + 1, stride)]14. Flatten one level
from itertools import chain
all_chunks = list(chain.from_iterable(doc.chunks for doc in docs))
# or
all_chunks = [c for doc in docs for c in doc.chunks]Unpacking and assignment
15. Tuple unpacking
# Before
pair = best_match(query)
score = pair[0]
doc = pair[1]
# After
score, doc = best_match(query)16. Star unpacking
system, *turns = messages
head, *_, tail = log_lines17. Swap without temp: a, b = b, a
18. Ignore values with _
for _ in range(n_retries): ...
name, _, ext = filename.rpartition(".")19. Unpack in for loops
for (q, a), score in zip(pairs, scores): ...
for i, (input_ids, labels) in enumerate(loader): ...20. Walrus for assign-and-test (3.8+, PY046)
# Before
m = re.search(r"```json\s*(.*?)```", reply, re.S)
if m:
payload = json.loads(m.group(1))
# After
if m := re.search(r"```json\s*(.*?)```", reply, re.S):
payload = json.loads(m.group(1))
# Read-loop
while chunk := stream.read(8192):
sink.write(chunk)Do not use the walrus where it hides an assignment in a long expression; readability wins.
21. Conditional expression for simple choices
device = "cuda" if torch.cuda.is_available() else "cpu"Comparisons and truthiness
22. is None (PY006)
if response is None: ...
if usage is not None: ...== calls __eq__, which numpy, pandas and some ORMs override.
23. Truthiness for containers (PY020)
if not docs: return [] # not: if len(docs) == 0
if history: ... # not: if len(history) > 0Exception: numpy arrays, tensors and DataFrames raise on bool(). Use arr.size == 0, t.numel() == 0, df.empty.
24. Don't compare to True/False (PY007)
if stream: ... # not: if stream == True
if not cached: ... # not: if cached == FalseWith numpy/pandas masks, mask == True is element-wise and legal but redundant: df[mask] works.
25. not in / is not (PY021)
if token not in vocab: ... # not: if not token in vocab26. isinstance, not type equality (PY008, PY022)
if isinstance(value, (int, float)): ...
# 3.10+
if isinstance(value, int | float): ...type(x) is bool is legitimate when True must not count as an int.
27. Chained comparisons
if 0.0 <= top_p <= 1.0: ...28. Membership in a set literal
if finish_reason in {"length", "content_filter"}: ...29. Float comparison
import math
math.isclose(similarity, 1.0, rel_tol=1e-6)
# numpy
np.allclose(a, b, atol=1e-6)Strings
30. f-strings (PY019)
# Before
"%s used %d tokens" % (model, n)
"{} used {} tokens".format(model, n)
# After
f"{model} used {n:,} tokens"31. f-string format specs
f"{latency_ms:.1f} ms"
f"{ratio:.1%}" # 0.873 -> 87.3%
f"{n:>8,}" # right-aligned with thousands separator
f"{now:%Y-%m-%d %H:%M}"
f"{name!r}" # repr
f"{tokens = }" # 3.8+: "tokens = 412" (debugging)32. join, not += in loops (PY018)
context = "\n\n".join(f"[{i}] {c.text}" for i, c in enumerate(chunks, 1))33. Multi-line templates (PY043)
import textwrap
SYSTEM = textwrap.dedent("""\
You are a support assistant.
Answer only from the provided context.
""")
RAG_PROMPT = "Context:\n{context}\n\nQuestion: {question}\nAnswer:"
prompt = RAG_PROMPT.format(context=context, question=question)34. removeprefix / removesuffix (3.9+)
model = name.removeprefix("models/") # not: name[len("models/"):] if name.startswith(...)35. startswith/endswith with tuples
if path.endswith((".md", ".mdx", ".txt")): ...36. str methods over regex for simple jobs
key, _, value = line.partition("=")
words = text.split() # splits on any whitespace, drops empties37. Raw strings for regex
CODE_BLOCK = re.compile(r"```(\w+)?\n(.*?)```", re.S)Dicts and sets
38. .get with default
temperature = params.get("temperature", 0.7)Caution: .get(k, d) returns None if the key exists with value None.
39. setdefault / defaultdict (PY017)
# Before
groups = {}
for chunk in chunks:
if chunk.doc_id not in groups:
groups[chunk.doc_id] = []
groups[chunk.doc_id].append(chunk)
# After
from collections import defaultdict
groups = defaultdict(list)
for chunk in chunks:
groups[chunk.doc_id].append(chunk)40. Counter for frequencies
from collections import Counter
label_counts = Counter(example["label"] for example in dataset)
label_counts.most_common(3)41. Merge dicts (3.9+)
params = DEFAULTS | user_params # new dict, right side wins
params |= {"stream": True} # in place42. Dict unpacking into calls
client.chat.completions.create(model=model, messages=messages, **gen_kwargs)43. Invert or filter dicts with comprehensions
nonnull = {k: v for k, v in payload.items() if v is not None}44. Ordered dicts are the default (3.7+)
Plain dict preserves insertion order. Use OrderedDict only for move_to_end / popitem(last=False) (e.g. a small LRU).
45. Set operations
missing = required_fields - payload.keys()
shared = set(a_tokens) & set(b_tokens)
jaccard = len(shared) / len(set(a_tokens) | set(b_tokens))46. Deduplicate preserving order
unique_ids = list(dict.fromkeys(doc_ids))Functions
47. No mutable defaults (PY001)
def retrieve(query: str, filters: dict | None = None) -> list[Doc]: # | in annotation: 3.10+ or __future__
filters = {} if filters is None else filters
...48. Keyword-only arguments for options
def generate(prompt: str, *, temperature: float = 0.0, max_tokens: int = 512) -> str: ...
generate("hi", temperature=0.2) # generate("hi", 0.2) is a TypeError: intent is explicit49. Positional-only for stable APIs (3.8+)
def cosine(a, b, /): ...50. def, not named lambdas (PY023)
# Before
score = lambda d: d.similarity * d.boost
# After
def score(d: Doc) -> float:
return d.similarity * d.boostLambdas are fine inline: sorted(docs, key=lambda d: d.score).
51. Return early, avoid deep nesting
def parse_tool_call(msg):
if msg.get("type") != "tool_use":
return None
if "name" not in msg:
raise ValueError("tool_use without name")
return ToolCall(msg["name"], msg.get("input", {}))52. Consistent return types
Return [] rather than sometimes None, sometimes a list. Raise rather than return error strings.
53. Generators for streams
def read_jsonl(path: Path):
with path.open(encoding="utf-8") as f:
for line in f:
if line.strip():
yield json.loads(line)54. functools.partial for pre-bound callables
from functools import partial
embed_query = partial(embed, model="text-embedding-3-small", dimensions=512)Exceptions
55. Catch specific exceptions (PY002, PY003)
try:
data = json.loads(reply)
except json.JSONDecodeError as err:
raise InvalidModelOutput(reply[:200]) from err56. Chain with from (PY011)
try:
cfg = CONFIGS[name]
except KeyError:
raise UnknownModel(name) from None # hide the KeyError deliberately57. contextlib.suppress for intentional ignores (PY045)
from contextlib import suppress
with suppress(FileNotFoundError):
tmp_path.unlink()
# 3.8+: Path.unlink(missing_ok=True) is even shorter
tmp_path.unlink(missing_ok=True)58. Log with traceback, then decide
try:
result = tool.run(args)
except ToolError:
logger.exception("tool %s failed", tool.name)
raise59. try/except/else/finally
try:
resp = client.post(url, json=body, timeout=30)
except httpx.TimeoutException:
metrics.incr("timeout")
raise
else:
metrics.incr("ok") # only when no exception
finally:
span.end()60. Keep the try block small
Only the call that can raise belongs inside try; processing of the result goes in else or after.
61. ExceptionGroup and except (3.11+)*
try:
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch(url))
except* httpx.TimeoutException as eg:
logger.warning("%d fetches timed out", len(eg.exceptions))Classes and data
62. dataclasses for plain data
from dataclasses import dataclass, field
@dataclass(frozen=True)
class GenerationConfig:
model: str
temperature: float = 0.0
stop: tuple[str, ...] = ()
extra: dict = field(default_factory=dict)63. __repr__ that helps debugging
dataclasses generate one; for hand-written classes return something that identifies the object: f"{type(self).__name__}(model={self.model!r})".
64. Properties instead of getters
class Usage:
def __init__(self, prompt: int, completion: int):
self.prompt, self.completion = prompt, completion
@property
def total(self) -> int:
return self.prompt + self.completion65. Enum for fixed choices
from enum import Enum
class Role(str, Enum): # 3.11+: class Role(StrEnum)
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"66. match for structure (3.10+, PY029)
match block:
case {"type": "text", "text": str(text)}:
out.append(text)
case {"type": "tool_use", "name": name, "input": dict(args)}:
calls.append(ToolCall(name, args))
case _:
raise ValueError(f"unknown block {block!r}")67. Protocols over inheritance for duck typing
from typing import Protocol
class Embedder(Protocol):
def embed(self, texts: list[str]) -> list[list[float]]: ...Files and resources
68. with for every resource (PY009)
with open(path, encoding="utf-8") as f:
config = json.load(f)69. pathlib (PY025)
from pathlib import Path
root = Path(__file__).resolve().parent
data = root / "data" / "eval.jsonl"
for md in (root / "docs").rglob("*.md"):
ingest(md.read_text(encoding="utf-8"))
data.parent.mkdir(parents=True, exist_ok=True)70. Explicit encoding (PY026)
open(p, encoding="utf-8"), p.read_text(encoding="utf-8"). Binary modes take no encoding.
71. Atomic writes
import os, tempfile
def write_atomic(path: Path, text: str) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path) # atomic on POSIX and Windows72. Temporary files and dirs
with tempfile.TemporaryDirectory() as tmp:
ckpt = Path(tmp) / "model.pt"Module hygiene
73. No star imports (PY010) — from prompts import SYSTEM, render not from prompts import *.
74. Main guard
def main() -> int:
...
return 0
if __name__ == "__main__":
raise SystemExit(main())75. No global (PY028) — wrap state in a class, pass it in, or cache a factory with functools.cache.
76. Don't shadow builtins (PY024) — doc_id, kind, user_input, items instead of id, type, input, list.
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.