Почему пароль не должен быть в коде
В прошлом уроке мы научили ruff ловить грязный код до коммита. Сейчас разберём вторую категорию того, что никогда не должно попадать в коммит — секреты. Пароли от БД, API-ключи, токены, connection strings с production-хостами. Это и тема безопасности (не отдать ключ от боевого Postgres в публичный репозиторий), и тема архитектуры (тот же код должен работать в локальной разработке, в staging, в production без изменений).
Решение этой задачи стандартизировано в манифесте под названием
Twelve-Factor: конфиг — в окружении
Цитата из 12-factor дословно:
Strict separation of config from code. Config varies substantially across deploys, code does not.
Иными словами: код одинаков везде, конфиг отличается. Локально вы ходите в localhost:5432, в production — в db.internal:5432. Логин и пароль в проде другие, чем у вас. Код, который читает из БД, должен быть один и тот же в обоих местах. Различия — в значениях
Что хранить в env-переменных:
- Креды: пароли, API-ключи, токены, секреты OAuth.
- Hostnames: адреса БД, очередей, внешних API.
- Флаги:
DEBUG=true,ENABLE_FEATURE_X=false. - Параметры окружения:
LOG_LEVEL=INFO,WORKERS=4.
Что не хранить:
- Бизнес-логику (если выбор переходит из конфига в код — это constant, не config).
- Стат-данные кода (имена таблиц, имена полей — это часть схемы).
- Большие списки (отдельный YAML/JSON-файл лучше).
Граница между кодом, конфигом и секретами в правильно устроенном проекте.
.env файлы: dev-конвенция
В production env-переменные приходят из системы оркестрации (Docker, Kubernetes, Airflow, systemd). В локальной разработке писать руками export DATABASE_URL=... перед каждым запуском — неудобно. Решение — файл .env в корне проекта:
# .env
DATABASE_URL=postgresql://user:secret@localhost:5432/etl_dev
API_TOKEN=test-token-12345
LOG_LEVEL=DEBUG
ENV=dev
Это просто текстовый файл с парами KEY=VALUE. Приложение читает его при старте, как если бы переменные пришли из окружения.
Критическое правило: .env никогда не коммитится в git. В .gitignore:
# .gitignore
.env
.env.local
.env.*.local
Вместо этого в репозиторий кладётся .env.example — шаблон без реальных значений:
# .env.example
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
API_TOKEN=your-token-here
LOG_LEVEL=DEBUG
ENV=dev
Когда коллега клонирует репо, он:
- Копирует
cp .env.example .env. - Заполняет реальными значениями (или берёт из 1Password / Vault команды).
- Запускает проект — конфиг подхватился.
Если случайно закоммитили .env с реальным паролем — этот пароль нужно считать утёкшим, даже если вы тут же удалите файл. История git хранит всё. Меняйте пароль немедленно. И настройте
python-dotenv: классика
Исторически первый и самый известный инструмент для работы с .env — библиотека python-dotenv. Использование тривиально:
uv add python-dotenv
from dotenv import load_dotenv
import os
load_dotenv() # читает .env в os.environ
database_url = os.environ["DATABASE_URL"]
log_level = os.environ.get("LOG_LEVEL", "INFO")
load_dotenv() ищет файл .env от текущей папки вверх и подгружает его переменные в os.environ. Дальше код работает через стандартное os.environ, как если бы переменные пришли извне.
Это простой подход, и он работает. Но у него есть два проблемы:
- Нет типизации.
os.environ["PORT"]всегда возвращает строку. Преобразовать вint— ваша задача. Забыли — упало сTypeErrorчерез час работы. - Нет валидации. Если в
.envнетDATABASE_URL,os.environ["DATABASE_URL"]упадёт сKeyErrorтолько в момент чтения. Хочется, чтобы приложение упало сразу на старте с понятным сообщением.
В DE-проектах с десятками настроек это превращается в проблему. Поэтому для серьёзных проектов используют следующий инструмент.
pydantic-settings: типизированный конфиг
pydantic-settings, специально для конфигов:
uv add pydantic-settings
Пример настройки реального ETL-приложения:
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Конфигурация приложения, читается из env + .env файла."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Обязательные — упадёт на старте, если не заданы
database_url: str
api_token: str
# Опциональные — есть значения по умолчанию
log_level: str = "INFO"
env: str = "dev"
batch_size: int = 1000
enable_dry_run: bool = False
# Сложнее: парсим список
allowed_origins: list[str] = Field(default_factory=list)
settings = Settings()
Что здесь происходит при Settings():
- читает переменные окружения иPydantic
.env. - Каждое поле приводится к указанному типу:
batch_size—int,enable_dry_run—bool. Если в.envнаписаноBATCH_SIZE=abc, упадёт с яснымValidationError. - Если обязательное поле без значения по умолчанию (
database_url,api_token) не задано — приложение падает на старте. - Имена полей мапятся в env-переменные без учёта регистра:
database_url→DATABASE_URL,LOG_LEVELилиlog_level.
После создания settings — это обычный объект с типизированными полями:
from settings import settings
print(settings.database_url) # str
print(settings.batch_size) # int — уже int, не "1000"
print(settings.enable_dry_run) # bool
if settings.env == "prod":
...
IDE и mypy знают типы, автокомплит работает, опечатки ловятся.
Создавайте settings = Settings() один раз — например, в модуле settings.py — и импортируйте этот объект везде. Не вызывайте Settings() в каждой функции: каждый вызов перечитывает .env, тратит время, и есть риск получить разные значения, если файл правился между вызовами.
Группировка через nested settings
В большом проекте сотни переменных. Их группируют по доменам:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
url: str
pool_size: int = 10
timeout: int = 30
class APISettings(BaseModel):
base_url: str
token: str
retry_count: int = 3
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
)
database: DatabaseSettings
api: APISettings
settings = Settings()
В .env:
DATABASE__URL=postgresql://...
DATABASE__POOL_SIZE=20
API__BASE_URL=https://api.example.com
API__TOKEN=secret
env_nested_delimiter="__" говорит: двойное подчёркивание — это разделитель для вложенности. DATABASE__URL попадёт в settings.database.url.
Это спасает от хаоса в крупных проектах: 50 переменных, разбитых по 5 группам, читаются проще, чем 50 в одной плоской структуре.
Source priority в pydantic-settings
pydantic-settings читает значения из нескольких источников. Приоритет (от высшего к низшему):
- Аргументы при создании
Settings(database_url="...")— для тестов. - Переменные окружения процесса (
os.environ). - Содержимое
.envфайла. - Значения по умолчанию из класса.
Логика: окружение перекрывает .env. То есть в production, когда env-переменные приходят от Kubernetes Secret, никакой .env файл их не перебьёт, даже если кто-то случайно положил его в Docker-образ.
Secrets management в проде — awareness
В локалке хватит .env. В production команды используют что-то посерьёзнее:
- — most popular, on-premise.HashiCorp Vault
- AWS Secrets Manager / GCP Secret Manager / Azure Key Vault — облачные, интегрированы с правами доступа cloud’а.
- Kubernetes Secrets — простой вариант, секреты лежат в кластере, монтируются в pod как env-переменные.
- Doppler / Infisical — SaaS-сервисы, веб-интерфейс + SDK.
Общая идея у всех одна: секрет лежит в защищённом сторадже, приложение запрашивает его на старте (по identity, не по паролю), получает env-переменные. Дальше код работает через тот же Settings() — он не различает, откуда переменные пришли.
В первый год работы вы будете встречать либо Vault (если команда зрелая), либо Kubernetes Secrets (если кубер уже стоит), либо просто .env, который SRE кладёт в /etc/myapp/.env (если проект простой). Принципы те же.
Чек-лист «правильно настроенного» проекта
Перед тем как мержить PR, проверьте:
- В корне есть
.env.exampleс примерами всех переменных, но без реальных значений. - В
.gitignoreесть.env. - В коде нет ни одного URL/пароля/токена.hardcoded
- Settings объявлены через
pydantic-settings, все поля типизированы. - Обязательные поля без
default— приложение падает на старте, если их нет. - В
README.mdнаписано, как заполнить.env.
Упражнение
- В новом проекте
settings-experimentустановитеpydantic-settings:
uv init --python 3.13
uv add pydantic-settings
- Создайте
.env.example:
APP_NAME=my-etl
DATABASE_URL=postgresql://user:password@localhost:5432/etl
API_TOKEN=your-token-here
BATCH_SIZE=1000
DEBUG=false
-
Скопируйте в
.envи заполните реальными (тестовыми) значениями. -
Добавьте
.envв.gitignore. -
Создайте
settings.py:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)
app_name: str
database_url: str
api_token: str
batch_size: int = 1000
debug: bool = False
settings = Settings()
- Создайте
main.py:
from settings import settings
print(f"App: {settings.app_name}")
print(f"DB: {settings.database_url}")
print(f"Batch: {settings.batch_size} (type: {type(settings.batch_size).__name__})")
print(f"Debug: {settings.debug} (type: {type(settings.debug).__name__})")
Запустите uv run main.py.
Критерии приёмки:
- Все четыре строки печатаются с реальными значениями из
.env. batch_size— этоint, неstr(видно в выводеtype: int).debug— этоbool, неstr.- Удалите
API_TOKENиз.envи запустите ещё раз — должно упасть сValidationError, в котором написано проapi_token.
В следующем уроке — упаковка кода в Python-пакет, чтобы коллеги могли его установить и Airflow смог импортировать.