15 KiB
trigger, description
| trigger | description |
|---|---|
| model_decision | When writing Python code, reviewing Python idioms, or working on any Python project (backend, CLI, or data pipeline) |
Python Idioms and Patterns
Core Philosophy
Python rewards explicitness and readability over cleverness. Follow the Zen of Python (import this) — beautiful code is not a luxury, it's a professional necessity. If it reads like plain English, it's probably idiomatic Python.
Scope: This file covers Python-specific coding idioms. For file layout, see
project-structure-python-backend.md. For test naming conventions, seetesting-strategy.md. For logging library choice, seelogging-and-observability-principles.md.
Type Hints — Non-Negotiable
Always annotate function signatures and public APIs. Use from __future__ import annotations for forward references.
# ✅ Fully annotated — self-documenting and mypy-verifiable
from __future__ import annotations
from collections.abc import Sequence
def calculate_discount(items: Sequence[Item], coupon: Coupon) -> float: ...
# ❌ Untyped — opaque to both mypy and the next developer
def calculate_discount(items, coupon): ...
-
Use
X | NoneoverOptional[X](Python 3.10+)# ✅ Modern union syntax def find_user(user_id: str) -> User | None: ... # ❌ Verbose legacy form (still valid, but prefer the above) from typing import Optional def find_user(user_id: str) -> Optional[User]: ... -
Use
TypeAliasandTypeVarfor reusable genericsfrom typing import TypeVar, TypeAlias T = TypeVar("T") UserId: TypeAlias = str -
Use
Protocolfor structural interfaces instead of ABCs when duck-typing is sufficientfrom typing import Protocol class TaskStorage(Protocol): def get_by_id(self, task_id: str) -> Task: ... def save(self, task: Task) -> None: ... -
TypedDictfor structured dicts crossing system boundaries (JSON, configs)from typing import TypedDict class CreateTaskRequest(TypedDict): title: str priority: Literal["low", "medium", "high"]
Error Handling
For general error handling principles, see
error-handling-principles.md. This section covers Python-specific idioms.
-
Prefer specific exception types over broad
except Exception# ✅ Precise — catches exactly what you expect try: task = storage.get_by_id(task_id) except TaskNotFoundError: raise HTTPException(status_code=404, detail="Task not found") # ❌ Broad — may swallow programming errors try: task = storage.get_by_id(task_id) except Exception: raise HTTPException(status_code=500, detail="Error") -
Define domain-specific exception hierarchies
class FathError(Exception): """Base exception for all domain errors.""" class NotFoundError(FathError): def __init__(self, resource: str, resource_id: str) -> None: self.resource = resource self.resource_id = resource_id super().__init__(f"{resource} '{resource_id}' not found") class ValidationError(FathError): def __init__(self, field: str, message: str) -> None: self.field = field self.message = message super().__init__(f"Validation failed on '{field}': {message}") -
Never silence exceptions — if an exception is caught and not re-raised, log it explicitly
# ❌ Silent swallow try: notify_user(user_id) except Exception: pass # ✅ Explicit intent + observability try: notify_user(user_id) except NotificationError: logger.warning("notification_failed", user_id=user_id, exc_info=True) -
Use
contextlib.suppressonly for truly expected, inconsequential exceptionsfrom contextlib import suppress with suppress(FileNotFoundError): cache_path.unlink() # OK — cleanup, not business logic
Dataclasses and Pydantic
-
Use
dataclassesfor internal domain models (no I/O, no validation)from dataclasses import dataclass, field @dataclass(frozen=True) # frozen = immutable value object class Task: id: str title: str priority: str tags: tuple[str, ...] = field(default_factory=tuple) -
Use Pydantic
BaseModelfor data crossing system boundaries (API requests/responses, config)from pydantic import BaseModel, Field class CreateTaskRequest(BaseModel): title: str = Field(min_length=1, max_length=200) priority: Literal["low", "medium", "high"] = "medium" due_date: datetime | None = None model_config = ConfigDict(frozen=True) # Pydantic v2 -
Keep domain models separate from API schemas — never use a Pydantic model as a domain entity
models.py → dataclasses (pure domain) schemas.py → Pydantic models (API boundary)
Interfaces and Dependency Injection
Python uses Protocols and constructor injection to achieve the same testability goal as Go interfaces.
-
Define the Protocol where it is used, not where it is implemented
# task/storage.py ← defined in the consumer feature from typing import Protocol class TaskStorage(Protocol): def get_by_id(self, task_id: str) -> Task: ... def save(self, task: Task) -> None: ... def delete(self, task_id: str) -> None: ... -
Inject dependencies through
__init__— never instantiate concrete dependencies inside a class# ✅ Testable — storage is injected class TaskService: def __init__(self, storage: TaskStorage) -> None: self._storage = storage # ❌ Not testable — concrete dependency hardwired class TaskService: def __init__(self) -> None: self._storage = PostgresTaskStorage() -
Wire dependencies in the entry point (e.g.,
main.py,app.py, or DI container)# app/main.py storage = PostgresTaskStorage(db=database) service = TaskService(storage=storage) router.include_router(build_task_router(service))
Async / Await
For general async principles (when to add concurrency), see
concurrency-and-threading-mandate.md. This section covers Python-specific async idioms.
-
Choose one async paradigm and stay consistent — do not mix
asyncio.runentry points# ✅ Fully async service layer async def get_task(self, task_id: str) -> Task: return await self._storage.get_by_id(task_id) -
Never call blocking I/O directly in an async function
# ❌ Blocks the event loop async def load_file(path: str) -> str: return open(path).read() # ✅ Use async I/O or run in executor import asyncio, aiofiles async def load_file(path: str) -> str: async with aiofiles.open(path) as f: return await f.read() -
Use
asyncio.gatherfor concurrent independent operations# ✅ Concurrent fan-out user, tasks = await asyncio.gather( get_user(user_id), get_tasks(user_id), ) -
Use
asyncio.TaskGroup(Python 3.11+) for structured concurrency with cancellation safetyasync with asyncio.TaskGroup() as tg: user_task = tg.create_task(get_user(user_id)) tasks_task = tg.create_task(get_tasks(user_id)) user = user_task.result() tasks = tasks_task.result()
Naming Conventions
Follow PEP 8 rigorously. No exceptions.
| Construct | Convention | Example |
|---|---|---|
| Module / Package | snake_case |
task_service.py |
| Class | PascalCase |
TaskService |
| Function / Method | snake_case |
get_by_id |
| Private method/attr | _snake_case |
_validate_title |
| Constant | UPPER_SNAKE_CASE |
MAX_TITLE_LENGTH = 200 |
| Type alias | PascalCase |
UserId = str |
| Protocol / Interface | PascalCase |
TaskStorage |
- Never use single-letter names outside list comprehensions or math — names must be descriptive
- Avoid
data,info,obj,resultas standalone names — describe the domain concept - Boolean variables and functions should read as yes/no questions
# ✅ is_active: bool has_permission: bool def can_edit(user: User, task: Task) -> bool: ... # ❌ active: bool permission: bool
Idiomatic Patterns
-
Context managers for resource cleanup — always prefer
withover manualclose()# ✅ async with database.transaction() as tx: await tx.execute(query) # ❌ tx = database.begin() tx.execute(query) tx.commit() # easily forgotten on exception -
Generator expressions over list comprehensions for lazy evaluation
# ✅ Lazy — does not materialise the entire list active_ids = (task.id for task in tasks if task.is_active) # Use list comprehension only when you need the full list active_tasks = [task for task in tasks if task.is_active] -
dataclasses.replace()for immutable updates (preferred over mutating frozen dataclasses)from dataclasses import replace updated_task = replace(task, title="New Title") -
functools.cache/functools.lru_cachefor pure function memoizationfrom functools import cache @cache def get_config() -> AppConfig: # called once; result reused return _load_config_from_env() -
__slots__on hot-path, frequently instantiated classes@dataclass class Vector: __slots__ = ("x", "y") x: float y: float -
enum.Enum(not raw strings) for domain-level constantsfrom enum import StrEnum # Python 3.11+ class Priority(StrEnum): LOW = "low" MEDIUM = "medium" HIGH = "high"
Testing
Test file naming and pyramid proportions are defined in
testing-strategy.md. This section covers Python-specific tooling only.
-
Use
pytestas the sole test runner — never mix withunittest.TestCaseclasses# ✅ Idiomatic pytest def test_calculate_discount_returns_zero_for_no_items() -> None: result = calculate_discount(items=[], coupon=Coupon(code="SAVE10")) assert result == 0.0 -
Parametrize test cases with
@pytest.mark.parametrize@pytest.mark.parametrize("priority,expected_score", [ ("low", 1), ("medium", 5), ("high", 10), ]) def test_priority_score(priority: str, expected_score: int) -> None: assert priority_score(priority) == expected_score -
Use
pytest-mock(mockerfixture) for mocking — notunittest.mockdirectlydef test_task_service_creates_task(mocker: MockerFixture) -> None: mock_storage = mocker.create_autospec(TaskStorage, instance=True) service = TaskService(storage=mock_storage) service.create(title="Test", priority="high") mock_storage.save.assert_called_once() -
Use a typed mock factory for Protocol-based interfaces
# storage_mock.py ← co-locate with storage.py class InMemoryTaskStorage: """In-memory TaskStorage implementation for unit tests.""" def __init__(self) -> None: self._store: dict[str, Task] = {} def get_by_id(self, task_id: str) -> Task: if task_id not in self._store: raise NotFoundError("Task", task_id) return self._store[task_id] def save(self, task: Task) -> None: self._store[task.id] = task def delete(self, task_id: str) -> None: self._store.pop(task_id, None) -
Use
pytest-asynciofor async tests — mark the whole module or useasyncio_mode = "auto"inpyproject.tomlimport pytest @pytest.mark.asyncio async def test_async_create_task() -> None: service = TaskService(storage=InMemoryTaskStorage()) task = await service.create(title="Async Task", priority="low") assert task.title == "Async Task" -
Fixtures for reusable setup — never repeat identical
Arrangeblocks across tests@pytest.fixture def task_service() -> TaskService: return TaskService(storage=InMemoryTaskStorage())
Formatting and Static Analysis
All of the following must pass with zero warnings/errors before any commit. See code-completion-mandate.md for the full checklist.
| Tool | Purpose | Command |
|---|---|---|
ruff format |
Canonical formatting (fast) | ruff format . |
ruff check |
Lint (replaces flake8, isort, ...) | ruff check . --fix |
mypy |
Static type checking | mypy src/ --strict |
bandit |
Security scanning | bandit -r src/ -c pyproject.toml |
pip-audit |
Dependency CVE scanning | pip-audit |
Configure all tools in pyproject.toml — never use per-file pragma comments to disable checks without a # NOQA: reason comment.
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "S", "B", "ANN"]
ignore = []
[tool.mypy]
strict = true
python_version = "3.11"
[tool.pytest.ini_options]
asyncio_mode = "auto"
Logging: Never use
print()in production code — it produces unstructured output. Use the standardloggingmodule orstructlogfor structured JSON logs. Seelogging-and-observability-principles.mdfor the required patterns.
Related Principles
- Code Idioms and Conventions @code-idioms-and-conventions.md
- Project Structure — Python Backend @project-structure-python-backend.md
- Testing Strategy @testing-strategy.md
- Error Handling Principles @error-handling-principles.md
- Concurrency and Threading Mandate @concurrency-and-threading-mandate.md
- Logging and Observability Principles @logging-and-observability-principles.md
- Security Principles @security-principles.md
- Dependency Management Principles @dependency-management-principles.md