diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2015-07-28 18:52:51 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2015-07-28 18:52:51 -0400 |
commit | 3836001181dc7f52ae111995df2ab71463cb3c96 (patch) | |
tree | a46e412c3a9d15190f8d5ffdd0b817d110cfbf55 | |
parent | 96ba33d092556be4935b7b28d92f7af90145a6c6 (diff) | |
download | alembic-3836001181dc7f52ae111995df2ab71463cb3c96.tar.gz |
- 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.
fixes #311
-rw-r--r-- | alembic/autogenerate/api.py | 4 | ||||
-rw-r--r-- | alembic/command.py | 4 | ||||
-rw-r--r-- | alembic/config.py | 8 | ||||
-rw-r--r-- | alembic/operations/ops.py | 3 | ||||
-rw-r--r-- | alembic/script/base.py | 22 | ||||
-rw-r--r-- | alembic/script/revision.py | 60 | ||||
-rw-r--r-- | docs/build/branches.rst | 35 | ||||
-rw-r--r-- | docs/build/changelog.rst | 13 | ||||
-rw-r--r-- | tests/test_command.py | 49 | ||||
-rw-r--r-- | tests/test_revision.py | 27 | ||||
-rw-r--r-- | tests/test_version_traversal.py | 43 |
11 files changed, 244 insertions, 24 deletions
diff --git a/alembic/autogenerate/api.py b/alembic/autogenerate/api.py index e9af4cf..811ebf4 100644 --- a/alembic/autogenerate/api.py +++ b/alembic/autogenerate/api.py @@ -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 diff --git a/alembic/command.py b/alembic/command.py index 3ce5131..aab5cc2 100644 --- a/alembic/command.py +++ b/alembic/command.py @@ -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) diff --git a/alembic/config.py b/alembic/config.py index b3fc36f..1d4169d 100644 --- a/alembic/config.py +++ b/alembic/config.py @@ -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( diff --git a/alembic/operations/ops.py b/alembic/operations/ops.py index da50c48..314b49b 100644 --- a/alembic/operations/ops.py +++ b/alembic/operations/ops.py @@ -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 diff --git a/alembic/script/base.py b/alembic/script/base.py index e30c8b2..a62fd60 100644 --- a/alembic/script/base.py +++ b/alembic/script/base.py @@ -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, diff --git a/alembic/script/revision.py b/alembic/script/revision.py index 0f502f6..e618c4d 100644 --- a/alembic/script/revision.py +++ b/alembic/script/revision.py @@ -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 diff --git a/docs/build/branches.rst b/docs/build/branches.rst index d0e3a6f..9fb1887 100644 --- a/docs/build/branches.rst +++ b/docs/build/branches.rst @@ -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 diff --git a/docs/build/changelog.rst b/docs/build/changelog.rst index 7637359..4b07c87 100644 --- a/docs/build/changelog.rst +++ b/docs/build/changelog.rst @@ -7,6 +7,19 @@ 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 A range of positional argument names have been changed to be diff --git a/tests/test_command.py b/tests/test_command.py index 0061023..9f8428d 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -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) diff --git a/tests/test_revision.py b/tests/test_revision.py index 4efedd0..a96aa5b 100644 --- a/tests/test_revision.py +++ b/tests/test_revision.py @@ -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_ + ) diff --git a/tests/test_version_traversal.py b/tests/test_version_traversal.py index 198c7c6..b22b177 100644 --- a/tests/test_version_traversal.py +++ b/tests/test_version_traversal.py @@ -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 |