快轉到主要內容
  1. 教學文章/

Python pydantic-settings 實戰:型別安全管理 .env 與設定檔

·6 分鐘· loading · loading · ·
Python Pydantic Pydantic-Settings Dotenv Configuration Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 64: 本文

featured

一. 前言:設定檔不是字典,設定檔是合約
#

很多 Python 專案一開始都很單純:一個 API key、一個資料庫網址,再加上一個 DEBUG=true。於是很自然地寫出這種程式:

import os
debug = os.getenv("DEBUG") == "true"
database_url = os.getenv("DATABASE_URL", "sqlite:///local.db")
openai_api_key = os.getenv("OPENAI_API_KEY")

這樣不是不能用。問題是專案一長大,設定會散落在 API server、背景 worker、CLI、測試與部署腳本裡。最後你會遇到幾種很煩的小 bug:

  • 字串 "false" 被當成 truthy value
  • port 明明應該是整數,卻被當成字串傳下去
  • 本機有預設值,正式環境忘記設,結果靜悄悄用錯
  • 同一個設定在不同檔案有不同名字
  • 新同事不知道到底需要哪些環境變數

這就是今天拍拍君要介紹 pydantic-settings 的原因。它不是單純幫你讀 .env,而是把應用程式設定變成一個可驗證、可型別檢查、可重用的 Settings 物件。 如果你之前看過 Pydantic 基礎教學,那篇重點是資料模型與驗證。如果你看過 python-dotenv,那篇重點是把 .env 讀進環境變數。這篇則往前走一步:把設定管理整理成一個清楚的系統。


二. 安裝:Pydantic v2 要另外裝 settings 套件
#

在 Pydantic v1 時,Settings 功能放在 Pydantic 本體裡。到了 Pydantic v2,設定管理被拆成官方獨立套件:

uv add pydantic-settings
# 或是
pip install pydantic-settings

建立一個小專案:

mkdir settings-playground
cd settings-playground
uv init
uv add pydantic-settings
touch settings.py

今天的範例都會先放在 settings.py 裡。重點不是架構多華麗,而是把「程式需要哪些設定」集中描述清楚。


三. 第一個 Settings:把環境變數變成物件
#

先看最小版本:

from pydantic_settings import BaseSettings
class Settings(BaseSettings):
    app_name: str = "Daily Pypy Lab"
    debug: bool = False
    database_url: str
settings = Settings()
print(settings.model_dump())

這裡有三個欄位。app_name 有預設值,debug 有預設值且型別是 booldatabase_url 沒有預設值,所以一定要從環境變數提供。 直接執行:

uv run python settings.py

你會看到錯誤。這是好事,因為 database_url 是必要設定,沒有提供就應該立刻失敗。 設定環境變數再跑一次:

DATABASE_URL="sqlite:///local.db" uv run python settings.py

這次就會正常印出設定。拍拍君很喜歡這個特性:設定缺了就早點爆,不要等服務跑到一半才爆。


四. .env 檔案:本機開發的舒服入口
#

正式部署通常會用平台提供的環境變數。但本機開發時,把所有變數都手動打在 shell 裡很煩,這時候可以使用 .env

APP_NAME="拍拍設定實驗室"
DEBUG=true
DATABASE_URL="sqlite:///dev.db"
API_TIMEOUT_SECONDS=10

接著改寫 Settings:

from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    app_name: str = "Daily Pypy Lab"
    debug: bool = False
    database_url: str
    api_timeout_seconds: int = 5

執行 uv run python settings.py 之後,.env 裡的值會被讀進來。而且 DEBUG=true 在檔案裡是文字,進到 Settings 之後會變成真正的布林值;API_TIMEOUT_SECONDS=10 也會變成整數。 這就是比單純 os.getenv() 舒服的地方。型別轉換不是靠你到處手寫,它集中在 Settings schema 裡。


五. 命名規則:Python 欄位與環境變數怎麼對應?
#

預設情況下,欄位名稱會對應到同名環境變數。例如 database_url 會讀取 DATABASE_URLdatabase_url。實務上,環境變數通常用全大寫,Python 欄位則通常用 snake_case。 如果你想讓所有環境變數都加上前綴,可以這樣:

from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="PYPY_",
    )
    app_name: str = "Daily Pypy Lab"
    debug: bool = False
    database_url: str

這樣它會期待:

PYPY_APP_NAME
PYPY_DEBUG
PYPY_DATABASE_URL

拍拍君建議中大型專案都加前綴。原因很實際:部署環境裡可能不只一個服務。如果大家都叫 DEBUGPORTDATABASE_URL,久了會很難追。前綴不是裝飾,前綴是避免設定撞名。


六. 巢狀設定:把一坨變數整理成群組
#

設定一多,全部攤平會很難讀。例如資料庫常常有 host、port、name、user、password。你可以把它整理成一個 model。

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
    host: str = "localhost"
    port: int = 5432
    name: str
    user: str
    password: str
class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_nested_delimiter="__",
    )
    database: DatabaseSettings
    debug: bool = False

對應的 .env 可以這樣寫:

DATABASE__HOST=localhost
DATABASE__PORT=5432
DATABASE__NAME=dailypypy
DATABASE__USER=pypy
DATABASE__PASSWORD=local-secret
DEBUG=true

雙底線 __ 會把環境變數拆成巢狀結構。於是你可以在程式裡使用 settings.database.hostsettings.database.port,比到處傳一堆 DATABASE_* 字串舒服很多。


七. 驗證規則:設定值不是能轉型就好
#

型別轉換只能解決一半問題。另一半是「這個值合不合理」。例如 timeout 應該大於 0,worker 數量不應該是 999,production 環境不應該打開 debug。 這些規則可以直接寫在 model 裡。

from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    environment: Literal["local", "staging", "production"] = "local"
    debug: bool = False
    api_timeout_seconds: int = Field(default=5, gt=0, le=60)
    worker_count: int = Field(default=2, ge=1, le=16)
    @model_validator(mode="after")
    def production_must_be_strict(self) -> "Settings":
        if self.environment == "production" and self.debug:
            raise ValueError("production cannot run with debug=true")
        return self

這段做了幾件事:

  • environment 只能是三個指定字串
  • api_timeout_seconds 必須介於 1 到 60
  • worker_count 必須介於 1 到 16
  • 正式環境不能開 debug

設定管理最怕「看起來有值,但值很荒謬」。把規則寫在 Settings 裡,錯誤會集中、清楚、提早發生。


八. SecretStr:不要不小心把密鑰印出來
#

設定通常會包含 secret,例如 API key、資料庫密碼、Webhook token。這些值最怕在 log 裡被印出來,Pydantic 提供 SecretStr

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    database_url: str
    openai_api_key: SecretStr

假設 .env 裡有:

DATABASE_URL=sqlite:///local.db
OPENAI_API_KEY=sk-demo-secret

執行:

settings = Settings()
print(settings.model_dump())
print(settings.openai_api_key)

輸出會遮住 secret。真正需要拿原始字串時,再明確呼叫:

api_key = settings.openai_api_key.get_secret_value()

這個設計很好。它不是讓 secret 永遠拿不到,而是逼你在需要明文的地方寫得更明確。


九. FastAPI:設定只建立一次,入口保持乾淨
#

FastAPI 專案常常需要 settings,但不要在每個 request 裡重新建立。比較好的做法是用 lru_cache 做一次性載入,再透過 dependency 注入。

from functools import lru_cache
from fastapi import Depends, FastAPI
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")
    app_name: str = "Daily Pypy API"
    debug: bool = False
    database_url: str
@lru_cache
def get_settings() -> Settings:
    return Settings()
app = FastAPI()
@app.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]:
    return {"app": settings.app_name, "debug": settings.debug, "status": "ok"}

這樣 Settings 只建立一次,測試時也可以 override dependency。endpoint 不需要自己知道設定從哪裡來,設定物件也可以被 IDE 與型別檢查器理解。
#

十. 一個完整範例:可部署的小型 API 設定
#

最後整理成一個比較完整的版本。

from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="PYPY_",
        env_nested_delimiter="__",
        extra="ignore",
    )
    app_name: str = "Daily Pypy API"
    environment: Literal["local", "staging", "production"] = "local"
    debug: bool = False
    database_url: str
    api_token: SecretStr
    request_timeout_seconds: int = Field(default=10, gt=0, le=60)
    worker_count: int = Field(default=2, ge=1, le=16)
    @property
    def is_production(self) -> bool:
        return self.environment == "production"
    @model_validator(mode="after")
    def validate_production_rules(self) -> "Settings":
        if self.is_production and self.debug:
            raise ValueError("production cannot run with debug=true")
        return self
@lru_cache
def get_settings() -> Settings:
    return Settings()

本機 .env

PYPY_ENVIRONMENT=local
PYPY_DEBUG=true
PYPY_DATABASE_URL=sqlite:///local.db
PYPY_API_TOKEN=local-dev-token
PYPY_REQUEST_TIMEOUT_SECONDS=10
PYPY_WORKER_COUNT=2

正式環境就不要靠這份檔案。讓部署平台提供:

PYPY_ENVIRONMENT=production
PYPY_DEBUG=false
PYPY_DATABASE_URL=postgresql://...
PYPY_API_TOKEN=...

這樣本機、CI、正式環境都走同一個 Settings schema,差別只在資料來源。這才是設定管理最值得追求的狀態:程式碼知道需要什麼,環境負責提供什麼。


結語
#

pydantic-settings 最迷人的地方,不是它可以讀 .env。這件事很多工具都做得到。它真正解決的是:你的專案到底需要哪些設定、每個設定是什麼型別、哪些設定必填、哪些值不合理、本機與正式環境的覆蓋規則是什麼,以及 secret 會不會不小心被印出來。 如果專案只有十幾行 script,os.getenv() 當然夠用。但只要你開始有 API server、worker、CLI、測試、部署環境,Settings schema 就很值得早點建立。 拍拍君的建議很簡單:不要等設定散落滿地才整理。在專案開始長出第二個執行入口時,就把 Settings 抽出來。未來的你會少罵現在的你很多次。


延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 64: 本文

相關文章

Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python difflib 實戰:文字差異比對、相似度比較與 patch 輸出完全攻略
·10 分鐘· loading · loading
Python Difflib Text-Processing Developer-Tools Cli
Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略
·10 分鐘· loading · loading
Python Prompt_toolkit Cli REPL Developer-Tools
Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools
Python secrets 實戰:安全產生 Token、密碼與一次性連結
·7 分鐘· loading · loading
Python Secrets Security Token Standard-Library Web