
Python
- 3 installs
- 19 repo stars
- Updated August 1, 2026
- xobotyi/cc-foundry
Helps with python tasks.
About
python is a Claude Code skill for python. It helps solo builders move faster with AI-assisted development.
- python
- Python
- AI-coding skill
Python by the numbers
- 3 all-time installs (skills.sh)
- Ranked #231 of 290 Python skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xobotyi/cc-foundry --skill pythonAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | xobotyi/cc-foundry ↗ |
What it does
Helps with python tasks.
Files
Python
Readability counts. Explicit is better than implicit. If your code needs a comment to explain its control flow, restructure it.
Python 3.14+ is the baseline. Use modern syntax unconditionally — no backward compatibility with older Python versions unless the project explicitly requires it.
References
| Topic | Reference | Contents |
|---|---|---|
| Type annotation patterns, generics, overloads, TypeVar, variance | [${CLAUDE_SKILL_DIR}/references/typing.md] | Full annotation examples, generic class patterns, Protocol implementation, TypeVar usage |
| Project layout, pyproject.toml, uv, dependency management | [${CLAUDE_SKILL_DIR}/references/packaging.md] | pyproject.toml templates, uv workflows, src layout, dependency groups, build backends |
Module system, imports, namespace packages, __init__.py | [${CLAUDE_SKILL_DIR}/references/modules.md] | Import resolution order, circular import fixes, lazy imports, namespace packages |
| asyncio, TaskGroup, cancellation, timeouts, threading interop | [${CLAUDE_SKILL_DIR}/references/concurrency.md] | TaskGroup error handling, timeout scopes, cancellation semantics, to_thread, eager task factory |
Naming
| Entity | Style | Examples |
|---|---|---|
| Variables, functions, methods | snake_case | user_name, fetch_data |
| Classes, type aliases | PascalCase | UserService, HttpClient |
| Constants | UPPER_SNAKE_CASE | MAX_RETRIES, API_BASE_URL |
| Modules, packages | snake_case, short | user_store, auth |
| Private attributes/methods | _ prefix | _internal_cache, _validate() |
| Name-mangled attributes | __ prefix | __secret (rarely needed) |
| Type variables | PascalCase, short | T, KT, VT, ResponseT |
| Protocols | PascalCase, -able/-ible suffix | Renderable, Serializable |
- Descriptive names.
user_countnotn. Short names (i,x) only in tiny scopes
(comprehensions, simple lambdas).
- No redundant context.
car.makenotcar.car_make. - Boolean names:
is_/has_/can_/should_prefix:is_valid,has_access. - Dunder methods are reserved for the data model. Never invent custom dunder names.
- Avoid single-character names outside loop indices, comprehension variables, and
well-established conventions (f for file, e for exception, k/v for key/value).
Type Annotations
Python 3.14+ uses modern annotation syntax natively. No from __future__ import annotations needed — all annotations are evaluated lazily by default.
Core Rules
- Annotate all public API boundaries — function signatures, class attributes, module-level
variables. Internal code often needs fewer annotations; types flow from context.
- Use built-in generics:
list[str],dict[str, int],tuple[int, ...],
set[float]. Never import List, Dict, Tuple, Set from typing.
- Union with `|`:
str | None,int | float. NeverOptional[X]orUnion[X, Y]. - `type` statement for aliases:
type Vector = list[float]. NotTypeAliasannotation. - `None` return: annotate
-> Noneon functions that return nothing. Omit return type
only on __init__.
- Avoid `Any` — it disables type checking. Use
objectwhen you mean "any type but still
type-safe." Use Any only at true interop boundaries with untyped code.
Generics
- `type` parameter syntax (3.12+):
class Stack[T]:anddef first[T](items: list[T]) -> T:
instead of TypeVar declarations.
- Constrained type parameters:
def process[T: (str, bytes)](data: T) -> T:for a
finite set of allowed types.
- Bounded type parameters:
def sort[T: Comparable](items: list[T]) -> list[T]:for
upper-bound constraints.
- Variance is inferred from usage in 3.12+ generics. No manual
covariant/contravariant
flags needed.
Protocols (Structural Typing)
- Prefer protocols over ABCs when you don't control the implementing types or when
structural compatibility is sufficient.
- `@runtime_checkable` only when you need
isinstance()checks — it adds overhead and
only validates method presence, not signatures.
- Keep protocols small — one to three methods. A protocol with many methods is a sign
you need an ABC or a concrete base class.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...Callable Types
- `collections.abc.Callable` for callable annotations:
Callable[[int, str], bool].
- `ParamSpec` for decorators that preserve signatures:
def decorator[**P, R](fn: Callable[P, R]) -> Callable[P, R]:.
- Use `Protocol` for complex callable signatures with keyword arguments or overloads.
TypeGuard and TypeIs
- `TypeIs` (3.13+) for narrowing that refines the input type:
def is_str_list(val: list[object]) -> TypeIs[list[str]]:.
- `TypeGuard` for narrowing where the output type is unrelated to input:
def is_valid_config(data: object) -> TypeGuard[Config]:.
See ${CLAUDE_SKILL_DIR}/references/typing.md for full annotation patterns, generics, overloads, and variance.
Data Classes and Structured Data
dataclasses
- Use `@dataclass` for data containers — classes that primarily hold data with minimal
behavior.
- `frozen=True` for immutable data:
@dataclass(frozen=True). Default to frozen unless
mutation is required.
- `slots=True` for memory efficiency and attribute safety:
@dataclass(slots=True, frozen=True).
- `kw_only=True` when constructors have more than 3 fields — prevents positional
argument ordering bugs.
- `field(default_factory=list)` for mutable defaults. Never use mutable default
arguments.
- Post-init processing:
__post_init__for derived fields and validation.
@dataclass(frozen=True, slots=True, kw_only=True)
class User:
name: str
email: str
roles: list[str] = field(default_factory=list)When NOT to Use dataclasses
- Simple value containers with 1-2 fields: use
NamedTupleor plain tuples. - Config/settings with validation: use Pydantic or attrs with validators.
- Persistence/ORM models: use the ORM's model base class.
NamedTuple
- Use `class` syntax over functional form:
class Point(NamedTuple): x: float; y: float. - NamedTuples are immutable and iterable — useful as dict keys and in destructuring.
Enums
- Use `enum.Enum` for categorical constants. Never use bare strings or ints as
pseudo-enums.
- `enum.StrEnum` when the enum must interoperate with string APIs (JSON, config keys).
- `enum.IntEnum` only when integer interop is mandatory (legacy protocols). Prefer
Enum otherwise.
- `@enum.unique` to prevent duplicate values.
- Access by value:
Color(1). Access by name:Color["RED"]. Iteration:for c in Color:. - Never subclass enums with members. Enums with members are final.
from enum import StrEnum, unique
@unique
class Status(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
SUSPENDED = "suspended"Pattern Matching
match/case (3.10+) is the preferred dispatch mechanism for structural patterns.
- Use match for structural dispatch — matching on type, shape, or destructured values.
Don't use match as a substitute for simple if/elif chains on a single value.
- Always include a wildcard `case _:` arm unless the match is provably exhaustive.
- Guard clauses with
if:case Point(x, y) if x > 0:. - Use `|` for alternatives:
case "quit" | "exit" | "q":. - Capture with walrus:
case {"error": str() as msg}:captures while matching type. - Class patterns require
__match_args__or keyword patterns:
case Point(x=0, y=y):.
match command:
case {"action": "move", "direction": str() as direction}:
move(direction)
case {"action": "attack", "target": str() as target}:
attack(target)
case _:
raise ValueError(f"Unknown command: {command}")Functions
- Early return. Guard clauses first, happy path flat. Reduce nesting.
- One function, one job. If the name contains "and", split it.
- Type-annotate all parameters and return types on public functions.
- Default arguments: immutable values only. Use
None+ conditional for mutable
defaults: def f(items: list[int] | None = None): then items = items or [] in body. Never def f(items: list[int] = []):.
- *`` to force keyword-only arguments** after positional params:
def connect(host: str, *, port: int = 443):.
- `/` to force positional-only for parameters that callers shouldn't name:
def sqrt(x: float, /) -> float:.
- Prefer returning values over mutating arguments. Functions should be referentially
transparent when possible.
- `None` means absent, not error. Return
T | Nonefor optional results. Raise
exceptions for errors.
Decorators
- Preserve signatures with
functools.wraps:
def retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapper- Decorator order matters. Decorators apply bottom-up.
@staticmethodand
@classmethod must be outermost (topmost in source).
- Parametric decorators return a decorator:
@retry(attempts=3)meansretryreturns
the actual decorator function.
- Don't over-abstract with decorators. If the decorator hides important control flow
(error handling, transaction management), make it explicit instead.
Context Managers
- `contextlib.contextmanager` for simple resource management:
@contextmanager
def managed_connection(url: str) -> Iterator[Connection]:
conn = Connection(url)
try:
yield conn
finally:
conn.close()- Class-based context managers when state management is complex — implement
__enter__ and __exit__.
- `contextlib.suppress(ExceptionType)` instead of empty
except: pass. - `contextlib.closing(thing)` for objects with
.close()but no__exit__. - `contextlib.asynccontextmanager` for async resource management.
- Always use `with` for files, locks, database connections, and any resource that
needs deterministic cleanup.
Generators and Iterators
- Generators for lazy sequences. Use
yieldto produce values on demand instead of
building full lists in memory.
- Generator expressions over list comprehensions when the result is iterated only once:
sum(x * x for x in range(1000)).
- `yield from` to delegate to sub-generators — preserves
.send(),.throw(),
.close() protocol.
- `itertools` for composition:
chain,islice,groupby,batched(3.12+),
pairwise (3.10+).
- Annotate generators:
def gen() -> Iterator[int]:for simple generators,
Generator[YieldType, SendType, ReturnType] when using .send().
- Never exhaust a generator twice. Generators are single-pass. If you need multiple
passes, materialize to a list or use itertools.tee.
Comprehensions
- List/dict/set comprehensions for simple transforms:
[x.name for x in users if x.active]. - One level of nesting maximum. Two nested
forclauses are the absolute limit.
Beyond that, extract to a function.
- Don't use comprehensions for side effects.
[print(x) for x in items]is wrong —
use a for loop.
- Walrus operator in comprehensions for compute-once-filter-and-use:
[y for x in data if (y := transform(x)) is not None].
- Dict comprehensions for key transformations:
{k.lower(): v for k, v in headers.items()}.
Exception Handling
- Be specific. Catch the narrowest exception type:
except ValueError:not
except Exception:.
- Never bare `except:`. It catches
SystemExit,KeyboardInterrupt, andGeneratorExit.
At minimum use except Exception:.
- *`except ExceptionGroup`** (3.11+) for handling multiple concurrent exceptions from
TaskGroup and similar.
- Wrap with context.
raise AppError("context") from errchains the original cause. - Don't use exceptions for flow control.
if key in dict:not
try: dict[key] except KeyError: (unless the miss is rare and lookup is expensive).
- Custom exceptions inherit from a project-specific base that extends
Exception:
class AppError(Exception): ...
class NotFoundError(AppError): ...
class ValidationError(AppError): ...- Error strings: lowercase, no trailing punctuation. They compose in chains:
"parse config: invalid format".
- `else` clause runs only when no exception was raised — use for code that should
execute on success but isn't part of the try body.
- `finally` for unconditional cleanup — prefer context managers when possible.
- Exception groups (3.11+): use
ExceptionGroupto bundle multiple errors. Handle
with except* which matches by type and re-raises unhandled exceptions.
- Add notes with `.add_note()` (3.11+) to attach context without creating new
exception types.
Strings
- f-strings for interpolation. Never
%formatting or.format()in new code. - f-string expressions must be simple. No function calls with multiple arguments,
no nested f-strings, no complex expressions. Extract to a variable first.
- `str.removeprefix()` / `str.removesuffix()` (3.9+) over slicing.
- Triple-quoted strings for multiline. Use
textwrap.dedentwhen indentation matters. - `"".join(parts)` for building strings in loops — never
+=in a loop. - Raw strings `r"..."` for regex patterns and Windows paths.
Pathlib
- `pathlib.Path` for all filesystem operations. Never
os.pathin new code. - `/` operator for path joining:
base / "subdir" / "file.txt". - Common operations:
path.exists(),path.is_file(),path.is_dir(),
path.read_text(), path.write_text(), path.mkdir(parents=True, exist_ok=True), path.iterdir(), path.glob("*.py"), path.rglob("**/*.py").
- `path.resolve()` for absolute paths.
path.relative_to(base)for relative paths. - Accept `str | Path` in public APIs, convert to
Pathinternally.
Imports
- Absolute imports by default:
from mypackage.utils import helper. - Relative imports only within packages for tightly coupled modules:
from .models import User.
- Import grouping (separated by blank lines):
1. Standard library (import os, from pathlib import Path) 2. Third-party (import httpx, from pydantic import BaseModel) 3. Local (from myapp.models import User)
- Import specific names:
from collections import defaultdictnotimport collections
(unless you use many names from the module).
- *Never `from module import `** — pollutes namespace, breaks type checkers, hides
dependencies.
- `if TYPE_CHECKING:` block for imports used only in annotations — avoids circular
imports and runtime overhead. In 3.14+ with lazy annotations, this is less necessary but still useful for avoiding circular import side effects.
- Lazy imports in function bodies when a top-level import would create a circular
dependency or when the import is expensive and rarely needed.
Classes
Slots
- Always use `__slots__` on classes that will have many instances — prevents
__dict__
creation, saves memory, catches typos in attribute names.
- `@dataclass(slots=True)` adds slots automatically.
- Slots and inheritance: every class in the hierarchy must declare
__slots__.
Missing slots on a parent reintroduces __dict__.
Dunder Methods
- `__repr__` on every class — must be unambiguous:
def __repr__(self) -> str: return f"User(name={self.name!r})".
- `__str__` only when a human-readable form differs from repr.
- `__eq__` and `__hash__` — if you define
__eq__, define__hash__too (or set
__hash__ = None to make unhashable). Mutable objects should not be hashable.
- `__bool__` — define when truthiness of instances has meaningful semantics.
- `__enter__`/`__exit__` for context manager protocol.
- `__init_subclass__` for class registration patterns without metaclasses.
- `__class_getitem__` to make classes subscriptable for generic type hints.
Inheritance
- Composition over inheritance. Use inheritance only for true "is-a" relationships.
- ABCs for interfaces when you control both sides and need enforced implementation:
from abc import ABC, abstractmethod.
- Protocols for duck typing when you don't control implementations.
- `super()` — always use
super()(no arguments in 3.x). Never hardcode parent class
names.
- MRO awareness. Understand method resolution order in diamond inheritance. When in
doubt, avoid multiple inheritance.
Class Methods and Static Methods
- `@classmethod` for alternative constructors:
User.from_dict(data). - `@staticmethod` for utility functions that don't need class or instance state — but
prefer module-level functions unless the function is logically part of the class's API.
Packaging and Toolchain
pyproject.toml
- `pyproject.toml` is the single source of truth for project metadata, dependencies,
tool configuration. Never setup.py or setup.cfg in new projects.
- Build backend: use
hatchling,flit-core, orsetuptoolswith
[build-system] table.
- Dependency specification: pin with
>=lower bound, avoid upper bounds unless
genuinely incompatible: httpx>=0.27.
uv
- `uv` is the preferred Python package manager and environment tool.
- `uv sync` to install dependencies from lock file.
- `uv add <package>` to add dependencies.
- `uv run <command>` to run commands in the project environment.
- `uv lock` to generate/update the lock file.
- `uv venv` to create virtual environments.
- `uv python install 3.14` to install Python versions.
Project Layout
my-project/
├── pyproject.toml
├── uv.lock
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── ...
└── tests/
├── conftest.py
└── ...- src layout — package code lives under
src/. Prevents accidental imports from the
project root during testing.
- `__init__.py` — keep minimal. Define
__all__for public API. Don't put substantial
logic in init files.
Linting and Formatting
- `ruff` for both linting and formatting. Single tool, fast.
- `ruff check` to lint. `ruff format` to format.
- Configure in `pyproject.toml` under
[tool.ruff].
See ${CLAUDE_SKILL_DIR}/references/packaging.md for pyproject.toml templates, uv workflows, and dependency management patterns.
Concurrency
asyncio
- `async`/`await` for I/O-bound concurrency.
- `asyncio.TaskGroup` (3.11+) for structured concurrency — replaces
asyncio.gather() with better error handling.
- Never use `asyncio.gather()` in new code — it has inconsistent error semantics.
Use TaskGroup instead.
- `asyncio.run()` as the single entry point. Never
loop.run_until_complete(). - Cancel via `asyncio.CancelledError` — always clean up resources in
finallyblocks.
threading
- `concurrent.futures.ThreadPoolExecutor` for CPU-light I/O-bound parallel work.
- `threading.Lock` for shared mutable state. Always use
with lock:context manager. - GIL note: in CPython, threads don't achieve true parallelism for CPU-bound work.
Use multiprocessing or ProcessPoolExecutor for CPU-bound tasks.
- Free-threaded Python (3.13+): when running with
--disable-gil, standard
thread-safety practices become critical. Guard all shared mutable state with locks.
General Rules
- Structured concurrency preferred.
TaskGroupand context managers over bare
create_task().
- Never fire-and-forget tasks or threads — always track completion.
- Cancellation must be cooperative. Check for cancellation and clean up.
Logging
- `logging` module over
print()for anything beyond quick debugging. - `logger = logging.getLogger(__name__)` at module level.
- Lazy formatting:
logger.info("User %s logged in", user_id)not
logger.info(f"User {user_id} logged in") — f-string evaluates even when level is disabled.
- Use appropriate levels:
DEBUGfor diagnostics,INFOfor operational events,
WARNING for degraded but working, ERROR for failures, CRITICAL for system-down.
Application
When writing Python code: apply all conventions silently — don't narrate each rule. If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.
When reviewing Python code: cite the specific violation and show the fix inline. Don't lecture — state what's wrong and how to fix it.
Bad: "According to Python best practices, you should use type unions
with the pipe operator instead of Optional..."
Good: "Optional[str] -> str | None"Code Navigation — LSP Required
A pyright-langserver LSP server is configured for .py and .pyi files. Always use LSP tools for code navigation instead of Grep or Glob. LSP understands Python's module system, type inference, scope rules, and package boundaries — text search does not.
Tool Routing
| Task | LSP Operation | Why LSP over text search |
|---|---|---|
| Find where a function/class/variable is defined | goToDefinition | Resolves imports, re-exports, aliases |
| Find all usages of a symbol | findReferences | Scope-aware, no false positives from string matches |
| Get type signature, docs, or return types | hover | Instant type info without reading source files |
| List all symbols in a file | documentSymbol | Structured output vs grepping for def/class |
| Find a symbol by name across the project | workspaceSymbol | Searches all packages, respects __all__ |
| Find implementations of a Protocol or ABC | goToImplementation | Knows the type system and structural subtyping |
| Find what calls a function | incomingCalls | Precise call graph across module boundaries |
| Find what a function calls | outgoingCalls | Structured dependency map |
Grep/Glob remain appropriate for: text in comments, string literals, log messages, TODO markers, config values, env vars, file name patterns, URLs, error message text — anything that isn't a Python identifier.
When spawning subagents for Python codebase exploration, instruct them to use LSP tools. Subagents have access to the same LSP server.
Toolchain
- `ruff`: single entry point for linting and formatting. Must pass before committing.
ruff check— lint.ruff check --fix— auto-fix.ruff format— format.- `uv`: package management, virtual environments, Python version management.
- `mypy` or `pyright`: static type checking. Configure in
pyproject.toml.
Integration
The coding skill governs workflow (discovery, planning, verification); this skill governs Python implementation choices. The pytest skill governs testing conventions — both are active simultaneously when writing Python tests.
Readability counts. If you read a function twice to understand it, rewrite it once to make it clear.
{
"sources": {
"Python: typing module": "https://docs.python.org/3.14/library/typing.html",
"Python: dataclasses module": "https://docs.python.org/3.14/library/dataclasses.html",
"Python: import system": "https://docs.python.org/3.14/reference/import.html",
"Python: pathlib module": "https://docs.python.org/3.14/library/pathlib.html",
"Python: enum module": "https://docs.python.org/3.14/library/enum.html",
"Python: contextlib module": "https://docs.python.org/3.14/library/contextlib.html",
"Python: asyncio tasks": "https://docs.python.org/3.14/library/asyncio-task.html",
"Packaging: Writing pyproject.toml": "https://packaging.python.org/en/latest/guides/writing-pyproject-toml/",
"Packaging: pyproject.toml specification": "https://packaging.python.org/en/latest/specifications/pyproject-toml/",
"uv: Project Guide": "https://docs.astral.sh/uv/guides/projects/",
"ruff: Configuration": "https://docs.astral.sh/ruff/configuration/",
"ruff: Formatter": "https://docs.astral.sh/ruff/formatter/",
"Google Python Style Guide": "https://google.github.io/styleguide/pyguide.html",
"PEP 8: Style Guide": "https://peps.python.org/pep-0008/",
"Python 3.14: What's New": "https://docs.python.org/3.14/whatsnew/3.14.html"
},
"lastFetched": "2026-02-28T20:10:28.354Z"
}
Concurrency Patterns
Extended patterns for asyncio structured concurrency, task management, and timeout handling in Python 3.14+. Complements the rules in SKILL.md with TaskGroup details, cancellation semantics, and threading interop.
TaskGroup (Structured Concurrency)
asyncio.TaskGroup (3.11+) is the preferred way to run concurrent tasks. It guarantees that all tasks complete (or are cancelled) before the async with block exits.
import asyncio
async def fetch_all(urls: list[str]) -> list[bytes]:
results: list[bytes] = []
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch(url))
# All tasks are done here — if any raised, an ExceptionGroup is raised
return resultsError Handling
When a task in the group raises an unhandled exception:
1. All remaining tasks in the group are cancelled 2. Tasks that haven't started yet are prevented from starting 3. The TaskGroup context manager waits for all tasks to finish 4. An ExceptionGroup containing all task exceptions is raised
async def resilient_fetch(urls: list[str]) -> list[bytes | None]:
results: dict[str, bytes | None] = {}
try:
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch_one(url, results))
except* httpx.HTTPError as eg:
# Handle HTTP errors — other exceptions re-raise
for exc in eg.exceptions:
logger.warning("fetch failed: %s", exc)
except* TimeoutError:
logger.warning("some fetches timed out")
return list(results.values())Use except* (3.11+) to selectively handle exception types within the group. Unhandled exception types are re-raised in a new ExceptionGroup.
TaskGroup vs gather
| TaskGroup | gather | |
|---|---|---|
| Error behavior | Cancels all tasks on first error | Inconsistent — depends on return_exceptions |
| Structured | Yes — all tasks bound to the async with scope | No — tasks can outlive the call |
| Exception type | ExceptionGroup | Single exception or mixed results |
| Use in new code | Always | Never |
Timeouts
asyncio.timeout (3.11+)
async def fetch_with_timeout(url: str) -> bytes:
async with asyncio.timeout(10):
return await fetch(url)
# Raises TimeoutError if 10 seconds elapseasyncio.timeout_at (absolute deadline)
async def fetch_batch(urls: list[str]) -> list[bytes]:
deadline = asyncio.get_event_loop().time() + 30.0
async with asyncio.timeout_at(deadline):
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(fetch(url)) for url in urls]
return [t.result() for t in tasks]timeout and timeout_at raise TimeoutError (not asyncio.TimeoutError). They cancel the current task's scope — nested timeouts work correctly because each creates its own cancellation scope.
wait_for (legacy)
# Prefer asyncio.timeout over wait_for in new code
result = await asyncio.wait_for(coro, timeout=5.0)wait_for cancels the awaitable and raises TimeoutError. It doesn't support structured scoping — asyncio.timeout is cleaner.
Task Cancellation
Cancellation in asyncio is cooperative — a cancelled task receives CancelledError at the next await point.
async def worker(queue: asyncio.Queue[str]) -> None:
try:
while True:
item = await queue.get()
await process(item)
except asyncio.CancelledError:
# Clean up resources
logger.info("worker cancelled, cleaning up")
raise # Always re-raise CancelledError
task = asyncio.create_task(worker(queue))
# Later...
task.cancel()
await task # Raises CancelledErrorRules:
- Always re-raise
CancelledErrorafter cleanup — swallowing it breaks structured
concurrency and TaskGroup semantics
- Use
try/finallyfor cleanup that must happen regardless of cancellation asyncio.shield()protects a coroutine from cancellation of its outer scope — use
sparingly, as it breaks structured concurrency guarantees
Shielding
async def critical_save(data: bytes) -> None:
# Even if the parent task is cancelled, this completes
await asyncio.shield(database.save(data))shield prevents the inner coroutine from being cancelled when the outer task is cancelled. The outer task still gets CancelledError. Use only for operations that must not be interrupted (database commits, payment processing).
Running Sync Code in Threads
asyncio.to_thread (3.9+)
async def process_image(path: str) -> bytes:
# Run CPU-bound PIL code in a thread to avoid blocking the event loop
return await asyncio.to_thread(PIL.Image.open(path).tobytes)to_thread runs a sync function in a thread pool and returns an awaitable. Use for:
- Blocking I/O that doesn't have an async API
- CPU-light processing that would block the event loop
- Legacy sync code that can't be easily rewritten
loop.run_in_executor (lower-level)
import concurrent.futures
async def compute_hash(data: bytes) -> str:
loop = asyncio.get_running_loop()
with concurrent.futures.ProcessPoolExecutor() as pool:
return await loop.run_in_executor(pool, hashlib.sha256(data).hexdigest)Use ProcessPoolExecutor for CPU-bound work that needs true parallelism.
Scheduling From Other Threads
# From a non-async thread, schedule work on the event loop
future = asyncio.run_coroutine_threadsafe(
some_coroutine(),
loop,
)
result = future.result(timeout=5.0) # Blocks the calling threadThis is the only safe way to interact with a running event loop from another thread. Direct calls to loop.create_task() from another thread are not thread-safe.
Eager Task Factory (3.12+)
By default, create_task() schedules the coroutine for later execution. The eager task factory starts executing the coroutine synchronously up to the first await:
async def main() -> None:
loop = asyncio.get_running_loop()
loop.set_task_factory(asyncio.eager_task_factory)
# Tasks now start executing immediately upon creation
task = asyncio.create_task(fast_coroutine())
# If fast_coroutine() completes before its first await,
# the task is already done hereBenefits: reduces scheduling overhead for coroutines that complete quickly (cache hits, already-available data). The task factory can be restored with loop.set_task_factory(None).
Common Async Patterns
Semaphore for Rate Limiting
async def fetch_all(urls: list[str], max_concurrent: int = 10) -> list[bytes]:
semaphore = asyncio.Semaphore(max_concurrent)
async def bounded_fetch(url: str) -> bytes:
async with semaphore:
return await fetch(url)
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(bounded_fetch(url)) for url in urls]
return [t.result() for t in tasks]Event for Coordination
async def producer(event: asyncio.Event, queue: asyncio.Queue[int]) -> None:
for i in range(10):
await queue.put(i)
event.set() # Signal completion
async def consumer(event: asyncio.Event, queue: asyncio.Queue[int]) -> None:
while not event.is_set() or not queue.empty():
try:
item = await asyncio.wait_for(queue.get(), timeout=1.0)
await process(item)
except TimeoutError:
continueModule System and Imports
Extended patterns for Python's import system, module organization, and resolution mechanics, distilled from the official Python import system documentation.
Import Resolution Order
When Python encounters import foo, the import machinery follows this sequence:
1. `sys.modules` cache — previously imported modules return immediately. The cache includes intermediate paths: importing foo.bar.baz creates entries for foo, foo.bar, and foo.bar.baz. 2. `sys.meta_path` finders — queried in order. Python's default meta path has three finders: built-in modules, frozen modules, and the path-based finder. 3. `sys.path` entries (via path-based finder) — directories and zip files searched left to right:
- Script directory (or current directory for
python -m) PYTHONPATHenvironment variable entries- Site-packages directories (where pip/uv install packages)
If no finder returns a module spec, ModuleNotFoundError is raised.
Cache behavior: sys.modules is writable. Deleting a key invalidates the cache entry, causing Python to search anew on next import. Setting a key to None forces ModuleNotFoundError on next import. importlib.reload() reuses the same module object and re-executes its code.
Import Styles
Absolute Imports (Default)
# Import the module
import mypackage.utils
mypackage.utils.helper()
# Import specific names (preferred for frequently used names)
from mypackage.utils import helper, validate
helper()
# Import module with alias (for long module names)
import mypackage.long_module_name as lmn
lmn.do_thing()Relative Imports (Within Packages Only)
# From same package
from .models import User
from .utils import validate
# From parent package
from ..core import Engine
# From sibling package
from ..auth.tokens import create_tokenWhen to use relative imports:
- Tightly coupled modules within the same package
- Internal package structure that might be reorganized
When to use absolute imports:
- Cross-package imports
- When the import path documents the dependency clearly
- In scripts and entry points
Packages
Python has two types of packages:
Regular Packages
A directory containing __init__.py. When imported, __init__.py is implicitly executed and its objects bound to names in the package namespace.
parent/
__init__.py
one/
__init__.py
two/
__init__.pyImporting parent.one executes both parent/__init__.py and parent/one/__init__.py.
Namespace Packages (PEP 420)
Directories without __init__.py — composite packages where portions may reside in different filesystem locations. Namespace packages use a custom iterable for __path__ that performs a new search on each import attempt if sys.path changes.
company/
├── auth/ # no __init__.py
│ └── tokens.py
└── billing/ # no __init__.py
└── invoices.pyBoth company.auth.tokens and company.billing.invoices work without __init__.py.
Use namespace packages when:
- Multiple distributions contribute to the same top-level package
- You want to split a large package across repositories
Use regular packages (`__init__.py`) when:
- Package is a single distribution
- You need package-level initialization
- You want to control the public API via
__all__
Circular Import Patterns
The Problem
# models.py
from myapp.services import UserService # imports services.py
# services.py
from myapp.models import User # imports models.py — circular!The module is added to sys.modules before its code fully executes. This prevents infinite recursion but can cause ImportError if the requested name hasn't been defined yet when the circular import occurs.
Solutions (in preference order)
1. Restructure to eliminate the cycle
Extract shared definitions to a third module:
# types.py — shared definitions
class UserData: ...
# models.py
from myapp.types import UserData
# services.py
from myapp.types import UserData
from myapp.models import User2. Import at function level (lazy import)
# services.py
class UserService:
def get_user(self, id: str) -> User:
from myapp.models import User # imported when called
return User.from_db(id)3. TYPE_CHECKING guard for annotation-only imports
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.models import User
class UserService:
def get_user(self, id: str) -> User:
...In Python 3.14+ with lazy annotation evaluation, forward references resolve automatically, reducing the need for TYPE_CHECKING for annotation purposes. The guard remains useful to avoid importing a module's side effects at runtime.
__all__ and Public API
# mypackage/__init__.py
from mypackage.core import Engine, process
from mypackage.models import Config, User
__all__ = [
"Config",
"Engine",
"User",
"process",
]- `__all__` defines the public API — what
from package import *exposes - Sort alphabetically for easy scanning
- Include only stable, documented names — internal helpers stay out
Dynamic and Conditional Imports
importlib for Programmatic Imports
import importlib
# Import by string name
module = importlib.import_module("mypackage.plugins.auth")
# Reload a module (rare — for development tools only)
importlib.reload(module)Conditional Imports for Optional Dependencies
try:
import orjson as json
except ImportError:
import json # stdlib fallback
# Or guard with availability check
import importlib.util
HAS_NUMPY = importlib.util.find_spec("numpy") is not None
if HAS_NUMPY:
import numpy as npImport Side Effects
Some imports execute code at module time. Rules:
- Minimize module-level side effects — move initialization into functions
- Side-effect imports (
import mypackage.setup) must be documented with a comment - Never rely on import order for correctness — fragile and hard to debug
- Use
if __name__ == "__main__":to guard script execution from import
__init__.py Best Practices
"""Package docstring — describes what the package provides."""
from mypackage.core import Engine
from mypackage.models import User
__all__ = ["Engine", "User"]
__version__ = "1.0.0"- Keep `__init__.py` minimal — imports and
__all__only - No business logic in
__init__.py - No conditional imports unless handling optional dependencies
- Import cost matters — heavy imports slow everything that touches the package
Packaging and Project Setup
Extended patterns for Python project configuration, dependency management, and toolchain setup targeting Python 3.14+. Complements the rules in SKILL.md with templates, edge cases, and tool-specific configuration details.
pyproject.toml Template
[project]
name = "my-package"
version = "0.1.0"
description = "What this package does"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.14"
authors = [
{ name = "Author Name", email = "author@example.com" },
]
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"mypy>=1.11",
"ruff>=0.6",
]
[project.scripts]
my-tool = "my_package.cli:main"
[project.urls]
Homepage = "https://github.com/me/my-package"
Documentation = "https://my-package.readthedocs.io"
Issues = "https://github.com/me/my-package/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
target-version = "py314"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"RUF", # ruff-specific
]
[tool.ruff.lint.isort]
known-first-party = ["my_package"]
[tool.mypy]
python_version = "3.14"
strict = true
warn_return_any = true
warn_unused_configs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"Build Backends
| Backend | When to Use |
|---|---|
hatchling | Default choice for new projects. Fast, configurable, well-maintained. |
flit-core | Minimal projects with no custom build steps. |
setuptools | Legacy projects, C extensions, complex build requirements. |
maturin | Rust extension modules (PyO3). |
uv-build | Projects already using uv as their primary tool. |
pdm-backend | If already using PDM as project manager. |
Build system declaration examples:
# hatchling (recommended default)
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
# setuptools
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
# uv-build
[build-system]
requires = ["uv_build >= 0.10.0, <0.11.0"]
build-backend = "uv_build"License Declaration (PEP 639)
The modern format uses SPDX license expressions (string, not table):
[project]
license = "MIT"
license-files = ["LICENSE"]
# Compound expressions
license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
# Custom license
license = "LicenseRef-My-Custom-License"The older license = {text = "..."} table format is deprecated. Supported since hatchling 1.27.0, setuptools 77.0.3, flit-core 3.12, uv-build 0.7.19.
Dynamic Metadata
Let the build backend compute fields like version:
[project]
name = "my-package"
dynamic = ["version"]
# hatchling: read version from source
[tool.hatch.version]
path = "src/my_package/__init__.py"Only use dynamic when the value genuinely needs to be computed. Static metadata is easier to inspect and more portable across tools.
uv Workflows
Project Initialization
# Create new project
uv init my-project
cd my-project
# Or initialize in existing directory
uv init
# Install a specific Python version
uv python install 3.14
# Pin Python version for the project
uv python pin 3.14Dependency Management
# Add a dependency
uv add httpx
uv add "pydantic>=2.0"
# Add dev dependency
uv add --dev pytest ruff mypy
# Add optional dependency group
uv add --group docs mkdocs
# Remove a dependency
uv remove httpx
# Update lock file
uv lock
# Upgrade a specific package to latest compatible version
uv lock --upgrade-package requests
# Sync environment from lock file
uv sync
# Sync including all dependency groups
uv sync --all-groups
# Add all dependencies from a requirements.txt
uv add -r requirements.txtRunning Commands
# Run a script in the project environment
uv run python script.py
# Run a tool
uv run pytest
uv run ruff check
uv run mypy src/
# Run with specific Python version
uv run --python 3.14 python script.pyuv run automatically syncs the environment before execution — it verifies the lockfile matches pyproject.toml and the environment matches the lockfile.
Virtual Environments
# Create a virtual environment (auto-created by uv sync)
uv venv
# Create with specific Python version
uv venv --python 3.14
# Activate (still needed for some workflows)
source .venv/bin/activate # Unix
.venv\Scripts\activate # WindowsBuilding and Version Management
# Build source distribution and wheel
uv build
# Output: dist/my-package-0.1.0.tar.gz, dist/my-package-0.1.0-py3-none-any.whl
# Check current version
uv version
uv version --short # just the version numberProject Layout
src Layout (Recommended)
my-project/
├── pyproject.toml
├── uv.lock
├── README.md
├── src/
│ └── my_package/
│ ├── __init__.py
│ ├── core.py
│ ├── models.py
│ └── utils.py
└── tests/
├── conftest.py
├── test_core.py
└── test_models.pyWhy src layout:
- Prevents accidental imports of the package from the project root during testing
- Forces tests to run against the installed version, catching packaging bugs early
- Matches how the package will be used by consumers
Flat Layout (Simple Scripts/Small Projects)
my-project/
├── pyproject.toml
├── my_package/
│ ├── __init__.py
│ └── core.py
└── tests/
└── test_core.pyAcceptable for small projects, scripts, and applications that won't be distributed as packages.
Dependency Specification
Version Constraints
dependencies = [
# Lower bound only — preferred for libraries
"httpx>=0.27",
# Compatible release (>=2.0, <3.0)
"pydantic~=2.0",
# Exact pin — only for applications, never libraries
"uvicorn==0.30.1",
# Exclusion
"numpy>=1.26,!=1.26.2",
# Platform-specific
"pywin32>=306; sys_platform == 'win32'",
]Rules
- Libraries: use
>=lower bound only. Upper bounds (<4.0) create dependency
conflicts for consumers. Only add upper bounds for known incompatibilities.
- Applications: can pin exact versions via lock file (
uv.lock). The lock file
handles reproducibility — pyproject.toml specifies intent.
- *Never use `` or unbounded dependencies** — they make builds non-reproducible.
Entry Points and Plugins
# CLI commands
[project.scripts]
my-tool = "my_package.cli:main"
# GUI scripts (no terminal window on Windows)
[project.gui-scripts]
my-gui = "my_package.gui:main"
# Plugin entry points (for extensible frameworks like pytest, pygments)
[project.entry-points."myapp.plugins"]
auth = "myapp_auth:AuthPlugin"__init__.py Patterns
# Minimal — just define public API
"""My package description."""
from my_package.core import process, transform
from my_package.models import Config, User
__all__ = ["Config", "User", "process", "transform"]- Keep `__init__.py` minimal. Import and re-export public API. No logic.
- Define `__all__` to control
from package import *and document the public API. - Sort `__all__` alphabetically for easy scanning.
- Avoid lazy imports in `__init__.py` unless startup time is critical.
- Empty `__init__.py` is acceptable for packages where users import from submodules
directly.
ruff Configuration
Recommended Rule Sets
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear (common bugs)
"UP", # pyupgrade (modern syntax)
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
"PTH", # flake8-use-pathlib
"T20", # flake8-print (no print statements)
"TCH", # flake8-type-checking (TYPE_CHECKING imports)
]
ignore = [
"E501", # line length — handled by formatter
]Per-File Overrides
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"S101", # allow assert in tests
"T20", # allow print in tests
]
"__init__.py" = [
"F401", # allow unused imports (re-exports)
]Config Hierarchy
Ruff uses hierarchical config discovery — the closest pyproject.toml (with a [tool.ruff] section), ruff.toml, or .ruff.toml wins for each file. Configs do not merge across levels. Use extend to inherit from a parent config:
[tool.ruff]
extend = "../pyproject.toml"
line-length = 100 # override just this settingWhen target-version is not set, ruff infers it from requires-python in the nearest pyproject.toml.
Formatter Configuration
[tool.ruff.format]
quote-style = "double" # default, same as Black
indent-style = "space" # default
skip-magic-trailing-comma = false
docstring-code-format = true # format code blocks in docstringsType Annotation Patterns
Extended examples and patterns for Python 3.14+ type annotations. Complements the rules in SKILL.md with detailed examples, edge cases, and lesser-used constructs.
Built-in Generic Syntax
# Modern (3.9+) — always use this
names: list[str] = []
config: dict[str, int] = {}
coordinates: tuple[float, float] = (0.0, 0.0)
unique_ids: set[int] = set()
optional_name: str | None = None
# Nested generics
matrix: list[list[float]] = []
registry: dict[str, list[Callable[[], None]]] = {}Type Aliases (3.12+)
# type statement — preferred
type Vector = list[float]
type Matrix = list[Vector]
type Handler = Callable[[Request], Response]
type Result[T] = T | Error
# Generic type alias
type Pair[T] = tuple[T, T]
type Mapping[K, V] = dict[K, list[V]]Generics with New Syntax (3.12+)
Generic Functions
def first[T](items: Sequence[T]) -> T:
return items[0]
def merge[K, V](a: dict[K, V], b: dict[K, V]) -> dict[K, V]:
return {**a, **b}
# Constrained type parameter — T must be str or bytes
def encode[T: (str, bytes)](data: T) -> T:
...
# Bounded type parameter — T must implement Comparable
def maximum[T: Comparable](items: Iterable[T]) -> T:
...Generic Classes
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def peek(self) -> T:
return self._items[-1]
# Usage — type is inferred
stack = Stack[int]()
stack.push(42)Generic Protocols
class Comparable[T](Protocol):
def __lt__(self, other: T, /) -> bool: ...
def __le__(self, other: T, /) -> bool: ...
class Repository[T](Protocol):
def get(self, id: str) -> T | None: ...
def save(self, entity: T) -> None: ...
def delete(self, id: str) -> bool: ...Bounded vs Constrained Type Variables
Bounded and constrained type parameters have different solving behavior:
# Bounded — solved to the most specific subtype
def print_cap[S: str](x: S) -> S:
print(x.capitalize())
return x
class MyStr(str): ...
reveal_type(print_cap(MyStr("hi"))) # MyStr (preserves subtype)
# Constrained — solved to exactly one of the constraints
def concat[A: (str, bytes)](x: A, y: A) -> A:
return x + y
reveal_type(concat(MyStr("a"), MyStr("b"))) # str (not MyStr!)
concat("one", b"two") # Error: can't mix str and bytesUse bounded when you want subtype preservation. Use constrained when the type must be exactly one of a fixed set.
NewType
Creates a distinct type for the type checker with zero runtime overhead. Unlike type aliases (which are interchangeable), NewType prevents accidental mixing:
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_user(uid: UserId) -> User: ...
get_user(UserId(42)) # OK
get_user(OrderId(42)) # Error — OrderId is not UserId
get_user(42) # Error — int is not UserId
# Arithmetic returns the base type
result = UserId(1) + UserId(2) # type is int, not UserIdUse NewType for domain identifiers (user IDs, order IDs, file paths) where mixing distinct-but-same-typed values would be a logic error.
ParamSpec for Decorator Signatures
from collections.abc import Callable
# Modern syntax (3.12+)
def retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(3):
try:
return fn(*args, **kwargs)
except Exception:
if attempt == 2:
raise
raise RuntimeError("unreachable")
return wrapper
# Decorator with parameters
def timeout[**P, R](seconds: float) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapper
return decoratorConcatenate for Argument Injection
Use Concatenate when a decorator adds or removes parameters:
from typing import Concatenate
from threading import Lock
def with_lock[**P, R](
f: Callable[Concatenate[Lock, P], R],
) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(my_lock, *args, **kwargs)
return inner
@with_lock
def update_data(lock: Lock, key: str, value: int) -> None:
with lock:
...
# Caller doesn't pass the lock — decorator injects it
update_data("key", 42)Overloads
from typing import overload
@overload
def process(data: str) -> str: ...
@overload
def process(data: bytes) -> bytes: ...
@overload
def process(data: int) -> float: ...
def process(data: str | bytes | int) -> str | bytes | float:
match data:
case str():
return data.upper()
case bytes():
return data.decode().upper().encode()
case int():
return float(data)TypeGuard vs TypeIs
from typing import TypeGuard, TypeIs
# TypeIs (3.13+) — narrows the input type, works in both branches
def is_str_list(val: list[object]) -> TypeIs[list[str]]:
return all(isinstance(x, str) for x in val)
# TypeGuard — output type unrelated to input, only narrows True branch
def is_valid_user(data: dict[str, object]) -> TypeGuard[UserDict]:
return "name" in data and "email" in data| TypeIs | TypeGuard | |
|---|---|---|
| True branch | intersection of original + narrowed type | exactly the guard type |
| False branch | excludes the narrowed type | no narrowing |
| Type relationship | narrowed must be subtype of input | no constraint |
| Use when | refining an existing type | output type unrelated to input |
Prefer TypeIs for standard narrowing. Use TypeGuard when the narrowed type is incompatible with the input (e.g., list[object] to list[str] — list is invariant).
TypedDict
from typing import TypedDict, Required, NotRequired, ReadOnly
# All keys required by default
class Movie(TypedDict):
title: str
year: int
director: str
# total=False makes all keys optional; use Required to mark exceptions
class Options(TypedDict, total=False):
verbose: bool
timeout: int
output: Required[str] # this one is required
# ReadOnly (3.13+) — key cannot be mutated
class Config(TypedDict):
host: ReadOnly[str]
port: ReadOnly[int]
retries: int # mutable
# Unpack for typed **kwargs (3.12+)
from typing import Unpack
class RequestOpts(TypedDict, total=False):
timeout: float
headers: dict[str, str]
def fetch(url: str, **kwargs: Unpack[RequestOpts]) -> bytes:
...
# Caller gets type-checked keyword arguments
fetch("https://example.com", timeout=30.0, headers={"Auth": "token"})Self Type
from typing import Self
class Builder:
def set_name(self, name: str) -> Self:
self._name = name
return self
def set_age(self, age: int) -> Self:
self._age = age
return self
class ExtendedBuilder(Builder):
def set_email(self, email: str) -> Self:
self._email = email
return self
# ExtendedBuilder.set_name() returns ExtendedBuilder, not BuilderUse Self for any method that returns self — fluent builders, __enter__, and @classmethod alternative constructors.
Variance
In 3.12+ generics, variance is inferred from usage:
# Inferred as covariant (T appears only in return positions)
class Producer[T]:
def get(self) -> T: ...
# Inferred as contravariant (T appears only in parameter positions)
class Consumer[T]:
def accept(self, item: T) -> None: ...
# Inferred as invariant (T appears in both positions)
class Container[T]:
def get(self) -> T: ...
def set(self, item: T) -> None: ...Never and NoReturn
from typing import Never
# Function that never returns (always raises or loops forever)
def fail(msg: str) -> Never:
raise RuntimeError(msg)
# Exhaustiveness checking with assert_never
from typing import assert_never
def handle(status: Status) -> str:
match status:
case Status.ACTIVE:
return "active"
case Status.INACTIVE:
return "inactive"
case _:
assert_never(status) # Error if Status gains a new memberLiteral and LiteralString
from typing import Literal, LiteralString
type Direction = Literal["north", "south", "east", "west"]
def move(direction: Direction, steps: int = 1) -> None: ...
move("north") # OK
move("up") # Error
# LiteralString — prevents injection attacks
def run_query(sql: LiteralString) -> None: ...
run_query("SELECT * FROM users") # OK — literal
run_query(f"SELECT * FROM {user_input}") # Error — not a literal stringAnnotated
Attach metadata to types without affecting type checking:
from typing import Annotated
# Pydantic-style validation metadata
type PositiveInt = Annotated[int, Gt(0)]
type Email = Annotated[str, Pattern(r"^[\w.]+@[\w.]+$")]
# FastAPI dependency injection
def get_user(user_id: Annotated[int, Path(ge=1)]) -> User: ...
# Metadata is preserved at runtime
>>> Annotated[int, "metadata"].__metadata__
('metadata',)Annotating Tricky Patterns
# Variadic tuples with TypeVarTuple
def head_tail[T](items: tuple[T, *tuple[T, ...]]) -> tuple[T, tuple[T, ...]]:
return items[0], items[1:]
# Final for constants
from typing import Final
MAX_CONNECTIONS: Final = 100
# ClassVar for class-level attributes
from typing import ClassVar
class Config:
instances: ClassVar[list[Config]] = []
name: str
# type[C] for class objects (not instances)
def create[T: Widget](cls: type[T]) -> T:
return cls()Annotating Generators and Coroutines
from collections.abc import Generator, Iterator, AsyncGenerator, AsyncIterator
# Simple generator — use Iterator
def count_up(start: int) -> Iterator[int]:
while True:
yield start
start += 1
# Generator with send/return — use Generator[YieldType, SendType, ReturnType]
# SendType and ReturnType default to None
def echo_round() -> Generator[int, float, str]:
sent = yield 0
while sent >= 0:
sent = yield round(sent)
return "Done"
# Async generator
async def stream_data(url: str) -> AsyncIterator[bytes]:
async with httpx.AsyncClient() as client:
async with client.stream("GET", url) as resp:
async for chunk in resp.aiter_bytes():
yield chunkCommon Pitfalls
- Don't annotate `self` or `cls` — the type checker infers them.
- `tuple[int, ...]` means variable-length homogeneous tuple.
tuple[int, str]means
exactly two elements of specified types. tuple[()] means empty tuple.
- `dict[str, Any]` disables value type checking. Prefer
dict[str, object]or a
TypedDict.
- Avoid circular type references — in 3.14+ with lazy annotation evaluation, forward
references resolve automatically. For older versions, use string literals "ClassName" or from __future__ import annotations.
- `object` vs `Any`:
objectis type-safe (rejects most operations),Anyis an
escape hatch (accepts all operations). Use object when you mean "any type but still type-safe."