| name | python-coding |
| description | Apply when writing or editing Python (.py) files. Behavioral corrections for error handling, resource management, async patterns, data modeling, type safety, security defaults, and common antipatterns. Project conventions always override these defaults. |
Python Coding
Match the project's existing conventions. When uncertain, read 2-3 existing modules to infer the local style. Check pyproject.toml for Python version target, linter config, and tooling. These defaults apply only when the project has no established convention.
Never rules
These are unconditional. They prevent bugs and vulnerabilities regardless of project style.
- Never
except: pass or bare except Exception without re-raise. Catch specific exception types. Broad catches silently swallow bugs — a KeyError from a typo looks the same as a network failure, and you'll spend hours debugging something the traceback would have told you instantly.
- Never
datetime.now() or datetime.utcnow() -- both produce naive datetimes that lose timezone info. Naive datetimes cause subtle bugs when code crosses timezone boundaries (servers, users, DST). Use datetime.now(tz=timezone.utc). Use zoneinfo.ZoneInfo for other timezones, not pytz.
- Never
random for security -- random uses a predictable PRNG; an attacker who observes a few outputs can predict future ones. Use secrets.token_hex(), secrets.token_urlsafe(), or secrets.token_bytes() for tokens, keys, session IDs.
- Never
shell=True in subprocess -- shell interpretation enables command injection if any argument contains user input. Use argument lists: subprocess.run(["cmd", arg1, arg2]).
- Never interpolate or unsafely deserialize external input -- no string-formatted SQL (parameterized queries only), no
yaml.load() (use yaml.safe_load()), no pickle.load() on untrusted data (use JSON/MessagePack), no eval()/exec() (use ast.literal_eval() for literals). All are injection or remote-code-execution vectors.
- Never mutable default arguments --
def f(items=[]) shares one list across all calls. Appending in one call mutates the default for every subsequent call. Use None sentinel: def f(items: list[str] | None = None) then if items is None: items = [] (not items = items or [], which also replaces a caller's passed-in empty list).
- Never shadow builtins -- don't use
list, dict, id, type, input, hash, map, set, filter as variable names. Shadowing causes confusing errors when you later need the builtin in the same scope.
- Never blocking calls in async -- no
time.sleep(), bare open(), or requests.get() inside async def. These block the entire event loop, freezing all concurrent tasks. Use asyncio.sleep(), aiofiles, httpx.
- Never
+= string concatenation in loops -- use "".join(parts). Strings are immutable, so repeated += is quadratic in the general case; CPython has a narrow in-place optimization that often masks it, but it is implementation-specific and easily defeated. join is reliably linear everywhere.
Error handling
Always use raise ... from e when re-raising at I/O boundaries. This preserves the original traceback -- essential for production debugging. Use raise ... from None only when the original exception is genuinely irrelevant to the caller.
async def get_user(user_id: int) -> User:
try:
result = await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
except asyncpg.PostgresError as e:
raise DatabaseError(f"Failed to query user {user_id}") from e
if result is None:
raise UserNotFoundError(user_id)
return User(**result)
Create custom exception types when callers need to distinguish failure modes:
class AppError(Exception):
"""Base exception."""
class NotFoundError(AppError):
def __init__(self, resource: str, id: Any) -> None:
self.resource = resource
self.id = id
super().__init__(f"{resource} not found: {id}")
When logging a caught exception, always preserve the traceback:
except httpx.HTTPStatusError as e:
logger.error(f"API call failed: {e}")
raise
except httpx.HTTPStatusError:
logger.exception("API call failed")
raise
Use exc_info=True for non-error log levels: logger.warning("retrying", exc_info=True).
Resource cleanup
Use context managers for anything that needs cleanup -- clients, connections, file handles. Never instantiate httpx.AsyncClient(), database pools, or similar without a context manager or explicit finally cleanup.
For managing multiple async resources, use AsyncExitStack:
from contextlib import AsyncExitStack, asynccontextmanager
@asynccontextmanager
async def setup_resources() -> AsyncIterator[Resources]:
async with AsyncExitStack() as stack:
db = await stack.enter_async_context(create_pool(dsn))
cache = await stack.enter_async_context(create_redis(url))
yield Resources(db=db, cache=cache)
For custom resource lifecycles where no built-in context manager exists:
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_session(config: Config) -> AsyncIterator[Session]:
session = await Session.connect(config)
try:
yield session
finally:
await session.disconnect()
Async patterns
Prefer TaskGroup (3.11+) over gather for most concurrent work. TaskGroup enforces structured concurrency: if one task fails, siblings are cancelled and errors are raised as ExceptionGroup. gather(return_exceptions=True) silently mixes exceptions into results, which is error-prone. Use gather when you genuinely need partial results despite failures, or when targeting Python < 3.11.
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch_users())
task2 = tg.create_task(fetch_orders())
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(operation_a())
tg.create_task(operation_b())
except* ConnectionError as eg:
for exc in eg.exceptions:
logger.error(f"Connection failed: {exc}")
except* ValueError as eg:
for exc in eg.exceptions:
logger.error(f"Validation failed: {exc}")
Share a single AsyncClient across concurrent requests -- don't create one per call. Configure timeouts explicitly:
async with httpx.AsyncClient(base_url="https://api.example.com", timeout=30.0) as client:
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(client.get("/users"))
task2 = tg.create_task(client.get("/orders"))
Type hints
Use modern syntax: list[str], dict[str, Any], X | None. Use native type parameter syntax when the project targets 3.12+; fall back to TypeVar for older targets.
class Repository[T]:
def __init__(self, model_class: type[T]) -> None:
self._model_class = model_class
self._items: dict[int, T] = {}
def get(self, id: int) -> T | None:
return self._items.get(id)
Use object instead of Any when you mean "accepts anything." Any silently disables type checking -- it's a hole in type safety. Reserve Any for when the type system genuinely cannot express something.
Accept abstract, return concrete. Function parameters should accept abstract types (Sequence, Mapping, Iterable); return types should be concrete (list, dict):
from collections.abc import Sequence, Mapping
def process_items(items: Sequence[str]) -> list[str]:
return [item.upper() for item in items]
def merge_configs(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
return {**base, **override}
Use TYPE_CHECKING for import-only types. When a type is only needed for annotations (not at runtime), import it under TYPE_CHECKING to avoid circular imports and reduce startup cost:
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.services import PaymentService
class OrderProcessor:
def __init__(self, payments: PaymentService) -> None:
self._payments = payments
Pattern matching
Use match/case (3.10+) when branching on structure — it's clearer than if/elif chains for destructuring dicts, tuples, or typed objects. Don't use it as a substitute for simple value comparisons where if/elif reads fine.
match event:
case {"type": "click", "target": target}:
handle_click(target)
case {"type": "scroll", "offset": int(offset)} if offset > 0:
handle_scroll(offset)
case _:
logger.warning("Unhandled event: %s", event.get("type"))
Data modeling
| Use Case | Choice | Reason |
|---|
| API request/response | Pydantic | Validation, serialization, OpenAPI |
| Config from env/files | Pydantic | Built-in settings management |
| Internal data transfer | dataclass | Lighter weight, no runtime validation |
| Simple value objects | dataclass | Minimal boilerplate |
Pydantic at system boundaries:
class UserCreate(BaseModel):
email: str = Field(..., min_length=5)
name: str = Field(..., min_length=1, max_length=100)
class UserResponse(BaseModel):
id: int
email: str
model_config = {"from_attributes": True}
Dataclasses internally. Use frozen=True for value objects, slots=True when you have many instances:
@dataclass(frozen=True, slots=True)
class CacheKey:
namespace: str
id: str
Protocol
Use Protocol for structural interfaces -- duck typing with full type safety, no inheritance required.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict[str, Any]: ...
Enums
Use StrEnum instead of raw string literals for known value sets. Catches typos at type-check time. Use IntEnum only when interfacing with systems that require integer codes.
from enum import StrEnum, unique
@unique
class OrderStatus(StrEnum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
Imports
Import order: standard library, third-party, local. Define __all__ if the project convention uses it. Use pathlib.Path over os.path. No import-time side effects -- don't connect to databases, read files, or run computations at module level. Defer to first use.
Retry logic
Use tenacity for transient errors: network failures, rate limits (429), 5xx responses. Let other 4xx errors propagate -- they indicate a request problem, not a transient failure.
def _is_transient(e: BaseException) -> bool:
if isinstance(e, httpx.HTTPStatusError):
return e.response.status_code >= 500 or e.response.status_code == 429
return isinstance(e, httpx.RequestError)
@retry(
retry=retry_if_exception(_is_transient),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> dict[str, Any]:
response = await client.get(url)
response.raise_for_status()
return response.json()
Logging
Match the project's logging library (stdlib logging, structlog, etc.). With stdlib, use __name__ and pass structured data via extra:
logger = logging.getLogger(__name__)
logger.info("User created", extra={"user_id": user.id})
Testing
Match the project's test runner and async plugin. Check for pytest-asyncio mode = "auto" (no manual @pytest.mark.asyncio needed) or anyio-based setup.