summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2015-07-28 18:52:51 -0400
committerMike Bayer <mike_mp@zzzcomputing.com>2015-07-28 18:52:51 -0400
commit3836001181dc7f52ae111995df2ab71463cb3c96 (patch)
treea46e412c3a9d15190f8d5ffdd0b817d110cfbf55
parent96ba33d092556be4935b7b28d92f7af90145a6c6 (diff)
downloadalembic-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.py4
-rw-r--r--alembic/command.py4
-rw-r--r--alembic/config.py8
-rw-r--r--alembic/operations/ops.py3
-rw-r--r--alembic/script/base.py22
-rw-r--r--alembic/script/revision.py60
-rw-r--r--docs/build/branches.rst35
-rw-r--r--docs/build/changelog.rst13
-rw-r--r--tests/test_command.py49
-rw-r--r--tests/test_revision.py27
-rw-r--r--tests/test_version_traversal.py43
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