◆ Chapter 10
Writing (and judging) Pythonic code: idioms, testing, logging, and a review checklist
Idioms, pytest, logging and a concrete review checklist — how to write Python that reads well and judge code that does not.
~2,634 words · chapter 10 of 15
You now read Python fluently. This chapter is about the last mile: telling good Python from bad Python at a glance. When you review a GenAI pull request, you are not asking "does this run?" — you are asking "is this idiomatic, safe, tested, and maintainable?" This chapter gives you the trained eye and, at the end, a checklist you'll reuse on every PR.
10.1 The Zen and Pythonic idioms
Python ships a manifesto. Run it:
import this
# => The Zen of Python, by Tim Peters
# => Beautiful is better than ugly.
# => Explicit is better than implicit.
# => ... (19 aphorisms total)
The practical upshot is a set of idioms. Below are BEFORE (JS-transliterated, un-Pythonic) vs AFTER (Pythonic) pairs. In review, the AFTER form is what you want to see.
EAFP vs LBYL. JS habit is Look Before You Leap — check first, then act. Python prefers Easier to Ask Forgiveness than Permission — just try it and catch the failure. EAFP is faster (no double lookup), avoids race conditions, and is the cultural default.
# BEFORE — LBYL (un-Pythonic)
if "temperature" in config and config["temperature"] is not None:
temp = config["temperature"]
else:
temp = 0.7
# AFTER — EAFP
try:
temp = config["temperature"]
except KeyError:
temp = 0.7
# ...or just express the default directly:
temp = config.get("temperature", 0.7) # => 0.7 if absent
JS → Python: JS leans LBYL (
if (obj.foo !== undefined)). Python leans EAFP (try/except KeyError). Don't read atry/exceptas exceptional-and-scary the way you might in JS — here it's the ordinary control-flow tool.
Truthiness over length checks. Empty collections, 0, "", and None are all falsy.
# BEFORE
if len(messages) > 0:
...
# AFTER
if messages: # empty list is falsy
...
if not messages: # guard clause for "nothing to do"
return []
Iterate values, not indices. Use enumerate when you need the index and zip to walk two sequences together.
prompts = ["summarize", "translate", "classify"]
# BEFORE — C-style index loop
for i in range(len(prompts)):
print(i, prompts[i])
# AFTER
for i, prompt in enumerate(prompts):
print(i, prompt)
# => 0 summarize
# => 1 translate
# => 2 classify
models = ["haiku", "sonnet", "opus"]
for prompt, model in zip(prompts, models):
print(f"{prompt} -> {model}") # walks both in lockstep
Comprehensions over manual accumulation.
tokens = [{"text": "hi", "logprob": -0.2}, {"text": "!", "logprob": -3.1}]
# BEFORE
confident = []
for t in tokens:
if t["logprob"] > -1.0:
confident.append(t["text"])
# AFTER
confident = [t["text"] for t in tokens if t["logprob"] > -1.0]
# => ['hi']
A comprehension that grows past one readable line, or has side effects, is a review flag — use a plain loop there.
Unpacking replaces index juggling:
role, content = ("user", "Explain RAG") # tuple unpack
first, *rest = [1, 2, 3, 4] # first=1, rest=[2,3,4]
merged = {**defaults, **overrides} # dict merge
with for resources guarantees cleanup even on exception (like a try/finally you don't have to write):
# BEFORE — leaks the handle if an error is thrown
f = open("prompt.txt")
data = f.read()
f.close()
# AFTER
from pathlib import Path
data = Path("prompt.txt").read_text(encoding="utf-8")
# ...or when you need the handle:
with open("prompt.txt", encoding="utf-8") as f:
data = f.read()
f-strings over concatenation; pathlib over string paths; is None over == None:
name, n = "sonnet", 3
label = f"{name} x{n}" # not name + " x" + str(n)
cfg = Path.home() / ".config" / "app.json" # not home + "/.config/..."
if result is None: # identity check, not ==
...
Try it: Take a JS-transliterated loop that builds a list with a counter and an
if, and rewrite it as a single comprehension. If you can't do it cleanly, that's a signal the logic is genuinely branchy — keep the loop.
Context over comments. Pythonic code prefers a well-named function or variable to a comment explaining a clever line. In review, a comment that merely restates the code (# increment i) is noise; a comment explaining why (a non-obvious API quirk, a workaround) is gold.
10.2 Style and tooling — the debates are over
PEP 8 is the style baseline. The essentials:
| Thing | Convention | Example |
|---|---|---|
| Variables, functions | snake_case |
max_tokens, build_prompt() |
| Constants | UPPER_CASE |
DEFAULT_MODEL = "sonnet" |
| Classes | PascalCase |
class ChatSession: |
| "Private" | leading underscore | _client, _retry() |
| Indent | 4 spaces (never tabs) | |
| Line length | 88 (ruff/Black default) | |
| Blank lines | 2 between top-level defs, 1 between methods |
Docstrings (PEP 257) are triple-quoted strings as the first statement of a module, class, or function:
def summarize(text: str, max_words: int = 100) -> str:
"""Return an LLM summary of *text* capped at max_words.
Raises ValueError if text is empty.
"""
...
Here is the liberating part: you do not enforce any of this by hand, and neither should a reviewer. Ruff — a Rust-based tool that reimplements Flake8, isort, Black, pyupgrade and 900+ rules — does it, running ~150× faster than the old stack. Two commands:
ruff format . # auto-formats (the Black-compatible formatter)
ruff check . --fix # lints + auto-fixes imports, unused vars, upgrades
Type-check separately with mypy or pyright:
mypy src/ # or: pyright
JS → Python: ESLint + Prettier collapse into one tool: ruff (
ruff check≈ ESLint,ruff format≈ Prettier). Configure it inpyproject.toml, not a.eslintrc. In review this means: never leave style comments ("add a space here"). If it's a style nit, the answer is "run ruff." Spend your review attention on logic, safety, and tests.
10.3 Immutability and purity as a review lens
Your global rules already say new objects, never mutate in place — and this matters most in agent/concurrent code, where shared mutable state causes heisenbugs.
Two red flags to train your eye on:
Mutable default arguments — the single most famous Python footgun. The default is created once, at definition time, and shared across all calls:
# BEFORE — BUG: the list persists between calls
def add_message(msg, history=[]):
history.append(msg)
return history
add_message("a") # => ['a']
add_message("b") # => ['a', 'b'] <-- leaked!
# AFTER — sentinel pattern
def add_message(msg, history=None):
history = [] if history is None else history
return [*history, msg] # return a NEW list, don't mutate the input
Frozen dataclasses for config and value objects — immutable, hashable, and self-documenting:
from dataclasses import dataclass
@dataclass(frozen=True)
class LLMConfig:
model: str
temperature: float = 0.7
max_tokens: int = 1024
cfg = LLMConfig("sonnet")
# cfg.temperature = 0.9 # => raises FrozenInstanceError
new_cfg = replace(cfg, temperature=0.9) # from dataclasses import replace
When reviewing agent code, ask: does this function mutate its arguments, or global/shared state? If two coroutines or threads touch the same list/dict without a lock, flag it.
10.4 Testing with pytest
You need to read and write basic tests. pytest (8.x in 2026) is the standard. Its magic: no boilerplate classes, plain assert.
Discovery rules: files named test_*.py, functions named test_*. Contrast Jest's describe/it — pytest has none of that ceremony.
# test_prompt.py
from prompt import build_prompt
def test_build_prompt_includes_system():
result = build_prompt("Hello")
assert "You are a helpful assistant" in result # plain assert
assert result.endswith("Hello")
pytest # discovers and runs everything
pytest -q # quiet
pytest --cov=src # coverage (needs pytest-cov)
Exceptions — assert that something raises:
import pytest
def test_empty_text_rejected():
with pytest.raises(ValueError, match="empty"):
summarize("")
Fixtures — reusable setup, injected by parameter name (dependency injection, not beforeEach):
@pytest.fixture
def sample_history():
return [{"role": "user", "content": "hi"}]
def test_append(sample_history): # name matches the fixture
out = add_message("bye", sample_history)
assert len(out) == 2
assert len(sample_history) == 1 # original untouched (immutability!)
Parametrize — one test, many cases (replaces Jest's test.each):
@pytest.mark.parametrize("text, expected", [
("hello world", 2),
("", 0),
("a b c", 3),
])
def test_word_count(text, expected):
assert word_count(text) == expected
# => runs as 3 separate tests
Mocking LLM calls — the crucial GenAI skill. You must never hit the real API in a unit test: it's slow, costs money, and is non-deterministic. Mock the client and test your logic — the prompt building, the response parsing, the validation, the tool dispatch — not the model.
from unittest.mock import MagicMock
from agent import extract_json_answer
def test_extract_json_answer_parses_tool_call():
# Fake the client so no network call happens
fake_client = MagicMock()
fake_client.messages.create.return_value = MagicMock(
content=[MagicMock(text='{"action": "search", "query": "python"}')]
)
result = extract_json_answer(fake_client, "find python docs")
assert result == {"action": "search", "query": "python"}
fake_client.messages.create.assert_called_once() # verify it was called
monkeypatch is pytest's built-in for patching module attributes and env vars:
def test_reads_model_from_env(monkeypatch):
monkeypatch.setenv("MODEL", "opus")
assert get_model() == "opus"
JS → Python: Jest → pytest.
describe/it→ baretest_*functions.expect(x).toBe(y)→assert x == y.beforeEach→@pytest.fixture.test.each→@pytest.mark.parametrize.jest.fn()/jest.mock()→MagicMock/monkeypatch. The philosophy for GenAI is identical to good JS testing: mock the network boundary, test your own code.
Try it: Find a function in a GenAI repo that parses a model's JSON response. Write one
test_*that feeds it a hard-coded good response and one that feeds it malformed JSON. If the second test crashes instead of failing gracefully, you've found a real bug.
10.5 Logging done right
print() in library or service code is a smell. It writes only to stdout, has no severity, no timestamps, no module origin, and can't be filtered or redirected. The logging module fixes all of that.
import logging
logger = logging.getLogger(__name__) # one logger per module, named by module
def call_model(prompt: str) -> str:
logger.info("calling model", extra={"prompt_len": len(prompt)})
try:
return _client.complete(prompt)
except TimeoutError:
logger.warning("model timed out, retrying")
raise
except Exception:
logger.exception("model call failed") # logs full traceback at ERROR
raise
Levels, in order: DEBUG < INFO < WARNING < ERROR < CRITICAL. Configure once, at your app's entry point:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
Structured logging (key/value or JSON instead of prose) is the modern standard for services — it's queryable in log aggregators. Libraries like structlog help, but even extra={...} above is a start.
Never log secrets or PII — no API keys, no full prompts containing user data, no auth tokens. Logging a whole request body that includes a customer's message is a compliance incident. This is a hard review flag.
JS → Python:
console.log→logging.console.error→logger.error()/logger.exception(). As in Node, a real service uses a logging library (Winston/Pino ↔logging/structlog), not raw console output.
10.6 Config and secrets
Cardinal rule: API keys come from the environment, never source code. The idiomatic tool is pydantic-settings — schema-validated config that reads from env vars and .env files, failing loudly at startup if something required is missing.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
anthropic_api_key: str # required — startup fails if unset
model: str = "sonnet"
max_tokens: int = 1024 # validated & coerced from string env
model_config = SettingsConfigDict(env_file=".env")
settings = Settings() # reads ANTHROPIC_API_KEY, MODEL, MAX_TOKENS
# settings.anthropic_api_key # never printed, never committed
.env is git-ignored; you commit a .env.example with blank values. Any api_key = "sk-..." literal in a diff is an automatic block-the-PR.
JS → Python:
dotenv+ manualprocess.env.X→ pydantic-settings, which adds validation and type coercion on top of loading. Missing/mistyped config fails at boot with a clear error, not at 3am in production.
10.7 The Python / GenAI code-review checklist
This is the deliverable. Use it to judge any Python (especially LLM/agent) PR.
Correctness
- Does the logic match the PR's stated intent (not just "does it run")?
- Mutable default arguments (
def f(x=[])/={})? → bug. - Off-by-one or
range(len(...))whereenumerate/zipbelongs? - Are edge cases handled: empty list,
None, empty string, zero? -
is Noneused for identity (not== None)?
Types & validation
- Are public function signatures type-annotated?
- Is LLM/JSON output validated before use (pydantic/
json.loadsin atry), not trusted blindly? - Is external input (API responses, user text, file content) validated at the boundary?
- Does
mypy/pyrightpass?
Errors & resources
- Any bare
except:orexcept Exception: passthat swallows errors silently? - Are exceptions specific (
except KeyError) rather than catch-all? - Is
withused for every file, network client, lock, or DB connection? - Are errors logged with context (
logger.exception) before re-raising? - Are user-facing error messages helpful and non-leaky?
Concurrency / async
- Any un-awaited coroutine (calling an
async defwithoutawait— silently does nothing)? - Is blocking I/O (
requests,time.sleep, sync SDK) called insideasynccode? → should be async or offloaded. - Shared mutable state (list/dict/counter) touched by multiple tasks/threads without a lock?
- Are
asyncio.gatherresults checked for exceptions?
Security / secrets
- Any hardcoded API key, token, or password? → block.
- Are secrets read from env / pydantic-settings, never committed?
- Are secrets/PII kept out of logs and error messages?
- Any
eval/exec/pickleon untrusted input, or unsanitized shell/SQL/f-string-built queries? - For agents: are tool calls / model-generated code sandboxed or constrained?
Performance / cost
- Are LLM/API calls retried with backoff on transient failure/rate limits?
- Is there a timeout on every network call?
- Are repeated identical LLM calls cached where sensible?
- Any accidental O(n²) (e.g.
inon a list inside a loop where asetfits)? - Are large files/responses streamed rather than fully loaded?
Readability / idioms
- Comprehensions where they clarify; plain loops where they don't?
- f-strings,
pathlib, unpacking, truthiness used idiomatically? - Functions small (<50 lines), files focused, nesting <4 deep?
- Names clear; docstrings on public API; comments explain why, not what?
- Does
ruff check/ruff formatpass (so no style nits needed in review)?
Testing
- Do tests exist for new logic, and do they cover failure paths?
- Is the LLM/API client mocked so tests are deterministic and free?
- Do tests check your parsing/validation/tool logic, not the model's output quality?
- Are edge cases parametrized rather than copy-pasted?
- No real network, no real secrets, no flakiness in the test suite?
Top 10 red flags — the 10-second smell test
- Hardcoded API key / secret in the diff → block immediately.
- Bare
except:orexcept: passswallowing errors.- Mutable default argument (
=[],={}).- LLM/JSON output used without validation or a
try.print()for logging in service/library code.- No timeout / no retry-with-backoff on API calls.
- Un-awaited coroutine, or blocking I/O inside
async.- Shared mutable state across tasks/threads without a lock.
- Real API hit in a unit test (slow, costly, flaky) instead of a mock.
- Secrets or PII written to logs.
Read a PR against these and you are doing exactly what a senior Python engineer does — judging not whether the code works today, but whether it will stay correct, safe, and testable tomorrow.
Sources: Ruff docs · Ruff CHANGELOG · pytest documentation