]> git.ipfire.org Git - thirdparty/sqlalchemy/alembic.git/commitdiff
- Added new multiple-capable argument ``--depends-on`` to the
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 28 Jul 2015 22:52:51 +0000 (18:52 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 28 Jul 2015 22:52:51 +0000 (18:52 -0400)
``alembic revision`` command, allowing ``depends_on`` to be
established at the command line level rather than having to edit
the file after the fact. ``depends_on`` identifiers may also be
specified as branch names at the command line or directly within
the migration file. The values may be specified as partial
revision numbers from the command line which will be resolved to
full revision numbers in the output file.
fixes #311

alembic/autogenerate/api.py
alembic/command.py
alembic/config.py
alembic/operations/ops.py
alembic/script/base.py
alembic/script/revision.py
docs/build/branches.rst
docs/build/changelog.rst
tests/test_command.py
tests/test_revision.py
tests/test_version_traversal.py

index e9af4cfdae67bf0551f55f42152fee3fcffb4a35..811ebf4bc071c8e4820b9a0b1cd382354b360600 100644 (file)
@@ -336,6 +336,7 @@ class RevisionContext(object):
             splice=migration_script.splice,
             branch_labels=migration_script.branch_label,
             version_path=migration_script.version_path,
+            depends_on=migration_script.depends_on,
             **template_args)
 
     def run_autogenerate(self, rev, context):
@@ -377,7 +378,8 @@ class RevisionContext(object):
             head=self.command_args['head'],
             splice=self.command_args['splice'],
             branch_label=self.command_args['branch_label'],
-            version_path=self.command_args['version_path']
+            version_path=self.command_args['version_path'],
+            depends_on=self.command_args['depends_on']
         )
         op._autogen_context = None
         return op
index 3ce5131100cdba4e3846f9ca6a3e611195bfa56d..aab5cc27862e990aea569e5d3ab46cb6ed8606c3 100644 (file)
@@ -68,7 +68,7 @@ def init(config, directory, template='generic'):
 def revision(
         config, message=None, autogenerate=False, sql=False,
         head="head", splice=False, branch_label=None,
-        version_path=None, rev_id=None):
+        version_path=None, rev_id=None, depends_on=None):
     """Create a new revision file."""
 
     script_directory = ScriptDirectory.from_config(config)
@@ -77,7 +77,7 @@ def revision(
         message=message,
         autogenerate=autogenerate,
         sql=sql, head=head, splice=splice, branch_label=branch_label,
-        version_path=version_path, rev_id=rev_id
+        version_path=version_path, rev_id=rev_id, depends_on=depends_on
     )
     revision_context = autogen.RevisionContext(
         config, script_directory, command_args)
index b3fc36fc71b23e1977f02698e92687f942812b4d..1d4169d68c42e2055b341a99300ea02bfd6e0b9b 100644 (file)
@@ -292,6 +292,14 @@ class CommandLine(object):
                         "'head' to splice onto"
                     )
                 ),
+                'depends_on': (
+                    "--depends-on",
+                    dict(
+                        action="append",
+                        help="Specify one or more revision identifiers "
+                        "which this revision should depend on."
+                    )
+                ),
                 'rev_id': (
                     "--rev-id",
                     dict(
index da50c488947d2675324c62ecce816c41c9e5a9e4..314b49b1e9f00d2bde4acef2da813f9d90b0f91c 100644 (file)
@@ -1896,7 +1896,7 @@ class MigrationScript(MigrateOperation):
             self, rev_id, upgrade_ops, downgrade_ops,
             message=None,
             imports=None, head=None, splice=None,
-            branch_label=None, version_path=None):
+            branch_label=None, version_path=None, depends_on=None):
         self.rev_id = rev_id
         self.message = message
         self.imports = imports
@@ -1904,5 +1904,6 @@ class MigrationScript(MigrateOperation):
         self.splice = splice
         self.branch_label = branch_label
         self.version_path = version_path
+        self.depends_on = depends_on
         self.upgrade_ops = upgrade_ops
         self.downgrade_ops = downgrade_ops
index e30c8b273a9b05c3d29d2f5528c7ac4706d5f9bc..a62fd60805abefc5de77e7b9e68c0a11f3bde58c 100644 (file)
@@ -123,7 +123,8 @@ class ScriptDirectory(object):
     @contextmanager
     def _catch_revision_errors(
             self,
-            ancestor=None, multiple_heads=None, start=None, end=None):
+            ancestor=None, multiple_heads=None, start=None, end=None,
+            resolution=None):
         try:
             yield
         except revision.RangeNotAncestorError as rna:
@@ -151,6 +152,12 @@ class ScriptDirectory(object):
                 "heads": util.format_as_comma(mh.heads)
             }
             compat.raise_from_cause(util.CommandError(multiple_heads))
+        except revision.ResolutionError as re:
+            if resolution is None:
+                resolution = "Can't locate revision identified by '%s'" % (
+                    re.argument
+                )
+            compat.raise_from_cause(util.CommandError(resolution))
         except revision.RevisionError as err:
             compat.raise_from_cause(util.CommandError(err.args[0]))
 
@@ -489,6 +496,19 @@ class ScriptDirectory(object):
                         "--splice to create a new branch from this revision"
                         % head.revision)
 
+        if depends_on:
+            with self._catch_revision_errors():
+                depends_on = [
+                    dep
+                    if dep in rev.branch_labels  # maintain branch labels
+                    else rev.revision  # resolve partial revision identifiers
+                    for rev in [
+                        self.revision_map.get_revision(dep)
+                        for dep in util.to_list(depends_on)
+                    ]
+
+                ]
+
         self._generate_template(
             os.path.join(self.dir, "script.py.mako"),
             path,
index 0f502f66ac05091a27cbe7bd458261599ac4c9fe..e618c4d051a5e2f5a9ebfa41c17e394b39b85389 100644 (file)
@@ -33,7 +33,9 @@ class MultipleHeads(RevisionError):
 
 
 class ResolutionError(RevisionError):
-    pass
+    def __init__(self, message, argument):
+        super(ResolutionError, self).__init__(message)
+        self.argument = argument
 
 
 class RevisionMap(object):
@@ -115,6 +117,7 @@ class RevisionMap(object):
         self._real_bases = ()
 
         has_branch_labels = set()
+        has_depends_on = set()
         for revision in self._generator():
 
             if revision.revision in map_:
@@ -123,6 +126,8 @@ class RevisionMap(object):
             map_[revision.revision] = revision
             if revision.branch_labels:
                 has_branch_labels.add(revision)
+            if revision.dependencies:
+                has_depends_on.add(revision)
             heads.add(revision.revision)
             _real_heads.add(revision.revision)
             if revision.is_base:
@@ -130,6 +135,14 @@ class RevisionMap(object):
             if revision._is_real_base:
                 self._real_bases += (revision.revision, )
 
+        # add the branch_labels to the map_.  We'll need these
+        # to resolve the dependencies.
+        for revision in has_branch_labels:
+            self._map_branch_labels(revision, map_)
+
+        for revision in has_depends_on:
+            self._add_depends_on(revision, map_)
+
         for rev in map_.values():
             for downrev in rev._all_down_revisions:
                 if downrev not in map_:
@@ -146,10 +159,10 @@ class RevisionMap(object):
         self._real_heads = tuple(_real_heads)
 
         for revision in has_branch_labels:
-            self._add_branches(revision, map_)
+            self._add_branches(revision, map_, map_branch_labels=False)
         return map_
 
-    def _add_branches(self, revision, map_):
+    def _map_branch_labels(self, revision, map_):
         if revision.branch_labels:
             for branch_label in revision._orig_branch_labels:
                 if branch_label in map_:
@@ -160,6 +173,12 @@ class RevisionMap(object):
                             map_[branch_label].revision)
                     )
                 map_[branch_label] = revision
+
+    def _add_branches(self, revision, map_, map_branch_labels=True):
+        if map_branch_labels:
+            self._map_branch_labels(revision, map_)
+
+        if revision.branch_labels:
             revision.branch_labels.update(revision.branch_labels)
             for node in self._get_descendant_nodes(
                     [revision], map_, include_dependencies=False):
@@ -167,7 +186,8 @@ class RevisionMap(object):
 
             parent = node
             while parent and \
-                    not parent._is_real_branch_point and not parent.is_merge_point:
+                    not parent._is_real_branch_point and \
+                    not parent.is_merge_point:
 
                 parent.branch_labels.update(revision.branch_labels)
                 if parent.down_revision:
@@ -175,6 +195,13 @@ class RevisionMap(object):
                 else:
                     break
 
+    def _add_depends_on(self, revision, map_):
+        if revision.dependencies:
+            revision._resolved_dependencies = tuple(
+                map_[dep].revision for dep
+                in util.to_tuple(revision.dependencies)
+            )
+
     def add_revision(self, revision, _replace=False):
         """add a single revision to an existing map.
 
@@ -191,6 +218,8 @@ class RevisionMap(object):
 
         map_[revision.revision] = revision
         self._add_branches(revision, map_)
+        self._add_depends_on(revision, map_)
+
         if revision.is_base:
             self.bases += (revision.revision, )
         if revision._is_real_base:
@@ -303,7 +332,8 @@ class RevisionMap(object):
             try:
                 nonbranch_rev = self._revision_for_ident(branch_label)
             except ResolutionError:
-                raise ResolutionError("No such branch: '%s'" % branch_label)
+                raise ResolutionError(
+                    "No such branch: '%s'" % branch_label, branch_label)
             else:
                 return nonbranch_rev
         else:
@@ -325,14 +355,15 @@ class RevisionMap(object):
                 revs = self.filter_for_lineage(revs, check_branch)
             if not revs:
                 raise ResolutionError(
-                    "No such revision or branch '%s'" % resolved_id)
+                    "No such revision or branch '%s'" % resolved_id,
+                    resolved_id)
             elif len(revs) > 1:
                 raise ResolutionError(
                     "Multiple revisions start "
                     "with '%s': %s..." % (
                         resolved_id,
                         ", ".join("'%s'" % r for r in revs[0:3])
-                    ))
+                    ), resolved_id)
             else:
                 revision = self._revision_map[revs[0]]
 
@@ -341,7 +372,7 @@ class RevisionMap(object):
                     revision.revision, branch_rev.revision):
                 raise ResolutionError(
                     "Revision %s is not a member of branch '%s'" %
-                    (revision.revision, check_branch))
+                    (revision.revision, check_branch), resolved_id)
         return revision
 
     def filter_for_lineage(
@@ -648,7 +679,13 @@ class RevisionMap(object):
             r for r in uppers if r.revision in total_space)
 
         # iterate for total_space being emptied out
+        total_space_modified = True
         while total_space:
+
+            if not total_space_modified:
+                raise RevisionError(
+                    "Dependency resolution failed; iteration can't proceed")
+            total_space_modified = False
             # when everything non-branch pending is consumed,
             # add to the todo any branch nodes that have no
             # descendants left in the queue
@@ -669,6 +706,7 @@ class RevisionMap(object):
             while todo:
                 rev = todo.popleft()
                 total_space.remove(rev.revision)
+                total_space_modified = True
 
                 # do depth first for elements within branches,
                 # don't consume any actual branch nodes
@@ -731,6 +769,7 @@ class Revision(object):
         self.revision = revision
         self.down_revision = tuple_rev_as_scalar(down_revision)
         self.dependencies = tuple_rev_as_scalar(dependencies)
+        self._resolved_dependencies = ()
         self._orig_branch_labels = util.to_tuple(branch_labels, default=())
         self.branch_labels = set(self._orig_branch_labels)
 
@@ -756,7 +795,7 @@ class Revision(object):
     @property
     def _all_down_revisions(self):
         return util.to_tuple(self.down_revision, default=()) + \
-            util.to_tuple(self.dependencies, default=())
+            self._resolved_dependencies
 
     @property
     def _versioned_down_revisions(self):
@@ -788,6 +827,9 @@ class Revision(object):
         """Return True if this :class:`.Revision` is a "real" base revision,
         e.g. that it has no dependencies either."""
 
+        # we use self.dependencies here because this is called up
+        # in initialization where _real_dependencies isn't set up
+        # yet
         return self.down_revision is None and self.dependencies is None
 
     @property
index d0e3a6f97ba290862c8fde240bdc7c3a4f850b72..9fb1887a565bff8ba89b2d902dee5132fd48734e 100644 (file)
@@ -1,4 +1,4 @@
-.. _branches:
+f.. _branches:
 
 Working with Branches
 =====================
@@ -663,14 +663,13 @@ a revision file to refer to another as a "dependency", very similar to
 an entry in ``down_revision`` from a graph perspective, but different
 from a semantic perspective.
 
-First we will build out our new revision on the ``networking`` branch
-in the usual way::
+To use ``depends_on``, we can specify it as part of our ``alembic revision``
+command::
 
-    $ alembic revision -m "add ip account table" --head=networking@head
+    $ alembic revision -m "add ip account table" --head=networking@head  --depends-on=55af2cb1c267
       Generating /path/to/foo/model/networking/2a95102259be_add_ip_account_table.py ... done
 
-Next, we'll add an explicit dependency inside the file, by placing the
-directive ``depends_on='55af2cb1c267'`` underneath the other directives::
+Within our migration file, we'll see this new directive present::
 
     # revision identifiers, used by Alembic.
     revision = '2a95102259be'
@@ -678,9 +677,18 @@ directive ``depends_on='55af2cb1c267'`` underneath the other directives::
     branch_labels = None
     depends_on='55af2cb1c267'
 
-Currently, ``depends_on`` needs to be a real revision number, not a partial
-number or branch name.   It can of course refer to a tuple of any number
-of dependent revisions::
+``depends_on`` may be either a real revision number or a branch
+name.  When specified at the command line, a resolution from a
+partial revision number will work as well.   It can refer
+to any number of dependent revisions as well; for example, if we were
+to run the command::
+
+    $ alembic revision -m "add ip account table" \\
+        --head=networking@head  \\
+        --depends-on=55af2cb1c267 --depends-on=d747a --depends-on=fa445
+      Generating /path/to/foo/model/networking/2a95102259be_add_ip_account_table.py ... done
+
+We'd see inside the file::
 
     # revision identifiers, used by Alembic.
     revision = '2a95102259be'
@@ -688,6 +696,15 @@ of dependent revisions::
     branch_labels = None
     depends_on = ('55af2cb1c267', 'd747a8a8879', 'fa4456a9201')
 
+We also can of course add or alter this value within the file manually after
+it is generated, rather than using the ``--depends-on`` argument.
+
+.. versionadded:: 0.8 The ``depends_on`` attribute may be set directly
+   from the ``alembic revision`` command, rather than editing the file
+   directly.  ``depends_on`` identifiers may also be specified as
+   branch names at the command line or directly within the migration file.
+   The values may be specified as partial revision numbers from the command
+   line which will be resolved to full revision numbers in the output file.
 
 We can see the effect this directive has when we view the history
 of the ``networking`` branch in terms of "heads", e.g., all the revisions that
index 76373590f0eb1136b43eeaae3c142b112080b1f7..4b07c87b3cd2df02efb13e6ddd7d2ac26e7aadf1 100644 (file)
@@ -6,6 +6,19 @@ Changelog
 .. changelog::
     :version: 0.8.0
 
+    .. change::
+      :tags: feature, commands
+      :tickets: 311
+
+      Added new multiple-capable argument ``--depends-on`` to the
+      ``alembic revision`` command, allowing ``depends_on`` to be
+      established at the command line level rather than having to edit
+      the file after the fact. ``depends_on`` identifiers may also be
+      specified as branch names at the command line or directly within
+      the migration file. The values may be specified as partial
+      revision numbers from the command line which will be resolved to
+      full revision numbers in the output file.
+
     .. change::
       :tags: change, operations
 
index 00610230bf6861521273fb04b395a594a3034858..9f8428d2129969955ef2e71a216af76b735497f6 100644 (file)
@@ -218,6 +218,55 @@ finally:
         command.upgrade(self.cfg, "heads")
         command.revision(self.cfg, autogenerate=True)
 
+    def test_create_rev_depends_on(self):
+        self._env_fixture()
+        command.revision(self.cfg)
+        rev2 = command.revision(self.cfg)
+        rev3 = command.revision(self.cfg, depends_on=rev2.revision)
+        eq_(
+            rev3._resolved_dependencies, (rev2.revision, )
+        )
+
+        rev4 = command.revision(
+            self.cfg, depends_on=[rev2.revision, rev3.revision])
+        eq_(
+            rev4._resolved_dependencies, (rev2.revision, rev3.revision)
+        )
+
+    def test_create_rev_depends_on_branch_label(self):
+        self._env_fixture()
+        command.revision(self.cfg)
+        rev2 = command.revision(self.cfg, branch_label='foobar')
+        rev3 = command.revision(self.cfg, depends_on='foobar')
+        eq_(
+            rev3.dependencies, 'foobar'
+        )
+        eq_(
+            rev3._resolved_dependencies, (rev2.revision, )
+        )
+
+    def test_create_rev_depends_on_partial_revid(self):
+        self._env_fixture()
+        command.revision(self.cfg)
+        rev2 = command.revision(self.cfg)
+        assert len(rev2.revision) > 7
+        rev3 = command.revision(self.cfg, depends_on=rev2.revision[0:4])
+        eq_(
+            rev3.dependencies, rev2.revision
+        )
+        eq_(
+            rev3._resolved_dependencies, (rev2.revision, )
+        )
+
+    def test_create_rev_invalid_depends_on(self):
+        self._env_fixture()
+        command.revision(self.cfg)
+        assert_raises_message(
+            util.CommandError,
+            "Can't locate revision identified by 'invalid'",
+            command.revision, self.cfg, depends_on='invalid'
+        )
+
     def test_create_rev_autogenerate_db_not_up_to_date_post_merge(self):
         self._env_fixture()
         command.revision(self.cfg)
index 4efedd08552727eebbea2de8dbbde732ed807e4d..a96aa5b2ec7ccee60c973b0b87b5d211e33a5aca 100644 (file)
@@ -843,7 +843,7 @@ class MultipleBaseCrossDependencyTestTwo(DownIterateTest):
                 Revision('b1', 'a1'),
                 Revision('c1', 'b1'),
 
-                Revision('base2', (), dependencies='base1', branch_labels='b_2'),
+                Revision('base2', (), dependencies='b_1', branch_labels='b_2'),
                 Revision('a2', 'base2'),
                 Revision('b2', 'a2'),
                 Revision('c2', 'b2'),
@@ -941,3 +941,28 @@ class LargeMapTest(DownIterateTest):
             remaining = set(revs[idx + 1:])
             if remaining:
                 assert remaining.intersection(ancestors)
+
+
+class DepResolutionFailedTest(DownIterateTest):
+    def setUp(self):
+        self.map = RevisionMap(
+            lambda: [
+                Revision('base1', ()),
+                Revision('a1', 'base1'),
+                Revision('a2', 'base1'),
+                Revision('b1', 'a1'),
+                Revision('c1', 'b1'),
+            ]
+        )
+        # intentionally make a broken map
+        self.map._revision_map['fake'] = self.map._revision_map['a2']
+        self.map._revision_map['b1'].dependencies = 'fake'
+        self.map._revision_map['b1']._resolved_dependencies = ('fake', )
+
+    def test_failure_message(self):
+        iter_ = self.map.iterate_revisions("c1", "base1")
+        assert_raises_message(
+            RevisionError,
+            "Dependency resolution failed;",
+            list, iter_
+        )
index 198c7c695c84ea01e51e5fa0c4e3efeb6a891404..b22b177cf345003b8e31a261663e0fdf74a074a1 100644 (file)
@@ -692,6 +692,49 @@ class DependsOnBranchTestTwo(MigrationTest):
         )
 
 
+class DependsOnBranchLabelTest(MigrationTest):
+    @classmethod
+    def setup_class(cls):
+        cls.env = env = staging_env()
+        cls.a1 = env.generate_revision(
+            util.rev_id(), '->a1',
+            branch_labels=['lib1'])
+        cls.b1 = env.generate_revision(util.rev_id(), 'a1->b1')
+        cls.c1 = env.generate_revision(
+            util.rev_id(), 'b1->c1',
+            branch_labels=['c1lib'])
+
+        cls.a2 = env.generate_revision(util.rev_id(), '->a2', head=())
+        cls.b2 = env.generate_revision(
+            util.rev_id(), 'a2->b2', head=cls.a2.revision)
+        cls.c2 = env.generate_revision(
+            util.rev_id(), 'b2->c2', head=cls.b2.revision,
+            depends_on=['c1lib'])
+
+        cls.d1 = env.generate_revision(
+            util.rev_id(), 'c1->d1',
+            head=cls.c1.revision)
+        cls.e1 = env.generate_revision(
+            util.rev_id(), 'd1->e1',
+            head=cls.d1.revision)
+        cls.f1 = env.generate_revision(
+            util.rev_id(), 'e1->f1',
+            head=cls.e1.revision)
+
+    def test_upgrade_path(self):
+        self._assert_upgrade(
+            self.c2.revision, self.a2.revision,
+            [
+                self.up_(self.a1),
+                self.up_(self.b1),
+                self.up_(self.c1),
+                self.up_(self.b2),
+                self.up_(self.c2),
+            ],
+            set([self.c2.revision, ])
+        )
+
+
 class ForestTest(MigrationTest):
 
     @classmethod