diff --git a/alembic/__init__.py b/alembic/__init__.py index 25833e5b..26180b10 100644 --- a/alembic/__init__.py +++ b/alembic/__init__.py @@ -3,4 +3,4 @@ from . import context from . import op -__version__ = "1.8.2" +__version__ = "1.9.0" diff --git a/alembic/command.py b/alembic/command.py index 5c33a95e..d2c5c85f 100644 --- a/alembic/command.py +++ b/alembic/command.py @@ -240,6 +240,62 @@ def retrieve_migrations(rev, context): return scripts +def check( + config: "Config", +) -> None: + """Check if revision command with autogenerate has pending upgrade ops. + + :param config: a :class:`.Config` object. + + .. versionadded:: 1.9.0 + + """ + + script_directory = ScriptDirectory.from_config(config) + + command_args = dict( + message=None, + autogenerate=True, + sql=False, + head="head", + splice=False, + branch_label=None, + version_path=None, + rev_id=None, + depends_on=None, + ) + revision_context = autogen.RevisionContext( + config, + script_directory, + command_args, + ) + + def retrieve_migrations(rev, context): + revision_context.run_autogenerate(rev, context) + return [] + + with EnvironmentContext( + config, + script_directory, + fn=retrieve_migrations, + as_sql=False, + template_args=revision_context.template_args, + revision_context=revision_context, + ): + script_directory.run_env() + + # the revision_context now has MigrationScript structure(s) present. + + migration_script = revision_context.generated_revisions[-1] + diffs = migration_script.upgrade_ops.as_diffs() + if diffs: + raise util.AutogenerateDiffsDetected( + f"New upgrade operations detected: {diffs}" + ) + else: + config.print_stdout("No new upgrade operations detected.") + + def merge( config: Config, revisions: str, diff --git a/alembic/util/__init__.py b/alembic/util/__init__.py index d5fa4d32..4374f46a 100644 --- a/alembic/util/__init__.py +++ b/alembic/util/__init__.py @@ -1,4 +1,5 @@ from .editor import open_in_editor +from .exc import AutogenerateDiffsDetected from .exc import CommandError from .langhelpers import _with_legacy_names from .langhelpers import asbool diff --git a/alembic/util/exc.py b/alembic/util/exc.py index f7ad0211..0d0496b1 100644 --- a/alembic/util/exc.py +++ b/alembic/util/exc.py @@ -1,2 +1,6 @@ class CommandError(Exception): pass + + +class AutogenerateDiffsDetected(CommandError): + pass diff --git a/docs/build/autogenerate.rst b/docs/build/autogenerate.rst index a623372d..4cb80066 100644 --- a/docs/build/autogenerate.rst +++ b/docs/build/autogenerate.rst @@ -886,3 +886,45 @@ be run against the newly generated file path:: Generating /path/to/project/versions/481b13bc369a_rev1.py ... done Running post write hook "spaces_to_tabs" ... done + +.. _alembic_check: + +Running Alembic Check to test for new upgrade operations +-------------------------------------------------------- + +When developing code it's useful to know if a set of code changes has made any +net change to the database model, such that new revisions would need to be +generated. To automate this, Alembic provides the ``alembic check`` command. +This command will run through the same process as +``alembic revision --autogenerate``, up until the point where revision files +would be generated, however does not generate any new files. Instead, it +returns an error code plus a message if it is detected that new operations +would be rendered into a new revision, or if not, returns a success code plus a +message. When ``alembic check`` returns a success code, this is an indication +that the ``alembic revision --autogenerate`` command would produce only empty +migrations, and does not need to be run. + +``alembic check`` can be worked into CI systems and on-commit schemes to ensure +that incoming code does not warrant new revisions to be generated. In +the example below, a check that detects new operations is illustrated:: + + + $ alembic check + FAILED: New upgrade operations detected: [ + ('add_column', None, 'my_table', Column('data', String(), table=)), + ('add_column', None, 'my_table', Column('newcol', Integer(), table=))] + +by contrast, when no new operations are detected:: + + $ alembic check + No new upgrade operations detected. + + +.. versionadded:: 1.9.0 + +.. note:: The ``alembic check`` command uses the same model comparison process + as the ``alembic revision --autogenerate`` process. This means parameters + such as :paramref:`.EnvironmentContext.configure.compare_type` + and :paramref:`.EnvironmentContext.configure.compare_server_default` + are in play as usual, as well as that limitations in autogenerate + detection are the same when running ``alembic check``. \ No newline at end of file diff --git a/docs/build/changelog.rst b/docs/build/changelog.rst index f88978a5..178a2a20 100644 --- a/docs/build/changelog.rst +++ b/docs/build/changelog.rst @@ -4,7 +4,7 @@ Changelog ========== .. changelog:: - :version: 1.8.2 + :version: 1.9.0 :include_notes_from: unreleased .. changelog:: diff --git a/docs/build/unreleased/724.rst b/docs/build/unreleased/724.rst new file mode 100644 index 00000000..5bd19873 --- /dev/null +++ b/docs/build/unreleased/724.rst @@ -0,0 +1,14 @@ +.. change:: + :tags: feature, commands + :tickets: 724 + + Added new Alembic command ``alembic check``. This performs the widely + requested feature of running an "autogenerate" comparison between the + current database and the :class:`.MetaData` that's currently set up for + autogenerate, returning an error code if the two do not match, based on + current autogenerate settings. Pull request courtesy Nathan Louie. + + .. seealso:: + + :ref:`alembic_check` + diff --git a/tests/test_command.py b/tests/test_command.py index e136c4e7..5ec35679 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -9,7 +9,9 @@ from sqlalchemy import exc as sqla_exc from sqlalchemy import text +from sqlalchemy import VARCHAR from sqlalchemy.engine import Engine +from sqlalchemy.sql.schema import Column from alembic import __version__ from alembic import command @@ -537,6 +539,63 @@ def test_sensical_sql_w_env(self): command.revision(self.cfg, sql=True) +class CheckTest(TestBase): + def setUp(self): + self.env = staging_env() + self.cfg = _sqlite_testing_config() + + def tearDown(self): + clear_staging_env() + + def _env_fixture(self, version_table_pk=True): + env_file_fixture( + """ + +from sqlalchemy import MetaData, engine_from_config +target_metadata = MetaData() + +engine = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.') + +connection = engine.connect() + +context.configure( + connection=connection, target_metadata=target_metadata, + version_table_pk=%r +) + +try: + with context.begin_transaction(): + context.run_migrations() +finally: + connection.close() + engine.dispose() + +""" + % (version_table_pk,) + ) + + def test_check_no_changes(self): + self._env_fixture() + command.check(self.cfg) # no problem + + def test_check_changes_detected(self): + self._env_fixture() + with mock.patch( + "alembic.operations.ops.UpgradeOps.as_diffs", + return_value=[ + ("remove_column", None, "foo", Column("old_data", VARCHAR())) + ], + ): + assert_raises_message( + util.AutogenerateDiffsDetected, + r"New upgrade operations detected: \[\('remove_column'", + command.check, + self.cfg, + ) + + class _StampTest: def _assert_sql(self, emitted_sql, origin, destinations): ins_expr = (