Commits

Robert Brewer  committed aeb494d Draft

Imported from svn by Bitbucket

  • Participants

Comments (0)

Files changed (19)

+#!python
+"""Bootstrap setuptools installation
+
+If you want to use setuptools in your package's setup.py, just include this
+file in the same directory with it, and add this to the top of your setup.py::
+
+    from ez_setup import use_setuptools
+    use_setuptools()
+
+If you want to require a specific version of setuptools, set a download
+mirror, or use an alternate download directory, you can do so by supplying
+the appropriate options to ``use_setuptools()``.
+
+This file can also be run as a script to install or upgrade setuptools.
+"""
+
+DEFAULT_VERSION = "0.5a13"
+DEFAULT_URL     = "http://www.python.org/packages/source/s/setuptools/"
+
+import sys, os
+
+def use_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir
+):
+    """Automatically find/download setuptools and make it available on sys.path
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end with
+    a '/').  `to_dir` is the directory where setuptools will be downloaded, if
+    it is not already available.
+
+    If an older version of setuptools is installed, this will print a message
+    to ``sys.stderr`` and raise SystemExit in an attempt to abort the calling
+    script.
+    """
+    try:
+        import setuptools
+        if setuptools.__version__ == '0.0.1':
+            print >>sys.stderr, (
+            "You have an obsolete version of setuptools installed.  Please\n"
+            "remove it from your system entirely before rerunning this script."
+            )
+            sys.exit(2)
+
+    except ImportError:
+        egg = download_setuptools(version, download_base, to_dir)
+        sys.path.insert(0, egg)
+        import setuptools; setuptools.bootstrap_install_from = egg
+
+    import pkg_resources
+    try:
+        pkg_resources.require("setuptools>="+version)
+
+    except pkg_resources.VersionConflict:
+        # XXX could we install in a subprocess here?
+        print >>sys.stderr, (
+            "The required version of setuptools (>=%s) is not available, and\n"
+            "can't be installed while this script is running. Please install\n"
+            " a more recent version first."
+        ) % version
+        sys.exit(2)
+
+def download_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir
+):
+    """Download setuptools from a specified location and return its filename
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end
+    with a '/'). `to_dir` is the directory where the egg will be downloaded.
+    """
+    import urllib2, shutil
+    egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3])
+    url = download_base + egg_name + '.zip'  # XXX
+    saveto = os.path.join(to_dir, egg_name)
+    src = dst = None
+
+    if not os.path.exists(saveto):  # Avoid repeated downloads
+        try:
+            from distutils import log
+            log.warn("Downloading %s", url)
+            src = urllib2.urlopen(url)
+            # Read/write all in one block, so we don't create a corrupt file
+            # if the download is interrupted.
+            data = src.read()
+            dst = open(saveto,"wb")
+            dst.write(data)
+        finally:
+            if src: src.close()
+            if dst: dst.close()
+
+    return os.path.realpath(saveto)
+
+def main(argv, version=DEFAULT_VERSION):
+    """Install or upgrade setuptools and EasyInstall"""
+
+    try:
+        import setuptools
+    except ImportError:
+        import tempfile, shutil
+        tmpdir = tempfile.mkdtemp(prefix="easy_install-")
+        try:
+            egg = download_setuptools(version, to_dir=tmpdir)
+            sys.path.insert(0,egg)
+            from setuptools.command.easy_install import main
+            main(list(argv)+[egg])
+        finally:
+            shutil.rmtree(tmpdir)
+    else:
+        if setuptools.__version__ == '0.0.1':
+            # tell the user to uninstall obsolete version
+            use_setuptools(version)
+
+    req = "setuptools>="+version
+    import pkg_resources
+    try:
+        pkg_resources.require(req)
+    except pkg_resources.VersionConflict:
+        try:
+            from setuptools.command.easy_install import main
+        except ImportError:
+            from easy_install import main
+        main(list(argv)+[download_setuptools()])
+        sys.exit(0) # try to force an exit
+    else:
+        if argv:
+            from setuptools.command.easy_install import main
+            main(argv)
+        else:
+            print "Setuptools version",version,"or greater has been installed."
+            print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)'
+if __name__=='__main__':
+    main(sys.argv[1:])

File postfacto/LICENSE.txt

+Copyright (c) 2008-2009, Post Facto Team (team@post-facto.org)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, 
+are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright notice, 
+      this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above copyright notice, 
+      this list of conditions and the following disclaimer in the documentation 
+      and/or other materials provided with the distribution.
+    * Neither the name of the Post Facto Team nor the names of its contributors 
+      may be used to endorse or promote products derived from this software 
+      without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File postfacto/__init__.py

+"""Post Facto, a version-control system for Postgres databases.
+
+Unlike traditional version-control software which tracks changes between
+files, Post Facto tracks changes between *databases*. The deltas are stored
+as .sql files, much like the result of pg_dump (but with more ALTER/REPLACE).
+
+If you've already got a "master" database and want to start tracking its
+changes, you can import your production copy as trunk@HEAD, then tag it
+as tags/master-1.0 (or whatever version number you choose to start with).
+Developers can then checkout trunk@HEAD, make changes for new features
+or to fix bugs in their local working copy, then commit their changes
+to trunk. They can also make branches as needed for collaboration or QA,
+and merge those changes back into trunk. When everything is ready, upgrade
+your production server to trunk@HEAD (or a new tag as you see fit).
+
+Databases involved in a typical Post Facto system:
+
+    SERVER Repository                   SERVER Local
+      DB [repo_name1]                     DB postfacto
+        TABLE r_[db_name1]                  TABLE checkouts
+        TABLE r_[db_name2]                
+      DB [repo_name2]                     DB postfacto_w_[db_name]
+        TABLE r_[db_name1]                DB [db_name]
+        TABLE r_[db_name2]
+"""
+from __future__ import with_statement
+
+import getpass
+try:
+    # Python 2.5+
+    from hashlib import md5
+except ImportError:
+    from md5 import new as md5
+import urlparse
+urlparse.uses_netloc.append('postfacto')
+
+__version__ = "1.0alpha"
+
+from postfacto import postgres
+from postfacto import differ
+
+
+class Failure(Exception):
+    """Exception raised when preconditions for a command are not met."""
+    pass
+
+class Conflict(Failure):
+    """Exception raised when two versions of a database are in conflict."""
+    pass
+
+
+# whitelists must come after differ and Failure since it imports them.
+from postfacto import whitelists
+from postfacto.whitelists import Whitelist
+
+
+class _DebugLog(object):
+    
+    logger = None
+    
+    def __call__(self, msg):
+        if self.logger is not None:
+            self.logger(msg)
+debug = _DebugLog()
+postgres.Database.log = debug
+
+
+def escape(s):
+    """Return the given string, escaped for entry in PG SQL."""
+    return str(s).replace("'", "''").replace('\\', r'\\')
+
+
+# You might have to set local_server.connargs['user'],
+# and maybe ['password'], from your calling code, unless
+# local conns use the 'trust' method in pg_hba.conf.
+local_server = postgres.Server(host='localhost', port=None, user='postgres',
+                               client_encoding="utf-8", unicode_results=1)
+local_postfacto = postgres.Database('postfacto', local_server)
+
+
+def assert_checkouts():
+    """Populate the local_postfacto database (as needed)."""
+    if not local_postfacto.exists():
+        local_postfacto.create()
+    local_postfacto.safe_execute(
+        "CREATE TABLE checkouts (working_copy text PRIMARY KEY, server text, "
+        "repo text, project text, branch text, whitelist text, "
+        "add text, delete text, working_revision integer);")
+    local_postfacto.safe_execute(
+         "CREATE VIEW metadata AS SELECT working_copy, server, repo, project, "
+         "branch, working_revision from checkouts;")
+
+
+def verify_repo_table(repo_db, relname):
+    if repo_db.fetchone("SELECT 1 FROM pg_class WHERE relname = '%s'" %
+                        relname) is None:
+        raise Failure("The project %r is not under version "
+                      "control by the repository at %r." %
+                      (relname[2:], repo_db.url))
+
+def parse_url(url):
+    """Return server, repo, project, branch, revision from the given url.
+    
+    url: a string of the form:
+    
+        postfacto://[[user@]server]/repo[/project[/branch][@revision]]
+    
+    If 'server' is blank, it is assumed to be 'localhost:5432'.
+    If 'branch' is missing, it is assumed to be '/trunk'.
+    If 'revision' is missing, it is assumed to be 'HEAD'.
+    """
+    (scheme, netloc, path, params,
+     query, fragment) = urlparse.urlparse(url, scheme='postfacto')
+    if scheme != 'postfacto':
+        raise Failure("Post Facto URL's must start with the 'postfacto://' scheme.")
+    
+    if netloc == '':
+        netloc = 'localhost'
+    path = path.lstrip('/').split('/', 1)
+    repo = path.pop(0)
+    
+    project, branch, revision = None, 'trunk', 'HEAD'
+    if path:
+        path = path[0]
+        if '@' in path:
+            path, revision = path.rsplit('@', 1)
+        if path:
+            path = path.split('/', 1)
+            project = path.pop(0)
+            if path:
+                branch = path[0]
+    
+    return netloc, repo, project, branch, revision
+
+def server_from_netloc(netloc):
+    """Return a postgres.Server object for the given netloc."""
+    user = None
+    if '@' in netloc:
+        user, netloc = netloc.split('@', 1)
+    
+    if netloc in ('', 'localhost', '127.0.0.1', '::1'):
+        if user == local_server.connargs.get('user', None):
+            return local_server
+        port  = local_server.connargs['port']
+    else:
+        # We do *not* want the remote port to use the PGPORT env var
+        port = 5432
+    
+    host = netloc or 'localhost'
+    colons = netloc.count(':')
+    if colons > 1:
+        # IPv6
+        if ']' in netloc:
+            host, port = netloc.split(']', 1)
+            port = int(port.strip(':'))
+        host = host.strip('[]')
+    elif colons == 1:
+        host, port = netloc.split(':', 1)
+        port = int(port)
+    return postgres.Server(host=host, port=port, unicode_results=1,
+                           client_encoding="utf-8", user=user)
+
+def closest_revision(repo_db, relname, branch, rev):
+    """Return the revision_id closest (less than or equal) to the given rev.
+    
+    If rev is 'HEAD', None, or '', the last revision_id will be used.
+    If no revision can be found for the given branch (or its ancestors),
+    then ValueError is raised.
+    """
+    if rev in (None, '', 'HEAD'):
+        rev = 'HEAD'
+        revid, = repo_db.fetchone(
+            "SELECT max(revision_id) FROM %s WHERE "
+            "branch = '%s'" % (relname, escape(branch)))
+    else:
+        revid, = repo_db.fetchone(
+            "SELECT max(revision_id) FROM %s WHERE "
+            "branch = '%s' AND revision_id <= %s" %
+            (relname, escape(branch), rev))
+    
+    if revid is not None:
+        return revid
+    
+    raise Failure("No such branch %r at revision %r." % (branch, rev))
+
+
+def revision_plan(repo_db, relname, fromrev, torev):
+    """List of revision id's to transform fromrev to torev."""
+    verify_repo_table(repo_db, relname)
+    plan = []
+    if fromrev < torev:
+        cur_rev = torev
+        while cur_rev is not None and cur_rev > fromrev:
+            plan.append(cur_rev)
+            cur_rev, = repo_db.fetchone(
+                "SELECT previous_id FROM %s WHERE revision_id = %s;" %
+                (relname, cur_rev))
+        plan = reversed(plan)
+    else:
+        cur_rev = fromrev
+        while cur_rev is not None and cur_rev > torev:
+            plan.append(cur_rev)
+            cur_rev, = repo_db.fetchone(
+                "SELECT previous_id FROM %s WHERE revision_id = %s;" %
+                (relname, cur_rev))
+    return plan
+
+
+def diff_plan(repo_db, relname, fromrev, torev):
+    """Yield (revision_id, diff, up?) tuples to convert fromrev to torev.
+    
+    The 'torev' argument MUST be an exact revision_id, and MUST be on the
+    desired target branch. Use closest_revision before calling this function
+    if you only have an approximate (or untrusted/user-provided) revision_id.
+    """
+    assert isinstance(fromrev, int)
+    assert isinstance(torev, int)
+    
+    if fromrev < torev:
+        field = 'diffup'
+    else:
+        field = 'diffdown'
+    ids = revision_plan(repo_db, relname, fromrev, torev)
+    for revid in ids:
+        d, = repo_db.fetchone("SELECT %s FROM %s WHERE revision_id = %d;" %
+                              (field, relname, revid))
+        yield revid, d, field == 'diffup'
+
+
+# ------------------------------- Commands ------------------------------- #
+
+
+def import_db(unversioned_db_name, repo_url, message=None, force=False,
+              checkout=True):
+    """Version the given Database in the given repo. Return the revision_id.
+    
+    Unlike other version control systems (e.g. Subversion), import DOES
+    register the imported DB as a working copy by default. Set 'checkout'
+    to False to omit that step.
+    """
+    db = postgres.Database(unversioned_db_name, local_server)
+    if not db.exists():
+        raise Failure("The given database %r does not exist." %
+                      unversioned_db_name)
+    
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        if server is local_server and force:
+            repo_db.create()
+        else:
+            if server is local_server:
+                msg = ("The repository database %r does not exist. "
+                       "Use --force to create it." % repo_url)
+            else:
+                msg = ("The repository database %r does not exist. Since "
+                       "the server is remote, --force will not help." %
+                       repo_url)
+            raise Failure(msg)
+    
+    if project is None:
+        project = db.name
+    
+    # Keep the rev tablename short to stay under the max identifier length
+    relname = 'r_%s' % project
+    if repo_db.fetchone("SELECT 1 FROM pg_class WHERE relname = '%s'" %
+                        relname) is not None:
+        raise Failure("The project %r is already under version "
+                      "control by the repository at %r." %
+                      (project, repo_db.url))
+    
+    if message is None:
+        message = 'Initial import.'
+    
+    with postgres.TransactionManager(r=repo_db, s=local_postfacto) as txmgr:
+        txmgr.r.execute(
+            "CREATE TABLE %s (revision_id serial, previous_id int, "
+            "branch text, whitelist text, who text, "
+            "created timestamp default now(), "
+            "msg text, diffup text, diffdown text);" % relname)
+        
+        txmgr.r.execute(
+            "INSERT INTO %s (previous_id, branch, whitelist, "
+            "who, msg, diffup, diffdown) "
+            "VALUES (NULL, E'%s', '', current_user, E'%s', '', '') "
+            "RETURNING revision_id;" %
+            (relname, escape(branch), escape(message)))
+        newrev, = txmgr.r.cursor.fetchone()
+        
+        if checkout:
+            assert_checkouts()
+            wrev = postgres.Database('postfacto_w_%s' % db.name, local_server)
+            wrev.erase()
+            txmgr.s.execute(
+                "INSERT INTO checkouts (working_copy, server, repo, project, "
+                "branch, whitelist, add, delete, working_revision) "
+                "VALUES ('%s', '%s', '%s', '%s', E'%s', '', '', '', %d);" %
+                (db.name, netloc, repo, project, escape(branch), newrev))
+    
+    return newrev
+
+
+def checkout(repo_url, working_copy, force=False):
+    """Create a new working copy from the given URL. Return the revision id."""
+    assert_checkouts()
+    exists = local_postfacto.fetchone(
+        "SELECT 1 FROM checkouts WHERE working_copy = '%s'" % working_copy)
+    if exists is not None:
+        if force:
+            local_postfacto.execute("DELETE FROM checkouts "
+                                    "WHERE working_copy = '%s';" % working_copy)
+        else:
+            raise Failure("The local database %r is already a working copy. "
+                          "Use the --force option with this command to "
+                          "delete the checkout." % working_copy)
+    
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    if project is None:
+        project = working_copy
+    server = server_from_netloc(netloc)
+    
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    relname = 'r_%s' % project
+    verify_repo_table(repo_db, relname)
+    
+    wcopy = postgres.Database(working_copy, local_server)
+    if wcopy.exists():
+        if force:
+            wcopy.drop()
+        else:
+            raise Failure("Working copy %r already exists. Use the "
+                          "--force option with this command to delete "
+                          "the working copy." % working_copy)
+    wcopy.erase()
+    
+    wrev = postgres.Database('postfacto_w_%s' % working_copy, local_server)
+    wrev.erase()
+    
+    # Get a diff plan
+    revid = closest_revision(repo_db, relname, branch, revision)
+    plan = list(diff_plan(repo_db, relname, 0, revid))
+    
+    with postgres.TransactionManager(
+            wr=wrev, wc=wcopy, s=local_postfacto) as txmgr:
+        # Apply the diffs to our working revision...
+        for revid, d, up in plan:
+            if d:
+                try:
+                    txmgr.wr.execute(d)
+                except Exception, e:
+                    e.args += (revid, d[:50])
+                    raise
+        
+        # ...and apply the same diffs to our working copy.
+        for revid, d, up in plan:
+            if d:
+                try:
+                    txmgr.wc.execute(d)
+                except Exception, e:
+                    e.args += (revid, d[:50])
+                    raise
+        
+        # Now update our checkouts table.
+        wl, = repo_db.fetchone(
+            "SELECT whitelist FROM %s WHERE revision_id = %d" %
+            (relname, revid))
+        txmgr.s.execute(
+            "INSERT INTO checkouts (working_copy, server, repo, project, "
+            "branch, whitelist, add, delete, working_revision) "
+            "VALUES ('%s', '%s', '%s', '%s', E'%s', E'%s', '', '', %d);" %
+            (working_copy, netloc, repo, project,
+             escape(branch), escape(wl), revid))
+    
+    return revid
+
+
+def add(working_copy, whitelist, expand=True):
+    """Mark all objects in whitelist to be added on the next commit."""
+    co = info(working_copy)
+    
+    wcopy = postgres.Database(working_copy, local_server)
+    if whitelist == "*":
+        # Collect all items in the DB (but not pg_catalog and information_schema)
+        new_wl = whitelists.all_objects(wcopy)
+    else:
+        new_wl = Whitelist.from_str(whitelist)
+        if expand:
+            new_wl = new_wl.union(whitelists.expand_all(wcopy, new_wl))
+    
+    old_wl = Whitelist.from_str(co['whitelist'])
+    add_wl = Whitelist.from_str(co['add'])
+    del_wl = Whitelist.from_str(co['delete'])
+    for category, items in new_wl.iteritems():
+        for item in items:
+            if item in del_wl.get(category, set()):
+                # Cancel them both out.
+                del_wl[category].remove(item)
+                pass
+            elif item in old_wl.get(category, set()):
+                # Adding an item which already is whitelisted.
+                pass
+            elif item in add_wl.get(category, set()):
+                # Adding an item which already is whitelisted.
+                pass
+            else:
+                add_wl.setdefault(category, set()).add(item)
+    
+    local_postfacto.execute(
+        "UPDATE checkouts SET add = E'%s', delete = E'%s' "
+        "WHERE working_copy = '%s';" %
+        (escape(add_wl), escape(del_wl), working_copy))
+    
+    return add_wl
+
+
+def delete(working_copy, whitelist, expand=True):
+    """Mark all objects in whitelist to be deleted on the next commit."""
+    co = info(working_copy)
+    
+    wcopy = postgres.Database(working_copy, local_server)
+    if whitelist == "*":
+        # Collect all items in the DB (but not pg_catalog and information_schema)
+        new_wl = whitelists.all_objects(wcopy)
+    else:
+        new_wl = Whitelist.from_str(whitelist)
+        if expand:
+            new_wl = new_wl.union(whitelists.expand_all(wcopy, new_wl))
+    
+    old_wl = Whitelist.from_str(co['whitelist'])
+    add_wl = Whitelist.from_str(co['add'])
+    del_wl = Whitelist.from_str(co['delete'])
+    for category, items in new_wl.iteritems():
+        for item in items:
+            if item in add_wl.get(category, set()):
+                # Cancel them both out.
+                add_wl[category].remove(item)
+                pass
+            elif item not in old_wl.get(category, set()):
+                # Removing an item which already is missing.
+                pass
+            elif item in del_wl.get(category, set()):
+                # Removing an item which already is marked for removal.
+                pass
+            else:
+                del_wl.setdefault(category, set()).add(item)
+    
+    local_postfacto.execute(
+        "UPDATE checkouts SET add = E'%s', delete = E'%s' "
+        "WHERE working_copy = '%s';" %
+        (escape(add_wl), escape(del_wl), working_copy))
+    
+    return del_wl
+
+
+def update(working_copy, revision=None):
+    """Update the working copy to the given revision. Return the revision id."""
+    co = info(working_copy)
+    
+    server = server_from_netloc(co['server'])
+    repo_db = postgres.Database(co['repo'], server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo)
+    
+    relname = 'r_%s' % co['project']
+    torev = closest_revision(repo_db, relname, co['branch'], revision)
+    if torev == co['working_revision']:
+        raise Failure("The working copy is already at revision %d." % torev)
+    
+    plan = list(diff_plan(repo_db, relname, co['working_revision'], torev))
+    
+    _combine(repo_db, relname, torev, plan, working_copy, True)
+    
+    return torev
+
+def revert(working_copy):
+    """Revert the working copy to the working revision."""
+    wcopy = postgres.Database(working_copy, local_server)
+    if not wcopy.exists():
+        raise Failure("The given working copy %r does not exist." % working_copy)
+    wrev = postgres.Database('postfacto_w_%s' % working_copy, local_server)
+    if not wrev.exists():
+        raise Failure("The working revision does not exist.")
+    
+    # Grab the whitelist from postfacto.checkouts
+    co = info(working_copy)
+    wl = Whitelist.from_str(co['whitelist'])
+    
+    with postgres.TransactionManager(wc=wcopy, s=local_postfacto) as txmgr:
+        # TODO: Should we revert everything that was added/deleted?
+        for block in differ.Differ(wrev, wcopy, wl, wl).reverse_diff():
+            if block:
+                txmgr.wc.execute(block)
+        txmgr.s.execute(
+            "UPDATE checkouts SET add='', delete='' "
+            "WHERE working_copy = '%s';" % working_copy)
+
+def info(working_copy):
+    """Return a row from the checkouts table as a dict."""
+    assert_checkouts()
+    co = local_postfacto.record(
+        "SELECT * FROM checkouts WHERE working_copy = '%s';" % working_copy)
+    if co is None:
+        raise Failure('The database %r is not a working copy.' % working_copy)
+    return co
+
+def list_projects(repo_url):
+    """Yield project names under version control at the given URL."""
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    for project, in repo_db.fetchall(
+        r"SELECT relname FROM pg_class WHERE relkind = 'r' "
+        r"AND relname LIKE 'r\\_%';"):
+        yield project[2:]
+
+def log(target):
+    """Yield dict records from the given target."""
+    if '/' in target:
+        return log_url(target)
+    else:
+        return log_wc(target)
+
+def log_wc(working_copy):
+    """Yield dict records from the given working copy."""
+    co = info(working_copy)
+    return log_repo(co['server'], co['repo'], co['project'], co['branch'])
+
+def log_url(repo_url):
+    """Yield dict records from the given URL."""
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    return log_repo(netloc, repo, project, branch, revision)
+
+def log_repo(netloc, repo, project, branch, revision=None):
+    """Yield dict records from the given netloc/repo."""
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    relname = 'r_%s' % project
+    verify_repo_table(repo_db, relname)
+    
+    # Work backward from branch@rev
+    cur_rev = closest_revision(repo_db, relname, branch, revision)
+    while cur_rev is not None:
+        row = repo_db.record(
+            "SELECT * FROM %s WHERE revision_id = %s;" % (relname, cur_rev))
+        if row is None:
+            break
+        yield row
+        cur_rev = row['previous_id']
+
+
+def diff(source, target, down=False):
+    """Return a diff between the given source and target.
+    
+    If source is None, and target is a repo URL with a revision number,
+    the forward diff stored at that revision is returned. Otherwise, target
+    is assumed to be a working copy name, and will be diffed with its working
+    revision.
+    """
+    if source is None:
+        if '/' in target:
+            # target must be a repo URL
+            netloc, repo, project, branch, revision = parse_url(target)
+            server = server_from_netloc(netloc)
+            repo_db = postgres.Database(repo, server)
+            if not repo_db.exists():
+                raise Failure("The repository database %r does not exist." % target)
+            relname = 'r_%s' % project
+            
+            baseurl = target.rsplit('@', 1)[0]
+            if ':' in revision:
+                fromrev, torev = revision.split(':', 1)
+                if down:
+                    fromrev, torev = torev, fromrev
+                fromrev = closest_revision(repo_db, relname, branch, fromrev)
+                torev = closest_revision(repo_db, relname, branch, torev)
+                plan = diff_plan(repo_db, relname, fromrev, torev)
+                for revid, d, up in plan:
+                    yield ('-- Postfacto revision: %s@%d (%s)--' %
+                           (baseurl, revid, {True: 'up', False: 'down'}[up]))
+                    for chunk in d.split('\n\n\n'):
+                        yield chunk
+                return
+            else:
+                rev = closest_revision(repo_db, relname, branch, revision)
+                if down:
+                    col = 'down'
+                else:
+                    col = 'up'
+                d, = repo_db.fetchone(
+                    "SELECT diff%s FROM %s WHERE revision_id = %d" %
+                    (col, relname, rev))
+                yield ('-- Postfacto revision: %s@%d (%s) --\n' %
+                       (baseurl, rev, col))
+                for chunk in d.split('\n\n\n'):
+                    yield chunk
+                return
+        else:
+            # target must be a working_copy DB.
+            source = 'postfacto_w_%s' % target
+            db1 = postgres.Database(source, local_server)
+            if not db1.exists():
+                raise Failure("The working revision database does not exist.")
+            
+            db2 = postgres.Database(target, local_server)
+            if not db2.exists():
+                raise Failure("The given working copy %r does not exist." %
+                              target)
+            
+            # Grab the whitelist from postfacto.checkouts
+            co = info(target)
+            wl1 = Whitelist.from_str(co['whitelist'])
+            wl2 = wl1.union(Whitelist.from_str(co['add']))
+            wl2 = wl2.difference(Whitelist.from_str(co['delete']))
+            
+            prologue = ('-- Postfacto working copy diff: %s (%s)--' %
+                        (target, {True: 'up', False: 'down'}[down]))
+    else:
+        raise NotImplemented
+    
+    d = differ.Differ(db1, db2, wl1, wl2)
+    if down:
+        diff = d.reverse_diff()
+    else:
+        diff = d.forward_diff()
+    param_block = diff.next()
+    if param_block == d.param_block:
+        # If this next line raises StopIteration, there are no changes,
+        # so we won't yield anything if we propagate it outward.
+        first_chunk = diff.next()
+        yield prologue
+        yield param_block
+        yield first_chunk
+    else:
+        yield prologue
+        yield param_block
+    
+    for chunk in diff:
+        yield chunk
+
+def commit(working_copy, message, force=False, forward=None, reverse=None):
+    """Commit changes in working copy to repo. Return new revision number.
+    
+    If 'forward' is given, it will be inserted as the diffup value instead
+    of calculating the difference between the working copy and its base.
+    
+    If 'reverse' is given, it will be inserted as the diffdown value instead
+    of calculating the difference between the working copy and its base.
+    
+    Both of these allow a diff to include RENAMEs, INSERTs, UPDATEs,
+    etc. which are not able to be inferred from the system catalog.
+    Use 'pf diff DB > up.sql' and 'pf diff --reverse DB > down.sql'
+    to obtain files which you can then modify and commit using
+    'commit --forward=up.sql --reverse=down.sql DB'.
+    
+    Note that the whitelists for the working copy will still be used even if
+    --forward and --reverse are provided; that is, you must still use the
+    'add' and 'remove' commands in sync with the provided diffs.
+    """
+    co = info(working_copy)
+    wl = Whitelist.from_str(co['whitelist'])
+    # First, expand the whitelist to include any added objects...
+    wl2 = wl.union(Whitelist.from_str(co['add']))
+    # ...and subtract any deleted objects.
+    wl2 = wl2.difference(Whitelist.from_str(co['delete']))
+    
+    server = server_from_netloc(co['server'])
+    repo_db = postgres.Database(co['repo'], server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % co['repo'])
+    
+    wrev = postgres.Database('postfacto_w_%s' % working_copy, local_server)
+    if not wrev.exists():
+        raise Failure("The working revision database does not exist.")
+    wcopy = postgres.Database(working_copy, local_server)
+    if not wcopy.exists():
+        raise Failure("The given working copy %r does not exist." % working_copy)
+    
+    # Get our diffs.
+    if not (forward and reverse):
+        d = differ.Differ(wrev, wcopy, wl, wl2)
+    if forward:
+        forward = open(forward, 'rb').read()
+    else:
+        forward = list(d.forward_diff())
+    if reverse:
+        reverse = open(reverse, 'rb').read()
+    else:
+        reverse = list(d.reverse_diff())
+    
+    if forward == [d.param_block] and reverse == [d.param_block] and not force:
+        msg = ("No changes were detected in the working copy. "
+               "Use --force to commit anyway.")
+        raise Failure(msg)
+    
+    relname = 'r_%s' % co['project']
+    
+    with postgres.TransactionManager(
+            r=repo_db, wr=wrev, s=local_postfacto) as txmgr:
+        txmgr.r.execute(
+            "SELECT max(revision_id) FROM %s WHERE branch = '%s';" %
+            (relname, escape(co['branch'])))
+        maxrev, = txmgr.r.cursor.fetchone()
+        if maxrev > co['working_revision']:
+            raise Conflict("Working revision is %d but repo is at %d. Run "
+                           "'postfacto update' before committing." % 
+                           (co['working_revision'], maxrev))
+        
+        # Update our working revision to mirror our working copy.
+        debug('Updating working revision...\n')
+        for block in forward:
+            try:
+                txmgr.wr.execute(block)
+            except postgres.pgsql.OperationalError, x:
+                x.args += (block,)
+                raise
+        debug('\ndone\n')
+        
+        # ...add our changeset to the repo...
+        txmgr.r.execute(
+            "INSERT INTO %s (previous_id, branch, whitelist, "
+            "who, msg, diffup, diffdown) "
+            "VALUES (%d, E'%s', E'%s', current_user, E'%s', E'%s', E'%s') "
+            "RETURNING revision_id;" %
+            (relname, co['working_revision'], escape(co['branch']), escape(wl2),
+             escape(message),
+             escape('\n\n\n'.join(forward)), escape('\n\n\n'.join(reverse))
+             ))
+        newrev, = txmgr.r.cursor.fetchone()
+        
+        # ...and mark our working copy as up to date.
+        txmgr.s.execute(
+            "UPDATE checkouts SET whitelist = E'%s', add='', delete='', "
+            "working_revision = %d WHERE working_copy = '%s';" %
+            (escape(wl2), newrev, working_copy))
+    
+    return newrev
+
+def copy(repo_url, new_branch, message=None):
+    """Copy the given url to a new branch. Return the new revision number."""
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    relname = 'r_%s' % project
+    rev = closest_revision(repo_db, relname, branch, revision)
+    message = message or ''
+    wl = "(SELECT whitelist FROM %s WHERE revision_id = %d)" % (relname, rev)
+    newrev, = repo_db.fetchone(
+        "INSERT INTO %s (previous_id, branch, whitelist, "
+        "who, msg, diffup, diffdown) "
+        "VALUES (%d, E'%s', %s, current_user, E'%s', '', '') "
+        "RETURNING revision_id;" %
+        (relname, rev, escape(new_branch), wl, escape(message)))
+    return newrev
+
+def merge(repo_url, working_copy):
+    """Merge changes from the given URL into the working copy.
+    
+    If 'revision' is a range from N:M, those changes will be applied.
+    If 'revision' is a single revision id N, we assume R:HEAD.
+    """
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    relname = 'r_%s' % project
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    if revision is None:
+        raise Failure("You must supply a valid revision range.")
+    
+    if ":" in revision:
+        fromrev, torev = revision.split(":", 1)
+    else:
+        fromrev, torev = revision, "HEAD"
+    if fromrev == '':
+        fromrev = 0
+    else:
+        fromrev = int(fromrev)
+    torev = closest_revision(repo_db, relname, branch, torev)
+    
+    # Get a diff plan
+    plan = list(diff_plan(repo_db, relname, fromrev, torev))
+    
+    _combine(repo_db, relname, torev, plan, working_copy, False)
+
+def _combine(repo_db, relname, torev, plan, working_copy, include_wrev):
+    """Internal merge func."""
+    wcopy = postgres.Database(working_copy, local_server)
+    if not wcopy.exists():
+        raise Failure("The given working copy %r does not exist." % working_copy)
+    
+    if include_wrev:
+        wrev = postgres.Database('postfacto_w_%s' % working_copy, local_server)
+        if not wrev.exists():
+            raise Failure("The working revision does not exist.")
+    
+    # Merge the whitelist of the far revision into the working_copy.
+    co = info(working_copy)
+    new_wl, = repo_db.fetchone(
+        "SELECT whitelist FROM %s WHERE revision_id = %d;" % (relname, torev))
+    new_wl = Whitelist.from_str(new_wl)
+    add_wl = Whitelist.from_str(co['add'])
+    for category, items in add_wl.iteritems():
+        if category in new_wl:
+            for item in items[:]:
+                if item in new_wl[category]:
+                    if force:
+                        add_wl[category].remove(item)
+                    else:
+                        # Assume for now they're different and error.
+                        # TODO: check if they're equal and merGe instead.
+                        raise Failure(
+                            "The new revision contains a new %s=%s, which "
+                            "conflicts with the one in your working copy. "
+                            "Use --force to abandon your copy." %
+                            (category, item))
+    merged_wl = Whitelist.from_str(co['whitelist']).union(new_wl)
+    
+    # Apply the diffs to our working copy.
+    if include_wrev:
+        m = postgres.TransactionManager(wr=wrev, wc=wcopy, s=local_postfacto)
+    else:
+        m = postgres.TransactionManager(wc=wcopy, s=local_postfacto)
+    with m as txmgr:
+        if include_wrev:
+            # Update the working revision.
+            for revid, d in plan:
+                if d:
+                    debug('%s: Applying revision %d' %
+                          (wrev.name.ljust(25), revid))
+                    debug(d)
+                    try:
+                        txmgr.wr.execute(d)
+                    except Exception, e:
+                        e.args += (revid, d[:50])
+                        raise
+        
+        # Update our working copy. We can't just copy the database from wrev
+        # because our working copy might have local uncommitted changes.
+        for revid, d in plan:
+            if d:
+                debug('%s: Applying revision %d' %
+                      (wcopy.name.ljust(25), revid))
+                debug(d)
+                try:
+                    txmgr.wc.execute(d)
+                except Exception, e:
+                    e.args += (revid, d[:50])
+                    raise
+        
+        # Now update our checkouts table.
+        txmgr.s.execute(
+            "UPDATE checkouts SET whitelist = E'%s', add = E'%s', "
+            "working_revision = %d WHERE working_copy = '%s';" %
+            (escape(merged_wl), escape(add_wl), torev, working_copy))
+
+
+def switch(repo_url, working_copy):
+    """Switch the working copy to the given URL (@ revision).
+    
+    This may be used to switch branches within the same repository,
+    or to a new repository on a different server.
+    """
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    relname = 'r_%s' % project
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    verify_repo_table(repo_db, relname)
+    
+    wcopy = postgres.Database(working_copy, local_server)
+    if not wcopy.exists():
+        raise Failure("The given working copy %r does not exist." % working_copy)
+    wrev = postgres.Database('postfacto_w_%s' % working_copy, local_server)
+    if not wrev.exists():
+        raise Failure("The working revision does not exist.")
+    
+    # Construct a temp database equal to the target URL+revision
+    torev = closest_revision(repo_db, relname, branch, revision)
+    temp = postgres.Database('_postfacto_blank', local_server)
+    temp.erase()
+    plan = diff_plan(repo_db, relname, 0, torev)
+    with temp.cursor() as c:
+        c.execute("BEGIN;")
+        for revid, d, up in plan:
+            if d:
+                debug('%s: Applying revision %d' % (temp.name.ljust(25), revid))
+                debug(d)
+                try:
+                    c.execute(d)
+                except Exception, e:
+                    e.args += (revid, d[:50])
+                    raise
+        c.execute("END;")
+    
+    # Get a direct diff between our working revision (NOT working copy)
+    # and the target URL@revision.
+    old_wl = info(working_copy)['whitelist']
+    old_wl = Whitelist.from_str(old_wl)
+    new_wl, = repo_db.fetchone("SELECT whitelist FROM %s WHERE revision_id = %d" %
+                               (relname, torev))
+    new_wl = Whitelist.from_str(new_wl)
+    
+    try:
+        d = differ.Differ(wrev, temp, old_wl, new_wl)
+        forward = '\n\n\n'.join(list(d.forward_diff()))
+    finally:
+        d = None
+        temp.drop()
+    
+    with postgres.TransactionManager(
+            wr=wrev, wc=wcopy, s=local_postfacto) as txmgr:
+        # Apply the diff to our working revision.
+        debug('Switching working revision...\n')
+        txmgr.wr.execute(forward)
+        debug('\ndone\n')
+        
+        # Apply the diff to our working copy.
+        debug('Switching working copy...')
+        txmgr.wc.execute(forward)
+        debug('done\n')
+        
+        # Update the checkouts table.
+        txmgr.s.execute(
+            "UPDATE checkouts SET server='%s', repo='%s', project='%s', "
+            "branch=E'%s', whitelist=E'%s', "
+            "working_revision=%d WHERE working_copy = '%s';" %
+            (netloc, repo, project, escape(branch), escape(new_wl),
+             torev, wcopy.name))
+    
+    return torev
+
+
+def repair(working_copy):
+    """Construct a working revision database."""
+    raise NotImplemented
+
+
+def grant(repo_url, username, password=None):
+    """GRANT the given user rights to access the identified project or repo.
+    
+    A new ROLE will be created in the repo db if necessary, in which case the
+    password argument is required.
+    """
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    with postgres.TransactionManager(r=repo_db) as txmgr:
+        role = repo_db.fetchone(
+            "SELECT rolcanlogin FROM pg_authid WHERE rolname = '%s';" % username)
+        if role:
+            if not role[0]:
+                txmgr.r.execute("ALTER ROLE %s WITH LOGIN;" % username)
+            if password:
+                txmgr.r.execute("ALTER ROLE %s ENCRYPTED PASSWORD '%s';" %
+                                (username, md5(password).hexdigest()))
+        else:
+            if not password:
+                raise Failure("You must supply a password for the new role.")
+            txmgr.r.execute(
+                "CREATE ROLE %s LOGIN ENCRYPTED PASSWORD '%s';" %
+                (username, md5(password).hexdigest()))
+        
+        if project is None:
+            # GRANT rights to the entire repo DB.
+            txmgr.r.execute(
+                "GRANT ALL ON DATABASE %s TO %s;" % (repo_db.name, username))
+        else:
+            # GRANT rights to just this project.
+            relname = 'r_%s' % project
+            verify_repo_table(repo_db, relname)
+            txmgr.r.execute(
+                "GRANT SELECT, INSERT, UPDATE ON %s TO %s;" %
+                (relname, username))
+            txmgr.r.execute(
+                "GRANT SELECT, UPDATE ON SEQUENCE %s_revision_id_seq TO %s;" %
+                (relname, username))
+
+def revoke(repo_url, username):
+    """REVOKE the given user rights to access the identified project or repo."""
+    netloc, repo, project, branch, revision = parse_url(repo_url)
+    server = server_from_netloc(netloc)
+    repo_db = postgres.Database(repo, server)
+    if not repo_db.exists():
+        raise Failure("The repository database %r does not exist." % repo_url)
+    
+    with postgres.TransactionManager(r=repo_db) as txmgr:
+        role = repo_db.fetchone(
+            "SELECT rolcanlogin FROM pg_authid WHERE rolname = '%s';" % username)
+        if not role:
+            return
+        
+        if project is None:
+            # REVOKE rights to the entire repo DB.
+            # TODO: Should this CASCADE?
+            txmgr.r.execute(
+                "REVOKE ALL ON DATABASE %s FROM %s;" % (repo_db.name, username))
+        else:
+            # REVOKE rights to just this project.
+            relname = 'r_%s' % project
+            verify_repo_table(repo_db, relname)
+            txmgr.r.execute("REVOKE ALL ON %s FROM %s;" % (relname, username))
+            txmgr.r.execute(
+                "REVOKE ALL ON SEQUENCE %s_revision_id_seq FROM %s;" %
+                (relname, username))
+
+

File postfacto/cmdline.py

+#!/usr/bin/python
+
+"""Post Facto command-line script."""
+
+from optparse import OptionParser
+import os
+import sys
+import textwrap
+
+import postfacto
+
+
+
+class PostFactoOptions(OptionParser):
+    
+    commands = ['add [--no-expand] DB whitelist',
+                'checkout [--force] URL DB',
+                'commit --message [--force] [--forward] [--reverse] DB',
+                'copy [--message] URL branch',
+                'delete [--no-expand] DB whitelist',
+                'diff [--down] URL|DB',
+                'grant URL username [password]',
+                'import [--message] [--force] [--no-checkout] DB URL',
+                'info DB',
+                'list URL',
+                'log [--limit] URL|DB',
+                'merge URL@REV DB',
+                'revert DB',
+                'revoke URL username',
+                'switch URL DB',
+                'update DB [REV]',
+                ]
+    
+    def __init__(self):
+        OptionParser.__init__(self)
+        self.usage = '%prog [options] command *args'
+        wltypes = sorted(postfacto.differ.whitelist_types)
+        self.epilog = (
+            os.linesep +
+            'URLs: postfacto://[[user@]server]/repodb[/project[/branch][@revision]]' +
+            os.linesep +
+            'Whitelist example: tables=users,accounts;schemas=test' +
+            os.linesep +
+            'Whitelist types  : ' + ', '.join(wltypes) +
+            os.linesep +
+            'Commands:' +
+            ''.join([os.linesep + '    ' + c for c in sorted(self.commands)])
+            )
+        self.version = 'Post Facto ' + postfacto.__version__
+        self.add_option('-m', '--message', default=None,
+                        action='store', type='string', dest='message',
+                        help='Commit message to store with the changeset.')
+        self.add_option('-l', '--limit', default=None,
+                        action='store', type='int', dest='limit',
+                        help='Limit output to the given number of items.')
+        self.add_option('-U', '--username', default=None,
+                        action='store', type='string', dest='username',
+                        help='Username for the local postfacto database.')
+        self.add_option('-p', '--port', default=None,
+                        action='store', type='string', dest='port',
+                        help='Local postfacto database server port.')
+        self.add_option('-d', '--debug', default=None,
+                        action='store_true', dest='debug',
+                        help="Emit extra info about postfacto's actions "
+                             "to stderr.")
+        self.add_option('-f', '--force', default=None,
+                        action='store_true', dest='force',
+                        help="Ignore warnings when performing the operation.")
+        self.add_option('--no-expand', default=False,
+                        action='store_true', dest='no_expand',
+                        help="Do not expand the given whitelist to include "
+                             "dependencies and referrers.")
+        self.add_option('--no-checkout', default=False,
+                        action='store_true', dest='no_checkout',
+                        help="Do not register the imported DB as a checked "
+                             "out copy.")
+        self.add_option('--forward', default=None,
+                        action='store', dest='forward',
+                        help="File to store instead of working copy diff up.")
+        self.add_option('--reverse', default=None,
+                        action='store', dest='reverse',
+                        help="File to store instead of working copy diff down.")
+        self.add_option('--down', default=False,
+                        action='store_true', dest='down',
+                        help="Return a reverse diff instead of a forward diff.")
+    
+    def format_epilog(self, formatter):
+        return self.epilog
+
+
+def run():
+    parser = PostFactoOptions()
+    opts, args = parser.parse_args()
+    
+    if opts.debug:
+        postfacto.debug.logger = sys.stderr.write
+    
+    try:
+        command = args.pop(0)
+    except IndexError:
+        print parser.format_help()
+        sys.exit(1)
+    
+    if opts.username is not None:
+        postfacto.local_server.connargs['user'] = opts.username
+    if opts.port is not None:
+        postfacto.local_server.connargs['port'] = int(opts.port)
+    
+    try:
+        if command in ('branch', 'copy', 'cp', 'tag'):
+            if (not opts.message) or len(args) != 2:
+                raise postfacto.Failure(
+                    'You must supply a repository URL, new branch name, '
+                    'and --message.')
+            rev = postfacto.copy(args[0], args[1], message=opts.message)
+            print "Copied at revision %d." % rev
+        elif command in ('checkout', 'co'):
+            if len(args) != 2 or '/' in args[1] or ':' in args[1]:
+                raise postfacto.Failure(
+                    'You must supply a repository URL and working copy name.')
+            # Create a new local DB exactly like the given TARGET
+            rev = postfacto.checkout(args[0], args[1], force=opts.force)
+            print "Checked out revision %d." % rev
+        elif command == 'add':
+            if len(args) != 2 or ('=' not in args[1] and args[1] != "*"):
+                raise postfacto.Failure(
+                    'You must supply a working copy name and whitelist.')
+            wl = postfacto.add(args[0], args[1].strip(),
+                               expand=(not opts.no_expand))
+            print "Added %d objects:" % sum([len(v) for v in wl.values()], 0)
+            for k in sorted(wl.keys()):
+                values = k + ': ' + ', '.join(sorted(wl[k]))
+                wrapper = textwrap.TextWrapper(
+                    initial_indent='  ',
+                    subsequent_indent='    ',
+                    width=70)
+                print wrapper.fill(values)
+        elif command in ('delete', 'del', 'remove'):
+            if len(args) != 2 or '=' not in args[1]:
+                raise postfacto.Failure(
+                    'You must supply a working copy name and whitelist.')
+            wl = postfacto.delete(args[0], args[1].strip(),
+                                  expand=(not opts.no_expand))
+            print "Deleted %d objects:" % sum([len(v) for v in wl.values()], 0)
+            for k in sorted(wl.keys()):
+                values = k + ': ' + ', '.join(sorted(wl[k]))
+                wrapper = textwrap.TextWrapper(
+                    initial_indent='  ',
+                    subsequent_indent='    ',
+                    width=70)
+                print wrapper.fill(values)
+        elif command in ('commit', 'ci'):
+            if (not opts.message) or len(args) != 1:
+                raise postfacto.Failure(
+                    'You must supply a working copy name and a --message.')
+            rev = postfacto.commit(args[0], opts.message, force=opts.force,
+                                   forward=opts.forward, reverse=opts.reverse)
+            print "Committed revision %d." % rev
+        elif command == 'diff':
+            started = False
+            if len(args) == 1:
+                for block in postfacto.diff(source=None, target=args[0],
+                                            down=opts.down):
+                    if not started:
+                        started = True
+                    else:
+                        print
+                        print
+                    print block
+            #elif len(args) == 2:
+            #    for block in postfacto.diff(source=args[0], target=args[1]):
+            #        if not started:
+            #            started = True
+            #        else:
+            #            print
+            #            print
+            #        print block
+            else:
+                raise postfacto.Failure(
+                    'You must supply either a working copy name, or a URL.')
+        elif command == 'info':
+            co = postfacto.info(args[0])
+            wl = postfacto.Whitelist.from_str(co['whitelist'])
+            wl_stats = '; '.join(["%d %s" % (len(wl[k]), k) for k in wl])
+            print "Post Facto info for working copy: %s" % args[0]
+            print "    Server:     %s" % co['server']
+            print "    Repository: %s" % co['repo']
+            print "    Project:    %s" % co['project']
+            print "    Branch:     %s" % co['branch']
+            print "    Revision:   %d" % co['working_revision']
+            print "    Whitelist:  %s" % wl_stats
+            print "    Add:        %s" % co['add']
+            print "    Delete:     %s" % co['delete']
+        elif command == 'import':
+            # Register the given DB as versioned by the given URL.
+            if len(args) != 2:
+                raise postfacto.Failure(
+                    'You must supply a database to import and a target '
+                    'repository URL.')
+            rev = postfacto.import_db(
+                args[0], args[1], message=opts.message,
+                force=opts.force, checkout=(not opts.no_checkout))
+            print "Committed revision %d." % rev
+        elif command in ('list', 'ls'):
+            # List the databases tracked by this repo.
+            for project in postfacto.list_projects(args[0]):
+                print project
+        elif command == 'log':
+            print "Rev     Who        Created             Message"
+            if opts.limit is None or opts.limit >= 0:
+                for i, row in enumerate(postfacto.log(args[0])):
+                    line = ('%s  %s %s %s' %
+                            (str(row['revision_id']).ljust(6),
+                             row['who'].ljust(10),
+                             row['created'].strftime('%Y-%m-%d %H:%M:%S'),
+                             row['msg']))
+                    print line
+                    if opts.limit is not None and opts.limit <= i + 1:
+                        break
+        elif command == 'merge':
+            print ("Merging %s into %s..." % (args[0], args[1])),
+            postfacto.merge(args[0], args[1])
+            print "done."
+        elif command == 'revert':
+            postfacto.revert(args[0])
+        elif command == 'switch':
+            rev = postfacto.switch(args[0], args[1])
+            print "Updated to revision %d." % rev
+        elif command == 'update':
+            revision = None
+            if len(args) == 2:
+                revision = args[1]
+            rev = postfacto.update(args[0], revision=revision)
+            print "Updated to revision %d." % rev
+        elif command == 'grant':
+            if len(args) not in (2, 3):
+                raise postfacto.Failure("You must supply a URL and username.")
+            postfacto.grant(*args)
+        elif command == 'revoke':
+            if len(args) != 2:
+                raise postfacto.Failure("You must supply a URL and username.")
+            postfacto.revoke(args[0], args[1])
+    except postfacto.Failure, f:
+        print f.args[0]
+        import traceback
+        postfacto.debug(traceback.format_exc())
+        sys.exit(1)
+
+if __name__ == '__main__':
+    run()
+

File postfacto/differ.py

+"""Difference generator for Post Facto.
+
+This proceeds in several stages:
+
+When you instantiate a Differ:
+ 1. The differ grabs *all* the objects it might care about from BOTH databases,
+    placing them into 2 DumpSet objects.
+ 2. If the same object exists in both databases, they are linked via the
+    "counterpart" attribute of each; otherwise, they are marked "altered".
+    The "sameness" of 2 objects is determined by comparing the key() of each.
+ 3. Almost all objects from each dumpset are put into 2 lists and the
+    dependencies among each set are calculated (and stored in each object's
+    "dependencies" and "referrers" sets). Depending on the kind of dependency,
+    some objects may have their emit_drop or emit_create flags turned off.
+ 4. All objects that have a "postlink" method run them. This gives each object
+    a chance to mark themselves or other objects as 'altered', turn on or off
+    emit_create/drop flags, and add dependencies which Postgres doesn't track.
+    This step should NOT add new objects to either DumpSet.
+
+When you ask for a forward/reverse diff:
+ 5. All objects from both sets are sorted topologically, so any object B which
+    depends on an object A gets its SQL statement placed after that of A.
+ 6. Each SQL statement is yielded in order.
+
+"""
+
+import re
+
+
+def qualname(name, schema):
+    """Return the name, schema-qualified."""
+    return '"%s"."%s"' % (schema, name)
+
+
+
+# --------------------------- Dumpable objects --------------------------- #
+
+
+
+class DumpableObject(object):
+    
+    classname = None
+    
+    # If 'emit_create' is true, emit SQL for CREATE/ALTER.
+    # If False, no SQL is emitted (unless Differ.include_placeholders is
+    # True, in which case the SQL is emitted but commented out).
+    emit_create = True
+    
+    # If 'emit_drop' is true, emit SQL for DROP, including ALTER ... DROP.
+    # If False, no SQL is emitted (unless Differ.include_placeholders is
+    # True, in which case the SQL is emitted but commented out).
+    emit_drop = True
+    
+    # If this object exists in the other dumpset, then self.counterpart
+    # will point to it and vice-versa, regardless of whether either is
+    # marked 'altered', or whether they emit_create or emit_drop.
+    counterpart = None
+    
+    # If this object has no counterpart, or differs from the counterpart
+    # in any way, altered is set to True, otherwise False.
+    altered = True
+    
+    whitelist_type = None
+    expand_dependencies = {}
+    expand_referrers = {}
+    
+    def __init__(self, dumpset, **attrs):
+        self.dumpset = dumpset
+        self._attrs = attrs
+        for k, v in attrs.iteritems():
+            setattr(self, k, v)
+        self.dependencies = set()
+        self.referrers = set()
+    
+    def __repr__(self):
+        return "%s(%s)" % (self.__class__.__name__, repr(self._attrs))
+    
+    def _get_class_id(self):
+        return self.dumpset.catalogids.get(self.classname, None)
+    classid = property(_get_class_id)
+    
+    def _get_name(self):
+        return getattr(self, self._name_field)
+    name = property(_get_name)
+    
+    def _get_whitelist_name(self):
+        return self.name.strip('"')
+    whitelist_name = property(_get_whitelist_name)
+    
+    def key(self):
+        """Return a hashable unique identifier for this instance."""
+        return self.name
+    
+    def _fetchall(cls, dumpset):
+        # Make all schema names explicit
+        if isinstance(cls.fetcher, dict):
+            # There are multiple versions for different DB versions.
+            # Pick the one closest to the current DB version (without
+            # going over).
+            candidates = [k for k in cls.fetcher.keys()
+                          if k and k < dumpset.db.version]
+            if candidates:
+                sql = cls.fetcher[max(candidates)]
+            else:
+                sql = cls.fetcher[None]
+        else:
+            sql = cls.fetcher
+        
+        sql = "SET search_path = pg_catalog; " + sql
+        descr, data = dumpset.db.fetchall_descr(sql)
+        keys = [d[0] for d in descr]
+        return [cls(dumpset, **dict(zip(keys, row)))
+                for row in data]
+    _fetchall = classmethod(_fetchall)
+    
+    def fetchall(cls, dumpset):
+        """Return all instances of this class in the database."""
+        return [o for o in cls._fetchall(dumpset)
+                if dumpset.match(o.whitelist_type, o.whitelist_name)]
+    fetchall = classmethod(fetchall)
+    
+    def depends_on(self, other):
+        """Mark self as depending on other."""
+        self.dependencies.add(other)
+        other.referrers.add(self)
+    
+    def link(self, other):
+        """Make other the counterpart of self and vice-versa."""
+        self.counterpart = other
+        other.counterpart = self
+        
+        if (hasattr(self, "alter") and self.create() != other.create()):
+            # A portion of the object has been altered, and we will use
+            # an ALTER statement for "create" and nothing for "drop".
+            # The exception to this is when an object has changed
+            # in an unsupported way, like changing the number
+            # of columns in a VIEW--that requires a DROP of the
+            # VIEW and a new CREATE statement. That sort of thing
+            # is handled by postlink functions calling recreate().
+            # (It has to occur later so the complete set of objects
+            # is available).
+            self.altered = True
+            self.emit_create = True
+            self.emit_drop = False
+            
+            other.altered = True
+            other.emit_create = True
+            other.emit_drop = False
+        else:
+            # If the objects are identical, exclude both.
+            self.altered = False
+            self.emit_create = False
+            self.emit_drop = False
+            
+            other.altered = False
+            other.emit_create = False
+            other.emit_drop = False
+    
+    def recreate(self):
+        """Mark self as needing a CREATE after a DROP of self.counterpart.
+        
+        Call this when a new object has an old counterpart, but has changed
+        in such a way that the old one needs to be DROP'ed and the new one
+        CREATE'd from scratch (i.e., an ALTER statement isn't enough).
+        """
+        c = self.counterpart
+        
+        c.altered = True
+        c.emit_drop = True
+        c.emit_create = True
+        
+        self.altered = True
+        self.emit_drop = True
+        self.emit_create = True
+        
+        # OK, this seems weird to make A depend on B depend on A.
+        #   self dep <-> dep c
+        # But keep in mind that either self or counterpart will have its
+        # dependencies and referrers swapped when we actually generate SQL:
+        #   self dep <-> ref c
+        self.dependencies.add(c)
+        c.dependencies.add(self)
+    
+    def up(self, include_placeholders=False):
+        """Return a SQL block to upgrade self."""
+        if not self.altered:
+            return None
+        
+        func = self.create
+        if hasattr(self, "alter"):
+            if self.counterpart is not None and not self.counterpart.emit_drop:
+                func = self.alter
+        
+        if self.emit_create:
+            return func()
+        elif include_placeholders:
+            return commented(func())
+    
+    def down(self, include_placeholders=False):
+        """Return a SQL block to revert self."""
+        if not self.altered:
+            return None
+        
+        if self.emit_drop:
+            return self.drop()
+        elif include_placeholders:
+            return commented(self.drop())
+
+
+class PLLanguage(DumpableObject):
+    
+    classname = 'pg_language'
+    _name_field = 'lanname'
+    schema = None
+    # All user languages are assumed to be present in pg_pltemplate.
+    fetcher = ('SELECT oid, lanname FROM pg_language WHERE lanispl;')
+    whitelist_type = 'languages'
+    
+    def drop(self):
+        """Return a SQL string to DROP this LANGUAGE."""
+        return 'DROP LANGUAGE %s;' % self.name
+    
+    def create(self):
+        """Return a SQL string to CREATE this LANGUAGE."""
+        return 'CREATE LANGUAGE %s;' % self.name
+
+
+class Role(DumpableObject):
+    
+    classname = 'pg_authid'
+    _name_field = 'rolname'
+    schema = None
+    fetcher = ('SELECT oid, rolname, rolsuper, rolinherit, rolcreaterole, '
+               'rolcreatedb, rolcatupdate, rolcanlogin, rolconnlimit, '
+               'rolpassword, rolvaliduntil FROM pg_authid;')
+    whitelist_type = 'roles'
+    
+    def switch(val, trueval, falseval):
+        if bool(val):
+            return trueval
+        return falseval
+    switch = staticmethod(switch)
+    
+    def members(self):
+        """Return (admins, members) of this ROLE."""
+        members = self.dumpset.db.fetchall(
+            "SELECT a.rolname, m.admin_option FROM pg_auth_members m "
+            "LEFT JOIN pg_authid a ON m.member = a.oid "
+            "WHERE m.roleid = %d;" % self.oid)
+        admins = [name for name, admin in members if admin]
+        roles = [name for name, admin in members if not admin]
+        return admins, roles
+    
+    def memberships(self):
+        """Return names of roles of whih this ROLE is a member."""
+        return [name for name, in self.dumpset.db.fetchall(
+            "SELECT a.rolname FROM pg_auth_members m "
+            "LEFT JOIN pg_authid a ON m.roleid = a.oid "
+            "WHERE m.member = %d;" % self.oid)]
+    
+    def option_diffs(self, old, new):
+        """Return a list of SQL statements to ALTER old to new."""
+        sql = []
+        for attr, t, f in [
+            ('rolsuper', 'SUPERUSER', 'NOSUPERUSER'),
+            ('rolcreatedb', 'CREATEDB', 'NOCREATEDB'),
+            ('rolcreaterole', 'CREATEROLE', 'NOCREATEROLE'),
+            ('rolcreaterole', 'CREATEUSER', 'NOCREATEUSER'),
+            ('rolinherit', 'INHERIT', 'NOINHERIT'),
+            ('rolcanlogin', 'LOGIN', 'NOLOGIN'),
+            ]:
+            if getattr(old, attr) != getattr(new, attr):
+                sql.append(self.switch(getattr(new, attr), t, f))
+        if old.rolconnlimit != new.rolconnlimit:
+            sql.append('CONNECTION LIMIT %d' % new.rolconnlimit)
+        if old.rolpassword != new.rolpassword:
+            if new.rolpassword is None:
+                sql.append("PASSWORD NULL")
+            else:
+                pw = new.rolpassword.replace("'", "''").replace('\\', r'\\')
+                sql.append("UNENCRYPTED PASSWORD E'%s'" % pw)
+        if old.rolvaliduntil != new.rolvaliduntil:
+            if new.rolvaliduntil is None:
+                valid = 'NULL'
+            else:
+                valid = "'%s'" % new.rolvaliduntil.strftime('%c')
+            sql.append("VALID UNTIL %s" % valid)
+        return sql
+    
+    def drop(self):
+        """Return a SQL string to DROP this ROLE."""
+        return 'DROP ROLE %s;' % self.name
+    
+    def alter(self):
+        """Return a SQL string to ALTER this ROLE."""
+        d = self.option_diffs(self.counterpart, self)
+        return 'ALTER ROLE %s %s;' % (self.name, ' '.join(d))
+    
+    def create(self):
+        """Return a SQL string to CREATE this ROLE."""
+        sql = []
+        for attr, t, f in [
+            ('rolsuper', 'SUPERUSER', 'NOSUPERUSER'),
+            ('rolcreatedb', 'CREATEDB', 'NOCREATEDB'),
+            ('rolcreaterole', 'CREATEROLE', 'NOCREATEROLE'),
+            ('rolcreaterole', 'CREATEUSER', 'NOCREATEUSER'),
+            ('rolinherit', 'INHERIT', 'NOINHERIT'),
+            ('rolcanlogin', 'LOGIN', 'NOLOGIN'),
+            ]:
+            sql.append(self.switch(getattr(self, attr), t, f))
+        sql.append('CONNECTION LIMIT %d' % self.rolconnlimit)
+        if self.rolpassword is None:
+            sql.append("PASSWORD NULL")
+        else:
+            pw = self.rolpassword.replace("'", "''").replace('\\', r'\\')
+            sql.append("UNENCRYPTED PASSWORD E'%s'" % pw)
+        if self.rolvaliduntil is None:
+            valid = 'NULL'
+        else:
+            valid = "'%s'" % self.rolvaliduntil.strftime('%c')
+        sql.append("VALID UNTIL %s" % valid)
+        
+        # Members
+        admins, roles = self.members()
+        if admins:
+            sql.append("ADMIN %s" % ', '.join(admins))
+        if roles:
+            sql.append("ROLE %s" % ', '.join(roles))
+
+        # Memberships
+        memberships = self.memberships()
+        if memberships:
+            sql.append("IN ROLE %s" % ', '.join(memberships))
+    
+        return 'CREATE ROLE %s %s;' % (self.name, ' '.join(sql))
+
+
+class Trigger(DumpableObject):
+    
+    classname = 'pg_trigger'
+    _name_field = 'tgname'
+    schema = None
+    fetcher = ('SELECT oid, tgrelid, tgname, pg_get_triggerdef(oid) AS triggerdef '
+               'FROM pg_trigger WHERE NOT tgisconstraint;')
+    whitelist_type = 'triggers'
+    
+    def drop(self):
+        """Return a SQL string to DROP this TRIGGER."""
+        qname = self.dumpset.classmap[self.tgrelid]
+        return 'DROP TRIGGER %s ON %s;' % (self.name, qname)
+    
+    def create(self):
+        """Return a SQL string to CREATE this TRIGGER."""
+        return self.triggerdef + ";"
+    
+    def postlink(self):
+        """Mark this TRIGGER for a DROP+CREATE if triggerdefs differ."""
+        c = self.counterpart
+        if c is None:
+            return
+        
+        if self.triggerdef != c.triggerdef:
+            self.recreate()
+
+
+class Schema(DumpableObject):
+    
+    classname = 'pg_namespace'
+    _name_field = 'nspname'
+    schema = None
+    fetcher = ("SELECT oid, nspname FROM pg_namespace")
+    whitelist_type = 'schemas'
+    
+    def drop(self):
+        """Return a SQL string to DROP this SCHEMA."""
+        return 'DROP SCHEMA "%s";' % self.name
+    
+    def create(self):
+        """Return a SQL string to CREATE this SCHEMA."""
+        return 'CREATE SCHEMA "%s";' % self.name
+
+
+# ---------------------------- SchemaObjects ---------------------------- #
+
+
+class SchemaObject(DumpableObject):
+    
+    def _get_schema(self):
+        schema_oid = getattr(self, self._schema_field)
+        return self.dumpset.schemamap[schema_oid]
+    schema = property(_get_schema)
+    
+    def _get_whitelist_name(self):
+        return "%s.%s" % (self.schema.strip('"'), self.name.strip('"'))
+    whitelist_name = property(_get_whitelist_name)
+    
+    def key(self):
+        """Return a hashable unique identifier for this instance."""
+        return (self.name, self.schema)
+
+
+class ShellType(SchemaObject):
+    classname = 'pg_type'
+    _name_field = 'typname'
+    _schema_field = 'typnamespace'
+    
+    def fetchall(cls, dumpset):
+        """Return all instances of this class in the database."""
+        # See DumpSet.populate_dependencies for ShellType instantiation.
+        return []
+    fetchall = classmethod(fetchall)
+    
+    def drop(self):
+        """Return a SQL string to DROP this TYPE."""
+        return """
+-- This SELECT statement is followed by a DROP TYPE ... CASCADE statement.
+-- The CASCADE is required to DROP both the TYPE and any of its in/out/send/
+-- recv/analyze FUNCTIONs in one step; there is no way to use separate DROP
+-- statements for each. When this diff was generated, it was safe to use
+-- CASCADE because all other dependent objects had previously been dropped.
+-- 
+-- If this SELECT raises the ERROR: 'more than one row returned by a subquery
+-- used as an expression', then the DROP ... CASCADE is NOT safe to apply to
+-- the current database (most likely a Post Facto working copy), because the
+-- current database contains additional objects which depend on the TYPE
+-- that were not present or accounted for when the diff was generated.
+-- 
+-- Long story short: you need to resolve the conflict by hand, either deleting
+-- the anomalous dependent objects, or choosing some upgrade path other than
+-- applying this diff.
+-- 
+-- If the SELECT statement passes without raising an error, then the TYPE and
+-- its FUNCTIONs will be deleted normally and safely.
+
+SELECT (
+
+    SELECT objid FROM pg_catalog.pg_depend deps WHERE (
+        
+        -- Find objects that depend on the TYPE we are dropping...
+        deps.refclassid = 'pg_catalog.pg_type'::regclass AND
+        deps.refobjid = '%(qname)s'::regtype
+        
+        -- ...which the TYPE itself does not depend on
+        AND NOT EXISTS (
+            SELECT 1 FROM pg_catalog.pg_depend refs
+            WHERE (refs.classid = 'pg_catalog.pg_type'::regclass AND
+                   refs.objid = '%(qname)s'::regtype
+                   AND refs.refclassid = deps.classid
+                   AND refs.refobjid = deps.objid))
+        
+        -- (but also skip the array type for this type).
+        AND CASE WHEN deps.classid = 'pg_catalog.pg_type'::regclass
+        THEN (deps.refobjid != (SELECT typelem FROM pg_type WHERE oid = deps.objid))
+        ELSE true END)
+    
+    -- Now, add ourself as an extra row. This forces the 'raise' subselect
+    -- always return at least 1 row. If the above pg_depend SELECT returns
+    -- any rows, then the entire 'raise' subselect will return 2 (or more)
+    -- rows, and a cardinality error is raised.
+    UNION SELECT oid FROM pg_catalog.pg_type WHERE oid = '%(qname)s'::regtype
+    
+    ) as raise;
+
+DROP TYPE %(qname)s CASCADE;""" % {'qname': qualname(self.name, self.schema)}
+    
+    def create(self):
+        """Return a SQL string to CREATE this TYPE."""
+        return 'CREATE TYPE %s;' % qualname(self.name, self.schema)
+    
+    def postlink(self):
+        """Add a dependency to this TYPE's namespace."""
+        # Shelltypes don't get a pg_depend entry to their
+        # namespace; instead, their real type gets that. So make one.
+        for schema in self.dumpset.objects.get(Schema, {}).itervalues():
+            if schema.name == self.schema:
+                self.depends_on(schema)
+
+
+class ArrayType(SchemaObject):
+    classname = 'pg_type'
+    _name_field = 'typname'
+    _schema_field = 'typnamespace'
+    
+    fetcher = ('SELECT oid, typname, typnamespace FROM pg_type '
+               "WHERE typtype = 'b' AND typisdefined "
+               "AND typelem != 0 AND typname LIKE '\_%' "
+               "ORDER BY typname;")
+    whitelist_type = 'types'
+    
+    emit_create = False
+    emit_drop = False
+    
+    def drop(self):
+        """Return a SQL string to DROP this TYPE."""
+        return 'DROP Array type: %s;' % qualname(self.name, self.schema)
+    
+    def create(self):
+        """Return a SQL string to CREATE this TYPE."""
+        return 'CREATE Array type: %s;' % qualname(self.name, self.schema)
+
+
+class BaseType(SchemaObject):
+    
+    classname = 'pg_type'
+    _name_field = 'typname'
+    _schema_field = 'typnamespace'
+    
+    fetcher = ('SELECT oid, typname, typnamespace, typlen, '
+               'typinput, typoutput, typreceive, typsend, typanalyze, '
+               # The above 5 are ::regproc. Also include them as ::oid
+               'typinput::oid AS typinputoid, typoutput::oid AS typoutputoid, '
+               'typreceive::oid AS typreceiveoid, typsend::oid AS typsendoid, '
+               'typanalyze::oid AS typanalyzeoid, '
+               'typdefault, typelem, typdelim, typalign, typstorage, typbyval '
+               "FROM pg_type WHERE typtype = 'b' AND typisdefined "
+               # Skip array types
+               "AND (typelem = 0 OR typname NOT LIKE '\_%') "
+               "ORDER BY typname;")
+    whitelist_type = 'types'
+    
+    alignments = {"c": ",\n    ALIGNMENT = char",
+                  "s": ",\n    ALIGNMENT = int2",
+                  "i": ",\n    ALIGNMENT = int4",
+                  "d": ",\n    ALIGNMENT = double",
+                  }
+    
+    storages = {"p": ",\n    STORAGE = plain",
+                "e": ",\n    STORAGE = external",
+                "x": ",\n    STORAGE = extended",
+                "m": ",\n    STORAGE = main",
+                }
+    
+    def __init__(self, dumpset, **attrs):
+        SchemaObject.__init__(self, dumpset, **attrs)
+        self.shelltype = ShellType(
+            self.dumpset, oid=self.oid, typname=self.typname,
+            typnamespace=self.typnamespace)
+    
+    def drop(self):
+        """Return a SQL string to DROP this TYPE."""
+        return 'DROP TYPE %s;' % qualname(self.name, self.schema)
+    
+    def create(self):
+        """Return a SQL string to CREATE this TYPE."""
+        qname = qualname(self.name, self.schema)
+        
+        def optional_func(key, val):
+            if val == '-':
+                return ''
+            else:
+                return ',\n    %s = %s' % (key, qualname(val, self.schema))
+        
+        tdata = [qname, self.typlen, self.typinput, self.typoutput]
+        tdata.append(optional_func('RECEIVE', self.typreceive))
+        tdata.append(optional_func('SEND', self.typsend))
+        tdata.append(optional_func('ANALYZE', self.typanalyze))
+        if self.typdefault is None:
+            tdata.append('')
+        else:
+            tdata.append(',\n    DEFAULT = %r' % self.typdefault)
+        if self.typelem:
+            tdata.append(',\n    ELEMENT = %s' % self.dumpset.typemap[self.typelem])
+        else:
+            tdata.append('')
+        if self.typdelim and self.typdelim != ',':
+            tdata.append(",\n   DELIMITER = '%s'" % self.typdelim)
+        else:
+            tdata.append('')
+        tdata.append(self.alignments.get(self.typalign, ''))
+        tdata.append(self.storages.get(self.typstorage, ''))
+        if self.typbyval == 't':
+            tdata.append(",\n    PASSEDBYVALUE")
+        else:
+            tdata.append('')
+        
+        return ("CREATE TYPE %s (\n"
+                "    INTERNALLENGTH = %s,\n"
+                "    INPUT = %s,\n"
+                "    OUTPUT = %s%s%s%s%s%s%s%s%s%s\n"
+                ");" % tuple(tdata))
+
+
+class CompositeType(SchemaObject):
+    classname = 'pg_type'
+    _name_field = 'typname'
+    _schema_field = 'typnamespace'
+    
+    fetcher = ("SELECT t.oid, typname, typnamespace, c.oid AS tableoid, c.relkind "
+               "FROM pg_type t LEFT JOIN pg_class c ON t.typrelid = c.oid "
+               "WHERE typtype = 'c' AND typisdefined = true AND "
+               "c.relkind IN ('c', 'v', 'r') "
+               "ORDER BY typname;")
+    whitelist_type = 'types'
+    
+    def __init__(self, dumpset, **attrs):
+        SchemaObject.__init__(self, dumpset, **attrs)
+        # Emit user-defined types only, not implicit table or view types.
+        # We still want *objects* for table and view types so dependencies work.
+        if attrs.get('relkind', 'c') != 'c':
+            self.emit_create = False
+            self.emit_drop = False
+    
+    def drop(self):
+        """Return a SQL string to DROP this TYPE."""
+        return 'DROP TYPE %s;' % qualname(self.name, self.schema)
+    
+    def create(self):
+        """Return a SQL string to CREATE this TYPE."""
+        attrs = ['\n    %s %s' % (k, v) for k, v
+                 in self.dumpset.db.fetchall(
+                     # Make format_type be explicit about all schemas.
+                     "SET search_path = pg_catalog; "
+                     'SELECT a.attname, pg_catalog.format_type('
+                     'a.atttypid, a.atttypmod) FROM '
+                     'pg_attribute a WHERE a.attrelid = %d '
+                     'AND NOT a.attisdropped '
+                     # Very important! :)
+                     'ORDER BY a.attnum ASC;' % self.tableoid)]
+        return "CREATE TYPE %s AS (%s\n);" % (qualname(self.name, self.schema),
+                                              ','.join(attrs))
+    
+    def postlink(self):
+        # Free-standing Composite types don't get a pg_depend entry to their
+        # namespace; instead, their pg_class gets that. So make one.
+        for schema in self.dumpset.objects.get(Schema, {}).itervalues():
+            if schema.name == self.schema:
+                self.depends_on(schema)
+        
+        if self.counterpart is not None:
+            if self.relkind == 'c':
+                if self.create() != self.counterpart.create():
+                    self.recreate()
+
+
+class Operator(SchemaObject):
+    classname = 'pg_operator'
+    _name_field = 'oprname'
+    _schema_field = 'oprnamespace'
+    
+    fetcher = {None: ("SELECT o.oid, o.oprname, o.oprnamespace, o.oprcode, o.oprleft, "
+               "o.oprright, o.oprcom, o.oprnegate, o.oprrest, o.oprjoin, "
+               "o.oprcanhash, o.oprlsortop, o.oprrsortop, o.oprltcmpop, "
+               "o.oprgtcmpop FROM pg_operator o "
+               "ORDER BY oprname;"),
+               80300: ("SELECT o.oid, o.oprname, o.oprnamespace, o.oprcode, o.oprleft, "
+               "o.oprright, o.oprcom, o.oprnegate, o.oprrest, o.oprjoin, "
+               "o.oprcanhash FROM pg_operator o "
+               "ORDER BY oprname;"),
+               }
+    whitelist_type = 'operators'
+    
+    def key(self):
+        """Return a hashable unique identifier for this instance."""
+        left = self.dumpset.typemap.get(self.oprleft, "NONE")
+        right = self.dumpset.typemap.get(self.oprright, "NONE")
+        return (self.name, self.schema, left, right)
+    
+    def drop(self):
+        """Return a SQL string to DROP this OPERATOR."""
+        left = self.dumpset.typemap.get(self.oprleft, "NONE")
+        right = self.dumpset.typemap.get(self.oprright, "NONE")
+        return 'DROP OPERATOR %s (%s, %s);' % (qualname(self.name, self.schema),
+                                               left, right)