Python for AI Engineers

◆ 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 a try/except as 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 in pyproject.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 → bare test_* 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.loglogging. console.errorlogger.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 + manual process.env.Xpydantic-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(...)) where enumerate/zip belongs?
  • Are edge cases handled: empty list, None, empty string, zero?
  • is None used for identity (not == None)?

Types & validation

  • Are public function signatures type-annotated?
  • Is LLM/JSON output validated before use (pydantic/json.loads in a try), not trusted blindly?
  • Is external input (API responses, user text, file content) validated at the boundary?
  • Does mypy/pyright pass?

Errors & resources

  • Any bare except: or except Exception: pass that swallows errors silently?
  • Are exceptions specific (except KeyError) rather than catch-all?
  • Is with used 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 def without await — silently does nothing)?
  • Is blocking I/O (requests, time.sleep, sync SDK) called inside async code? → should be async or offloaded.
  • Shared mutable state (list/dict/counter) touched by multiple tasks/threads without a lock?
  • Are asyncio.gather results 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/pickle on 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. in on a list inside a loop where a set fits)?
  • 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 format pass (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

  1. Hardcoded API key / secret in the diff → block immediately.
  2. Bare except: or except: pass swallowing errors.
  3. Mutable default argument (=[], ={}).
  4. LLM/JSON output used without validation or a try.
  5. print() for logging in service/library code.
  6. No timeout / no retry-with-backoff on API calls.
  7. Un-awaited coroutine, or blocking I/O inside async.
  8. Shared mutable state across tasks/threads without a lock.
  9. Real API hit in a unit test (slow, costly, flaky) instead of a mock.
  10. 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


PART III — THE JOB: DATA, LLMs & AGENTS (chapters 11–13)