agentleFS
mattstrublemattstruble/skills/python-design/SKILL.md

python-design

Skill2 starsChanged 4 months agoMITView on GitHub

You MUST consult this skill when writing, reviewing, or generating Python code. Also trigger on Python-specific design choices (Protocol vs ABC, TypedDict vs dataclass, Pydantic boundaries), code style anti-patterns (broad exceptions, sentinel defaults, redundant docstrings, unnecessary future imports), or advanced patterns (functools, generator pipelines, typing). NOT for debugging Python errors, framework-specific setup (Django, Flask, FastAPI config), package management, or writing tests (see test-design).

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

16 rules. About 0 are common, 8 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈8

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use these tables as a first pass, then read the relevant reference for implementation details and worked examples.

    line 21
  2. Very specific:

    TypedDict vs frozen dataclass: If the data has a domain name (OrderSummary, UserStats) — use a frozen dataclass.

    line 35
  3. Very specific:

    Use __post_init__ validation or a class-method factory to enforce constraints on construction.

    line 95
  4. Very specific:

    When None spreads through domain code, the root cause is usually raw external data entering the domain core without refinement (see above).

    line 130
  5. Distinctive:

    If you can't write a one-paragraph module docstring that accurately describes what the module does, the module has too many …

    line 202
  6. Distinctive:

    Renaming test: if you had to rename a module so a new engineer finds it on their first try, what would you call it?

    line 234
  7. Very specific:

    Only add it for genuine circular type references via TYPE_CHECKING.

    line 243
  8. Very specific:

    Never bare except: or except BaseException — these catch KeyboardInterrupt and SystemExit.

    line 249
  9. Very specific:

    Use .get("key") without a default.

    line 280
  10. Very specific:

    Omit sections that restate what the signature already says: If Args names and types tell the whole story, or Returns type is …

    line 320
  11. Very specific:

    Use with_suffix, with_stem, with_name instead of string concatenation or f-string path assembly.

    line 348
  12. Distinctive:

    All imports belong at the top of the file — never inside functions.

    line 364
  13. Distinctive:

    No docstrings that restate the signature.

    line 383
  14. Distinctive:

    No comments that restate the code.

    line 384
  15. Distinctive:

    No single-use type aliases.

    line 385
  16. Distinctive:

    No unnecessary helper functions that are only called once and don't simplify.

    line 386

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Python Design Patterns

Python-specific patterns for expressing clean software design. For the underlying design philosophy (why small interfaces, why composition, why immutability), see the software-design skill.


Choosing the Right Abstraction

Use these tables as a first pass, then read the relevant reference for implementation details and worked examples.

Data containers — pick based on what the data is, not just its shape:

SituationReach forWhy
External input needing validation (API, config, files)Pydantic BaseModelValidates + coerces at the boundary
JSON/API input that stays dict-compatible (already trusted)TypedDictZero overhead, serializable, type-safe
Domain object with a meaningful name (Order, UserSummary)@dataclass(frozen=True)Immutable, hashable, identity beyond "bag of keys"
Lightweight immutable record, supports unpackingNamedTupleLighter than dataclass, tuple semantics
Fixed set of choices (statuses, modes, event types)Enum / StrEnumPrevents typos, exhaustive matching
Collection that shouldn't mutate after creationtuple / frozensetSignals intent, prevents accidental mutation

TypedDict vs frozen dataclass: If the data has a domain name (OrderSummary, UserStats) — use a frozen dataclass. If the data is genuinely dict-shaped and must stay a plain dict for serialization or library interop — use TypedDict. The access pattern tells you: data["user_id"] → TypedDict; summary.total_revenue → dataclass.

Literal vs Enum: For fields on Pydantic models with a small, stable set of values, Literal["idle", "busy", "dead"] gives you validation and IDE autocomplete without a separate class. Reach for Enum/StrEnum when the set is reused across multiple models or needs methods/behavior. Note that Literal values are not iterable and have no runtime members — if you ever need to enumerate valid values or use them in a match guard, use StrEnum instead. This complements the Enum/StrEnum row in the table above — use Literal only when the field is a local discriminator, not a shared domain concept.

Interface contracts:

NeedReach forWhy
Caller-facing contractProtocolStructural typing, no inheritance coupling
Shared implementation for authorsABCCallers type against Protocol, not the ABC
Shared behavior across unrelated classesMixinImplementation without contract

Generics restraint: TypeVar and generics shine in container and collection abstractions — type-safe wrappers, reusable data structures, utility functions like first(items: Sequence[T]) -> T. The trap is over-genericizing business logic that only ever has one concrete type: if UserRepository will never be anything other than a UserRepository, making it Repository[T] adds abstraction without value. Even when multiple concrete types exist, if every subclass adds domain-specific methods anyway, the generic base captures only the trivial skeleton — evaluate whether that skeleton justifies the abstraction overhead. A caller-facing Protocol often provides more value than a generic concrete base. Reach for generics when the type relationship is genuinely reusable across multiple concrete types; otherwise, name the type directly. See references/advanced-patterns.md for TypeVar syntax and examples.

Collection semantics: Match the collection to the operation — set for membership tests, list for ordered/indexed access, dict for keyed lookup. Using a dict for membership checks or a list for uniqueness signals unreviewed code.


Key Patterns

Validation Boundaries

Pydantic BaseModel at the edges, frozen dataclasses inside. External data (APIs, config, CLI) gets validated by Pydantic, then converted to immutable domain types. This prevents Pydantic's validation overhead and mutability from spreading through internal logic.

class CreateOrderRequest(BaseModel):  # boundary — validates external input
    product_id: str
    quantity: int
    price: Decimal

@dataclass(frozen=True)
class Order:  # internal — immutable, no validation overhead
    product_id: str
    quantity: int
    price: Decimal

def create_order(request: CreateOrderRequest) -> Order:
    return Order(**request.model_dump())

Refine fields into proven types. The domain object should carry proof that its fields satisfy domain invariants — not just copy the raw values. Use __post_init__ validation or a class-method factory to enforce constraints on construction; downstream code then needs no re-checks.

@dataclass(frozen=True)
class Order:
    product_id: str      # ponytail: NewType('ProductId', str) if ID format matters
    quantity: int
    price: Decimal

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValueError(f"quantity must be positive, got {self.quantity}")
        if self.price <= 0:
            raise ValueError(f"price must be positive, got {self.price}")

def create_order(request: CreateOrderRequest) -> Order:
    # Raises on invalid data — caller holds an Order known to be valid.
    return Order(
        product_id=request.product_id,
        quantity=request.quantity,
        price=request.price,
    )

Enforcement is runtime: the smart constructor raises on bad data; type checkers (pyright/mypy) narrow types after isinstance/assert but cannot prove field-level numeric constraints from the type alone. Do not claim compile-time proofs. The value is that all callers are protected by one construction-time guard, not per-caller re-checks. See software-design/references/type-driven-design.md for the language-agnostic principle.

Avoiding None in Domain Logic

When None spreads through domain code, the root cause is usually raw external data entering the domain core without refinement (see above). When the refined-type approach doesn't cover a case:

  • Better defaults — use [], False, or 0 instead of None when empty is valid.
  • Raise instead of returning None — domain exception makes the return type non-optional.
  • Model states as distinct classes — OfflineDrone, ConnectedDrone, ReadyDrone; each function accepts only the state it needs.
  • Null Object — NullDiagnostics implementing the same Protocol eliminates if x is not None: guards.
  • Sentinel — MISSING = object() distinguishes "caller passed None" from "caller omitted the argument."
  • Result types (Route | RouteFailed) — ArjanCodes argues prefer exceptions over Result types in Python; the returns package exists but is not recommended for typical Python code.

Protocols Over ABCs for Callers

Define caller-facing interfaces as Protocol. Any object with matching methods satisfies the contract — no inheritance required. Use ABCs only when implementors need shared logic; callers always type against the Protocol.

Composition via Functions, Not Class Hierarchies

Python's composition strength comes from generators, itertools, functools, and first-class functions. Prefer standalone functions over methods for operations on objects (serialization, formatting, orchestration). Generator pipelines compose streaming transforms without materializing intermediate collections.

Shallow Error Hierarchies

One base exception per domain, one level of specific errors beneath it. Callers catch the base or the specific — nothing deeper. Use context managers (contextlib.contextmanager) for resource cleanup.

Module Hygiene

Define __all__ in every module with a public API — place it after imports. Structure modules so readers encounter the public interface first. Run pyright or mypy in CI to enforce Protocol conformance and catch type mismatches.

__all__ = ["process_orders", "Order", "OrderSummary", "OrderError"]

Split a module when it exceeds ~300 lines or has 3+ distinct responsibilities — whichever comes first. Prefer flat structure (src/formatters.py) over deep nesting (src/utils/helpers/formatters/date.py).

__init__.py as Design Document

A module's __init__.py with explicit __all__ is a design document — it defines the public interface and forces deliberate decisions about what to expose.

# app/payments/__init__.py
"""Payment processing domain.

Public interface:
  - charge(amount, payment_method) -> Receipt
  - refund(receipt) -> Refund
  - list_charges(account_id) -> list[Receipt]
"""

from .charge import charge
from .refund import refund
from .queries import list_charges

__all__ = ["charge", "refund", "list_charges"]

# Everything else is implementation detail
# _processor, _validation, _webhook_handler are NOT exported

If you can't write a one-paragraph module docstring that accurately describes what the module does, the module has too many responsibilities.

Naming as Information Scent

Strong names communicate domain concept, not mechanism. Weak names could describe code in five different modules — they're placeholders, not names.

Strong scent (domain concept):

  • payment_processor, invoice_generator, user_authenticator
  • scheduled_for, expires_at, cancelled_reason
  • SeatReservation, FlightManifest, BoardingPass

Weak scent (mechanism, not meaning):

  • data_handler, process_manager, item_controller
  • flag, result, status, value, info
  • Manager, Helper, Util, Base

Graveyard names where scent goes to die: utils/, helpers/, common/, shared/, misc/. These contain every kind of content, so they provide no navigation signal. A new engineer looking for tax calculation logic will never guess to check utils/helpers.py.

Import path as scent trail:

# Every token narrows the search — strong scent trail
from app.billing.invoices.line_items import calculate_tax

# Trail goes cold — what kind of util? what does it help with?
from utils.helpers import calculate_tax

Renaming test: if you had to rename a module so a new engineer finds it on their first try, what would you call it? That's the right name.


Code Hygiene

No Unnecessary from __future__ import annotations

Only add it for genuine circular type references via TYPE_CHECKING. Modern Python (3.10+) has X | Y and list[int] natively. See the TYPE_CHECKING section in references/advanced-patterns.md for the legitimate use case.

Specific Exception Types

Never bare except: or except BaseException — these catch KeyboardInterrupt and SystemExit. Never except Exception without justification. Look up which exceptions the called code actually raises and catch those. When genuinely warranted, add an inline comment explaining why.

# Bad
except Exception:
    log.error("failed")

# Good
except KeyError:
    ...

# Also fine
except (KeyError, TypeError):
    ...

# Acceptable when justified with a comment
# Plugin loader must never crash the host process.
except Exception:
    logger.exception("plugin failed to load")

# Also acceptable in rollback/teardown paths — must always re-raise
except Exception:  # must rollback on any application exception, then re-raise
    conn.rollback()
    raise

Let None Propagate from Dict Access

Use .get("key") without a default. Providing "" or 0 as a fallback masks missing keys — None is the honest signal that data is absent. Supply a default only when None is a valid value in the dict and you need to distinguish "missing" from "explicitly None."

# Bad — masks that "count" could be missing
count = data.get("count", 0)

# Good — let None propagate; handle it explicitly downstream
count = data.get("count")

# Acceptable — None is a valid value, so a default disambiguates
enabled = config.get("enabled", True)

No Empty-String Defaults

"" is not "no value." Use None, or better, require the argument. Same applies to [] and {} as sentinels. Same for model/dataclass fields — if a field can be absent, type it as T | None = None, not str = "".

# Bad — "" is not "no value"
def find_user(name: str = "") -> User: ...

# Good
def find_user(name: str | None = None) -> User: ...

# Best — require it
def find_user(name: str) -> User: ...

Docstring Discipline

Google-style docstrings. Two sub-rules:

  • Never put type annotations in docstrings — types belong in PEP 484 annotations on the signature. Duplicating them creates noise and a second source of truth that drifts.
  • Omit sections that restate what the signature already says. If Args names and types tell the whole story, or Returns type is self-explanatory, skip those sections.
# Bad — redundant types and sections
def fetch_user(user_id: int, timeout: float = 5.0) -> User:
    """Fetch a user by ID.

    Args:
        user_id (int): The user's ID.
        timeout (float): Request timeout in seconds.

    Returns:
        User: The fetched user.
    """

# Good — only document what isn't obvious
def fetch_user(user_id: int, timeout: float = 5.0) -> User:
    """Fetch a user by ID.

    Raises:
        UserNotFoundError: If no user exists for the given ID.
    """

Prefer Pathlib Methods Over String Manipulation

Use with_suffix, with_stem, with_name instead of string concatenation or f-string path assembly. These methods handle edge cases (double extensions, empty stems) that string manipulation silently gets wrong.

# Bad
output = str(input_path) + ".json"
output = f"{input_path.parent}/{input_path.stem}.json"

# Good
output = input_path.with_suffix(".json")
output = input_path.with_name("output.json")

Imports at Module Top

All imports belong at the top of the file — never inside functions. Lazy imports hide dependencies and make it harder to see what a module depends on at a glance. Sort: stdlib, third-party, local.

# Bad
def process():
    from pathlib import Path
    ...

# Good
from pathlib import Path

def process():
    ...

Be Concise

  • No docstrings that restate the signature.
  • No comments that restate the code.
  • No single-use type aliases.
  • No unnecessary helper functions that are only called once and don't simplify.

References

ReferenceWhen to read it
references/core-patterns.mdFull examples for type contracts (Protocols, ABCs, Mixins), data representation (frozen dataclasses, TypedDict, Enums, NamedTuple), error hierarchies, and context managers
references/composition.mdGenerator pipelines, itertools, functools (partial, singledispatch, cache, wraps), standalone functions vs methods
references/advanced-patterns.mdAdvanced typing (@overload, TypeGuard, generics, TYPE_CHECKING), structural pattern matching, immutability toolkit, async patterns, module organization and docstring conventions

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.