Skip to content

Commit

Permalink
Added list-templates command and support for custom templates
Browse files Browse the repository at this point in the history
  • Loading branch information
miguelgrinberg committed Aug 1, 2021
1 parent fbaaa37 commit 0f9094a
Show file tree
Hide file tree
Showing 13 changed files with 298 additions and 14 deletions.
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ include README.md
include LICENSE
include src/flask_migrate/templates/flask/*
include src/flask_migrate/templates/flask-multidb/*
include src/flask_migrate/templates/aioflask/*
include src/flask_migrate/templates/aioflask-multidb/*
include tests/*
7 changes: 5 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,11 @@ After the extension is initialized, a ``db`` group will be added to the command-
- ``flask db --help``
Shows a list of available commands.

- ``flask db init [--multidb]``
Initializes migration support for the application. The optional ``--multidb`` enables migrations for multiple databases configured as `Flask-SQLAlchemy binds <http://flask-sqlalchemy.pocoo.org/binds/>`_.
- ``flask db list-templates``
Shows a list of available database repository templates.

- ``flask db init [--multidb] [--template TEMPLATE] [--package]``
Initializes migration support for the application. The optional ``--multidb`` enables migrations for multiple databases configured as `Flask-SQLAlchemy binds <http://flask-sqlalchemy.pocoo.org/binds/>`_. The ``--template`` option allows you to explicitly select a database repository template, either from the stock templates provided by this package, or a custom one, given as a path to the template directory. The ``--package`` option tells Alembic to add ``__init__.py`` files in the migrations and versions directories.

- ``flask db revision [--message MESSAGE] [--autogenerate] [--sql] [--head HEAD] [--splice] [--branch-label BRANCH_LABEL] [--version-path VERSION_PATH] [--rev-id REV_ID]``
Creates an empty revision script. The script needs to be edited manually with the upgrade and downgrade changes. See `Alembic's documentation <http://alembic.zzzcomputing.com/en/latest/index.html>`_ for instructions on how to write migration scripts. An optional migration message can be included.
Expand Down
35 changes: 29 additions & 6 deletions src/flask_migrate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ def metadata(self):


class Config(AlembicConfig):
def __init__(self, *args, **kwargs):
self.template_directory = kwargs.pop('template_directory', None)
super().__init__(*args, **kwargs)

def get_template_directory(self):
if self.template_directory:
return self.template_directory
package_dir = os.path.abspath(os.path.dirname(__file__))
return os.path.join(package_dir, 'templates')

Expand Down Expand Up @@ -97,19 +103,36 @@ def wrapped(*args, **kwargs):


@catch_errors
def init(directory=None, multidb=False):
def list_templates():
"""List available templates."""
config = Config()
config.print_stdout("Available templates:\n")
for tempname in sorted(os.listdir(config.get_template_directory())):
with open(
os.path.join(config.get_template_directory(), tempname, "README")
) as readme:
synopsis = next(readme).strip()
config.print_stdout("%s - %s", tempname, synopsis)


@catch_errors
def init(directory=None, multidb=False, template=None, package=False):
"""Creates a new migration repository"""
if directory is None:
directory = current_app.extensions['migrate'].directory
config = Config()
template_directory = None
if template is not None and ('/' in template or '\\' in template):
template_directory, template = os.path.split(template)
config = Config(template_directory=template_directory)
config.set_main_option('script_location', directory)
config.config_file_name = os.path.join(directory, 'alembic.ini')
config = current_app.extensions['migrate'].\
migrate.call_configure_callbacks(config)
if multidb:
command.init(config, directory, 'flask-multidb')
else:
command.init(config, directory, 'flask')
if multidb and template is None:
template = 'flask-multidb'
elif template is None:
template = 'flask'
command.init(config, directory, template=template, package=package)


@catch_errors
Expand Down
17 changes: 15 additions & 2 deletions src/flask_migrate/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import click
from flask.cli import with_appcontext
from flask_migrate import list_templates as _list_templates
from flask_migrate import init as _init
from flask_migrate import revision as _revision
from flask_migrate import migrate as _migrate
Expand All @@ -21,15 +22,27 @@ def db():
pass


@db.command()
@with_appcontext
def list_templates():
"""List available templates."""
_list_templates()


@db.command()
@click.option('-d', '--directory', default=None,
help=('Migration script directory (default is "migrations")'))
@click.option('--multidb', is_flag=True,
help=('Support multiple databases'))
@click.option('-t', '--template', default=None,
help=('Repository template to use (default is "flask")'))
@click.option('--package', is_flag=True,
help=('Write empty __init__.py files to the environment and '
'version locations'))
@with_appcontext
def init(directory, multidb):
def init(directory, multidb, template, package):
"""Creates a new migration repository."""
_init(directory, multidb)
_init(directory, multidb, template, package)


@db.command()
Expand Down
2 changes: 1 addition & 1 deletion src/flask_migrate/templates/aioflask-multidb/README
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Multi-database configuration for aioflask and alchemical.
Multi-database configuration for aioflask.
2 changes: 1 addition & 1 deletion src/flask_migrate/templates/aioflask/README
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Generic single-database configuration with an async engine.
Single-database configuration for aioflask.
2 changes: 1 addition & 1 deletion src/flask_migrate/templates/flask-multidb/README
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Multi-database configuration.
Multi-database configuration for Flask.
2 changes: 1 addition & 1 deletion src/flask_migrate/templates/flask/README
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Generic single-database configuration.
Single-database configuration for Flask.
1 change: 1 addition & 0 deletions tests/custom_template/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Custom template.
50 changes: 50 additions & 0 deletions tests/custom_template/alembic.ini.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Custom template

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
92 changes: 92 additions & 0 deletions tests/custom_template/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Custom template
from __future__ import with_statement

import logging
from logging.config import fileConfig

from flask import current_app

from alembic import context

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.get_engine().url).replace(
'%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)

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


def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""

# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')

connectable = current_app.extensions['migrate'].db.get_engine()

with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)

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


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
25 changes: 25 additions & 0 deletions tests/custom_template/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Custom template
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

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

# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade():
${upgrades if upgrades else "pass"}


def downgrade():
${downgrades if downgrades else "pass"}
75 changes: 75 additions & 0 deletions tests/test_custom_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import os
import shutil
import unittest
import subprocess
import shlex


def run_cmd(app, cmd):
"""Run a command and return a tuple with (stdout, stderr, exit_code)"""
os.environ['FLASK_APP'] = app
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(stdout, stderr) = process.communicate()
print('\n$ ' + cmd)
print(stdout.decode('utf-8'))
print(stderr.decode('utf-8'))
return stdout, stderr, process.wait()


class TestMigrate(unittest.TestCase):
def setUp(self):
os.chdir(os.path.split(os.path.abspath(__file__))[0])
try:
os.remove('app.db')
except OSError:
pass
try:
shutil.rmtree('migrations')
except OSError:
pass
try:
shutil.rmtree('temp_folder')
except OSError:
pass

def tearDown(self):
try:
os.remove('app.db')
except OSError:
pass
try:
shutil.rmtree('migrations')
except OSError:
pass
try:
shutil.rmtree('temp_folder')
except OSError:
pass

def test_alembic_version(self):
from flask_migrate import alembic_version
self.assertEqual(len(alembic_version), 3)
for v in alembic_version:
self.assertTrue(isinstance(v, int))

def test_migrate_upgrade(self):
(o, e, s) = run_cmd('app.py', 'flask db init -t ./custom_template')
self.assertTrue(s == 0)
(o, e, s) = run_cmd('app.py', 'flask db migrate')
self.assertTrue(s == 0)
(o, e, s) = run_cmd('app.py', 'flask db upgrade')
self.assertTrue(s == 0)

from .app import db, User
db.session.add(User(name='test'))
db.session.commit()

with open('migrations/README', 'rt') as f:
assert f.readline().strip() == 'Custom template.'
with open('migrations/alembic.ini', 'rt') as f:
assert f.readline().strip() == '# Custom template'
with open('migrations/env.py', 'rt') as f:
assert f.readline().strip() == '# Custom template'
with open('migrations/script.py.mako', 'rt') as f:
assert f.readline().strip() == '# Custom template'

0 comments on commit 0f9094a

Please sign in to comment.