#!/usr/bin/env python
# encoding: utf-8
"""
svnlog

Show a log of SVN revisions. Similar to (and indeed wrapping) svn log, but with extra filtering and output options.

Requires Python 2.5 or later.

Created by Simon Brunning on 2007-11-13.
"""

from optparse import OptionParser
from xml.parsers.expat import ExpatError
import datetime
import os, sys
import re
import subprocess
import xml.etree.ElementTree as ET

usage = os.path.basename(sys.argv[0]) + ' [options] [path]'
description = '''
Show a log of SVN revisions to specified path. Similar to svn log, but with extra filtering and output options.
'''

ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S"
REVISION_TEMPLATE = '''------------------------------------------------------------------------
r%(revision)s | %(author)s | %(date)s | %(path_count)s file%(paths_plural)s modified

%(paths)s%(message)s
'''
        
def main(argv=None):
    argv = argv or sys.argv
    options, script, args = get_options(argv)
        
    svnlog(args, options.author, options.message, options.path, options.range, options.limit, options.operations, options.detail, options.comma, options.xml, options.invert, options.stop_on_copy)

def svnlog(paths,
           author_filter=None,
           message_filter=None,
           path_filter=None,
           range_filter=None,
           limit=None,
           operations_filter=None,
           detail=False,
           comma=False,
           xml=False, 
          invert=False,
          stop_on_copy=False):
    """Print a list of SVN revisions"""
    repository_urls = get_repository_urls(paths or ['.'])
    revisions = list()
    for repository_url in repository_urls:
        revisions_tree = run_svn_log(limit, range_filter, stop_on_copy, repository_url)
        revisions += extract_revisions_from_tree(revisions_tree, author_filter, message_filter, path_filter, operations_filter)
    sort_revisions(revisions, invert)
    if comma:
        comma_revisions(revisions)
    elif xml:
        xml_revisions(revisions, detail)
    else:
        text_revisions(revisions, detail)
    
def get_repository_urls(local_paths):
    '''Get SVN repository URLs for local paths'''
    for local_path in local_paths:
        svnargs = ["svn", "info", "--xml", local_path]
        
        xml, errors = subprocess.Popen(svnargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
        
        if errors:
            raise SvnException(errors)
        
        try:
            info_tree = ET.XML(xml)
        except ExpatError, exception:
            raise SvnException("Can't parse 'svn info' output: " + xml)
            
        yield info_tree.findtext('*/url')

def run_svn_log(limit, range_filter, stop_on_copy, local_path):
    '''Constuct args for 'svn log' command, run it, and return result.''' 
    svnargs = ["svn", "log", "--xml", '-v']
    if limit: svnargs += ["--limit", str(limit)]
    if range_filter: svnargs += ['-r' + range_filter]
    if stop_on_copy: svnargs += ["--stop-on-copy"]
    svnargs += [local_path]
    
    xml, errors = subprocess.Popen(svnargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
    return build_revisions_tree_from_xml(errors, xml)
        

def build_revisions_tree_from_xml(errors, xml):
    '''Either return a valid revisions tree, or die trying.'''
    if errors:
        raise SvnException(errors)
        
    try:
        revisions_tree = ET.XML(xml)
    except ExpatError, exception:
        raise SvnException("Can't parse 'svn log' output: " + xml)
            
    return revisions_tree

def extract_revisions_from_tree(revisions_tree, author_filter, message_filter, path_filter, operations_filter):
    '''Convert revisions tree into a list of revision objects'''
    revisions = list(extract_revision_details(revision)
                    for revision
                    in revisions_tree)
    revisions = list(revision
                    for revision
                    in revisions            
                    if not unwanted_revision(revision, author_filter, message_filter, path_filter, operations_filter))
    return revisions

def extract_revision_details(revision_node):
    '''Extract revision details from revision XML node.'''
    revision = revision_node.attrib['revision']
    author = revision_node.findtext('author')
    date = datetime.datetime.strptime(revision_node.findtext('date').partition('.')[0], ISO8601_FORMAT)
    message = revision_node.findtext('msg')
    paths = list((path.attrib['action'], path.text) for path in revision_node.find('paths'))
    path_count = len(paths)
    
    return Bunch(revision=revision, author=author, date=date, message=message, paths=paths, path_count=path_count)

def unwanted_revision(revision, author_filter, message_filter, path_filter, operations_filter):
    '''Is this an unwanted revision?'''
    
    return author_filter and not re.search(author_filter, revision.author, re.IGNORECASE) \
        or message_filter and not re.search(message_filter, revision.message, re.IGNORECASE) \
        or path_filter and not any(re.search(path_filter, path[-1], re.IGNORECASE) for path in revision.paths) \
        or operations_filter and not any(path[0] in operations_filter for path in revision.paths)
        
def sort_revisions(revisions, invert):
    revisions.sort(key=lambda revision: revision.revision, reverse=not invert)
    
def comma_revisions(revisions):
    '''Print comma separated list of revisions.'''
    print ','.join(revision.revision for revision in revisions)
        
def text_revisions(revisions, detail):
    '''Print revision details in text format.'''
    
    for revision in revisions:
        revision.paths = "".join(("%s\t%s\n" % path) for path in revision.paths) if detail else ""
        revision.paths_plural = "s" if revision.path_count > 1 else ""
        print REVISION_TEMPLATE % revision

class Bunch(object):
    '''General purpose container.
    See http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52308
    '''
    def __init__(self, **kwds):
        self.__dict__.update(kwds)
        
    def __getitem__(self, key):
        return self.__dict__[key]
        
    def __str__(self):
        state = ["%s=%r" % (attribute, value)
                 for (attribute, value)
                 in self.__dict__.items()]
        return '\n'.join(state)
    
class SvnException(Exception):
    """SVN execution exception"""
    def __init__(self, message):
        super(SvnException, self).__init__()
        self.message = message
        
    def __str__(self):
        """docstring for __str__"""
        return self.message

def get_options(argv):
    parser = OptionParser(usage=usage)
    parser.description=description
    # Filtering options
    parser.add_option("-a", "--author",
                    dest="author",
                    default=None,
                    help="Filter by author - report only this author's revisions.")
    parser.add_option("-m", "--message",
                    dest="message",
                    default=None,
                    help="Filter by message - report on only revisions with messages matching this case-insensitive regex.")
    parser.add_option("-p", "--path",
                    dest="path",
                    default=None,
                    help="Filter by path - report on only revisions with updates to directories or fully-qualified file names matching this case-insensitive regex.")
    parser.add_option("-r", "--range",
                    dest="range",
                    default=None,
                    help="Range - range of revisions to report - e.g. -r400:456. Can be either by revision number, date in {YYYY-MM-DD} format, or by special value such as HEAD, BASE or PREV.")
    parser.add_option("-l", "--limit",
                    dest="limit",
                    type="int",
                    default=100,
                    help="Limit - Look at only this many revisions. (Note that the limit is applied *before* some other filtering options, so you may end up seeing fewer revisions than you specify here.) Defaults to 100. Set to 0 for no limit.")
    parser.add_option("-s", "--stop-on-copy",
                    dest="stop_on_copy",
                    action="store_true",
                    help="Stop on copy - don't show revisions from before current branch was taken.")
    parser.add_option("-o", "--operation",
                    dest="operations",
                    default=None,
                    help="Filter by file operation - report on only revisions where the specified file operations (A, M or D) have been performed on the specified paths.")
    #  Output options
    parser.add_option("-d", "--detail",
                    dest="detail",
                    action="store_true",
                    help="Detail - display details of individual files and directories modified.")
    parser.add_option("-i", "--invert",
                    dest="invert",
                    action="store_true",
                    help="Invert - show revisions in reverse order, i.e. oldest first. Useful if you aren't piping the output into less.")
    parser.add_option("-c", "--comma",
                    dest="comma",
                    action="store_true",
                    help="Comma separated - output comma sepeated list of matching revisions only.")
    parser.add_option("-x", "--XML",
                    dest="xml",
                    action="store_true",
                    help="XML - output in XML format. NOT YET IMPLEMENTED!")
    
    options, args = parser.parse_args(argv)
    script, args = args[0], args[1:]
    return options, script, args

if __name__ == "__main__":
    sys.exit(main())

