Modern Python Idioms skill for Codex
Pythonic rewrites your minimum version can run. Codex reads the project's minimum Python version first, lets Ruff's pyupgrade and simplify rules make the mechanical edits, then hand-rewrites only where an idiom makes code shorter or safer: dict records into slotted dataclasses, shape checks into match statements, os.path into pathlib, try/finally into context managers. Tests run after each step and any hunk that changes behavior is reverted.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- python-modern-idioms Installs to ~/.agents/skills/python-modern-idioms/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install python-modern-idioms The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project python-modern-idioms Installs to .agents/skills/python-modern-idioms/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/python-modern-idioms.zipunzip -o python-modern-idioms.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) python-modern-idioms PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $python-modern-idioms, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Rewrites Python into current idioms for 3.10 to 3.14 - dataclasses with slots and kw_only, structural pattern matching, pathlib instead of os.path, context managers and ExitStack, enum.StrEnum, itertools.batched, zoneinfo, tomllib and f-string debugging - and automates the safe parts with Ruff pyupgrade rules. Use when a user asks to modernize or clean up old Python, replace dict-based records with typed classes, convert if/elif ladders over shapes of data, or review code for Pythonic style.
Use it when
- A codebase still carries Python 2 or 3.6-era patterns
- You raised the minimum Python version and want to use what it allows
- Records are passed around as loosely shaped dicts or tuples
- A style review asks for more Pythonic code
Use something else when
- You want to configure Ruff rules and formatting for the repo: use ruff-setup
- The change is a larger structural refactor beyond idioms: use safe-refactoring
How to check the result
Ruff reports no remaining pyupgrade fixes, the test suite passes unchanged, and the code compiles on the minimum supported interpreter.
The mistake it prevents
Using a feature newer than requires-python because the developer's own interpreter accepts it, which breaks users on the oldest supported version.
Done well means: each rewrite uses a feature the project’s minimum Python actually has, behavior is unchanged (tests prove it), mechanical upgrades are done by a tool, and the hand-written changes make the code shorter or safer, never just different.
Before changing anything
- Read
requires-pythoninpyproject.toml(orpython_requires, CI matrix, Dockerfile base image). The lowest supported version caps every choice below. - Read
AGENTS.mdfor style rules; respect existing conventions over personal taste. - Confirm tests exist for the code you will touch. If not, add characterization tests first or limit the change to tool-driven rewrites.
- Let the tool do the mechanical part before any manual edit:
ruff check --select UP,SIM,PTH,C4,RUF --target-version py311 --diff src/ # preview
ruff check --select UP,SIM,C4 --fix src/ # apply safe fixes
UP (pyupgrade) handles typing.List to list, Optional[X] to X | None, old string
formatting, super() arguments and more. PTH flags os.path calls but has no autofix;
those are manual. Commit tool output separately from hand edits.
Version map
| Idiom | Min | Replaces |
|---|---|---|
match statements | 3.10 | isinstance / key-probing ladders |
dataclass(slots=True, kw_only=True) | 3.10 | hand-written __slots__, positional-arg confusion |
zip(a, b, strict=True) | 3.10 | silent truncation on length mismatch |
contextlib.chdir, tomllib, enum.StrEnum, datetime.UTC, ExceptionGroup | 3.11 | os.chdir try/finally, third-party toml, str, Enum mixins |
itertools.batched, PEP 695 generics, Path.walk | 3.12 | manual chunking loops, os.walk |
copy.replace() for dataclasses and namedtuples | 3.13 | dataclasses.replace only |
Template strings (t"..."), lazy annotations | 3.14 | from __future__ import annotations |
Never introduce a feature above the minimum, even if CI’s newest interpreter accepts it.
Records: choose the right container
| Data | Use |
|---|---|
| Internal value object, methods, defaults | @dataclass(slots=True, frozen=True) |
| Many small immutable tuples, unpacked positionally | typing.NamedTuple |
| Shape of a JSON dict you do not own, stays a dict | TypedDict |
| Untrusted input needing validation and coercion | Pydantic model (see $pydantic-v2) |
| A bag of config with 2 keys used once | A plain dict is fine |
from dataclasses import dataclass, field
from datetime import datetime, UTC
@dataclass(slots=True, kw_only=True)
class Job:
name: str
retries: int = 3
tags: list[str] = field(default_factory=list)
created: datetime = field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self) -> None:
if self.retries < 0:
raise ValueError("retries must be >= 0")
kw_only=True prevents Job("x", 5) call sites that break when fields are reordered.
slots=True cuts memory and blocks typo attributes; it breaks code that sets ad-hoc
attributes or relies on __dict__, so search for that first.
Structural pattern matching
Use match when branching on the shape of data (tuples, dicts from JSON, AST-like objects).
Do not use it as a switch over plain constants where a dict lookup is clearer.
def handle(event: dict) -> str:
match event:
case {"type": "click", "pos": [x, y]}:
return f"click at {x},{y}"
case {"type": "key", "key": str(k)} if k.isprintable():
return f"key {k}"
case {"type": "resize", "size": {"w": int(w), "h": int(h)}}:
return f"resize {w}x{h}"
case _:
return "ignored"
Traps:
- A bare name in a case (
case status:) captures and always matches. Compare constants through a dotted name (case Status.OK:) or a literal. - Mapping patterns match if the listed keys exist; extra keys are allowed.
- Class patterns with positional args need
__match_args__(dataclasses generate it).
Paths and files
from pathlib import Path
root = Path(__file__).resolve().parent
config = root / "config" / "app.toml"
for csv_path in sorted(root.glob("data/**/*.csv")):
text = csv_path.read_text(encoding="utf-8")
out = root / "build"
out.mkdir(parents=True, exist_ok=True)
- Always pass
encoding="utf-8"for text I/O; the platform default differs on Windows until UTF-8 mode is default (3.15). Path.read_textloads the whole file; stream large files withwith path.open() as f:.- Use
shutilfor copy/move trees;Path.renamefails across filesystems.
Context managers
from contextlib import ExitStack, contextmanager, suppress
@contextmanager
def timed(label: str):
start = time.perf_counter()
try:
yield
finally:
log.info("%s took %.3fs", label, time.perf_counter() - start)
with ExitStack() as stack: # variable number of resources
files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
with suppress(FileNotFoundError):
tmp.unlink() # or tmp.unlink(missing_ok=True)
Every open, lock, connection and temp dir gets a with. A try/finally that only releases
one resource is a context manager waiting to be written.
Smaller idioms worth applying
| Old | New |
|---|---|
for i in range(len(xs)): x = xs[i] | for i, x in enumerate(xs) |
| manual chunk loop | for chunk in itertools.batched(items, 500) (3.12) |
zip(a, b) that must align | zip(a, b, strict=True) |
d.has_key, k in d.keys() | k in d |
x = d[k] if k in d else default | d.get(k, default) |
print("x=", x) debugging | print(f"{x=}") |
pytz.timezone("Europe/Paris") | zoneinfo.ZoneInfo("Europe/Paris") |
datetime.utcnow() (deprecated 3.12) | datetime.now(UTC) |
class Color(str, Enum) | class Color(StrEnum) |
@lru_cache(maxsize=None) | @functools.cache |
s[len(prefix):] if s.startswith(prefix) | s.removeprefix(prefix) |
while True: line = f.readline(); if not line: break | while line := f.readline(): |
Walrus (:=) is for avoiding a duplicated call in a condition; do not use it to compress
readable code into one line.
Gotchas
datetime.now(UTC)returns an aware datetime; comparing it with existing naive values raisesTypeError. Migrate a whole module’s datetimes together.StrEnummembers format as their value (f"{Color.RED}"gives"red"), unlike mixin enums on some versions. Check string formatting in logs and URLs after converting.slots=Truemakes the decorator return a new class, so zero-argumentsuper()and__class__inside its methods can fail on older interpreters. Test such methods on the minimum version, or passsuper(Cls, self)explicitly.functools.cacheon methods keepsselfalive forever; usecached_propertyor a module function instead.- Ruff
UPfixes respecttarget-version; a wrong target rewrites code into syntax older interpreters cannot parse.
Verification
ruff check --select UP,SIM,C4 src/ # nothing left the tool would change
python -m pytest -q # behavior unchanged
python3.11 -m compileall -q src/ # parses on the minimum version (use the real minimum)
If a test fails after a rewrite, revert that hunk rather than adjusting the test; an idiom change is not allowed to change behavior.
Output
Summarize in the PR: tool-applied rules (with counts), hand rewrites grouped by idiom, and any rewrite skipped because the minimum Python version does not support it.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Modern Python Idioms"
short_description: "Pythonic rewrites your minimum version can run"
default_prompt: "$python-modern-idioms Modernize this Python 2-era module for Python 3.12" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.