Конфиги используются в каждом приложении. Многие разработчики используют для управления конфигурационными файлами стандартные библиотеки по типу json
и yaml
, а также python-dotenv
для загрузки чувствительных данных из файла в переменные окружения. В этой статье мы научимся загружать как нечувствительные данные из файлов TOML, так и переменные из .env
в классы
Подготовка
Установим нужные библиотеки в окружение:
pip install pydantic-settings
Затем в корне проекта создадим:
Файл
main.py
Директорию
settings
, которая будет содержать 2 файла:config.toml
и.env
Директорию
config
, которая непосредственно будет содержать код для управления конфигами. Внутри нее создадимmain.py
и__init__.py
Получаем следующую структуру:
│ main.py
│
├───config
│ │ main.py
│ │ __init__.py
│
├───settings
.env
config.toml
Наполнение файла settings/.env
:
db_host = "localhost"
db_password = "db-password-123"
tg_bot_token = "bot-token-secret"
tg_api_id = "api-id-secret"
settings/config.toml
:
[fastapi]
host = "127.0.0.1"
port = 6000
[bot]
admin_id = "@admin_username"
[redis]
host = "127.0.0.1"
port = 6739
Обрабатываем переменные окружения
Для того, чтобы не тратить время, напишем в config/__init__.py
:
from .main import *
Теперь переходим к config/main.py
. Для начала импортируем нужные библиотеки
from pydantic_settings import BaseSettings, SettingsConfigDict
Теперь создадим базовые настройки для конфига
class ConfigBase(BaseSettings):
model_config = SettingsConfigDict(
env_file="settings/.env", env_file_encoding="utf-8", extra="ignore"
)
Рассмотрим код выше
Класс BaseSettings
предоставляет функционал для чтения переменных окружения в атрибуты класса. Поле model_config
позволяет нам настроить класс, чтобы не дублировать его при объявлении каждого атрибута. Класс SettingsConfigDict
наследуется от ConfigDict
из основного модуля pydantic
, который в свою очередь наследуется от TypedDict
. Теперь рассмотрим каждый аргумент более подробно:
env_file
указывает путь до файла с переменными окруженияenv_file_encoding
- кодировка файлаextra
- принимает строкиallow
,ignore
иforbird
. При значенииallow
модель разрешает загрузку переменных окружения, которые не соответствуют объявленным требованиям (рассмотрим их в следующем листинге).ignore
, в свою очередь, отбрасывает такие переменные, аforbird
запрещает неподходящие переменные
Теперь начнем объявлять классы конфигов. Начнем с конфигурации для Telegram
class TelegramConfig(ConfigBase):
model_config = SettingsConfigDict(env_prefix="tg_")
bot_token: str
api_id: str
Обратите внимание, что мы наследуемся от созданного нами базового класса. Теперь мы можем не дублировать настройки модели каждый раз. Однако для удобства скажем модели, что нужные нам переменные начинаются с tg_
.
В итоге при инициализации класса модель прочитает переменные tg_bot_token
и tg_api_id
из окружения и запишет их в объявленные поля класса.
Чтобы убедиться, что все работает, в main.py
попробуем вызвать наш класс и посмотреть, что у него внутри
from config import TelegramConfig
config = TelegramConfig()
print(config)
# bot_token='bot-token-secret' api_id='api-id-secret'
Все работает так, как и задумано. Теперь по аналогии сделаем классы для всех групп переменных
class DatabaseConfig(ConfigBase):
model_config = SettingsConfigDict(env_prefix="db_")
host: str
password: str
Теперь объединим все конфиги в один класс
class Config(BaseSettings):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
db: DatabaseConfig = Field(default_factory=DatabaseConfig)
Аргумент default_factory
принимает callable
, который будет вызван при инициализации класса.
Наконец, для читаемости создадим классовый метод для загрузки конфига
class Config(BaseSettings):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
db: DatabaseConfig = Field(default_factory=DatabaseConfig)
@classmethod
def load(cls) -> "Config":
return cls()
Теперь протестируем то, что у нас получилось
from config import Config
config = Config.load()
print(f"{config.telegram=}")
print(f"{config.db=}")
# config.telegram=TelegramConfig(bot_token='bot-token-secret', api_id='api-id-secret')
# config.db=DatabaseConfig(host='localhost', password='db-password-123')
Переходим в обработке TOML конфига
Для начала перенесем классы для env
переменных в отдельный модуль
config/
│ main.py
│ __init__.py
│
├───env_config
main.py
__init__.py
Теперь config/main.py
будет выглядеть следующим образом
from .env_config import TelegramConfig, DatabaseConfig
from pydantic_settings import BaseSettings
from pydantic import Field
class Config(BaseSettings):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
db: DatabaseConfig = Field(default_factory=DatabaseConfig)
@classmethod
def load(cls) -> "Config":
return cls()
Далее создадим модуль для обработки TOML
конфига, после чего дерево будет выглядеть следующим образом
├───config
│ │ main.py
│ │ __init__.py
│ │
│ ├───env_config
│ │ │ main.py
│ │ │ __init__.py
│ │
│ ├───toml_config
│ │ │ main.py
│ │ │ __init__.py
pydantic_settings
предоставляет нам возможность переопределять источники, из которых модуль загружает переменные. В том числе, поддерживается формат TOML
Для начала в файле config/toml_config/main.py
определим модели, которые будут соответствовать каждому блоку из settings/config.toml
from pydantic import BaseModel
class RedisConfig(BaseModel):
host: str
port: int
class FastapiConfig(BaseModel):
host: str
port: int
class BotConfig(BaseModel):
admin_id: str
На этом наша работа с этим файлом завершена. Теперь возвращаемся в config/main.py
from .env_config import TelegramConfig, DatabaseConfig
from .toml_config import RedisConfig, BotConfig, FastapiConfig
from pydantic_settings import BaseSettings, TomlConfigSettingsSource
from pydantic import Field
class Config(BaseSettings):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
db: DatabaseConfig = Field(default_factory=DatabaseConfig)
fastapi: FastapiConfig
redis: RedisConfig
bot: BotConfig
@classmethod
def load(cls) -> "Config":
return cls()
@classmethod
def settings_customise_sources(cls, settings_cls, **kwargs):
return (TomlConfigSettingsSource(settings_cls, "settings/config.toml"),)
Здесь видим, что добавился новый классовый метод settings_customise_sources
Он позволяет изменить стандартное поведение класса BaseSettings
таким образом, как нам нужно. Подробнее - в документации
Скрываем чувствительные данные в терминале при помощи SecretStr
На данный момент при печати конфига мы можем увидеть все чувствительные данные. Чтобы это исправить, воспользуемся типом SecretStr
, который предоставляется модулем pydantic.types
class TelegramConfig(ConfigBase):
model_config = SettingsConfigaDict(env_prefix="tg_")
bot_token: SecretStr
api_id: SecretStr
class DatabaseConfig(ConfigBase):
model_config = SettingsConfigDict(env_prefix="db_")
host: str
password: SecretStr
Для того, чтобы получить непосредственно значение, хранимое этим типом, нужно воспользоваться методом get_secret_value()
, например:
Config.load().db.password.get_secret_value()
#db-password-123
Спасибо @Andrey_Solomatin за это исправление
Итог
Теперь посмотрим, что у нас получилось:
В файле main.py
загрузим и распечатаем конфиг
from config import Config
config = Config.load()
print(config)
Результат:
telegram=TelegramConfig(
bot_token=SecretStr('**********'),
api_id=SecretStr('**********'))
db=DatabaseConfig(
host='localhost',
password=SecretStr('**********'))
fastapi=FastapiConfig(
host='127.0.0.1',
port=6000)
redis=RedisConfig(
host='127.0.0.1',
port=6739)
bot=BotConfig(
admin_id='@admin_username'
)
Комментарии (6)
4uku
14.12.2024 09:02Спасибо за статью. Не понравился тип возвращаемых данных для метода load. Думаю, уместнее будет указать тип Self или TypeVar из модуля typing (или typing_extensions, в зависимости от вашей версии питона).
Andrey_Solomatin
Подумайте о безопастности. Поменяйте на специальные типы данных для секретов.
Or1Eq1 Автор
Можно поподробнее? Не слышал о таких
Andrey_Solomatin
https://docs.pydantic.dev/latest/api/types/#pydantic.types.SecretStr
Or1Eq1 Автор
Спасибо. Обновил статью
Andrey_Solomatin
Я бы в ранних примерах тоже заменил, не все дочитывают до конца статьи. Ну и когда нужно скопипастить примеры, возьмут первый попавшийся.