mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 10:45:03 +00:00
182 lines
6.7 KiB
Markdown
182 lines
6.7 KiB
Markdown
# Python Devices
|
|
|
|
Python's type hints are optional and unenforced at runtime, which splits every device into two
|
|
questions: what the checker catches, and what actually holds when the code runs.
|
|
|
|
**Prerequisite**: `mypy --strict` (or `pyright` in strict mode) as a *required* CI check.
|
|
Without it, annotations are documentation, rung zero. Pair it with `ruff` at error level.
|
|
|
|
## Contact, NewType for cheap distinctness
|
|
|
|
```python
|
|
from typing import NewType
|
|
|
|
UserId = NewType("UserId", str)
|
|
OrderId = NewType("OrderId", str)
|
|
|
|
def transfer(src: UserId, dst: UserId) -> None: ...
|
|
|
|
transfer(order_id, user_id) # mypy: error: zero runtime cost
|
|
```
|
|
|
|
`NewType` is free at runtime and stops the mix-up at check time. It does not validate, use it
|
|
when the concepts differ but the shape doesn't need checking.
|
|
|
|
## Contact, parse at the boundary with Pydantic
|
|
|
|
When the value needs checking, parse into a model and let the type carry the proof:
|
|
|
|
```python
|
|
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
|
|
|
class CreateUser(BaseModel):
|
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
|
|
email: EmailStr
|
|
age: int = Field(ge=0, le=150)
|
|
```
|
|
|
|
Two settings do most of the work. `extra="forbid"` turns a typo'd field into an error instead
|
|
of a silently ignored key: the difference between a 400 and a user whose preference never
|
|
saved. `frozen=True` blocks reassignment of the model's fields, so nothing downstream can
|
|
quietly replace what you verified. It is shallow, though: a `list` or `dict` field is still
|
|
mutable in place, so reach for `tuple`, `frozenset`, or a nested frozen model where that
|
|
matters.
|
|
|
|
Apply at every edge: request bodies, queue messages, third-party responses, file loads.
|
|
|
|
## Contact, keyword-only arguments
|
|
|
|
Python's answer to swapped parameters, and it costs one character:
|
|
|
|
```python
|
|
def transfer(*, source: AccountId, dest: AccountId, amount: Money) -> None: ...
|
|
|
|
transfer(source=a, dest=b, amount=m) # the only legal form
|
|
transfer(a, b, m) # TypeError
|
|
```
|
|
|
|
Force keyword-only for anything with more than two parameters, and always when two share a
|
|
type. This is Warning-rung. It makes the mistake visible rather than impossible, but it is
|
|
the highest-value one-character change in the language.
|
|
|
|
## Fixed-value, exhaustiveness
|
|
|
|
```python
|
|
from typing import assert_never, Literal
|
|
|
|
Status = Literal["pending", "active", "closed"]
|
|
|
|
def label(s: Status) -> str:
|
|
match s:
|
|
case "pending": return "Pending"
|
|
case "active": return "Active"
|
|
case "closed": return "Closed"
|
|
case _: assert_never(s) # mypy errors here if a variant is unhandled
|
|
```
|
|
|
|
`assert_never` turns "someone added a status" into a build failure at every site that must
|
|
change. Works with `Literal`, `Enum`, and tagged dataclass unions.
|
|
|
|
## Fixed-value, config validated at startup
|
|
|
|
```python
|
|
from pydantic_settings import BaseSettings
|
|
|
|
class Settings(BaseSettings):
|
|
database_url: str
|
|
stripe_key: str
|
|
region: str # no default: an unset value should stop the deploy
|
|
|
|
settings = Settings() # raises at import, before the service reports healthy
|
|
```
|
|
|
|
Import this once at startup and pass the object down. Every `os.getenv` buried in a handler is
|
|
a 3am page waiting for the one request that reaches it.
|
|
|
|
## Contact, immutable value objects
|
|
|
|
```python
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
class Money:
|
|
cents: int
|
|
currency: str
|
|
|
|
def __add__(self, other: "Money") -> "Money":
|
|
if self.currency != other.currency:
|
|
raise ValueError(f"cannot add {self.currency} to {other.currency}")
|
|
return Money(cents=self.cents + other.cents, currency=self.currency)
|
|
```
|
|
|
|
`frozen=True` prevents mutation after validation, `slots=True` makes a typo'd attribute
|
|
assignment an `AttributeError` rather than a silently-created new attribute, and `kw_only=True`
|
|
kills positional swaps. Three flags, three hazard classes closed.
|
|
|
|
## Motion-step, context managers
|
|
|
|
Any acquire/release pair belongs in a context manager. Never expose `open()`/`close()` as
|
|
separate public methods: the error path will leak, and only under load.
|
|
|
|
```python
|
|
from contextlib import contextmanager
|
|
|
|
@contextmanager
|
|
def transaction(conn):
|
|
tx = conn.begin()
|
|
try:
|
|
yield tx
|
|
tx.commit()
|
|
except Exception:
|
|
tx.rollback()
|
|
raise # re-raise: swallowing here would be X1
|
|
```
|
|
|
|
## Python-specific traps worth checking every time
|
|
|
|
- **Mutable default arguments**: `def f(items=[])` shares one list across every call. Use
|
|
`None` and construct inside. Caught by ruff `B006`.
|
|
- **Bare `except:`** catches `KeyboardInterrupt` and `SystemExit` too. Caught by `E722`.
|
|
- **`assert` for validation** is stripped under `python -O`. Never use it for anything
|
|
security- or correctness-critical; raise instead.
|
|
- **Naive `datetime.now()`**: use `datetime.now(timezone.utc)`, and inject a clock so time
|
|
is testable. Caught by ruff `DTZ`.
|
|
- **Float money**: use `int` cents or `decimal.Decimal`, never `float`.
|
|
- **`==` vs `is`** on strings and ints works by accident via interning and breaks in
|
|
production on longer values. Caught by `F632`.
|
|
- **`asyncio.create_task` without keeping a reference**: the task can be garbage collected
|
|
mid-flight, so the work silently doesn't happen. Caught by ruff `RUF006`.
|
|
|
|
## Ruff rule sets that are poka-yoke
|
|
|
|
Style rules aren't mistake-proofing; these are, which is why `E` appears only as its
|
|
bug-shaped subsets and not whole. Select at error level:
|
|
|
|
```toml
|
|
[tool.ruff.lint]
|
|
select = [
|
|
"F", # pyflakes: undefined names, unused imports
|
|
"E4", "E7", "E9", # pycodestyle's bug-shaped rules: bare except, `== None`, syntax errors
|
|
"B", # bugbear: mutable defaults, loop variable capture, assert-on-tuple
|
|
"S", # bandit: hardcoded secrets, unsafe subprocess, weak crypto
|
|
"DTZ", # naive datetimes
|
|
"ASYNC", # blocking calls inside async functions
|
|
"RUF006", # dangling asyncio tasks
|
|
"PLE", # pylint errors: genuine bugs only
|
|
"T20", # stray print/pprint
|
|
]
|
|
```
|
|
|
|
## Known limits
|
|
|
|
- **Annotations are not enforced at runtime.** Anything crossing a boundary, or reachable
|
|
from unchecked code, needs a real runtime parse. Pydantic is how you get Control; mypy
|
|
alone gives you Control only over code mypy actually checks.
|
|
- **`Any` is contagious** and an untyped dependency reintroduces it silently. Set
|
|
`disallow_any_unimported` and `warn_return_any`; audit `# type: ignore` comments and require
|
|
a reason on each.
|
|
- **No affine types**, so use-after-close isn't preventable; context managers are the answer.
|
|
- **Monkey-patching means no encapsulation is absolute.** Push invariants that truly must hold
|
|
into the database rather than into a class.
|