From 6d69285f3833b1bdb1d0005756ffcdcad97502f1 Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Mon, 11 Jul 2016 15:27:45 -0400 Subject: [PATCH] Report on other branch dependencies in "current" Fixed bug where the "alembic current" command wouldn't show a revision as a current head if it were also a dependency of a version in a different branch that's also applied. Extra logic is added to extract "implied" versions on different branches from the top-level versions listed in the alembic_version table. Change-Id: I9f485fbc67555d13f737ecffdd25e4c0d8e33f1c Fixes: #378 --- alembic/command.py | 3 +- alembic/script/base.py | 14 ++++++- alembic/script/revision.py | 11 +++++ docs/build/changelog.rst | 10 +++++ tests/test_command.py | 83 +++++++++++++++++++++++++++++++++----- 5 files changed, 109 insertions(+), 12 deletions(-) diff --git a/alembic/command.py b/alembic/command.py index 6b2bc336..0034c181 100644 --- a/alembic/command.py +++ b/alembic/command.py @@ -316,8 +316,9 @@ def current(config, verbose=False, head_only=False): "Current revision(s) for %s:", util.obfuscate_url_pw(context.connection.engine.url) ) - for rev in script.get_revisions(rev): + for rev in script.get_all_current(rev): config.print_stdout(rev.cmd_format(verbose)) + return [] with EnvironmentContext( diff --git a/alembic/script/base.py b/alembic/script/base.py index 9fb9e251..98c5311b 100644 --- a/alembic/script/base.py +++ b/alembic/script/base.py @@ -191,6 +191,16 @@ class ScriptDirectory(object): with self._catch_revision_errors(): return self.revision_map.get_revisions(id_) + def get_all_current(self, id_): + with self._catch_revision_errors(): + top_revs = set(self.revision_map.get_revisions(id_)) + top_revs.update( + self.revision_map._get_ancestor_nodes( + list(top_revs), include_dependencies=True) + ) + top_revs = self.revision_map._filter_into_branch_heads(top_revs) + return top_revs + def get_revision(self, id_): """Return the :class:`.Script` instance with the given rev id. @@ -665,11 +675,11 @@ class Script(revision.Revision): " (head)" if self._is_real_head else "", " (effective head)" if self.is_head and not self._is_real_head else "" - ) + ) if tree_indicators: text += "%s%s" % ( " (branchpoint)" if self.is_branch_point else "", - " (mergepoint)" if self.is_merge_point else "", + " (mergepoint)" if self.is_merge_point else "" ) if include_doc: text += ", %s" % self.doc diff --git a/alembic/script/revision.py b/alembic/script/revision.py index 3b4fac9c..6feba778 100644 --- a/alembic/script/revision.py +++ b/alembic/script/revision.py @@ -375,6 +375,17 @@ class RevisionMap(object): (revision.revision, check_branch), resolved_id) return revision + def _filter_into_branch_heads(self, targets): + targets = set(targets) + + for rev in list(targets): + if targets.intersection( + self._get_descendant_nodes( + [rev], include_dependencies=False)).\ + difference([rev]): + targets.discard(rev) + return targets + def filter_for_lineage( self, targets, check_against, include_dependencies=False): id_, branch_label = self._resolve_revision_number(check_against) diff --git a/docs/build/changelog.rst b/docs/build/changelog.rst index 2ccc8b52..f96232cf 100644 --- a/docs/build/changelog.rst +++ b/docs/build/changelog.rst @@ -6,6 +6,16 @@ Changelog .. changelog:: :version: 0.8.7 + .. change:: + :tags: bug, versioning + :tickets: 378 + + Fixed bug where the "alembic current" command wouldn't show a revision + as a current head if it were also a dependency of a version in a + different branch that's also applied. Extra logic is added to + extract "implied" versions of different branches from the top-level + versions listed in the alembic_version table. + .. change:: :tags: bug, versioning diff --git a/tests/test_command.py b/tests/test_command.py index e13dee0f..d20412d5 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -7,9 +7,21 @@ from alembic.testing.env import staging_env, _sqlite_testing_config, \ _sqlite_file_db, write_script, env_file_fixture from alembic.testing import eq_, assert_raises_message, mock from alembic import util +from contextlib import contextmanager +import re -class HistoryTest(TestBase): +class _BufMixin(object): + def _buf_fixture(self): + # try to simulate how sys.stdout looks - we send it u'' + # but then it's trying to encode to something. + buf = BytesIO() + wrapper = TextIOWrapper(buf, encoding='ascii', line_buffering=True) + wrapper.getvalue = buf.getvalue + return wrapper + + +class HistoryTest(_BufMixin, TestBase): @classmethod def setup_class(cls): @@ -34,14 +46,6 @@ class HistoryTest(TestBase): ]).encode("ascii", "replace").decode("ascii").strip() ) - def _buf_fixture(self): - # try to simulate how sys.stdout looks - we send it u'' - # but then it's trying to encode to something. - buf = BytesIO() - wrapper = TextIOWrapper(buf, encoding='ascii', line_buffering=True) - wrapper.getvalue = buf.getvalue - return wrapper - def test_history_full(self): self.cfg.stdout = buf = self._buf_fixture() command.history(self.cfg, verbose=True) @@ -90,6 +94,67 @@ class HistoryTest(TestBase): self._eq_cmd_output(buf, [self.c, self.b, self.a]) +class CurrentTest(_BufMixin, TestBase): + + @classmethod + def setup_class(cls): + cls.env = env = staging_env() + cls.cfg = _sqlite_testing_config() + cls.a1 = env.generate_revision("a1", "a1") + cls.a2 = env.generate_revision("a2", "a2") + cls.a3 = env.generate_revision("a3", "a3") + cls.b1 = env.generate_revision("b1", "b1", head="base") + cls.b2 = env.generate_revision("b2", "b2", head="b1", depends_on="a2") + cls.b3 = env.generate_revision("b3", "b3", head="b2") + + @classmethod + def teardown_class(cls): + clear_staging_env() + + @contextmanager + def _assert_lines(self, revs): + self.cfg.stdout = buf = self._buf_fixture() + + yield + + lines = set([ + re.match(r'(^.\w)', elem).group(1) + for elem in re.split( + "\n", + buf.getvalue().decode('ascii', 'replace').strip()) if elem]) + + eq_(lines, set(revs)) + + def test_no_current(self): + command.stamp(self.cfg, ()) + with self._assert_lines([]): + command.current(self.cfg) + + def test_plain_current(self): + command.stamp(self.cfg, ()) + command.stamp(self.cfg, self.a3.revision) + with self._assert_lines(['a3']): + command.current(self.cfg) + + def test_two_heads(self): + command.stamp(self.cfg, ()) + command.stamp(self.cfg, (self.a1.revision, self.b1.revision)) + with self._assert_lines(['a1', 'b1']): + command.current(self.cfg) + + def test_heads_one_is_dependent(self): + command.stamp(self.cfg, ()) + command.stamp(self.cfg, (self.b2.revision, )) + with self._assert_lines(['a2', 'b2']): + command.current(self.cfg) + + def test_heads_upg(self): + command.stamp(self.cfg, (self.b2.revision, )) + command.upgrade(self.cfg, (self.b3.revision)) + with self._assert_lines(['a2', 'b3']): + command.current(self.cfg) + + class RevisionTest(TestBase): def setUp(self): self.env = staging_env() -- 2.47.2