iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

在 Day 2–9,我們把「能跑且一致」的地基(環境、依賴、可重現、工作流、風格)架好了。接下來要把正確性前移到更前面:

  • mypy / pyright 在編譯期(更準確地說是「靜態分析階段」)攔下型別錯誤。
  • Pydantic v2 把「輸入/輸出資料」做成可驗證、可序列化、可回報結構化錯誤的契約。
  • 透過 Hatch scripts × Nox × pre-commit,讓型別檢查與資料契約在本機、提交前、CI 都一致運作。

我們今天要達成什麼

  1. 在專案中啟用 pyright(或 basedpyright) 與/或 mypy 的嚴格模式。
  2. pyproject.toml 一次設定好工具行為;本機以 Hatch 指令快速執行。
  3. Nox 加上 typecheckcontracts 工作流,接到 CI。
  4. Pydantic v2 實作 1 個資料邊界(例:HTTP/CLI/環境變數),並附最小測試。

工具選擇建議(簡短版)

  • pyright / basedpyright:極速、IDE 體驗佳;適合日常開發的「即時回饋」。
  • mypy:規則久經社群驗證;適合 CI 做第二層更嚴格的把關。
  • 你可以二擇一;或本機走 pyright、CI 再跑 mypy

安裝與 pyproject.toml 設定

1) 依賴宣告(建議放到 dev 類別)

[project.optional-dependencies]
dev = [
  "pyright>=1.1.375",     
  "mypy>=1.10",
  "pydantic>=2.8",
  "pydantic-settings>=2.3", # 若需讀環境變數
  "pytest>=8.0"
]

2) 型別檢查設定(嚴格起步、逐步放寬)

[tool.pyright]              # 若改用 basedpyright,區塊名為 [tool.basedpyright]
pythonVersion = "3.11"
typeCheckingMode = "strict"
reportMissingTypeStubs = "warning"

[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_ignores = true
warn_redundant_casts = true
no_implicit_optional = true
disallow_any_generics = true
# 視需要:Pydantic mypy 外掛
plugins = ["pydantic.mypy"]

口訣:先嚴格再標註。先把規則拉高,用型別註記與局部忽略(# type: ignore[code])收斂歷史區塊。


Pydantic v2:把「邊界資料」變成契約

1) 最小可行模型(欄位驗證 + 結構化錯誤)

from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator, ValidationError

Username = Annotated[str, Field(min_length=3, max_length=20)]
Role = Literal["admin", "staff", "guest"]

class User(BaseModel):
    id: int
    username: Username
    email: Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
    role: Role = "guest"

    @field_validator("username")
    @classmethod
    def no_spaces(cls, v: str) -> str:
        if " " in v:
            raise ValueError("username must not contain spaces")
        return v

try:
    User(id=1, username="a b", email="x@example.com")
except ValidationError as e:
    # e.errors() 為結構化列表,方便記錄或回應 API 錯誤
    print(e.errors())

2) 跨欄位規則(模型級)

from pydantic import BaseModel, model_validator

class PasswordPair(BaseModel):
    password1: str
    password2: str

    @model_validator(mode="after")
    def passwords_match(self):
        if self.password1 != self.password2:
            raise ValueError("passwords do not match")
        return self

3) 任意型別一次性驗證/序列化:TypeAdapter

from typing import List
from pydantic import TypeAdapter

ta = TypeAdapter(List[int])
assert ta.validate_python(["1","2","3"]) == [1,2,3]


邊界三例:CLI、環境變數、HTTP

A. CLI(搭配 argparse

import argparse
from pydantic import BaseModel, TypeAdapter

class Args(BaseModel):
    host: str
    port: int

parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--port", required=True)
ns = parser.parse_args()

args = TypeAdapter(Args).validate_python(vars(ns))
# 從這刻起,args 就是型別安全+驗證過的契約資料

B. 環境變數(pydantic-settings

from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    host: str = "127.0.0.1"
    port: int = 8000
    debug: bool = False

settings = AppSettings()  # 自動讀取 APP_HOST, APP_PORT, APP_DEBUG

C. HTTP(以 FastAPI 為例)

from fastapi import FastAPI
from pydantic import BaseModel

class CreateUser(BaseModel):
    username: str
    email: str

app = FastAPI()

@app.post("/users")
def create_user(payload: CreateUser):
    # payload 已驗證並轉型完成
    return {"ok": True}


串進 Hatch 與 Nox(延續 Day 8 節奏)

Hatch:本地一鍵指令

[tool.hatch.envs.default.scripts]
start = "python -m demo_app.main"
test = "pytest -q"
lint = "ruff check ."
format = "black ."
 type-pyright = "pyright ."
 type-mypy = "mypy ."
 typecheck = [
  "pyright .",
  "mypy src/",
]
contracts = "pytest -q tests/contracts"

Nox:CI/多版本 Python 一致化

# noxfile.py
import nox

@nox.session(name="typecheck", venv_backend="none")
def typecheck(session: nox.Session) -> None:
    """Run static type checks (pyright + mypy) using current Hatch env."""
    session.run("pyright", ".")
    session.run("mypy", "src/")

@nox.session(name="contracts", venv_backend="none")
def contracts(session: nox.Session) -> None:
    """Run contract tests (Pydantic models)."""
    session.run("pytest", "-q", "tests/contracts")

@nox.session(name="tests-3.11", venv_backend="none")
def tests_311(session: nox.Session) -> None:
    """
    Run tests (labelled 3.11) using the CURRENT env.
    建議在 CI job 裡用 setup-python matrix 指定 3.11 再呼叫此 session。
    """
    session.run("pytest", "-q")


pre-commit:提交前就攔下

# .pre-commit-config.yaml 片段
- repo: local
  hooks:
    - id: typecheck-pyright
      name: pyright
      entry: pyright
      language: system
      pass_filenames: false
    - id: typecheck-mypy
      name: mypy
      entry: mypy
      language: system
      pass_filenames: false

上述會是直接使用pyright和mypy,但我們使用了hatch我們只要定義在pyproject.toml然後在pre-commit中設定呼叫即可,就像下面範例。

repos:
  - repo: local
    hooks:
      - id: hatch-fmt
        name: hatch fmt
        entry: hatch
        language: system
        args: ["run", "fmt"]
        types: [python]
        pass_filenames: false

      - id: hatch-check
        name: hatch check
        entry: hatch
        language: system
        args: ["run", "check"]
        types: [python]
        pass_filenames: false

      - id: hatch-typecheck
        name: hatch typecheck
        entry: hatch
        language: system
        args: ["run", "typecheck"]
        types: [python]
        pass_filenames: false

這些設定都完成後,我們在git commit時,就會幫我們的團隊所撰寫的程式碼進行檢查,確保我們程式碼是有符合規定的。

https://ithelp.ithome.com.tw/upload/images/20250924/20178117dOZqYzZPIO.png

常見錯誤與收斂策略

  • 嚴格模式一開錯誤爆量 → 列出「技術債清單」,先針對活躍目錄補註記,其餘以 # type: ignore[code] 擋住,逐步還債。
  • Any 擴散 → 在 mypy 啟用 disallow_any_generics、在 pyright 觀察 reportAny* 系列診斷。
  • 把所有內部資料都做成 Pydantic 模型 → 避免。Pydantic 專注在邊界(I/O、HTTP、Config),內部資料結構用標準 typing 即可。
  • 測試缺席 → 為關鍵 Model 準備至少一個「有效/無效樣本」的測試檔(例如放 tests/contracts/)。
  • IDE 設定與 CI 不一致 → 以 pyproject.toml 為單一事實來源,Hatch/Nox/CI 全吃同一份設定。

結論

今天我們加入了更多的檢查,這為了確保我們未來在團隊合作的時候,大家寫出來的程式碼可以較為相近,不能保證百分之百,但可以提可閱讀性。在未來讓不同的人接手的時候都能有一個脈絡的快速理解我們這些程式碼到底在幹什麼。

其實到了今天第十天前置作業才算真正結束,這些前置作業的過程中我們其實針對每一個工具都還能更深入的剖析,但我們做為一個較為全局觀來看待的系列文章,要讓大家可以先有全局,再來後續逐步深入,也就是這只是一把鑰匙,在未來還有更多的領域要探索。

而第十一天開始我們先進入道測試領域的部分,讓我們明天繼續吧。


延伸閱讀(可補連結)

  • Pydantic v2:validators、TypeAdapter、mypy 外掛與遷移要點
  • Pyright / basedpyright:typeCheckingModereport* 規則與 pyproject.toml 設定
  • mypy:strict 常見旗標、逐步嚴格化實務

上一篇
Day 9 - 風格統一:Black + Ruff + isort + pre-commit
系列文
30 天 Python 專案工坊:環境、結構、測試到部署全打通10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言