Python Typing skill for Codex
Strict type checks without drowning in Any. Codex picks up the project's existing checker, enables strict mode globally and loosens only named legacy modules, then works leaf-first until the override list is empty. It writes PEP 695 generics when the Python version allows, uses Protocols and ParamSpec instead of Any, narrows with TypeIs and assert_never, and leaves every remaining ignore with an error code and a reason.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- python-typing Installs to ~/.agents/skills/python-typing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install python-typing 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-typing Installs to .agents/skills/python-typing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/python-typing.zipunzip -o python-typing.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-typing 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-typing, 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
Adds and tightens Python type hints checked by mypy or pyright - strict configs, per-module ratchets, generics with PEP 695 syntax, Protocols, TypedDict, overloads, ParamSpec and narrowing with TypeIs. Use when a user asks to add types to untyped code, turn on mypy --strict or pyright strict, fix type checker errors, type a decorator or generic container, or replace Any and type: ignore comments with real types.
Use it when
- An untyped or partly typed Python package needs annotations
- You are enabling mypy --strict or pyright strict mode
- A generic container, decorator or plugin interface needs precise types
- Type checker errors pile up after a dependency or Python upgrade
Use something else when
- You need runtime validation of input data rather than static checks: use pydantic-v2
- The task is linting and formatting rules rather than types: use ruff-setup
How to check the result
The checker passes at the configured strictness in CI, the error count did not rise, and the test suite still passes unchanged.
The mistake it prevents
Silencing errors with Any or bare type: ignore comments, which hides exactly the bugs strict mode was meant to catch.
Done well means: the checker runs in CI at a declared strictness, every public function has
complete annotations, Any appears only at real boundaries (untyped third-party code, raw
JSON), and every remaining type: ignore carries an error code and a reason.
Before changing anything
- Read
AGENTS.mdandpyproject.tomlfor the existing checker, its config section ([tool.mypy],[tool.pyright],pyrightconfig.json,mypy.ini) and the run command. - Find the minimum Python version (
requires-python). It decides which syntax is legal:
| Feature | Needs |
|---|---|
list[int], dict[str, X] at runtime | 3.9 |
X | None at runtime | 3.10 |
Self, Required/NotRequired, assert_never, LiteralString | 3.11 (else typing_extensions) |
def f[T](x: T), class Box[T], type Alias = ... (PEP 695), @override | 3.12 |
TypeIs, ReadOnly, type parameter defaults (PEP 696) | 3.13 |
| Annotations evaluated lazily by default (PEP 649/749) | 3.14 |
- Run the checker once and save the baseline count:
mypy src | tail -1orpyright --outputjson | python3 -c "import json,sys;print(json.load(sys.stdin)['summary'])". - Install missing stubs only with approval if network is off:
types-requests,pandas-stubs, ormypy --install-types --non-interactive(needs network).
Choosing a checker
| Situation | Default |
|---|---|
| Repo already uses one | Keep it. Never add a second checker in the same change |
| New project, CLI and CI focus | mypy with strict = true |
| Editor-heavy team on VS Code / Pylance | pyright (or basedpyright) with typeCheckingMode = "strict" |
| Want very fast feedback on a large codebase | Astral ty or Meta pyrefly are usable but still young; keep mypy or pyright as the CI gate |
Strict config with a ratchet
Turn strict on globally and loosen legacy modules explicitly. New code is strict on day one; the override list only shrinks.
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
files = ["src", "tests"]
[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingTypeStubs = "warning"
executionEnvironments = [{ root = "src" }]
# per-file opt-out while migrating: put "# pyright: basic" at the top of a legacy file
Order of work on an untyped package: leaf modules first (utils, models), then services, then entry points. Each module leaves the override list in the same PR it becomes clean.
Annotation rules
- Annotate every parameter and return of public functions, including
-> None. - Accept abstract, return concrete: parameters take
Iterable[T],Mapping[K, V],Sequence[T]; returns givelist[T],dict[K, V]. - Use
X | None, never bare defaults likedef f(x: int = None). - Prefer
objectoverAnyfor “anything, and I will not touch it”.Anysilently disables checking;objectforces narrowing before use. - Raw JSON: parse into a
TypedDictor a Pydantic model at the boundary instead of passingdict[str, Any]through the codebase. - Constants:
Final. Closed string sets:Literal["asc", "desc"]or anenum.StrEnum.
Generics (3.12+ syntax first)
from collections.abc import Callable, Iterable
def first[T](items: Iterable[T], default: T) -> T:
for item in items:
return item
return default
class Registry[K, V]:
def __init__(self) -> None:
self._items: dict[K, V] = {}
def register(self, key: K, value: V) -> None:
self._items[key] = value
type Handler[E] = Callable[[E], None] # generic type alias
def clamp[N: (int, float)](x: N, lo: N, hi: N) -> N: # constrained
return max(lo, min(x, hi))
Pre-3.12 equivalent: T = TypeVar("T") and class Registry(Generic[K, V]). Do not mix the
two styles in one class.
Rules: a type parameter used only once in a signature is a smell (use the concrete type or
object). Use a bound ([T: Comparable]) when you call methods on T.
Protocols instead of base classes
from typing import Protocol, runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
def shutdown(resources: list[SupportsClose]) -> None:
for r in resources:
r.close()
Any class with a matching close satisfies this without inheriting. Add
@runtime_checkable only when you need isinstance, and know it checks method names, not
signatures.
Decorators keep signatures with ParamSpec
import functools
from collections.abc import Awaitable, Callable
def retry[**P, R](times: int) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
def wrap(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@functools.wraps(fn)
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(times):
try:
return await fn(*args, **kwargs)
except ConnectionError:
if attempt == times - 1:
raise
raise AssertionError("unreachable")
return inner
return wrap
Use Concatenate[Request, P] when the decorator injects or strips a leading argument.
Narrowing
from typing import TypeIs, assert_never
def is_str_list(val: list[object]) -> TypeIs[list[str]]:
return all(isinstance(x, str) for x in val)
def area(shape: Circle | Square) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r * r
case Square(side=s):
return s * s
case _:
assert_never(shape) # adding a new shape type becomes a type error here
Prefer TypeIs (3.13 / typing_extensions) over TypeGuard: it narrows in both the true
and false branches.
Overloads for return types that depend on arguments
from typing import Literal, overload
@overload
def load(path: str, *, raw: Literal[True]) -> bytes: ...
@overload
def load(path: str, *, raw: Literal[False] = ...) -> str: ...
def load(path: str, *, raw: bool = False) -> str | bytes:
data = open(path, "rb").read()
return data if raw else data.decode()
Suppressions
- Always scoped:
# type: ignore[arg-type] # lib stub wrong, fixed in v3or# pyright: ignore[reportArgumentType].strictin mypy includeswarn_unused_ignores, so stale ignores fail the build. cast()is an assertion you cannot test. Prefer anisinstancecheck orTypeIshelper; usecastonly where the runtime check is too expensive, with a comment.TYPE_CHECKINGimports break circular imports; withfrom __future__ import annotations(or 3.14 lazy annotations) the names are not needed at runtime. Libraries that read annotations at runtime (Pydantic, FastAPI, dataclasses withfieldtypes) still need the real import.
Gotchas
list[int]is not assignable tolist[float](invariance). TakeSequence[float]in parameters that only read.dict.getreturnsV | None; strict mode will flag every unguarded use. Narrow once.- Mutable default
def f(x: list[int] = [])type-checks fine and is still a bug. reveal_type(x)is the fastest debugging tool; remove it before committing (pyright and mypy both report it).py.typedmarker file is required for a package to export its inline types to users.- Typing tests too (
files = ["src", "tests"]) catches fixture misuse early, but give tests a looser override if the user wants speed over rigor.
Verification
mypy # or: pyright / basedpyright
mypy --strict src/myapp/newmodule.py # spot-check a module claimed clean
python -m pytest -q # annotations must not change runtime behavior
- Error count is at or below the baseline; no new
Anyin public signatures (mypy --disallow-any-expron one module, or grep for: Any). - If an error resists, reduce it to a 10-line snippet and run the checker on that alone before adding an ignore.
Report template
**Checker:** mypy 1.x, strict on; 3 modules remain in overrides (legacy.billing, legacy.csv, scripts.*)
**Errors:** 412 -> 0 in strict scope
**Ignores:** 4, all coded, listed with reasons in the PR description
**Runtime check:** pytest 238 passed; no behavior changes
**Next:** move legacy.csv out of overrides (est. 30 errors) 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: "Python Typing"
short_description: "Strict type checks without drowning in Any"
default_prompt: "$python-typing Turn on mypy strict for this repo without breaking CI on day one" 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.