diff --git a/CHANGES b/CHANGES index 7950a13..001bd6a 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,24 @@ +0.3.0 +===== +- [general] The focus of 0.3 is to clean up + and more fully document the public API of Alembic, + including better accessors on the MigrationContext + and ScriptDirectory objects. Methods that are + not considered to be public on these objects have + been underscored, and methods which should be public + have been cleaned up and documented, including: + + MigrationContext.get_current_revision() + ScriptDirectory.iterate_revisions() + ScriptDirectory.get_current_head() + ScriptDirectory.get_heads() + ScriptDirectory.get_base() + ScriptDirectory.generate_revision() + +- [feature] Added a bit of autogenerate to the + public API in the form of the function + alembic.autogenerate.compare_metadata. + 0.2.2 ===== - [feature] Informative error message when op.XYZ @@ -196,7 +217,7 @@ into a new schema. For dev environments, the dev installer should be building the whole DB from scratch. Or just use Postgresql, which is a much - better database for non-trivial schemas. + better database for non-trivial schemas. Requests for full ALTER support on SQLite should be reported to SQLite's bug tracker at http://www.sqlite.org/src/wiki?name=Bug+Reports, @@ -258,7 +279,7 @@ by key, etc. for full support here. - Support for tables in remote schemas, - i.e. "schemaname.tablename", is very poor. + i.e. "schemaname.tablename", is very poor. Missing "schema" behaviors should be reported as tickets, though in the author's experience, migrations typically proceed only diff --git a/alembic/__init__.py b/alembic/__init__.py index 7c50518..c1c253c 100644 --- a/alembic/__init__.py +++ b/alembic/__init__.py @@ -1,6 +1,6 @@ from os import path -__version__ = '0.2.2' +__version__ = '0.3.0' package_dir = path.abspath(path.dirname(__file__)) diff --git a/alembic/autogenerate.py b/alembic/autogenerate.py index ba0ecd9..5fa856c 100644 --- a/alembic/autogenerate.py +++ b/alembic/autogenerate.py @@ -11,10 +11,95 @@ log = logging.getLogger(__name__) ################################################### -# top level +# public +def compare_metadata(context, metadata): + """Compare a database schema to that given in a :class:`~sqlalchemy.schema.MetaData` + instance. + + The database connection is presented in the context + of a :class:`.MigrationContext` object, which + provides database connectivity as well as optional + comparison functions to use for datatypes and + server defaults - see the "autogenerate" arguments + at :meth:`.EnvironmentContext.configure` + for details on these. + + The return format is a list of "diff" directives, + each representing individual differences:: + + from alembic.migration import MigrationContext + from alembic.autogenerate import compare_metadata + from sqlalchemy.schema import SchemaItem + from sqlalchemy.types import TypeEngine + from sqlalchemy import (create_engine, MetaData, Column, + Integer, String, Table) + import pprint + + engine = create_engine("sqlite://") + + engine.execute(''' + create table foo ( + id integer not null primary key, + old_data varchar, + x integer + )''') + + engine.execute(''' + create table bar ( + data varchar + )''') + + metadata = MetaData() + Table('foo', metadata, + Column('id', Integer, primary_key=True), + Column('data', Integer), + Column('x', Integer, nullable=False) + ) + Table('bat', metadata, + Column('info', String) + ) + + mc = MigrationContext.configure(engine.connect()) + + diff = compare_metadata(mc, metadata) + pprint.pprint(diff, indent=2, width=20) + + Output:: + + [ ( 'add_table', + Table('bat', MetaData(bind=None), Column('info', String(), table=), schema=None)), + ( 'remove_table', + Table(u'bar', MetaData(bind=None), Column(u'data', VARCHAR(), table=), schema=None)), + ( 'add_column', + 'foo', + Column('data', Integer(), table=)), + ( 'remove_column', + 'foo', + Column(u'old_data', VARCHAR(), table=None)), + [ ( 'modify_nullable', + 'foo', + u'x', + { 'existing_server_default': None, + 'existing_type': INTEGER()}, + True, + False)]] + + + :param context: a :class:`.MigrationContext` + instance. + :param metadata: a :class:`~sqlalchemy.schema.MetaData` + instance. + + """ + autogen_context, connection = _autogen_context(context, None) + diffs = [] + _produce_net_changes(connection, metadata, diffs, autogen_context) + return diffs +################################################### +# top level -def produce_migration_diffs(context, template_args, imports): +def _produce_migration_diffs(context, template_args, imports): opts = context.opts metadata = opts['target_metadata'] if metadata is None: @@ -24,15 +109,9 @@ def produce_migration_diffs(context, template_args, imports): "a MetaData object to the context." % ( context.script.env_py_location )) - connection = context.bind + autogen_context, connection = _autogen_context(context, imports) + diffs = [] - autogen_context = { - 'imports':imports, - 'connection':connection, - 'dialect':connection.dialect, - 'context':context, - 'opts':opts - } _produce_net_changes(connection, metadata, diffs, autogen_context) template_args[opts['upgrade_token']] = \ _indent(_produce_upgrade_commands(diffs, autogen_context)) @@ -40,6 +119,16 @@ def produce_migration_diffs(context, template_args, imports): _indent(_produce_downgrade_commands(diffs, autogen_context)) template_args['imports'] = "\n".join(sorted(imports)) +def _autogen_context(context, imports): + opts = context.opts + connection = context.bind + return { + 'imports':imports, + 'connection':connection, + 'dialect':connection.dialect, + 'context':context, + 'opts':opts + }, connection def _indent(text): text = "### commands auto generated by Alembic - please adjust! ###\n" + text @@ -178,7 +267,7 @@ def _compare_type(tname, cname, conn_col, log.info("Column '%s.%s' has no type within the model; can't compare" % (tname, cname)) return - isdiff = autogen_context['context'].compare_type(conn_col, metadata_col) + isdiff = autogen_context['context']._compare_type(conn_col, metadata_col) if isdiff: @@ -203,7 +292,7 @@ def _compare_server_default(tname, cname, conn_col, metadata_col, if conn_col_default is None and metadata_default is None: return False rendered_metadata_default = _render_server_default(metadata_default, autogen_context) - isdiff = autogen_context['context'].compare_server_default( + isdiff = autogen_context['context']._compare_server_default( conn_col, metadata_col, rendered_metadata_default ) diff --git a/alembic/command.py b/alembic/command.py index 18b421c..ed7b830 100644 --- a/alembic/command.py +++ b/alembic/command.py @@ -1,7 +1,7 @@ from alembic.script import ScriptDirectory -from alembic import util, ddl, autogenerate as autogen, environment +from alembic.environment import EnvironmentContext +from alembic import util, ddl, autogenerate as autogen import os -import functools def list_templates(config): """List available templates""" @@ -44,14 +44,14 @@ def init(config, directory, template='generic'): if os.access(config_file, os.F_OK): util.msg("File %s already exists, skipping" % config_file) else: - script.generate_template( + script._generate_template( os.path.join(template_dir, file_), config_file, script_location=directory ) else: output_file = os.path.join(directory, file_) - script.copy_file( + script._copy_file( os.path.join(template_dir, file_), output_file ) @@ -68,18 +68,18 @@ def revision(config, message=None, autogenerate=False): if autogenerate: util.requires_07("autogenerate") def retrieve_migrations(rev, context): - if script._get_rev(rev) is not script._get_rev("head"): + if script.get_revision(rev) is not script.get_revision("head"): raise util.CommandError("Target database is not up to date.") - autogen.produce_migration_diffs(context, template_args, imports) + autogen._produce_migration_diffs(context, template_args, imports) return [] - with environment.configure( + with EnvironmentContext( config, script, fn = retrieve_migrations ): script.run_env() - script.generate_rev(util.rev_id(), message, **template_args) + script.generate_revision(util.rev_id(), message, **template_args) def upgrade(config, revision, sql=False, tag=None): @@ -92,10 +92,14 @@ def upgrade(config, revision, sql=False, tag=None): if not sql: raise util.CommandError("Range revision not allowed") starting_rev, revision = revision.split(':', 2) - with environment.configure( + + def upgrade(rev, context): + return script._upgrade_revs(revision, rev) + + with EnvironmentContext( config, script, - fn = functools.partial(script.upgrade_from, revision), + fn = upgrade, as_sql = sql, starting_rev = starting_rev, destination_rev = revision, @@ -114,10 +118,13 @@ def downgrade(config, revision, sql=False, tag=None): raise util.CommandError("Range revision not allowed") starting_rev, revision = revision.split(':', 2) - with environment.configure( + def downgrade(rev, context): + return script._downgrade_revs(revision, rev) + + with EnvironmentContext( config, script, - fn = functools.partial(script.downgrade_to, revision), + fn = downgrade, as_sql = sql, starting_rev = starting_rev, destination_rev = revision, @@ -143,7 +150,7 @@ def branches(config): for rev in sc.nextrev: print "%s -> %s" % ( " " * len(str(sc.down_revision)), - script._get_rev(rev) + script.get_revision(rev) ) def current(config): @@ -154,10 +161,10 @@ def display_version(rev, context): print "Current revision for %s: %s" % ( util.obfuscate_url_pw( context.connection.engine.url), - script._get_rev(rev)) + script.get_revision(rev)) return [] - with environment.configure( + with EnvironmentContext( config, script, fn = display_version @@ -174,12 +181,12 @@ def do_stamp(rev, context): current = False else: current = context._current_rev() - dest = script._get_rev(revision) + dest = script.get_revision(revision) if dest is not None: dest = dest.revision context._update_current_rev(current, dest) return [] - with environment.configure( + with EnvironmentContext( config, script, fn = do_stamp, diff --git a/alembic/config.py b/alembic/config.py index 998bb85..92e0921 100644 --- a/alembic/config.py +++ b/alembic/config.py @@ -32,6 +32,7 @@ class Config(object): from alembic.config import Config alembic_cfg = Config() + alembic_cfg.set_main_option("script_location", "myapp:migrations") alembic_cfg.set_main_option("url", "postgresql://foo/bar") alembic_cfg.set_section_option("mysection", "foo", "bar") diff --git a/alembic/environment.py b/alembic/environment.py index 231aceb..52c353a 100644 --- a/alembic/environment.py +++ b/alembic/environment.py @@ -13,6 +13,50 @@ class EnvironmentContext(object): :class:`.EnvironmentContext` is available via the ``alembic.context`` datamember. + :class:`.EnvironmentContext` is also a Python context + manager, that is, is intended to be used using the + ``with:`` statement. A typical use of :class:`.EnvironmentContext`:: + + from alembic.config import Config + from alembic.script import ScriptDirectory + + config = Config() + config.set_main_option("script_location", "myapp:migrations") + script = ScriptDirectory.from_config(config) + + def my_function(rev, context): + '''do something with revision "rev", which + will be the current database revision, + and "context", which is the MigrationContext + that the env.py will create''' + + with EnvironmentContext( + config, + script, + fn = my_function, + as_sql = False, + starting_rev = 'base', + destination_rev = 'head', + tag = "sometag" + ): + script.run_env() + + The above script will invoke the ``env.py`` script + within the migration environment. If and when ``env.py`` + calls :meth:`.MigrationContext.run_migrations`, the + ``my_function()`` function above will be called + by the :class:`.MigrationContext`, given the context + itself as well as the current revision in the database. + + .. note:: + + For most API usages other than full blown + invocation of migration scripts, the :class:`.MigrationContext` + and :class:`.ScriptDirectory` objects can be created and + used directly. The :class:`.EnvironmentContext` object + is *only* needed when you need to actually invoke the + ``env.py`` module present in the migration environment. + """ _migration_context = None @@ -31,6 +75,15 @@ class EnvironmentContext(object): """ def __init__(self, config, script, **kw): + """Construct a new :class:`.EnvironmentContext`. + + :param config: a :class:`.Config` instance. + :param script: a :class:`.ScriptDirectory` instance. + :param \**kw: keyword options that will be ultimately + passed along to the :class:`.MigrationContext` when + :meth:`.EnvironmentContext.configure` is called. + + """ self.config = config self.script = script self.context_opts = kw @@ -495,4 +548,3 @@ def get_bind(self): def get_impl(self): return self.get_context().impl -configure = EnvironmentContext diff --git a/alembic/migration.py b/alembic/migration.py index d610b3b..80e53b2 100644 --- a/alembic/migration.py +++ b/alembic/migration.py @@ -15,31 +15,48 @@ ) class MigrationContext(object): - """Represent the state made available to a migration script, - or otherwise a series of migration operations. + """Represent the database state made available to a migration + script. - Mediates the relationship between an ``env.py`` environment script, - a :class:`.ScriptDirectory` instance, and a :class:`.DefaultImpl` instance. - - The :class:`.MigrationContext` that's established for a - duration of a migration command is available via the + :class:`.MigrationContext` is the front end to an actual + database connection, or alternatively a string output + stream given a particular database dialect, + from an Alembic perspective. + + When inside the ``env.py`` script, the :class:`.MigrationContext` + is available via the :meth:`.EnvironmentContext.get_context` method, which is available at ``alembic.context``:: + # from within env.py script from alembic import context migration_context = context.get_context() - A :class:`.MigrationContext` can be created programmatically - for usage outside of the usual Alembic migrations flow, - using the :meth:`.MigrationContext.configure` method:: + For usage outside of an ``env.py`` script, such as for + utility routines that want to check the current version + in the database, the :meth:`.MigrationContext.configure` + method to create new :class:`.MigrationContext` objects. + For example, to get at the current revision in the + database using :meth:`.MigrationContext.get_current_revision`:: - conn = myengine.connect() - ctx = MigrationContext.configure(conn) + # in any application, outside of an env.py script + from alembic.migration import MigrationContext + from sqlalchemy import create_engine + + engine = create_engine("postgresql://mydatabase") + conn = engine.connect() + + context = MigrationContext.configure(conn) + current_rev = context.get_current_revision() - The above context can then be used to produce + The above context can also be used to produce Alembic migration operations with an :class:`.Operations` - instance. - + instance:: + + # in any application, outside of the normal Alembic environment + from alembic.operations import Operations + op = Operations(context) + op.alter_column("mytable", "somecolumn", nullable=True) """ def __init__(self, dialect, connection, opts): @@ -119,7 +136,15 @@ def configure(cls, return MigrationContext(dialect, connection, opts) - def _current_rev(self): + def get_current_revision(self): + """Return the current revision, usually that which is present + in the ``alembic_version`` table in the database. + + If this :class:`.MigrationContext` was configured in "offline" + mode, that is with ``as_sql=True``, the ``starting_rev`` + parameter is returned instead, if any. + + """ if self.as_sql: return self._start_from_rev else: @@ -130,6 +155,9 @@ def _current_rev(self): _version.create(self.connection, checkfirst=True) return self.connection.scalar(_version.select()) + _current_rev = get_current_revision + """The 0.2 method name, for backwards compat.""" + def _update_current_rev(self, old, new): if old == new: return @@ -145,11 +173,30 @@ def _update_current_rev(self, old, new): ) def run_migrations(self, **kw): - + """Run the migration scripts established for this :class:`.MigrationContext`, + if any. + + The commands in :mod:`alembic.command` will set up a function + that is ultimately passed to the :class:`.MigrationContext` + as the ``fn`` argument. This function represents the "work" + that will be done when :meth:`.MigrationContext.run_migrations` + is called, typically from within the ``env.py`` script of the + migration environment. The "work function" then provides an iterable + of version callables and other version information which + in the case of the ``upgrade`` or ``downgrade`` commands are the + list of version scripts to invoke. Other commands yield nothing, + in the case that a command wants to run some other operation + against the database such as the ``current`` or ``stamp`` commands. + + :param \**kw: keyword arguments here will be passed to each + migration callable, that is the ``upgrade()`` or ``downgrade()`` + method within revision scripts. + + """ current_rev = rev = False self.impl.start_migrations() for change, prev_rev, rev in self._migrations_fn( - self._current_rev(), + self.get_current_revision(), self): if current_rev is False: current_rev = prev_rev @@ -174,6 +221,14 @@ def run_migrations(self, **kw): _version.drop(self.connection) def execute(self, sql): + """Execute a SQL construct or string statement. + + The underlying execution mechanics are used, that is + if this is "offline mode" the SQL is written to the + output buffer, otherwise the SQL is emitted on + the current SQLAlchemy connection. + + """ self.impl._exec(sql) def _stdout_connection(self, connection): @@ -203,7 +258,7 @@ def bind(self): """ return self.connection - def compare_type(self, inspector_column, metadata_column): + def _compare_type(self, inspector_column, metadata_column): if self._user_compare_type is False: return False @@ -222,7 +277,7 @@ def compare_type(self, inspector_column, metadata_column): inspector_column, metadata_column) - def compare_server_default(self, inspector_column, + def _compare_server_default(self, inspector_column, metadata_column, rendered_metadata_default): diff --git a/alembic/script.py b/alembic/script.py index 952b572..bef7dfb 100644 --- a/alembic/script.py +++ b/alembic/script.py @@ -15,7 +15,22 @@ class ScriptDirectory(object): """Provides operations upon an Alembic script directory. - + + This object is useful to get information as to current revisions, + most notably being able to get at the "head" revision, for schemes + that want to test if the current revision in the database is the most + recent:: + + from alembic.script import ScriptDirectory + from alembic.config import Config + config = Config() + config.set_main_option("script_location", "myapp:migrations") + script = ScriptDirectory.from_config(config) + + head_revision = script.get_current_head() + + + """ def __init__(self, dir, file_template=_default_file_template): self.dir = dir @@ -29,6 +44,13 @@ def __init__(self, dir, file_template=_default_file_template): @classmethod def from_config(cls, config): + """Produce a new :class:`.ScriptDirectory` given a :class:`.Config` + instance. + + The :class:`.Config` need only have the ``script_location`` key + present. + + """ return ScriptDirectory( util.coerce_resource_to_filename( config.get_main_option('script_location') @@ -45,23 +67,25 @@ def walk_revisions(self): with leaf nodes being heads. """ - heads = set(self._get_heads()) - base = self._get_rev("base") + heads = set(self.get_heads()) + base = self.get_revision("base") while heads: todo = set(heads) heads = set() for head in todo: if head in heads: break - for sc in self._revs(head, base): + for sc in self.iterate_revisions(head, base): if sc.is_branch_point and sc.revision not in todo: heads.add(sc.revision) break else: yield sc - def _get_rev(self, id_): - id_ = self._as_rev_number(id_) + def get_revision(self, id_): + """Return the :class:`.Script` instance with the given rev id.""" + + id_ = self.as_revision_number(id_) try: return self._revision_map[id_] except KeyError: @@ -80,16 +104,34 @@ def _get_rev(self, id_): else: return self._revision_map[revs[0]] - def _as_rev_number(self, id_): + _get_rev = get_revision + + def as_revision_number(self, id_): + """Convert a symbolic revision, i.e. 'head' or 'base', into + an actual revision number.""" + if id_ == 'head': - id_ = self._current_head() + id_ = self.get_current_head() elif id_ == 'base': id_ = None return id_ - def _revs(self, upper, lower): - lower = self._get_rev(lower) - upper = self._get_rev(upper) + _as_rev_number = as_revision_number + + def iterate_revisions(self, upper, lower): + """Iterate through script revisions, starting at the given + upper revision identifier and ending at the lower. + + The traversal uses strictly the `down_revision` + marker inside each migration script, so + it is a requirement that upper >= lower, + else you'll get nothing back. + + The iterator yields :class:`.Script` objects. + + """ + lower = self.get_revision(lower) + upper = self.get_revision(upper) script = upper while script != lower: yield script @@ -99,15 +141,15 @@ def _revs(self, upper, lower): raise util.CommandError( "Couldn't find revision %s" % downrev) - def upgrade_from(self, destination, current_rev, context): - revs = self._revs(destination, current_rev) + def _upgrade_revs(self, destination, current_rev): + revs = self.iterate_revisions(destination, current_rev) return [ (script.module.upgrade, script.down_revision, script.revision) for script in reversed(list(revs)) ] - def downgrade_to(self, destination, current_rev, context): - revs = self._revs(current_rev, destination) + def _downgrade_revs(self, destination, current_rev): + revs = self.iterate_revisions(current_rev, destination) return [ (script.module.downgrade, script.revision, script.down_revision) for script in revs @@ -115,12 +157,12 @@ def downgrade_to(self, destination, current_rev, context): def run_env(self): """Run the script environment. - + This basically runs the ``env.py`` script present in the migration environment. It is called exclusively by the command functions in :mod:`alembic.command`. - - + + """ util.load_python_file(self.dir, 'env.py') @@ -132,7 +174,7 @@ def env_py_location(self): def _revision_map(self): map_ = {} for file_ in os.listdir(self.versions): - script = Script.from_filename(self.versions, file_) + script = Script._from_filename(self.versions, file_) if script is None: continue if script.revision in map_: @@ -158,8 +200,16 @@ def _rev_path(self, rev_id, message): ) return os.path.join(self.versions, filename) - def _current_head(self): - current_heads = self._get_heads() + def get_current_head(self): + """Return the current head revision. + + If the script directory has multiple heads + due to branching, an error is raised. + + Returns a string revision number. + + """ + current_heads = self.get_heads() if len(current_heads) > 1: raise util.CommandError("Only a single head supported so far...") if current_heads: @@ -167,22 +217,45 @@ def _current_head(self): else: return None - def _get_heads(self): + _current_head = get_current_head + """the 0.2 name, for backwards compat.""" + + def get_heads(self): + """Return all "head" revisions as strings. + + Returns a list of string revision numbers. + + This is normally a list of length one, + unless branches are present. The + :meth:`.ScriptDirectory.get_current_head()` method + can be used normally when a script directory + has only one head. + + """ heads = [] for script in self._revision_map.values(): if script and script.is_head: heads.append(script.revision) return heads - def _get_origin(self): + def get_base(self): + """Return the "base" revision as a string. + + This is the revision number of the script that + has a ``down_revision`` of None. + + Behavior is not defined if more than one script + has a ``down_revision`` of None. + + """ for script in self._revision_map.values(): if script.down_revision is None \ and script.revision in self._revision_map: - return script + return script.revision else: return None - def generate_template(self, src, dest, **kw): + def _generate_template(self, src, dest, **kw): util.status("Generating %s" % os.path.abspath(dest), util.template_to_file, src, @@ -190,15 +263,33 @@ def generate_template(self, src, dest, **kw): **kw ) - def copy_file(self, src, dest): + def _copy_file(self, src, dest): util.status("Generating %s" % os.path.abspath(dest), shutil.copy, src, dest) - def generate_rev(self, revid, message, refresh=False, **kw): - current_head = self._current_head() + def generate_revision(self, revid, message, refresh=False, **kw): + """Generate a new revision file. + + This runs the ``script.py.mako`` template, given + template arguments, and creates a new file. + + :param revid: String revision id. Typically this + comes from ``alembic.util.rev_id()``. + :param message: the revision message, the one passed + by the -m argument to the ``revision`` command. + :param refresh: when True, the in-memory state of this + :class:`.ScriptDirectory` will be updated with a new + :class:`.Script` instance representing the new revision; + the :class:`.Script` instance is returned. + If False, the file is created but the state of the + :class:`.ScriptDirectory` is unmodified; ``None`` + is returned. + + """ + current_head = self.get_current_head() path = self._rev_path(revid, message) - self.generate_template( + self._generate_template( os.path.join(self.dir, "script.py.mako"), path, up_revision=str(revid), @@ -208,18 +299,24 @@ def generate_rev(self, revid, message, refresh=False, **kw): **kw ) if refresh: - script = Script.from_path(path) + script = Script._from_path(path) self._revision_map[script.revision] = script if script.down_revision: self._revision_map[script.down_revision].\ add_nextrev(script.revision) return script else: - return revid + return None class Script(object): - """Represent a single revision file in a ``versions/`` directory.""" + """Represent a single revision file in a ``versions/`` directory. + + The :class:`.Script` instance is returned by methods + such as :meth:`.ScriptDirectory.iterate_revisions`. + + """ + nextrev = frozenset() def __init__(self, module, rev_id, path): @@ -228,8 +325,21 @@ def __init__(self, module, rev_id, path): self.path = path self.down_revision = getattr(module, 'down_revision', None) + revision = None + """The string revision number for this :class:`.Script` instance.""" + + module = None + """The Python module representing the actual script itself.""" + + path = None + """Filesystem path of the script.""" + + down_revision = None + """The ``down_revision`` identifier within the migration script.""" + @property def doc(self): + """Return the docstring given in the script.""" return re.split(r"\n\n", self.module.__doc__)[0] def add_nextrev(self, rev): @@ -237,10 +347,25 @@ def add_nextrev(self, rev): @property def is_head(self): + """Return True if this :class:`.Script` is a 'head' revision. + + This is determined based on whether any other :class:`.Script` + within the :class:`.ScriptDirectory` refers to this + :class:`.Script`. Multiple heads can be present. + + """ return not bool(self.nextrev) @property def is_branch_point(self): + """Return True if this :class:`.Script` is a branch point. + + A branchpoint is defined as a :class:`.Script` which is referred + to by more than one succeeding :class:`.Script`, that is more + than one :class:`.Script` has a `down_revision` identifier pointing + here. + + """ return len(self.nextrev) > 1 def __str__(self): @@ -252,12 +377,12 @@ def __str__(self): self.doc) @classmethod - def from_path(cls, path): + def _from_path(cls, path): dir_, filename = os.path.split(path) - return cls.from_filename(dir_, filename) + return cls._from_filename(dir_, filename) @classmethod - def from_filename(cls, dir_, filename): + def _from_filename(cls, dir_, filename): m = _rev_file.match(filename) if not m: return None diff --git a/docs/build/api.rst b/docs/build/api.rst index def71a3..de8f37e 100644 --- a/docs/build/api.rst +++ b/docs/build/api.rst @@ -1,3 +1,5 @@ +.. _api: + =========== API Details =========== @@ -40,26 +42,36 @@ database connectivity, though in such a way that it does not care if the :class:`.MigrationContext` is talking to a real database or just writing out SQL to a file. -env.py Directives -================= +The Environment Context +======================= -This section covers the objects that are generally used within an -``env.py`` environmental configuration script. Alembic normally generates -this script for you; it is however made available locally within the migrations -environment so that it can be customized. +The :class:`.EnvironmentContext` class provides most of the +API used within an ``env.py`` script. Within ``env.py``, +the instantated :class:`.EnvironmentContext` is made available +via a special *proxy module* called ``alembic.context``. That is, +you can import ``alembic.context`` like a regular Python module, +and each name you call upon it is ultimately routed towards the +current :class:`.EnvironmentContext` in use. In particular, the key method used within ``env.py`` is :meth:`.EnvironmentContext.configure`, which establishes all the details about how the database will be accessed. - -.. autofunction:: sqlalchemy.engine.engine_from_config - .. automodule:: alembic.environment :members: +The Migration Context +===================== + .. automodule:: alembic.migration :members: +The Operations Object +===================== + +Within migration scripts, actual database migration operations are handled +via an instance of :class:`.Operations`. See :ref:`ops` for an overview +of this object. + Commands ========= @@ -75,31 +87,68 @@ object, as in:: alembic_cfg = Config("/path/to/yourapp/alembic.ini") command.upgrade(alembic_cfg, "head") +To write small API functions that make direct use of database and script directory +information, rather than just running one of the built-in commands, +use the :class:`.ScriptDirectory` and :class:`.MigrationContext` +classes directly. + .. currentmodule:: alembic.command .. automodule:: alembic.command :members: - :undoc-members: Configuration ============== +The :class:`.Config` object represents the configuration +passed to the Alembic environment. From an API usage perspective, +it is needed for the following use cases: + +* to create a :class:`.ScriptDirectory`, which allows you to work + with the actual script files in a migration environment +* to create an :class:`.EnvironmentContext`, which allows you to + actually run the ``env.py`` module within the migration environment +* to programatically run any of the commands in the :mod:`alembic.command` + module. + +The :class:`.Config` is *not* needed for these cases: + +* to instantiate a :class:`.MigrationContext` directly - this object + only needs a SQLAlchemy connection or dialect name. +* to instantiate a :class:`.Operations` object - this object only + needs a :class:`.MigrationContext`. + .. currentmodule:: alembic.config .. automodule:: alembic.config :members: - :undoc-members: +Script Directory +================ -Internals -========= +The :class:`.ScriptDirectory` object provides programmatic access +to the Alembic version files present in the filesystem. .. automodule:: alembic.script :members: - :undoc-members: + +Autogeneration +============== + +Alembic 0.3 introduces a small portion of the autogeneration system +as a public API. + +.. autofunction:: alembic.autogenerate.compare_metadata DDL Internals -------------- +============= + +These are some of the constructs used to generate migration +instructions. The APIs here build off of the :class:`sqlalchemy.schema.DDLElement` +and :mod:`sqlalchemy.ext.compiler` systems. + +For programmatic usage of Alembic's migration directives, the easiest +route is to use the higher level functions given by :mod:`alembic.operations`. .. automodule:: alembic.ddl :members: @@ -114,7 +163,7 @@ DDL Internals :undoc-members: MySQL -^^^^^ +----- .. automodule:: alembic.ddl.mysql :members: @@ -122,7 +171,7 @@ MySQL :show-inheritance: MS-SQL -^^^^^^ +------ .. automodule:: alembic.ddl.mssql :members: @@ -130,7 +179,7 @@ MS-SQL :show-inheritance: Postgresql -^^^^^^^^^^ +---------- .. automodule:: alembic.ddl.postgresql :members: @@ -138,7 +187,7 @@ Postgresql :show-inheritance: SQLite -^^^^^^ +------ .. automodule:: alembic.ddl.sqlite :members: diff --git a/docs/build/conf.py b/docs/build/conf.py index 44dde4b..2bb0961 100644 --- a/docs/build/conf.py +++ b/docs/build/conf.py @@ -206,6 +206,8 @@ #{'python': ('http://docs.python.org/3.2', None)} +autoclass_content = "both" + intersphinx_mapping = { 'sqla':('http://www.sqlalchemy.org/docs/', None), } diff --git a/docs/build/front.rst b/docs/build/front.rst index bbc6437..cee535c 100644 --- a/docs/build/front.rst +++ b/docs/build/front.rst @@ -50,6 +50,36 @@ is installed, in addition to other dependencies. Alembic will work with SQLAlchemy as of version **0.6**, though with a limited featureset. The latest version of SQLAlchemy within the **0.7** series is strongly recommended. +Upgrading from Alembic 0.2 to 0.3 +================================= + +Alembic 0.3 is mostly identical to version 0.2 except for some API +changes, allowing better programmatic access and less ambiguity +between public and private methods. In particular: + +* :class:`.ScriptDirectory` now features these methods - the old + versions have been removed unless noted: + + * :meth:`.ScriptDirectory.iterate_revisions()` + * :meth:`.ScriptDirectory.get_current_head()` (old name ``_current_head`` is available) + * :meth:`.ScriptDirectory.get_heads()` + * :meth:`.ScriptDirectory.get_base()` + * :meth:`.ScriptDirectory.generate_revision()` + * :meth:`.ScriptDirectory.get_revision()` (old name ``_get_rev`` is available) + * :meth:`.ScriptDirectory.as_revision_number()` (old name ``_as_rev_number`` is available) + +* :meth:`.MigrationContext.get_current_revision()` (old name ``_current_rev`` remains available) + +* Methods which have been made private include ``ScriptDirectory._copy_file()``, + ``ScriptDirectory._generate_template()``, ``ScriptDirectory._upgrade_revs()``, + ``ScriptDirectory._downgrade_revs()``. ``autogenerate._produce_migration_diffs``. + It's pretty unlikely that end-user applications + were using these directly. + +See the newly cleaned up :ref:`api` documentation for what are hopefully clearly +laid out use cases for API usage, particularly being able to get at the revision +information in a database as well as a script directory. + Upgrading from Alembic 0.1 to 0.2 ================================= diff --git a/tests/__init__.py b/tests/__init__.py index e8baba8..d3193ae 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -121,6 +121,10 @@ def ne_(a, b, msg=None): """Assert a != b, with repr messaging on failure.""" assert a != b, msg or "%r == %r" % (a, b) +def is_(a, b, msg=None): + """Assert a is b, with repr messaging on failure.""" + assert a is b, msg or "%r is not %r" % (a, b) + def assert_raises_message(except_cls, msg, callable_, *args, **kwargs): try: callable_(*args, **kwargs) @@ -294,7 +298,7 @@ def write_script(scriptdir, rev_id, content): pyc_path = util.pyc_file_from_path(path) if os.access(pyc_path, os.F_OK): os.unlink(pyc_path) - script = Script.from_path(path) + script = Script._from_path(path) old = scriptdir._revision_map[script.revision] if old.down_revision != script.down_revision: raise Exception("Can't change down_revision " @@ -309,7 +313,7 @@ def three_rev_fixture(cfg): c = util.rev_id() script = ScriptDirectory.from_config(cfg) - script.generate_rev(a, "revision a", refresh=True) + script.generate_revision(a, "revision a", refresh=True) write_script(script, a, """ revision = '%s' down_revision = None @@ -324,7 +328,7 @@ def downgrade(): """ % a) - script.generate_rev(b, "revision b", refresh=True) + script.generate_revision(b, "revision b", refresh=True) write_script(script, b, """ revision = '%s' down_revision = '%s' @@ -339,7 +343,7 @@ def downgrade(): """ % (b, a)) - script.generate_rev(c, "revision c", refresh=True) + script.generate_revision(c, "revision c", refresh=True) write_script(script, c, """ revision = '%s' down_revision = '%s' diff --git a/tests/test_autogenerate.py b/tests/test_autogenerate.py index e03ceea..9a1f6e7 100644 --- a/tests/test_autogenerate.py +++ b/tests/test_autogenerate.py @@ -129,7 +129,7 @@ def test_diffs(self): diffs = [] autogenerate._produce_net_changes(connection, metadata, diffs, self.autogen_context) - + eq_( diffs[0], ('add_table', metadata.tables['item']) @@ -177,7 +177,7 @@ def test_render_nothing(self): } ) template_args = {} - autogenerate.produce_migration_diffs(context, template_args, set()) + autogenerate._produce_migration_diffs(context, template_args, set()) eq_(re.sub(r"u'", "'", template_args['upgrades']), """### commands auto generated by Alembic - please adjust! ### pass @@ -192,7 +192,7 @@ def test_render_diffs(self): metadata = self.m2 template_args = {} - autogenerate.produce_migration_diffs(self.context, template_args, set()) + autogenerate._produce_migration_diffs(self.context, template_args, set()) eq_(re.sub(r"u'", "'", template_args['upgrades']), """### commands auto generated by Alembic - please adjust! ### op.create_table('item', @@ -320,7 +320,7 @@ def setup_class(cls): 'sqlalchemy_module_prefix':'sa.' } ) - + connection = empty_context.bind cls.autogen_empty_context = { 'imports':set(), @@ -332,7 +332,7 @@ def setup_class(cls): @classmethod def teardown_class(cls): clear_staging_env() - + def test_diffs_order(self): """ Added in order to test that child tables(tables with FKs) are generated @@ -342,10 +342,10 @@ def test_diffs_order(self): metadata = self.m4 connection = self.empty_context.bind diffs = [] - + autogenerate._produce_net_changes(connection, metadata, diffs, self.autogen_empty_context) - + eq_(diffs[0][0], 'add_table') eq_(diffs[0][1].name, "parent") eq_(diffs[1][0], 'add_table') diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index 3eea778..38560ef 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -18,7 +18,7 @@ def setUp(self): self.rid = rid = util.rev_id() self.script = script = ScriptDirectory.from_config(cfg) - script.generate_rev(rid, None, refresh=True) + script.generate_revision(rid, None, refresh=True) def tearDown(self): clear_staging_env() diff --git a/tests/test_revision_create.py b/tests/test_revision_create.py index 2bdca93..eaf1859 100644 --- a/tests/test_revision_create.py +++ b/tests/test_revision_create.py @@ -1,4 +1,4 @@ -from tests import clear_staging_env, staging_env, eq_, ne_ +from tests import clear_staging_env, staging_env, eq_, ne_, is_ from alembic import util import os @@ -16,20 +16,20 @@ def test_002_rev_ids(): ne_(abc, def_) def test_003_heads(): - eq_(env._get_heads(), []) + eq_(env.get_heads(), []) def test_004_rev(): - script = env.generate_rev(abc, "this is a message", refresh=True) + script = env.generate_revision(abc, "this is a message", refresh=True) eq_(script.doc, "this is a message") eq_(script.revision, abc) eq_(script.down_revision, None) assert os.access( os.path.join(env.dir, 'versions', '%s_this_is_a_message.py' % abc), os.F_OK) assert callable(script.module.upgrade) - eq_(env._get_heads(), [abc]) + eq_(env.get_heads(), [abc]) def test_005_nextrev(): - script = env.generate_rev(def_, "this is the next rev", refresh=True) + script = env.generate_revision(def_, "this is the next rev", refresh=True) assert os.access( os.path.join(env.dir, 'versions', '%s_this_is_the_next_rev.py' % def_), os.F_OK) eq_(script.revision, def_) @@ -38,7 +38,7 @@ def test_005_nextrev(): assert script.module.down_revision == abc assert callable(script.module.upgrade) assert callable(script.module.downgrade) - eq_(env._get_heads(), [def_]) + eq_(env.get_heads(), [def_]) def test_006_from_clean_env(): # test the environment so far with a @@ -50,17 +50,18 @@ def test_006_from_clean_env(): eq_(abc_rev.nextrev, set([def_])) eq_(abc_rev.revision, abc) eq_(def_rev.down_revision, abc) - eq_(env._get_heads(), [def_]) + eq_(env.get_heads(), [def_]) def test_007_no_refresh(): - script = env.generate_rev(util.rev_id(), "dont' refresh") - ne_(script, env._as_rev_number("head")) + rid = util.rev_id() + script = env.generate_revision(rid, "dont' refresh") + is_(script, None) env2 = staging_env(create=False) - eq_(script, env2._as_rev_number("head")) + eq_(env2._as_rev_number("head"), rid) def test_008_long_name(): rid = util.rev_id() - script = env.generate_rev(rid, + script = env.generate_revision(rid, "this is a really long name with " "lots of characters and also " "I'd like it to\nhave\nnewlines") diff --git a/tests/test_revision_paths.py b/tests/test_revision_paths.py index fd09a85..127fda8 100644 --- a/tests/test_revision_paths.py +++ b/tests/test_revision_paths.py @@ -6,11 +6,11 @@ def setup(): global env env = staging_env() global a, b, c, d, e - a = env.generate_rev(util.rev_id(), None, refresh=True) - b = env.generate_rev(util.rev_id(), None, refresh=True) - c = env.generate_rev(util.rev_id(), None, refresh=True) - d = env.generate_rev(util.rev_id(), None, refresh=True) - e = env.generate_rev(util.rev_id(), None, refresh=True) + a = env.generate_revision(util.rev_id(), None, refresh=True) + b = env.generate_revision(util.rev_id(), None, refresh=True) + c = env.generate_revision(util.rev_id(), None, refresh=True) + d = env.generate_revision(util.rev_id(), None, refresh=True) + e = env.generate_revision(util.rev_id(), None, refresh=True) def teardown(): clear_staging_env() @@ -19,7 +19,7 @@ def teardown(): def test_upgrade_path(): eq_( - env.upgrade_from(e.revision, c.revision, None), + env._upgrade_revs(e.revision, c.revision), [ (d.module.upgrade, c.revision, d.revision), (e.module.upgrade, d.revision, e.revision), @@ -27,7 +27,7 @@ def test_upgrade_path(): ) eq_( - env.upgrade_from(c.revision, None, None), + env._upgrade_revs(c.revision, None), [ (a.module.upgrade, None, a.revision), (b.module.upgrade, a.revision, b.revision), @@ -38,7 +38,7 @@ def test_upgrade_path(): def test_downgrade_path(): eq_( - env.downgrade_to(c.revision, e.revision, None), + env._downgrade_revs(c.revision, e.revision), [ (e.module.downgrade, e.revision, e.down_revision), (d.module.downgrade, d.revision, d.down_revision), @@ -46,7 +46,7 @@ def test_downgrade_path(): ) eq_( - env.downgrade_to(None, c.revision, None), + env._downgrade_revs(None, c.revision), [ (c.module.downgrade, c.revision, c.down_revision), (b.module.downgrade, b.revision, b.down_revision), diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 2f19157..989d612 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -16,7 +16,7 @@ def test_001_revisions(self): c = util.rev_id() script = ScriptDirectory.from_config(self.cfg) - script.generate_rev(a, None, refresh=True) + script.generate_revision(a, None, refresh=True) write_script(script, a, """ revision = '%s' down_revision = None @@ -31,7 +31,7 @@ def downgrade(): """ % a) - script.generate_rev(b, None, refresh=True) + script.generate_revision(b, None, refresh=True) write_script(script, b, """ revision = '%s' down_revision = '%s' @@ -46,7 +46,7 @@ def downgrade(): """ % (b, a)) - script.generate_rev(c, None, refresh=True) + script.generate_revision(c, None, refresh=True) write_script(script, c, """ revision = '%s' down_revision = '%s' @@ -117,7 +117,7 @@ def test_option(self): self.cfg.set_main_option("file_template", "myfile_%%(slug)s") script = ScriptDirectory.from_config(self.cfg) a = util.rev_id() - script.generate_rev(a, "some message", refresh=True) + script.generate_revision(a, "some message", refresh=True) write_script(script, a, """ revision = '%s' down_revision = None @@ -141,7 +141,7 @@ def test_lookup_legacy(self): self.cfg.set_main_option("file_template", "%%(rev)s") script = ScriptDirectory.from_config(self.cfg) a = util.rev_id() - script.generate_rev(a, None, refresh=True) + script.generate_revision(a, None, refresh=True) write_script(script, a, """ down_revision = None @@ -164,7 +164,7 @@ def test_error_on_new_with_missing_revision(self): self.cfg.set_main_option("file_template", "%%(slug)s_%%(rev)s") script = ScriptDirectory.from_config(self.cfg) a = util.rev_id() - script.generate_rev(a, "foobar", refresh=True) + script.generate_revision(a, "foobar", refresh=True) assert_raises_message( util.CommandError, "Could not determine revision id from filename foobar_%s.py. "