Конфиги используются в каждом приложении. Многие разработчики используют для управления конфигурационными файлами стандартные библиотеки по типу 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)


  1. Andrey_Solomatin
    14.12.2024 09:02

    bot_token: str

    Подумайте о безопастности. Поменяйте на специальные типы данных для секретов.


    1. Or1Eq1 Автор
      14.12.2024 09:02

      Можно поподробнее? Не слышал о таких


      1. Andrey_Solomatin
        14.12.2024 09:02

        1. Or1Eq1 Автор
          14.12.2024 09:02

          Спасибо. Обновил статью


          1. Andrey_Solomatin
            14.12.2024 09:02

            Я бы в ранних примерах тоже заменил, не все дочитывают до конца статьи. Ну и когда нужно скопипастить примеры, возьмут первый попавшийся.


  1. 4uku
    14.12.2024 09:02

    Спасибо за статью. Не понравился тип возвращаемых данных для метода load. Думаю, уместнее будет указать тип Self или TypeVar из модуля typing (или typing_extensions, в зависимости от вашей версии питона).