#!/usr/bin/env python2.4

"""
'patch flow' module.

This script lets you define darcs repositories and relate them
together in what is essentially a directed graph.  This script manages
the directional flow of patches between repositories with user-defined
relationships and fires up darcs in the right way to prompt you
whether patches should be pushed or pulled where appropriate.
"""

import os

darcs_command = '/usr/bin/darcs'

class BadAction(Exception):
    pass

class DarcsException(Exception):
    pass

class BadProjectName(Exception):
    pass

class BadProjectStructure(Exception):
    pass

class OperationalError(Exception):
    pass

def darcs(*args):
    """
    Run darcs with the specified command-line arguments.  Looks at the
    'darcs_command' global, which you should set if your darcs lives
    some place special.
    """
    args = [darcs_command] + list(args)
    return os.system(" ".join(args))

class Repository:
    """
    Represents a darcs repository.  Particularly, it's helpful to know
    if a repository is local or remote, what its human-readable name
    is, and what its path is.  The remote flag of a repository object
    merely keeps the push/pull operations from trying to do weird
    things like *pull to* (or *push from*) a remote repository.
    """
    def __init__(self, name, path, remote=False):
        self.path = path
        self.name = name
        self.remote = remote

    def __repr__(self):
        return self.name

class Operation:
    """
    Represents an operation between two repositories.  An operation
    instance states, for given repositories r1 and r2, whether patches
    can be pushed from r1 to r2, pulled from r2 to r1, or both.
    """
    def __init__(self, r1, r2, push=False, pull=False):
        self.push = push
        self.pull = pull
        self.r1 = r1
        self.r2 = r2

    @classmethod
    def push(cls, r1, r2):
        """
        A factory method which returns an instance of Operation with
        push=True.
        """
        return cls(r1, r2, push=True)

    @classmethod
    def pull(cls, r1, r2):
        """
        A factory method which returns an instance of Operation with
        pull=True.
        """
        return cls(r1, r2, pull=True)

    @classmethod
    def pushpull(cls, r1, r2):
        """
        A factory method which returns an instance of Operation with
        pull=True and push=True.
        """
        return cls(r1, r2, push=True, pull=True)


####
# Define actions.
####

def pull(prj_name, repos):
    """
    Initiates pulls between the appropriate repos.  prj_name is
    unused, but repos is a list of Operation instances.
    """
    repos = list(repos)
    # reverse so the list starts with upstream, ends with downstream
    repos.reverse()
    for op in repos:
        if op.pull:
            src, dest = op.r1, op.r2
            if src.remote:
                raise OperationalError('Cannot pull to remote repository')
            print
            print "Pulling from %s to %s" % (dest, src)
            # Check to see if src.path doesn't exist and needs a
            # 'darcs get' first.
            try:
                os.chdir(os.path.dirname(src.path))
            except OSError, e:
                raise OperationalError(str(e))
            result = darcs("get",
                           dest.path,
                           "--repo-name=%s" % (os.path.basename(src.path)),
                           ">/dev/null 2>&1")
            result = darcs("pull",
                           "--repodir=%s" % (src.path,),
                           dest.path)
            if result != 0:
                raise DarcsException("Darcs returned non-zero exit code.")

def push(prj_name, repos):
    """
    Initiates pushes between the appropriate repos.  prj_name is
    unused, but repos is a list of Operation instances.
    """
    for op in repos:
        if op.push:
            src, dest = op.r1, op.r2
            if src.remote:
                raise OperationalError("Cannot push from remote repository")
            print
            print "Pushing from %s to %s" % (src, dest)
            result = darcs("push",
                           "--repodir=%s" % (src.path,),
                           dest.path)
            if result != 0:
                raise DarcsException("Darcs returned non-zero exit code.")

def checkData(stuff):
    """
    Just do some *basic* sanity checking on the projects dict.
    """
    try:
        # Stuff must be a dict...
        assert type(stuff) is type({})
        # Of lists...
        [assert type(stuff[k]) is type([]) for k in stuff]
    except AssertionError:
        raise BadProjectStructure

_actions = {
    'pull': pull,
    'push': push,
    }

def run(projects, action, project_name=None):
    """
    Given a project dict, an action, and a project to operate on,
    perform the specified action by calling a callable in _actions.
    This is probably what you want to call if you're using this module
    programmatically; if you just want to invoke this script from the
    command line, call runWithArgs(projects) instead.

    This function possibly raises AssertionError, BadProjectName,
    BadAction, OperationalError, and DarcsException.
    """
    
    checkData(projects)

    try:
        assert action in _actions
    except AssertionError:
        raise BadAction("bad action '%s', must be one of %s" %
                        (action, _actions.keys()))

    try:
        assert project_name in projects
    except AssertionError:
        raise BadProjectName("bad project name '%s', must be one of %s" %
                             (project_name, projects.keys()))

    # Call the action with the specified project name.
    _actions[action](project_name, projects[project_name])

def runWithArgs(projects, env_proj_name=None):
    """
    Given a projects dict, look at sys.argv for the action and project
    name to act on, and call run() accordingly.  sys.argv[1] should be
    the name of an action (a key in _actions).  If env_proj_name is
    not None, os.environ[env_proj_name] is an alternative to
    sys.argv[2].  If the environment variable is not defined,
    sys.argv[2] is expected.  This function catches all exceptions
    from run() and prints messages to standard output to show usage
    information or error messages.  This is probably what you want for
    scripting purposes.
    """
    
    import sys
    import os

    def usage():
        print "Usage: %s <%s> <%s>" % (sys.argv[0],
                                       "|".join(_actions.keys()),
                                       "|".join(projects.keys()))

    try:
        try:
            project_name = os.environ[env_proj_name]
            action = sys.argv[1]
        except KeyError:
            action, project_name = sys.argv[1:]
        run(projects, action, project_name)
    except (KeyError, ValueError, IndexError,
            BadAction, BadProjectName):
        usage()
        sys.exit(1)
    except DarcsException, e:
        print "darcs error: %s" % (e,)
        sys.exit(1)
    except OperationalError, e:
        print "error: %s" % (e,)
        sys.exit(1)
