Skip to content

Latest commit

 

History

History
111 lines (71 loc) · 3.24 KB

modern-python.rules.md

File metadata and controls

111 lines (71 loc) · 3.24 KB

Modern Python fundamentals

Rules for writing Python with the same spirit as strict TypeScript: explicit shapes, safe boundaries, and easy-to-debug data flow.


Constants and labelled values

Don't: use loose strings scattered across the codebase for fixed values.

Do: use StrEnum for named string values that cross boundaries, or a Final mapping when you mostly need lookup/display values.

from enum import StrEnum
from typing import Final


class PaymentStatus(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    FAILED = "failed"


PAYMENT_STATUS_LABELS: Final[dict[PaymentStatus, str]] = {
    PaymentStatus.PENDING: "Pending",
    PaymentStatus.PAID: "Paid",
    PaymentStatus.FAILED: "Failed",
}

Why: Python cannot derive literal unions as ergonomically as TypeScript, so prefer a runtime value (StrEnum) that is still type-checkable and readable.


Typed POJOs: dataclass vs Pydantic

Do: use @dataclass(frozen=True, slots=True) for internal plain data objects when values are already trusted.

Do: use Pydantic at boundaries (API input/output, config, external payloads) where runtime validation and parsing matter.

Don't: pass untyped dict values deep into business logic.

from dataclasses import dataclass
from pydantic import BaseModel


class CreateUserInput(BaseModel):
    email: str
    name: str


@dataclass(frozen=True, slots=True)
class UserInfo:
    id: int
    email: str
    name: str

Why: dataclasses keep internal code lightweight; Pydantic protects messy edges without turning every internal value into a validation model.


Function inputs and outputs

Do: prefer one typed input object for functions with multiple fields, and return a typed object instead of a tuple.

Don't: pass many positional arguments or return positional multi-values that callers must remember by order.

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class PriceInput:
    amount: int
    tax_rate: float


@dataclass(frozen=True, slots=True)
class PriceResult:
    subtotal: int
    tax: int


def calculate_price(input: PriceInput) -> PriceResult:
    tax = round(input.amount * input.tax_rate)
    return PriceResult(subtotal=input.amount, tax=tax)

Why: this mirrors the POJO habit from TypeScript: named fields, safer refactors, and easier destructuring-like access.


Type hints and narrowing

  • Do not use Any unless there is no practical alternative; use object, Protocol, generics, or a validated boundary model.
  • Use TypedDict only for dictionary-shaped data you cannot reasonably model as a dataclass/Pydantic model.
  • Prefer Protocol for “anything with this behaviour” instead of inheritance-heavy abstractions.
  • Use Final for constants that should not be reassigned.

Quick reference

  • Fixed values crossing boundaries -> StrEnum.
  • Internal POJO -> @dataclass(frozen=True, slots=True).
  • Boundary validation/config/external payloads -> Pydantic BaseModel.
  • Avoid deep untyped dict; convert to typed objects near the edge.
  • Prefer one typed input object and one typed return object over many positional arguments/tuples.
  • Avoid Any; reach for Protocol, generics, TypedDict, or Pydantic validation.