From 21992137974999e60870fb021829576dca5eb0a4 Mon Sep 17 00:00:00 2001 From: Stefan Scherfke Date: Tue, 28 Oct 2025 08:06:31 -0400 Subject: [PATCH] Add "--check-heads" option to "current" command Added :paramref:`.command.current.check_heads` parameter to :func:`.command.current` command, available from the command line via the ``--check-heads`` option to ``alembic current``. This tests if all head revisions are applied to the database and raises :class:`.DatabaseNotAtHead` (or from the command line, exits with a non-zero exit code) if this is not the case. The parameter operates equvialently to the cookbook recipe :ref:`cookbook_check_heads`. Pull request courtesy Stefan Scherfke. Fixes: #1705 Closes: #1739 Pull-request: https://github.com/sqlalchemy/alembic/pull/1739 Pull-request-sha: 955f9b7ff6216c3c2940597a652cce538a6e0f29 Change-Id: I1173217ff6a4927226ec9e7b8c7ef67c6431d728 --- alembic/command.py | 15 +++++++++++++- alembic/config.py | 11 ++++++++++ alembic/util/__init__.py | 1 + alembic/util/exc.py | 20 +++++++++++++++++- docs/build/api/exceptions.rst | 9 ++++++++ docs/build/api/index.rst | 1 + docs/build/cookbook.rst | 14 +++++++++++++ docs/build/unreleased/1705.rst | 11 ++++++++++ tests/test_command.py | 38 +++++++++++++++++++++++++++++++++- 9 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 docs/build/api/exceptions.rst create mode 100644 docs/build/unreleased/1705.rst diff --git a/alembic/command.py b/alembic/command.py index 8e485474..4897c0d9 100644 --- a/alembic/command.py +++ b/alembic/command.py @@ -681,11 +681,18 @@ def branches(config: Config, verbose: bool = False) -> None: ) -def current(config: Config, verbose: bool = False) -> None: +def current( + config: Config, check_heads: bool = False, verbose: bool = False +) -> None: """Display the current revision for a database. :param config: a :class:`.Config` instance. + :param check_heads: Check if all head revisions are applied to the + database. Raises :class:`.DatabaseNotAtHead` if this is not the case. + + .. versionadded:: 1.17.1 + :param verbose: output in verbose mode. """ @@ -698,6 +705,12 @@ def current(config: Config, verbose: bool = False) -> None: "Current revision(s) for %s:", util.obfuscate_url_pw(context.connection.engine.url), ) + if check_heads and ( + set(context.get_current_heads()) != set(script.get_heads()) + ): + raise util.DatabaseNotAtHead( + "Database is not on all head revisions" + ) for rev in script.get_all_current(rev): config.print_stdout(rev.cmd_format(verbose)) diff --git a/alembic/config.py b/alembic/config.py index b8c60a48..fb8dd8a6 100644 --- a/alembic/config.py +++ b/alembic/config.py @@ -808,6 +808,17 @@ class CommandLine: "environment and version locations", ), ), + "check_heads": ( + "-c", + "--check-heads", + dict( + action="store_true", + help=( + "Check if all head revisions are applied to the database. " + "Exit with an error code if this is not the case." + ), + ), + ), } _POSITIONAL_OPTS = { "directory": dict(help="location of scripts directory"), diff --git a/alembic/util/__init__.py b/alembic/util/__init__.py index 1d3a2179..c1411157 100644 --- a/alembic/util/__init__.py +++ b/alembic/util/__init__.py @@ -1,6 +1,7 @@ from .editor import open_in_editor as open_in_editor from .exc import AutogenerateDiffsDetected as AutogenerateDiffsDetected from .exc import CommandError as CommandError +from .exc import DatabaseNotAtHead as DatabaseNotAtHead from .langhelpers import _with_legacy_names as _with_legacy_names from .langhelpers import asbool as asbool from .langhelpers import dedupe_tuple as dedupe_tuple diff --git a/alembic/util/exc.py b/alembic/util/exc.py index c790e18a..4658f782 100644 --- a/alembic/util/exc.py +++ b/alembic/util/exc.py @@ -10,10 +10,28 @@ if TYPE_CHECKING: class CommandError(Exception): - pass + """Base command error for all exceptions""" + + +class DatabaseNotAtHead(CommandError): + """Indicates the database is not at current head revisions. + + Raised by the :func:`.command.current` command when the + :paramref:`.command.current.check_heads` parameter is used. + + .. versionadded:: 1.17.1 + + """ class AutogenerateDiffsDetected(CommandError): + """Raised when diffs were detected by the :func:`.command.check` + command. + + .. versionadded:: 1.9.0 + + """ + def __init__( self, message: str, diff --git a/docs/build/api/exceptions.rst b/docs/build/api/exceptions.rst new file mode 100644 index 00000000..4e88101e --- /dev/null +++ b/docs/build/api/exceptions.rst @@ -0,0 +1,9 @@ +.. _alembic.runtime.exceptions.toplevel: + +======================= +Exception Objects +======================= + + +.. automodule:: alembic.util.exc + :members: diff --git a/docs/build/api/index.rst b/docs/build/api/index.rst index f9efb899..50a543b5 100644 --- a/docs/build/api/index.rst +++ b/docs/build/api/index.rst @@ -29,4 +29,5 @@ to run commands programmatically, as discussed in the section :doc:`/api/command autogenerate script ddl + exceptions diff --git a/docs/build/cookbook.rst b/docs/build/cookbook.rst index 59437af6..7f0bf3aa 100644 --- a/docs/build/cookbook.rst +++ b/docs/build/cookbook.rst @@ -1480,10 +1480,24 @@ at :func:`.autogenerate.compare_metadata`:: +.. _cookbook_check_heads: Test current database revision is at head(s) ============================================ +.. versionchanged:: 1.17.1 This recipe is now part of the ``alembic current`` + command using the :paramref:`.command.current.check_heads` parameter, + available from the command line as ``--check-heads``: + + .. sourcecode:: + + alembic current --check-heads + + INFO [alembic.runtime.migration] Context impl SQLiteImpl. + INFO [alembic.runtime.migration] Will assume non-transactional DDL. + ERROR [alembic.util.messaging] Database is not on all head revisions + FAILED: Database is not on all head revisions + A recipe to determine if a database schema is up to date in terms of applying Alembic migrations. May be useful for test or installation suites to determine if the target database is up to date. Makes use of the diff --git a/docs/build/unreleased/1705.rst b/docs/build/unreleased/1705.rst new file mode 100644 index 00000000..f1daf2e0 --- /dev/null +++ b/docs/build/unreleased/1705.rst @@ -0,0 +1,11 @@ +.. change:: + :tags: usecase, commands + :tickets: 1705 + + Added :paramref:`.command.current.check_heads` parameter to + :func:`.command.current` command, available from the command line via the + ``--check-heads`` option to ``alembic current``. This tests if all head + revisions are applied to the database and raises :class:`.DatabaseNotAtHead` + (or from the command line, exits with a non-zero exit code) if this is not + the case. The parameter operates equvialently to the cookbook recipe + :ref:`cookbook_check_heads`. Pull request courtesy Stefan Scherfke. diff --git a/tests/test_command.py b/tests/test_command.py index 5920ac70..8608ba18 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -311,6 +311,8 @@ class CurrentTest(_BufMixin, TestBase): eq_(lines, set(revs)) def test_doesnt_create_alembic_version(self): + with self.bind.begin() as conn: + conn.exec_driver_sql("drop table if exists alembic_version") command.current(self.cfg) engine = self.bind with engine.connect() as conn: @@ -345,6 +347,40 @@ class CurrentTest(_BufMixin, TestBase): with self._assert_lines(["a2", "b3"]): command.current(self.cfg) + def test_check_heads_success(self): + """ + "--check-heads" succeeds if all head revisions are applied. + """ + command.stamp(self.cfg, ()) + command.stamp(self.cfg, (self.a3.revision, self.b3.revision)) + with self._assert_lines(["a3", "b3"]): + command.current(self.cfg, check_heads=True) + + @testing.combinations( + ["a2"], + ["a3"], + ["b3"], + ["a2", "b3"], + ["a3", "b2"], + argnames="revs", + ) + def test_check_heads_fail(self, revs): + """ + "--check-heads" succeeds if all head revisions are applied. + """ + command.stamp(self.cfg, ()) + command.stamp( + self.cfg, tuple(getattr(self, rev).revision for rev in revs) + ) + assert_raises_message( + util.DatabaseNotAtHead, + "Database is not on all head revisions", + command.current, + self.cfg, + check_heads=True, + ) + command.stamp(self.cfg, ()) + class RevisionTest(TestBase): def setUp(self): @@ -1562,7 +1598,7 @@ someconfig = 'bar'""" yield config.Config( self.cfg.config_file_name, toml_file=root / "pyproject.toml" ) - shutil.rmtree(root) + os.unlink(root / "pyproject.toml") @testing.variation("cmd", ["list_templates", "init"]) def test_init_custom_template_location(self, cmd, custom_template_fixture): -- 2.47.3