Logo Craft Homelab Docs Контакты Telegram

Автоматическая генерация миграций

Alembic может просматривать состояние базы данных (на которую указывает sqlalchemy.url в вашем файле alembic.ini, использующем текущую схему) и сравнивать её с метаданными таблицы в приложении (вашей ORM, которая определяет предлагаемую схему), генерируя «очевидные» миграции на основе сравнения. Это достигается с помощью параметра --autogenerate команды alembic revision, которая помещает так называемые миграции-кандидаты в наш новый файл миграций. Мы проверяем и изменяем их вручную по мере необходимости, а затем продолжаем работу в обычном режиме.

Чтобы использовать автогенерацию, нам сначала нужно изменить наш env.py так, чтобы он получил доступ к объекту метаданных таблицы, содержащему целевой объект. Предположим, что в нашем приложении есть декларативная база данных в myapp.mymodel. Эта база данных содержит объект MetaData, содержащий объекты Table, определяющие нашу базу данных. Мы загружаем его в env.py и передаем в EnvironmentContext.configure() через аргумент target_metadata. В примере скрипта env.py, используемом в универсальном шаблоне, для удобства в начале уже есть объявление переменной, где мы заменяем None на наши метаданные. Найдем:

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None

и меняем на:

from myapp.mymodel import Base
target_metadata = Base.metadata

Приведённый выше пример относится к универсальному шаблону alembic env.py, например, создаваемому по умолчанию при вызове alembic init, а не к специальным шаблонам, таким как multidb. За подробной информацией о том, где и как устанавливаются автоматически сгенерированные метаданные, обращайтесь к исходному коду и комментариям внутри скрипта env.py.

Если мы посмотрим дальше в скрипте, в run_migrations_online(), мы увидим директиву, переданную в EnvironmentContext.configure():

def run_migrations_online():
    engine = engine_from_config(
                config.get_section(config.config_ini_section), prefix='sqlalchemy.')

    with engine.connect() as connection:
        context.configure(
                    connection=connection,
                    target_metadata=target_metadata
                    )

        with context.begin_transaction():
            context.run_migrations()

Затем мы можем использовать команду alembic revision вместе с опцией --autogenerate. Предположим, наши метаданные содержат определение для таблицы account, а база данных — нет. Мы получим следующий вывод:

$ alembic revision --autogenerate -m "Added account table"
INFO [alembic.context] Detected added table 'account'
Generating /path/to/foo/alembic/versions/27c6a30d7c24.py...done

Затем мы можем просмотреть наш файл 27c6a30d7c24.py и увидеть, что элементарная миграция уже присутствует:

"""empty message

Revision ID: 27c6a30d7c24
Revises: None
Create Date: 2011-11-08 11:40:27.089406

"""

# revision identifiers, used by Alembic.
revision = '27c6a30d7c24'
down_revision = None

from alembic import op
import sqlalchemy as sa

def upgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.create_table(
    'account',
    sa.Column('id', sa.Integer()),
    sa.Column('name', sa.String(length=50), nullable=False),
    sa.Column('description', sa.VARCHAR(200)),
    sa.Column('last_transaction_date', sa.DateTime()),
    sa.PrimaryKeyConstraint('id')
    )
    ### end Alembic commands ###

def downgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.drop_table("account")
    ### end Alembic commands ###

Конечно, миграция ещё не запущена. Мы делаем это с помощью обычной команды upgrade. Также нам нужно зайти в файл миграции и изменить его по мере необходимости, включая корректировку директив, а также добавление других директив, от которых они могут зависеть, в частности, от изменений данных между операциями create/alter/drop.

Что обнаруживает функция Autogenerate (и что она не обнаруживает?)

Подавляющее большинство проблем пользователей Alembic связано с тем, какие изменения функция Autogenerate может и не может обнаружить, а также с тем, как она отображает код Python для обнаруженных изменений. Важно отметить, что Autogenerate не претендует на идеал. Всегда необходимо вручную проверять и исправлять потенциальные миграции, создаваемые Autogenerate. С выходом новых версий функция становится всё более полной и безошибочной, но следует учитывать текущие ограничения.

Функция автоматической генерации обнаружит:

  • Добавление и удаление таблиц.
  • Добавление и удаление столбцов.
  • Изменение статуса допустимости значений NULL в столбцах.
  • Основные изменения в индексах и явно именованных уникальных ограничениях.
  • Основные изменения в ограничениях внешнего ключа.

Функция автогенерации может опционально определять:

  • Изменение типа столбца. Это происходит по умолчанию, если параметр EnvironmentContext.configure.compare_type не имеет значение False. Реализация по умолчанию надежно обнаруживает существенные изменения, например, между Numeric и String типами, а также учитывает типы, генерируемые «универсальными» типами SQLAlchemy, такими как Boolean. Аргументы, общие для обоих типов, такие как значения длины и точности, также будут сравниваться. Если тип метаданных или тип базы данных имеет дополнительные аргументы, помимо аргументов другого типа, они не сравниваются. Например, если один числовой тип имеет «scale», а другой — нет, это будет рассматриваться как неподдерживаемое значение базой данных или сообщение о значении по умолчанию, которое не указано в метаданных. Логика сравнения типов также полностью расширяема.

  • Изменение значения сервера по умолчанию. Это произойдёт, если параметру EnvironmentContext.configure.compare_server_default будет присвоено значение True или пользовательская вызываемая функция. Эта функция хорошо работает в простых случаях, но не всегда даёт точные результаты. Бэкенд PostgreSQL фактически будет обращаться к базе данных с «detected» и «metadata» для определения эквивалентности. Эта функция отключена по умолчанию, чтобы её можно было сначала протестировать на целевой схеме. Как и сравнение типов, её можно настроить, передав вызываемую функцию.

Автогенерация не может обнаружить:

  • Изменения имени таблицы. Это будет выглядеть как добавление/удаление двух разных таблиц, и вместо этого их следует вручную отредактировать, изменив имя.
  • Изменения имени столбца. Как и изменения имени таблицы, они определяются как добавление/удаление столбца, что совсем не то же самое, что изменение имени.
  • Ограничения с анонимными именами. Дайте ограничениям имена, например, UniqueConstraint('col1', 'col2', name="my_name").
  • Специальные типы SQLAlchemy, такие как Enum, генерируются на сервере, который напрямую не поддерживает ENUM. Это связано с тем, что представление такого типа в неподдерживаемой базе данных, например, ограничение CHAR+CHECK, может быть любым видом CHAR+CHECK. Для SQLAlchemy определение, что это действительно ENUM, будет лишь догадкой, что, как правило, плохая идея. Чтобы реализовать собственную функцию «угадывания», используйте событие sqlalchemy.events.DDLEvents.column_reflect() для обнаружения отражения CHAR (или любого другого целевого типа) и измените его на ENUM (или любой другой желаемый тип), если известно, что это предназначение типа. SQLalchemy.events.DDLEvents.after_parent_attach() можно использовать в процессе автоматической генерации для перехвата и отсоединения нежелательных ограничений CHECK.

Автоматическая генерация в настоящее время невозможна, но со временем будет добавлена:

  • Некоторые автономные добавления и удаления ограничений могут не поддерживаться, включая PRIMARY KEY, EXCLUDE, CHECK; они не обязательно реализованы в системе обнаружения автогенерации и также могут не поддерживаться соответствующим диалектом SQLAlchemy.
  • Добавление и удаление последовательностей — пока не реализовано.

Известные сторонние библиотеки, расширяющие встроенную функциональность автогенерации Alembic

  • alembic-utils Библиотека, которая добавляет поддержку автоматической генерации функций PostgreSQL, представлений, триггеров и т. д.
  • alembic-postgresql-enum Библиотека, которая добавляет поддержку автоматической генерации для создания, изменения и удаления перечислений в PostgreSQL.

Автоматическая генерация нескольких коллекций метаданных

Коллекция target_metadata также может быть определена как последовательность, если в приложении задействовано несколько коллекций MetaData:

from myapp.mymodel1 import Model1Base
from myapp.mymodel2 import Model2Base
target_metadata = [Model1Base.metadata, Model2Base.metadata]

Последовательность коллекций MetaData будет проверена в процессе автоматической генерации. Обратите внимание, что каждый объект метаданных должен содержать уникальные ключи таблиц (например, «ключ» — это комбинация имени таблицы и схемы); если два объекта метаданных содержат таблицу с одинаковой комбинацией схемы и имени, возникает ошибка.

Управление тем, что должно быть автоматически сгенерировано

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

Список объектов, сканируемых в целевом соединении с базой данных, включает:

  • Схема «по умолчанию», на которую в данный момент ссылается соединение с базой данных.
  • Если параметр EnvironmentContext.configure.include_schemas установлен в значение True, все нестандартные «схемы», то есть имена, возвращаемые методом get_schema_names() класса Inspector.
  • В каждой «схеме» все имеющиеся таблицы сканируются с использованием метода get_table_names() Inspector.
  • Внутри каждой «таблицы» сканируется большинство подобъектов конструкции Table, включая столбцы и некоторые виды ограничений. Этот процесс в конечном итоге включает использование методов Inspector, включая get_columns(), get_indexes(), get_unique_constraints() и get_foreign_keys() (на момент написания статьи ограничения CHECK и ограничения первичного ключа ещё не включены).

Исключение имен схем из процесса автогенерации

Поскольку указанный выше набор объектов базы данных обычно сравнивается с содержимым одного объекта MetaData, особенно при включённом флаге EnvironmentContext.configure.include_schemas, возникает насущная необходимость отфильтровать нежелательные «схемы», которые для некоторых бэкендов баз данных могут представлять собой список всех имеющихся баз данных. Эту фильтрацию лучше всего осуществлять с помощью хука EnvironmentContext.configure.include_name, который предоставляет вызываемый объект, возвращающий логическое значение true/false, указывающее, следует ли включать определённое имя схемы:

def include_name(name, type_, parent_names):
    if type_ == "schema":
        # note this will not include the default schema
        return name in ["schema_one", "schema_two"]
    else:
        return True

context.configure(
    # ...
    include_schemas = True,
    include_name = include_name
)

Выше, когда список имен схем извлекается впервые, имена будут отфильтрованы с помощью указанной выше функции include_name, так что только схемы с именами schema_one и schema_two будут рассмотрены в процессе автоматической генерации.

Чтобы включить схему по умолчанию, то есть схему, на которую ссылается подключение к базе данных без явного указания схемы, в хук передаётся имя None. Чтобы изменить наш пример выше и включить схему по умолчанию, мы также сравниваем с None:

def include_name(name, type_, parent_names):
    if type_ == "schema":
        # this **will* include the default schema
        return name in [None, "schema_one", "schema_two"]
    else:
        return True

context.configure(
    # ...
    include_schemas = True,
    include_name = include_name
)

Исключение имен таблиц из процесса автогенерации

Хук EnvironmentContext.configure.include_name также наиболее подходит для ограничения имён рассматриваемых таблиц в целевой базе данных. Если в целевой базе данных много таблиц, не входящих в MetaData, процесс автогенерации обычно предполагает, что это лишние таблицы в базе данных, подлежащие удалению, и генерирует операцию Operations.drop_table() для каждой из них. Чтобы избежать этого, можно использовать хук EnvironmentContext.configure.include_name для поиска каждого имени в коллекции tables объекта MetaData и исключения отсутствующих имён:

target_metadata = MyModel.metadata

def include_name(name, type_, parent_names):
    if type_ == "table":
        return name in target_metadata.tables
    else:
        return True

context.configure(
    # ...
    target_metadata = target_metadata,
    include_name = include_name,
    include_schemas = False
)

Приведённый выше пример ограничен именами таблиц, присутствующими только в схеме по умолчанию. Чтобы выполнить поиск в коллекции MetaData имён таблиц, квалифицированных схемой, таблица, присутствующая в схеме, отличной от схемы по умолчанию, будет представлена под именем вида <schemaname>.<tablename>. Хук EnvironmentContext.configure.include_name представит это имя схемы для каждой таблицы в словаре parent_names, используя ключ schema_name, который ссылается на имя текущей рассматриваемой схемы, или значение None, если схема является схемой по умолчанию для подключения к базе данных:

# example fragment

if parent_names["schema_name"] is None:
    return name in target_metadata.tables
else:
    # build out schema-qualified name explicitly...
    return (
        "%s.%s" % (parent_names["schema_name"], name) in
        target_metadata.tables
    )

Однако проще говоря, словарь parent_names также будет включать в себя имя, уже сформированное с помощью ключа schema_qualified_table_name и отформатированное соответствующим образом для таблиц в схеме по умолчанию, без точки. Таким образом, полный пример исключения таблиц с поддержкой схемы может выглядеть так:

target_metadata = MyModel.metadata

def include_name(name, type_, parent_names):
    if type_ == "schema":
        return name in [None, "schema_one", "schema_two"]
    elif type_ == "table":
        # use schema_qualified_table_name directly
        return (
            parent_names["schema_qualified_table_name"] in
            target_metadata.tables
        )
    else:
        return True

context.configure(
    # ...
    target_metadata = target_metadata,
    include_name = include_name,
    include_schemas = True
)

Словарь parent_names также будет включать ключ table_name, когда рассматриваемое имя является именем столбца или объекта ограничения, локального для конкретной таблицы.

Хук EnvironmentContext.configure.include_name относится только к отражённым объектам, а не к объектам, находящимся в целевой коллекции MetaData. Для более детальных правил, включающих как MetaData, так и отражённые объекты, более подходящим будет хук EnvironmentContext.configure.include_object, обсуждаемый в следующем разделе.

Исключение на основе объекта

Хук EnvironmentContext.configure.include_object обеспечивает правила включения/исключения на уровне объектов, основанные на отражаемом объекте Table и его элементах. Этот хук можно использовать для ограничения объектов как из локальной коллекции MetaData, так и из целевой базы данных. Ограничение заключается в том, что при отчёте об объектах в базе данных этот объект будет полностью отражён, что может быть затратно, если будет пропущено большое количество объектов. В примере ниже представлено детальное правило, которое будет пропускать изменения в объектах Column, имеющих пользовательский флаг skip_autogenerate, помещённый в словарь info:

def include_object(object, name, type_, reflected, compare_to):
    if (type_ == "column" and
        not reflected and
        object.info.get("skip_autogenerate", False)):
        return False
    else:
        return True

context.configure(
    # ...
    include_object = include_object
)

Сравнение и рендеринг типов

Область поведения функции autogenerate, связанная со сравнением и рендерингом объектов типов Python в скриптах миграции, представляет собой сложную задачу, поскольку в скриптах требуется рендерить очень широкий спектр типов, включая типы SQLAlchemy и пользовательские типы. Предлагается несколько вариантов решения этой задачи.

Управление префиксом модуля

При рендеринге типов они генерируются с префиксом модуля, поэтому они доступны при относительно небольшом количестве импортов. Правила, определяющие префикс, зависят от типа данных и конфигурационных настроек. Например, при рендеринге типов SQLAlchemy Alembic по умолчанию добавляет к имени типа префикс sa.:

Column("my_column", sa.Integer())

Использование префикса sa. можно контролировать, изменяя значение EnvironmentContext.configure.sqlalchemy_module_prefix:

def run_migrations_online():
    # ...

    context.configure(
                connection=connection,
                target_metadata=target_metadata,
                sqlalchemy_module_prefix="sqla.",
                # ...
                )

    # ...

В любом случае префикс sa. или любой другой желаемый префикс также должен быть включен в раздел импорта script.py.mako; он также по умолчанию import sqlalchemy as sa.

Для пользовательских типов, то есть, любого пользовательского типа, который не находится в пространстве имен модуля sqlalchemy., Alembic по умолчанию будет использовать значение module для этого пользовательского типа:

Column("my_column", myapp.models.utils.types.MyCustomType())

Импорт для вышеуказанного типа снова должен быть реализован в миграции, либо вручную, либо путем добавления его в script.py.mako.

Указанный выше пользовательский тип имеет длинное и громоздкое имя, основанное на прямом использовании __module__, что также подразумевает необходимость множества импортов для поддержки большого количества типов. По этой причине рекомендуется, чтобы пользовательские типы, используемые в скриптах миграции, были доступны из одного модуля. Предположим, мы назовём его myapp.migration_types:

# myapp/migration_types.py

from myapp.models.utils.types import MyCustomType

Сначала мы можем добавить импорт для migration_types в наш script.py.mako:

from alembic import op
import sqlalchemy as sa
import myapp.migration_types
${imports if imports else ""}

Затем мы переопределяем использование Alembic __module__, предоставляя фиксированный префикс с помощью параметра EnvironmentContext.configure.user_module_prefix:

def run_migrations_online():
    # ...

    context.configure(
                connection=connection,
                target_metadata=target_metadata,
                user_module_prefix="myapp.migration_types.",
                # ...
                )

    # ...

Выше мы бы получили миграцию следующего вида:

Column("my_column", myapp.migration_types.MyCustomType())

Теперь, когда мы неизбежно реорганизуем наше приложение, чтобы переместить MyCustomType куда-то еще, нам нужно всего лишь изменить модуль myapp.migration_types вместо поиска и замены всех экземпляров в наших скриптах миграции.

Влияние на отображение самих типов

Методология, используемая Alembic для генерации SQLAlchemy и пользовательских типовых конструкций в виде кода Python, — это старый добрый __repr__(). Встроенные типы SQLAlchemy по большей части имеют __repr__(), который точно отображает вызов конструктора, совместимого с Python, но есть и исключения, особенно в тех случаях, когда конструктор принимает аргументы, несовместимые с __repr__(), например, функция консервирования.

При создании пользовательского типа, который будет визуализирован в скрипте миграции, часто требуется явно указать для типа метод __repr__(), который будет точно воспроизводить конструктор для этого типа. В сочетании с EnvironmentContext.configure.user_module_prefix этого обычно достаточно. Однако, если требуются дополнительные функции, более универсальным хуком является параметр EnvironmentContext.configure.render_item. Этот хук позволяет предоставить вызываемую функцию в env.py, которая полностью возьмет на себя управление визуализацией типа, включая префикс его модуля:

def render_item(type_, obj, autogen_context):
    """Apply custom rendering for selected items."""

    if type_ == 'type' and isinstance(obj, MySpecialType):
        return "mypackage.%r" % obj

    # default rendering for other objects
    return False

def run_migrations_online():
    # ...

    context.configure(
                connection=connection,
                target_metadata=target_metadata,
                render_item=render_item,
                # ...
                )

    # ...

В приведенном выше примере мы бы гарантировали, что наш MySpecialType включает соответствующий метод __repr__(), который вызывается, когда мы вызываем его для %r.

Вызываемый объект, который мы используем для EnvironmentContext.configure.render_item, также может добавлять импорты в наш скрипт миграции. Переданный AutogenContext содержит элемент данных AutogenContext.imports, представляющий собой метод Python set(), для которого мы можем добавлять новые импорты. Например, если MySpecialType находится в модуле mymodel.types, мы можем добавить для него импорт при обнаружении этого типа:

def render_item(type_, obj, autogen_context):
    """Apply custom rendering for selected items."""

    if type_ == 'type' and isinstance(obj, MySpecialType):
        # add import for this type
        autogen_context.imports.add("from mymodel import types")
        return "types.%r" % obj

    # default rendering for other objects
    return False

Готовый скрипт миграции будет включать наши импорты, в которых используется выражение ${imports}, что даст следующий вывод:

from alembic import op
import sqlalchemy as sa
from mymodel import types

def upgrade():
    op.add_column('sometable', Column('mycolumn', types.MySpecialType()))

Сравнение типов

Логика сравнения типов по умолчанию будет работать как для встроенных типов SQLAlchemy, так и для базовых пользовательских типов. Эта логика включена по умолчанию. Её можно отключить, установив EnvironmentContext.configure.compare_type в значение False:

context.configure(
    # ...
    compare_type = False
)

Изменено в версии 1.12.0: Значение по умолчанию EnvironmentContext.configure.compare_type изменено на True.

Логика сравнения типов по умолчанию (расширяемая конечным пользователем) в настоящее время (начиная с версии Alembic 1.4.0) работает путём сравнения сгенерированного SQL-запроса для столбца. Это происходит в два этапа:

  • Сначала он сравнивает внешний тип каждого столбца, например, VARCHAR или TEXT. Реализации диалектов могут иметь синонимы, которые считаются эквивалентными, поскольку некоторые базы данных поддерживают типы, преобразуя их в другие типы. Например, NUMERIC и DECIMAL считаются эквивалентными во всех бэкендах, в то время как в бэкенде Oracle к этому списку эквивалентов добавляются дополнительные синонимы: BIGINT, INTEGER, NUMBER, SMALLINT.
  • Затем сравниваются аргументы внутри типа, такие как длина строк, значения точности для числовых значений и элементы внутри перечисления. Если ОБА столбца имеют аргументы, И они различны, будет обнаружено изменение. Если для одного столбца установлено значение по умолчанию, а для другого — аргументы, Alembic не будет пытаться сравнивать их. Обоснование заключается в том, что сложно определить, какое значение по умолчанию установлено серверной частью базы данных, не генерируя ложных срабатываний.

В качестве альтернативы параметр EnvironmentContext.configure.compare_type принимает вызываемую функцию, которая может использоваться для реализации пользовательской логики сравнения типов, например, в случаях, когда используются специальные определяемые пользователем типы:

def my_compare_type(context, inspected_column,
            metadata_column, inspected_type, metadata_type):
    # return False if the metadata_type is the same as the inspected_type
    # or None to allow the default implementation to compare these
    # types. a return value of True means the two types do not
    # match and should result in a type change operation.
    return None

context.configure(
    # ...
    compare_type = my_compare_type
)

Выше inspected_column — это столбец sqlalchemy.schema.Column, возвращаемый функцией sqlalchemy.engine.reflection.Inspector.reflect_table(), тогда как metadata_column — это столбец sqlalchemy.schema.Column из локальной среды модели. Возвращаемое значение None указывает на продолжение сравнения типов по умолчанию.

Применение постобработки и инструментов форматирования кода Python к сгенерированным ревизиям

Скрипты ревизий, сгенерированные командой alembic revision, могут быть дополнительно переданы через ряд постпроизводственных функций, которые могут анализировать или переписывать исходный код Python, сгенерированный Alembic, в рамках выполнения команды revision. Основное предназначение этой функции — запуск инструментов форматирования кода, таких как Black или autopep8, а также специально написанных функций форматирования и линтера, для файлов ревизий по мере их генерации Alembic. Можно настроить любое количество хуков, и они будут запускаться последовательно с учётом пути к новому сгенерированному файлу и параметров конфигурации.

Хуки пост-записи, если они настроены, запускаются для сгенерированных файлов ревизий независимо от того, использовалась ли функция автоматической генерации.

Система пост-записи Alembic частично вдохновлена инструментом pre-commit, который настраивает git-хуки, переформатирующие исходные файлы при их коммите в Git-репозиторий. Pre-commit может выполнять ту же функцию и для файлов ревизий Alembic, применяя к ним форматировщики кода при коммите. Пост-запись Alembic полезна только тем, что позволяет форматировать файлы сразу после генерации, а не во время коммита, и может быть полезна для проектов, в которых pre-commit не используется.

Базовая конфигурация постпроцессора

Примеры шаблонов для alembic.ini, а также pyproject.toml для соответствующих шаблонов теперь включают закомментированную конфигурацию, иллюстрирующую настройку инструментов форматирования кода или других инструментов, таких как линтеры, для работы с вновь сгенерированным путем к файлу. Пример из файла alembic.ini:

[post_write_hooks]

# format using "black"
hooks=black

black.type = console_scripts
black.entrypoint = black
black.options = -l 79 REVISION_SCRIPT_FILENAME

Тот же пример, настроенный в файле pyproject.toml, будет выглядеть так:

[[tool.alembic.post_write_hooks]]

# format using "black"
name = "black"
type = "console_scripts"
entrypoint = "black"
options = "-l 79 REVISION_SCRIPT_FILENAME"

Выше мы настраиваем hooks как одиночный хук записи поста с меткой black. Обратите внимание, что эта метка произвольная. Затем мы определяем конфигурацию для хука записи поста black, которая включает в себя:

  • type — это тип используемого нами крючка. Alembic включает в себя три типа крючков:
    • console_scripts, которая представляет собой функцию Python, использующую subprocess.run() для вызова отдельного скрипта Python для файла ревизий
    • exec, который использует subprocess.run() для выполнения произвольного двоичного файла
    • module, который использует subprocess.run() для непосредственного вызова модуля Python

Для пользовательской функции-хука эта переменная конфигурации будет ссылаться на имя, под которым был зарегистрирован пользовательский хук.

Следующий параметр конфигурации относится только к обработчику хуков console_scripts:

  • entrypoint — имя точки входа setuptools, используемой для определения консольного скрипта. В рамках стандартных консольных скриптов Python это имя будет совпадать с именем команды оболочки, которая обычно выполняется для инструмента форматирования кода, в данном случае — black.

Следующий параметр конфигурации относится только к обработчику прерываний exec:

  • executable — имя исполняемого файла для вызова. Может быть как простым именем исполняемого файла, которое будет искаться в $PATH, так и полным путём к нему, чтобы избежать потенциальных проблем с перехватом пути.

Следующие параметры поддерживаются как console_scripts, так и exec:

  • options — строка параметров командной строки, которая будет передана инструменту форматирования кода. В данном случае мы хотим выполнить команду black /path/to/revision.py -l 79. По умолчанию путь к ревизии позиционируется как первый аргумент. Чтобы указать другую позицию, можно использовать токен REVISION_SCRIPT_FILENAME, как показано в последующих примерах.

Убедитесь, что для скрипта указаны параметры, позволяющие ему перезаписывать входной файл на месте. Например, при запуске autopep8 следует указать параметр --in-place:

[post_write_hooks]
hooks = autopep8
autopep8.type = console_scripts
autopep8.entrypoint = autopep8
autopep8.options = --in-place REVISION_SCRIPT_FILENAME
  • cwd — необязательный рабочий каталог, из которого запускается инструмент обработки кода.

При запуске alembic revision -m "rev1" мы теперь также увидим black вывод инструмента:

$ alembic revision -m "rev1"
  Generating /path/to/project/versions/481b13bc369a_rev1.py ... done
  Running post write hook "black" ...
reformatted /path/to/project/versions/481b13bc369a_rev1.py
All done! ✨ 🍰 ✨
1 file reformatted.
  done

Хуки также можно указать в виде списка имён, соответствующих обработчикам хуков, которые будут запускаться последовательно. Например, мы можем запустить инструмент перезаписи импорта zimports (написанный автором Alembic) после запуска инструмента black. Конфигурация alembic.ini будет выглядеть следующим образом:

[post_write_hooks]

# format using "black", then "zimports"
hooks=black, zimports

black.type = console_scripts
black.entrypoint = black
black.options = -l 79 REVISION_SCRIPT_FILENAME

zimports.type = console_scripts
zimports.entrypoint = zimports
zimports.options = --style google REVISION_SCRIPT_FILENAME

Эквивалентная конфигурация pyproject.toml будет такой:

# format using "black", then "zimports"

[[tool.alembic.post_write_hooks]]
name = "black"
type="console_scripts"
entrypoint = "black"
options = "-l 79 REVISION_SCRIPT_FILENAME"

[[tool.alembic.post_write_hooks]]
name = "zimports"
type="console_scripts"
entrypoint = "zimports"
options = "--style google REVISION_SCRIPT_FILENAME"

При использовании указанной выше конфигурации вновь созданный файл ревизий сначала будет обработан инструментом «black», а затем инструментом «zimports».

В качестве альтернативы можно запустить pre-commit следующим образом:

[post_write_hooks]

hooks = pre-commit

pre-commit.type = console_scripts
pre-commit.entrypoint = pre-commit
pre-commit.options = run --files REVISION_SCRIPT_FILENAME
pre-commit.cwd = %(here)s

(Последняя строка помогает гарантировать, что файл .pre-commit-config.yaml всегда будет найден, независимо от того, откуда был вызван хук.)

Написание пользовательских хуков как функций Python

В предыдущем разделе было показано, как запускать форматировщики кода командной строки с помощью хука, выполняемого после записи, предоставленного Alembic, известного как console_scripts. Этот хук фактически представляет собой функцию Python, зарегистрированную под этим именем с помощью функции регистрации, которую можно использовать и для регистрации других типов хуков.

Для иллюстрации мы используем пример короткой функции Python, которая переписывает сгенерированный код, используя табуляцию вместо четырёх пробелов. Для простоты мы покажем, как эта функция может быть представлена непосредственно в файле env.py. Функция объявляется и регистрируется с помощью декоратора write_hooks.register():

from alembic.script import write_hooks
import re

@write_hooks.register("spaces_to_tabs")
def convert_spaces_to_tabs(filename, options):
    lines = []
    with open(filename) as file_:
        for line in file_:
            lines.append(
                re.sub(
                    r"^(    )+",
                    lambda m: "\t" * (len(m.group(1)) // 4),
                    line
                )
            )
    with open(filename, "w") as to_write:
        to_write.write("".join(lines))

Наш новый хук spaces_to_tabs можно настроить в alembic.ini следующим образом:

[alembic]

# ...

# ensure the revision command loads env.py
revision_environment = true

[post_write_hooks]

hooks = spaces_to_tabs

spaces_to_tabs.type = spaces_to_tabs

При запуске alembic revision файл env.py будет загружен во всех случаях, пользовательская функция «spaces_to_tabs» будет зарегистрирована, а затем она будет запущена для вновь сгенерированного пути к файлу:

$ alembic revision -m "rev1"
  Generating /path/to/project/versions/481b13bc369a_rev1.py ... done
  Running post write hook "spaces_to_tabs" ...
  done

Запуск Alembic Check для тестирования новых операций обновления

При разработке кода полезно знать, внёс ли набор изменений кода какие-либо изменения в модель базы данных, требующие создания новых ревизий. Для автоматизации этого процесса Alembic предоставляет команду alembic check. Эта команда выполняет тот же процесс, что и alembic revision --autogenerate, до момента создания файлов ревизий, однако не создаёт никаких новых файлов. Вместо этого она возвращает код ошибки и сообщение, если обнаруживает, что новые операции будут преобразованы в новую ревизию, или, если нет, возвращает код успешного выполнения и сообщение. Когда alembic check возвращает код успешного выполнения, это означает, что команда alembic revision --autogenerate создаст только пустые миграции и не требует запуска.

alembic check можно внедрить в системы непрерывной интеграции (CI) и схемы on-commit, чтобы гарантировать, что входящий код не потребует создания новых версий. В примере ниже показана проверка, обнаруживающая новые операции:

$ alembic check
FAILED: New upgrade operations detected: [
  ('add_column', None, 'my_table', Column('data', String(), table=<my_table>)),
  ('add_column', None, 'my_table', Column('newcol', Integer(), table=<my_table>))]

в противоположность этому, когда не обнаружено никаких новых операций:

$ alembic check
No new upgrade operations detected.

Команда alembic check использует тот же процесс сравнения моделей, что и команда alembic revision --autogenerate. Это означает, что такие параметры, как EnvironmentContext.configure.compare_type и EnvironmentContext.configure.compare_server_default, действуют как обычно, а ограничения на обнаружение автогенерации остаются теми же при запуске alembic check.