Данные и инструменты
Инструменты вокруг кода на Python: тесты на pytest, окружения и зависимости, работа с базой через SQLAlchemy 2, миграции Alembic и настройки через pydantic-settings. Почти у всего есть аналог в .NET — xUnit, NuGet, EF Core, — и на собеседовании спрашивают именно о различиях.
Перед этой темой: Язык Python, EF Core.
Как писать тесты на pytest?
Заголовок раздела «Как писать тесты на pytest?»Что это
pytest — самый распространённый фреймворк тестов в Python. Тест — функция с именем test_... в файле test_*.py или *_test.py, проверка — обычный assert.
Как пользоваться
- Напишите функцию
test_parse_price, а в нейassert parse_price("$300") == 300. - Запустите
pytestв корне проекта — он сам найдёт тесты. - Исключение проверяйте блоком
with pytest.raises(ValueError):. - Один тест на много входов — параметризация
@pytest.mark.parametrize, как[Theory]в xUnit. - Подготовку выносите в фикстуру с
@pytest.fixture. Готовые фикстуры:tmp_path— временный каталог,monkeypatch— временная подмена атрибутов и переменных окружения.
Частая ошибка
Назвать файл prices_tests.py или функцию check_price. pytest их не соберёт и напишет no tests ran — тест молча не выполняется.
Как устроено
- Сбор. pytest ищет файлы
test_*.py, в них — функцииtest_*и классыTest*без__init__. - Assertion rewriting. При импорте тестовых модулей pytest переписывает
assert, поэтому в отчёте видны обе стороны сравнения. Методы вродеAssert.Equalне нужны. - Фикстуры. pytest читает имена параметров теста, находит фикстуры с такими именами и вызывает их, в том числе по цепочке. Код после
yield— teardown. - Области.
scope— сколько живёт результат:function(по умолчанию),class,module,package,session. - conftest.py. Фикстуры из него доступны тестам этого каталога и вложенных без импорта.
Сравнение с xUnit
| xUnit | pytest |
|---|---|
[Fact] |
функция test_* |
[Theory] и [InlineData] |
@pytest.mark.parametrize |
конструктор и Dispose |
фикстура с yield |
IClassFixture, ICollectionFixture |
фикстура module или session в conftest.py |
Ловушки
- Широкая фикстура не может зависеть от узкой:
session-фикстура сmonkeypatchупадёт сScopeMismatch. - Изменяемый объект из
module-фикстуры общий: один тест портит данные другому. mock.patch("requests.get")не подменит имя, уже импортированное черезfrom requests import get. Патчат там, где имя ищет тестируемый код.
Правило
Область по умолчанию — function. Шире — только для дорогого и неизменяемого.
Под капотом
- Фикстуры образуют граф. pytest создаёт каждую при первом запросе в её области, кеширует и закрывает в обратном порядке при выходе из области.
- Фикстура с
params=[...]размножает все зависящие от неё тесты — так один набор тестов гоняют на двух СУБД. monkeypatchиmock.patchменяют атрибут модуля или класса и возвращают его в teardown. Дляsession-фикстур естьpytest.MonkeyPatch.context().
Цена
- Связь с фикстурой — только имя параметра. Откуда она, показывает
pytest --fixtures. autouse=Trueприменяет фикстуру ко всем тестам области незаметно для читателя — такие фикстуры держат минимальными.
Компромиссы
- База. Engine и схема —
session-фикстура, а сессия теста —Session(bind=connection, join_transaction_mode="create_savepoint")внутри транзакции, которую фикстура откатывает. Быстрее пересоздания базы, ноcommit()кода становится точкой сохранения. - Моки.
create_autospecпроверяет сигнатуры, голыйMockпринимает любой вызов и прячет опечатки. Чем больше моков, тем сильнее тест привязан к реализации. - Параллельность. pytest-xdist (
-n auto) запускает несколько процессов, общие ресурсы разделяют по фикстуреworker_id.
Как проверить в проде
В CI — strict = true в настройках (pytest 9+), отчёт --junitxml и --durations=10 для самых медленных тестов. Зависимость тестов от порядка находит плагин pytest-randomly.
Итог: фикстуры — граф с временем жизни: узкая область даёт изоляцию, широкая — скорость, а откат транзакции на тест совмещает оба.
# test_prices.py — запуск: pytest -vimport pytest
def parse_price(text: str) -> int: digits = "".join(ch for ch in text if ch.isdigit()) if not digits: raise ValueError(f"no price in {text!r}") return int(digits)
@pytest.mark.parametrize( ("text", "expected"), [("12 500 UAH", 12500), ("$300", 300)], ids=["uah", "usd"],)def test_parse_price(text, expected): assert parse_price(text) == expected # два теста: [uah] и [usd]
def test_parse_price_without_digits(): with pytest.raises(ValueError, match="no price"): parse_price("negotiable")import jsonimport sqlite3
import pytest
@pytest.fixture(scope="module")def db(): conn = sqlite3.connect(":memory:") # одно соединение на все тесты модуля conn.execute("CREATE TABLE ads (id INTEGER PRIMARY KEY, price INTEGER)") yield conn # здесь выполняются тесты conn.close() # teardown — после последнего теста модуля
def export_ads(conn, path): rows = conn.execute("SELECT id, price FROM ads ORDER BY id").fetchall() path.write_text(json.dumps(rows), encoding="utf-8")
def test_export(db, tmp_path): # tmp_path — свой каталог у каждого теста db.execute("INSERT INTO ads (price) VALUES (12500)") out = tmp_path / "ads.json" export_ads(db, out) assert json.loads(out.read_text(encoding="utf-8")) == [[1, 12500]]from unittest.mock import create_autospec
class AdSource: def fetch(self, page: int) -> list[dict]: raise RuntimeError("network is not allowed in tests")
def count_cheap(source: AdSource, limit: int) -> int: return sum(1 for ad in source.fetch(page=1) if ad["price"] <= limit)
def test_with_monkeypatch(monkeypatch): # подмена атрибута только на время теста monkeypatch.setattr(AdSource, "fetch", lambda self, page: [{"price": 100}, {"price": 900}]) assert count_cheap(AdSource(), limit=500) == 1
def test_with_mock(): source = create_autospec(AdSource, instance=True) # сигнатуры как у настоящего класса source.fetch.return_value = [{"price": 100}] assert count_cheap(source, limit=500) == 1 source.fetch.assert_called_once_with(page=1)- Как pytest находит тесты? — Файлы
test_*.pyили*_test.py, в них функцииtest_*и классыTest*без__init__. - Чем фикстура лучше
setUp? — Подключается по имени только туда, где нужна, комбинируется с другими и сама делает teardown послеyield. - Что будет, если
session-фикстура попроситtmp_path? —ScopeMismatch: широкая область не зависит от узкой; для неё естьtmp_path_factory. - Где патчить функцию через
mock.patch? — В модуле, где её ищет тестируемый код, а не там, где она объявлена. - Как запустить тесты параллельно? — Плагином pytest-xdist:
pytest -n auto.
Как управлять окружениями и зависимостями в Python-проекте?
Заголовок раздела «Как управлять окружениями и зависимостями в Python-проекте?»Что это
- venv — папка, обычно
.venv, со ссылкой на интерпретатор и своими пакетами. Активированное окружение подменяетpythonиpipв терминале. - pyproject.toml — файл проекта: имя, версия, нужная версия Python, зависимости, настройки инструментов. Аналог
.csproj.
Зачем
Глобальный pip install ставит пакет на всю систему, и два проекта с разными версиями SQLAlchemy там не уживутся. Системный Python в Debian и Ubuntu такую установку вообще запрещает: externally-managed-environment.
Как пользоваться
uv initсоздаётpyproject.toml.uv add sqlalchemyдобавляет зависимость, создаёт.venvиuv.lock.uv run pytestзапускает команду внутри окружения проекта..venvв git не коммитят,uv.lock— коммитят.
Частая ошибка
Поставить пакет через pip install, не записав его в pyproject.toml. У коллеги и в CI проект не запустится.
Как устроено
| .NET | Python |
|---|---|
.csproj с PackageReference |
pyproject.toml, таблица [project] |
packages.lock.json |
uv.lock, poetry.lock, requirements.txt с == |
dotnet restore |
uv sync |
| анализаторы и предупреждения компилятора | ruff, mypy или pyright |
dependencies— прямые зависимости с диапазонами. Инструменты разработки — в[dependency-groups](PEP 735), в пакет они не попадают.- Lock фиксирует точные версии и хеши всех транзитивных зависимостей.
uv runперед запуском сам обновляет lock и синхронизирует окружение.
Когда что выбирать
| Инструмент | Когда |
|---|---|
| uv | новый проект: окружения, lock, версии Python, утилиты |
| Poetry | проект уже на нём; с версии 2.0 понимает [project] |
| pip-tools | минимум: pip-compile закрепляет requirements.in в requirements.txt |
Ловушки
requirements.txtс диапазонами — не lock: сборка через месяц получит другие пакеты.ruff check— линтер, замена flake8 и isort;ruff format— форматтер, замена Black. В CI нужны обе команды.- Интерпретатор аннотации типов не проверяет. Без mypy или pyright в CI ошибка типа доживёт до продакшена.
Правило
Приложение: диапазоны в pyproject.toml, lock в git, uv sync --locked в CI.
Под капотом
- uv — один бинарник на Rust. Lock у него универсальный: граф разрешается сразу для всех платформ, и один
uv.lockгодится и для Windows, и для Linux-образа. - Пакеты лежат в глобальном кеше, а в
.venvпопадают жёсткими ссылками или копиями, поэтому окружение создаётся за секунды. uv python installставит нужную версию интерпретатора,uvxзапускает утилиту во временном окружении.
Цена
uv.lock— собственный формат uv. Стандартныйpylock.toml(PEP 751) uv умеет экспортировать.- uv ещё 0.x, и в марте 2026 года OpenAI объявила о покупке Astral. Инструменты обещают оставить open source, но зависимость от одного вендора — довод держаться стандартных таблиц
pyproject.toml.
Компромиссы
- Приложение или библиотека. Приложению нужен lock. Библиотеке — широкие диапазоны: узкий диапазон ломает потребителю разрешение зависимостей.
- Строгость типов.
mypy --strictокупается на новом коде, на старом её включают по модулям. ty от Astral в 2026 году ещё бета. - Правила ruff. Широкий
selectловит больше, но обновление ruff приносит новые предупреждения, поэтому его версию закрепляют в lock.
Как проверить в проде
- CI:
uv sync --locked,ruff check,ruff format --check, mypy, pytest. - Docker-образ собирают из lock командой
uv sync --locked --no-dev, а неpip installпо диапазонам. - pip-audit или Dependabot по lock-файлу находят уязвимые версии.
Итог: воспроизводимость держится на lock-файле и --locked в CI, а стандартный pyproject.toml оставляет свободу сменить инструмент.
[project]name = "ads-parser"version = "0.1.0"requires-python = ">=3.12"dependencies = [ # прямые зависимости с диапазонами версий "sqlalchemy>=2.0", "alembic>=1.16", "psycopg[binary]>=3.2", "pydantic-settings>=2.6", "typer>=0.15",]
[dependency-groups]dev = ["pytest>=9", "ruff", "mypy"] # PEP 735: в пакет не попадают
[tool.ruff]line-length = 100target-version = "py312"
[tool.ruff.lint]select = ["E", "F", "I", "B", "UP"] # pycodestyle, pyflakes, isort, bugbear, pyupgrade
[tool.mypy]strict = true
[tool.pytest] # нативная TOML-таблица, pytest 9+testpaths = ["tests"]uv init ads-parser && cd ads-parser # pyproject.toml, .python-version, src/, gituv add sqlalchemy alembic "psycopg[binary]" # [project].dependencies, uv.lock и .venvuv add --dev pytest ruff mypy # группа dev в [dependency-groups]uv run pytest # перед запуском lock и sync — автоматически
# CI: падает, если uv.lock не соответствует pyproject.tomluv sync --locked# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.uv run ruff check && uv run ruff format --check && uv run mypy src
uv export --format pylock.toml -o pylock.toml # стандартный lock (PEP 751)- Зачем venv, если есть
pip install --user? — Пакеты всё равно общие для всех проектов пользователя, версии конфликтуют. - Чем
uv sync --lockedотличается от--frozen? —--lockedпадает, если lock устарел;--frozenберёт lock как есть, не сверяя сpyproject.toml. - Нужен ли lock-файл библиотеке? — Для её CI полезен, но потребителю не передаётся; в метаданных пакета — только диапазоны.
- Что заменяет ruff? — Линтеры flake8 с плагинами, isort и pyupgrade, а
ruff format— Black. - Почему mypy, если код и так запускается? — Python не проверяет аннотации; ошибку типа без проверки в CI найдёт только продакшен.
Как работать с базой через SQLAlchemy 2?
Заголовок раздела «Как работать с базой через SQLAlchemy 2?»Что это
SQLAlchemy — ORM для Python. Версия 2.0 вышла в 2023 году и поменяла стиль API, в октябре 2026 года актуальна 2.1.
Как пользоваться
- Создайте
Engine:create_engine("postgresql+psycopg://user:pass@host/db"). Он один на приложение. - Опишите модель: класс от
DeclarativeBase, поля —Mapped[int]иmapped_column(...). ТипMapped[str | None]даёт колонку, допускающуюNULL. - Откройте сессию:
with Session(engine) as session:. - Читайте:
session.scalars(select(Ad).where(Ad.price > 1000)). - Пишите:
session.add(ad), затемsession.commit().
Частая ошибка
Писать запросы в старом стиле session.query(Ad).filter(...). Он ещё работает, но считается legacy; новый код пишут через select().
Как устроено
Sessionберёт соединение из пулаEngineпри первом запросе и сама открывает транзакцию (autobegin).- Загруженные объекты попадают в identity map: одна строка — один объект, повторный
session.get(Ad, 1)в базу не ходит. - Изменения копятся. Перед запросом (autoflush) и при
commit()сессия делает flush — отправляетINSERT,UPDATEиDELETEв порядке зависимостей. - После
commit()атрибуты объектов истекают и перечитываются при следующем обращении.
SQLAlchemy и EF Core
| SQLAlchemy | EF Core |
|---|---|
Session |
DbContext |
session.commit() |
SaveChanges() |
select(Ad).where(...) |
LINQ Where |
joinedload |
Include |
selectinload |
Include и AsSplitQuery |
| ленивая загрузка по умолчанию | только с UseLazyLoadingProxies |
Ловушки
- N+1. Обращение к
seller.adsв цикле — запрос на каждого продавца.selectinloadдаёт второй запрос сIN,joinedload—JOIN; для коллекций обычно берутselectinload. joinedloadколлекции дублирует строки родителя, поэтому результат требует.unique(), иначеInvalidRequestError.- Сессия на всё приложение растит identity map и держит транзакцию открытой. Сессия — на единицу работы: запрос, задачу, пачку объявлений.
Правило
Один Engine на процесс, одна Session на единицу работы, session.begin() для явной границы транзакции, стратегия загрузки — в самом запросе.
Под капотом
- Core — уровень SQL:
Table,select(),insert(),Connection. ORM переводит запросы к классам в те же конструкции, поэтомуselect()общий для обоих уровней. EngineдержитQueuePool: по умолчанию 5 соединений и ещё до 10 сверх них.pool_pre_ping=Trueпроверяет соединение перед выдачей.AsyncSessionработает поверх того же синхронного ядра через greenlet. В 2.1 greenlet не ставится по умолчанию, нужен пакетsqlalchemy[asyncio].
Цена
- ORM-объект дороже строки: identity map, история изменений, события. Тысячи объявлений быстрее вставить через Core:
session.execute(insert(Ad), rows)или upsertpostgresql.insert(...).on_conflict_do_update(...). - Неявный запрос в async-коде — ленивая связь или истёкший атрибут — падает с
MissingGreenlet. Поэтому там берутexpire_on_commit=Falseи грузят связи жадно.
Компромиссы
lazy="raise"на связи илиraiseload("*")в запросе делают случайный N+1 исключением. Строже, но каждый запрос сам заказывает связи.expire_on_commit=Falseубирает лишние запросы после коммита, но объект может показывать устаревшие данные.- Одна
AsyncSessionне годится для параллельных задачasyncio.gather: каждой задаче — своя сессия.
Как проверить в проде
echo=Trueили логгерsqlalchemy.engineпоказывают каждый SQL, а в тестах число запросов считают событиемbefore_cursor_execute.- В PostgreSQL
pg_stat_statementsвыдаёт N+1 тысячами одинаковых коротких запросов. - Ошибка
QueuePool limit ... reached— признак сессий, которые не закрываются.
Итог: ORM — удобная единица работы поверх Core; в горячих местах спускаются на Core, а ленивую загрузку держат под контролем через selectinload и raiseload.
from sqlalchemy import ForeignKey, create_engine, selectfrom sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
class Base(DeclarativeBase): pass
class Seller(Base): __tablename__ = "sellers" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] # NOT NULL: тип без None ads: Mapped[list["Ad"]] = relationship(back_populates="seller")
class Ad(Base): __tablename__ = "ads" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] price: Mapped[int | None] # NULL разрешён seller_id: Mapped[int] = mapped_column(ForeignKey("sellers.id")) seller: Mapped[Seller] = relationship(back_populates="ads")
engine = create_engine("sqlite://") # в проекте: "postgresql+psycopg://..."Base.metadata.create_all(engine) # в проекте схему создаёт Alembic# Продолжение: модели и engine из предыдущего примераfrom sqlalchemy.orm import selectinload
with Session(engine) as session, session.begin(): # commit на выходе, rollback при исключении session.add( Seller(name="Olga", ads=[Ad(title="Bike", price=5000), Ad(title="Lamp", price=None)]) )
with Session(engine) as session: stmt = select(Ad).where(Ad.price > 1000).order_by(Ad.title) print([ad.title for ad in session.scalars(stmt)]) # ['Bike']
stmt = select(Seller).options(selectinload(Seller.ads)) # 2 запроса вместо 1 + N for seller in session.scalars(stmt): print(seller.name, [ad.title for ad in seller.ads]) # Olga ['Bike', 'Lamp']
seller = session.get(Seller, 1) # объект уже в identity map — без запроса seller.name = "Olga K." # изменение отслеживается session.commit() # flush: UPDATE sellers SET name=... и COMMIT# Модели и импорты из предыдущих примеров; пакеты: sqlalchemy[asyncio], aiosqliteimport asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def main() -> None: engine = create_async_engine("sqlite+aiosqlite://") # в проекте: "postgresql+asyncpg://..." async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all)
new_session = async_sessionmaker(engine, expire_on_commit=False) async with new_session() as session, session.begin(): session.add(Seller(name="Olga", ads=[Ad(title="Bike", price=5000)]))
async with new_session() as session: stmt = select(Seller).options(selectinload(Seller.ads)) # связи — только жадно seller = await session.scalar(stmt) print([ad.title for ad in seller.ads]) # ['Bike']; без selectinload — MissingGreenlet await engine.dispose()
asyncio.run(main())- Чем
session.flush()отличается отcommit()? — flush отправляет SQL внутри текущей транзакции,commit()делает flush и фиксирует транзакцию. - Откуда лишний
SELECTпослеcommit()? —expire_on_commit=True: атрибуты истекли и перечитываются при обращении. selectinloadилиjoinedload? — Для коллекций —selectinload, для связи «многие к одному» —joinedload.- Что значит
MissingGreenlet? — Неявный ввод-вывод в async-коде: ленивая связь или истёкший атрибут; связи грузят жадно. - Когда спускаться на Core? — Массовые вставки и upsert, отчётные запросы — всё, где объекты и отслеживание не нужны.
Как вести миграции и конфигурацию в продакшене: Alembic и pydantic-settings?
Заголовок раздела «Как вести миграции и конфигурацию в продакшене: Alembic и pydantic-settings?»Что это
- Миграция — файл, который переводит схему базы на следующую версию: добавить таблицу, колонку, индекс.
- Alembic — инструмент миграций от авторов SQLAlchemy. Миграции образуют цепочку: каждая знает предыдущую.
- pydantic-settings — класс настроек, поля которого заполняются из переменных окружения и файла
.env.
Как пользоваться
alembic init migrationsсоздаёт каталог миграций иenv.py.- В
env.pyукажитеtarget_metadata = Base.metadata— так Alembic видит модели. - Изменили модель — выполните
alembic revision --autogenerate -m "add price". - Прочитайте сгенерированный файл и примените его:
alembic upgrade head. - Строку подключения в
env.pyберите изSettings, а не пишите вalembic.ini.
Частая ошибка
Закоммитить .env с паролем. В репозиторий кладут .env.example без секретов, а .env добавляют в .gitignore.
Как устроено
- Ревизия — файл с
revision,down_revision,upgrade()иdowngrade(). Цепочкаdown_revisionзадаёт порядок. alembic upgrade headчитает версию изalembic_versionи выполняет недостающие ревизии.- Autogenerate сравнивает
Base.metadataс базой и пишет черновик:op.add_column,op.create_index.
Чего autogenerate не видит
| Изменение | Что получится |
|---|---|
| Переименование таблицы или колонки | удаление и добавление — данные пропадут |
| Ограничение без имени | не отслеживается |
server_default |
не сравнивается без compare_server_default |
Как читаются настройки
Источники по убыванию приоритета: аргументы конструктора, переменные окружения, .env, каталог секретов, значения по умолчанию. Имена переменных нечувствительны к регистру, env_prefix отделяет свои переменные от чужих, строки приводятся к типам полей.
Ловушки
- Ограничению без имени имя даёт база, и autogenerate не сгенерирует его удаление. Задайте
naming_conventionвMetaData. - По умолчанию
extra="forbid": неизвестная строка в.env, даже чужая без префикса, роняет старт сValidationError. downgrade()сdrop_columnне возвращает данные, а удаляет их.
Правило
Каждую сгенерированную миграцию читают и прогоняют в CI upgrade, downgrade и снова upgrade.
Под капотом
- Ревизии образуют граф. Две ветки от одной ревизии дают две head, и
upgrade headпадает сMultiple head revisions are present.alembic merge headsсоздаёт ревизию с двумя родителями вdown_revision. - DDL в PostgreSQL транзакционный: по умолчанию весь
upgrade— одна транзакция.CREATE INDEX CONCURRENTLYв транзакции запрещён, его оборачивают вop.get_context().autocommit_block(). alembic upgrade head --sqlвыводит SQL без подключения — для ревью или ручного применения.
Компромиссы
- Данные в миграциях. Небольшие правки —
op.executeнад снимкомsa.table(): ORM-модель через полгода не совпадёт со схемой этой ревизии. Backfill большой таблицы — отдельной задачей пакетами. - Онлайн-изменения. Переименование под нагрузкой — expand/contract на несколько релизов, подробно в теме Проектирование и эксплуатация.
- Запуск. Защиты от одновременного запуска у Alembic нет, поэтому миграции применяют отдельным шагом деплоя, а не при старте каждой реплики.
Секреты и CLI
SecretStrскрывает значение вreprи логах, настоящее — черезget_secret_value().- В продакшене секреты приходят переменными окружения или файлами из
secrets_dir, а не из.env. Окружение всегда приоритетнее.env. - Команды CLI на Typer — функции с аннотациями типов. Настройки читают внутри команды, чтобы
--helpработал без окружения.
Как проверить в проде
- CI: одна head в
alembic heads, пустойalembic check, циклupgrade—downgrade—upgrade. - Версия базы:
SELECT version_num FROM alembic_versionпротив head релиза.
Итог: миграция — код для продакшен-данных: ревью, именованные ограничения, одна head; настройки проверяются при старте, секреты приходят из окружения.
"""add ads.price_uah
Revision ID: b7e1f2a3c4d5Revises: 4c288caa5ad9"""
import sqlalchemy as safrom alembic import op
revision = "b7e1f2a3c4d5"down_revision = "4c288caa5ad9"
# Снимок таблицы на момент миграции, а не импорт ORM-модели: модель будет меняться дальшеads = sa.table("ads", sa.column("price", sa.Integer), sa.column("price_uah", sa.Integer))
def upgrade() -> None: op.add_column("ads", sa.Column("price_uah", sa.Integer(), nullable=True)) # expand op.execute(ads.update().values(price_uah=ads.c.price)) # данные; большие таблицы — пакетами # PostgreSQL: CREATE INDEX CONCURRENTLY нельзя выполнять внутри транзакции with op.get_context().autocommit_block(): op.create_index("ix_ads_price_uah", "ads", ["price_uah"], postgresql_concurrently=True)
def downgrade() -> None: with op.get_context().autocommit_block(): op.drop_index("ix_ads_price_uah", table_name="ads", postgresql_concurrently=True) op.drop_column("ads", "price_uah")alembic upgrade head --sql для PostgreSQL покажет COMMIT перед CREATE INDEX CONCURRENTLY и новую транзакцию после него.
from typing import Annotated
import typerfrom pydantic import SecretStrfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="PARSER_", env_file=".env")
db_url: SecretStr # обязательна: без неё — ValidationError при старте pages: int = 5 # PARSER_PAGES=10 → 10: строка из окружения приводится к int
app = typer.Typer()
@app.command()def crawl(pages: Annotated[int | None, typer.Option(help="Override PARSER_PAGES")] = None) -> None: settings = Settings() # окружение + .env, проверка один раз при запуске команды typer.echo(f"pages={pages or settings.pages} db={settings.db_url}")
if __name__ == "__main__": app()
# $ PARSER_DB_URL=postgresql+psycopg://app:pw@db/ads python cli.py --pages 2# pages=2 db=**********- Почему autogenerate превратил переименование в удаление колонки? — Он сравнивает только имена; переименование пишут руками через
op.alter_column(..., new_column_name=...). - Что делать с двумя head после слияния веток? —
alembic merge heads, а в CI — проверка, что head одна. - Почему в миграции нельзя импортировать ORM-модели? — Модель отражает текущую схему, а миграция — схему на свой момент; через полгода они разойдутся.
- Чем опасен
config.set_main_option("sqlalchemy.url", url)? — Значение проходит интерполяцию ConfigParser:%в пароле нужно удваивать; проще создать engine вenv.pyиз настроек. - Что приоритетнее: переменная окружения или
.env? — Переменная окружения;.env— запасной источник для разработки.