開發時,不免都會需要實作設定檔,藉由設定檔簡單地改變系統的行為。例如 Debug 模式的開關、資料庫相關設定等等,都通常會放在設定檔中。

而 Python 不僅提供內建模組 configparser 能夠簡單實作設定檔之外,也可以簡單利用 class 實作設定檔,例如以下 2 個檔案就是 class 形式的設定檔範例:

# settings.py
class Settings(object):
    DB_HOST = 'localhost'
    DB_PORT = 8888


settings = Settings()
# test.py
from settings import settings


print(settings.DB_HOST, settings.DB_PORT)

然而,現今開發專案也經常會使用 dotenv (例如 python-dotenv )讓設定更加方便。

除了自行開發結合 dotenv 的功能之外,其實可以選擇利用 pydantic 輕鬆地整合 class 形式的設定檔與 dotenv

本文環境

  • macOS 10.15
  • Python 3.7
  • pydantic 1.5.1
$ pip install pydantic==1.5.1

pydantic

pydantic 是透過型別註記(type annotations)提供資料型別驗證與設定管理的套件。

例如以下範例透過 pydantic 的 BaseModel 定義 Car 類別,同時也註記該類別各個屬性的型別:

from datetime import datetime
from pydantic import BaseModel


class Car(BaseModel):
    brand_id: int
    brand_name: str
    wheels: int = 4
    created_at: datetime = datetime.now()

當我們試圖建立該類別的實例(instance)時, pydantic 就會進行型別的驗證:

Car(brand_id='bad_id', brand_name='Bad name')

上述執行結果會出現類似以下的錯誤,顯示我們應該將 brand_id 設定為整數型別:

Traceback (most recent call last):
  File "test.py", line 13, in <module>
    Car(brand_id='bad_id', brand_name='Bad name')
  File "pydantic/main.py", line 338, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for Car
brand_id
  value is not a valid integer (type=type_error.integer)

了解 pydantic 大致樣貌之後,接著可以試試 pydantic 提供的設定管理功能。

pydantic 設定管理

pydantic 的設定管理可以透過繼承 BaseSettings 類別進行實作,該類別與 BaseModel 類別最大差別在於 BaseSettings 提供環境變數(environment variables)與 dotenv 的整合。

當我們試圖建立繼承 BaseSettings 類別的實例(instance)時, BaseSettings 會試圖載入環境變數:

from pydantic import BaseSettings


class Settings(BaseSettings):
    HOME: str


print(Settings())

上述範例執行結果如下,可以看到我們值都沒有設定給 HOME , pydantic 就自動為我們載入環境變數中的 HOME 到 Settings 中的 HOME :

HOME='/Users/<username>'

知道 BaseSettings 的用途之後,我們就能夠針對不同的環境設計不一樣的設定,例如以下範例,我們將通用的設定放在 Settings 類別中,藉由繼承 Settings 類別覆寫(override)因環境有所不同的設定:

import os

from pydantic import BaseSettings


class Settings(BaseSettings):
    API_BASE: str
    DB_HOST: str = 'localhost'
    DB_PORT: int = 8888
    DB_NAME: str
    TESTING: bool = False


class Production(Settings):
    API_BASE = 'https://example.com/api'
    DB_NAME = 'production'


class Testing(Settings):
    API_BASE = 'https://testing.example.com/api'
    DB_NAME = 'testing'
    TESTING = True


def get_settings():
    env = os.getenv('ENV', 'TESTING')
    if env == 'PRODUCTION':
        return Production()
    return Testing()


settings = get_settings()

print('DB Host =', settings.DB_HOST)
print('DB Port =', settings.DB_PORT)

print('Override =', Production(TESTING=True).dict())

上述範例執行結果:

DB Host = localhost
DB Port = 8888
Override = {'API_BASE': 'https://example.com/api', 'DB_HOST': 'localhost', 'DB_PORT': 8888, 'DB_NAME': 'production', 'TESTING': True}

再來看看 pydantic 提供的型別驗證(Data validation)功能,當我們試圖將 DB_PORT 故意設定為字串時:

Production(DB_PORT='abc')

就會出現以下錯誤,該錯誤代表 DB_PORT 必須是整數才可以。這種型別驗證也能夠預防未來設定檔因為型別不一致所導致的問題:

Traceback (most recent call last):
  File "pydantic_test.py", line 40, in <module>
    Production(DB_PORT='abc')
  File "pydantic/env_settings.py", line 28, in pydantic.env_settings.BaseSettings.__init__
  File "pydantic/main.py", line 338, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 1 validation error for Production
DB_PORT
  value is not a valid integer (type=type_error.integer)

最後看看 pydantic 提供的 dotenv 整合功能。

BaseSettings 類別內可以透過定義 Config 類別,並指定 env_file 屬性,輕鬆整合 dotenv ,例如:

import os

from pydantic import BaseSettings


class Settings(BaseSettings):
    API_BASE: str
    DB_HOST: str
    DB_PORT: int
    DB_NAME: str
    TESTING: bool = False

    class Config:
        env_file = '.env'


class Production(Settings):
    API_BASE = 'https://example.com/api'
    DB_NAME = 'production'


class Testing(Settings):
    API_BASE = 'https://testing.example.com/api'
    DB_NAME = 'testing'

    class Config:
        env_file = '.testing.env'


def get_settings():
    env = os.getenv('ENV', 'TESTING')
    if env == 'PRODUCTION':
        return Production()
    return Testing()


settings = get_settings()

print('DB Host =', settings.DB_HOST)
print('DB Port =', settings.DB_PORT)

print('Override =', Production(TESTING=True).dict())

上述範例中的第 13, 14 行以及第 26, 27 行就是針對不同環境載入不同dotenv 設定檔的部分。

.env.testing.env 2 個檔案的內容以及在檔案系統中的位置如下:

.
├── .env
├── .testing.env
└── settings.py

.env 內容:

DB_HOST=localhost
DB_PORT=8888
BLAH_BLAH=Banana

.testing.env 內容:

DB_HOST=127.0.0.1
DB_PORT=6666

整合 dotenv 之後的執行結果為:

DB Host = localhost
DB Port = 8888
Override = {'API_BASE': 'https://example.com/api', 'DB_HOST': 'localhost', 'DB_PORT': 8888, 'DB_NAME': 'production', 'TESTING': True}

必須注意的是 .env 中的 BLAH_BLAH=Banana 並不是前述 SettingsProduction 2 類別內所定義的屬性之一,所以並不會被自動載入到 Production 內,如果想要載入 BLAH_BLAH 就必須為 Settings 或者 Production 類別內定義 BLAH_BLAH 屬性才行。

以上就是利用 pydantic 進行設定管理的簡單介紹。

事實上 pydantic 還有其他不一樣的用途,例如產生 JSON schema, 整合 Python 3.7 dataclass 等等,有興趣的話可以詳閱 官方文件 ,肯定可以帶來不少收穫。

References

pydantic