#!/usr/bin/env python

# mvsed.py - Rename files with regular expressions
# Released: 2009-11-23
#
# This utility will apply a regular expression to the names given on the
# command line, and rename the file based on the expression. Back-references
# of the form \1 .. \9 are permitted, and (?: ) is also permitted to exclude
# groups from backreferences.
#
# Files will never be overwritten after a rename.
#
# To preview the effects of a rename, use the -t flag before the regular
# expressions.
#
# Example
# =======
# 
# Rename "test/module-name.c" to "test-module-name.c"
#
# $ mvsed.py '^test/(.*\.c)'   'test-\1'   test/*.c
#
# Rename "IMG_9272.JPG" to "IMG_19272.JPG"
#
# $ mvsed.py '_([0-9]{4})\.'    '_1\1.'    IMG_*.JPG
#
# Rename "S01E22 abcd.xvid.crap.avi" to "TV Series - s01e22.avi"
#
# $ mvsed.py '^S([0-9]{1,2})E([0-9]{1,2}).*(\.[^.]+)$'    \
#            'TV Series - s\1e\2\3'                       \
#            *.avi
# 
#
# Known bugs
# ==========
#
# - This utility does not use the GNU getopt()-style syntax or parser.
# - The source regular expression "-t" cannot be used as-is. Use [-]t instead.
# - No test cases
#
# License
# =======
#
# This utility is licensed under the terms of the BSD license, as follows:
#
# Copyright (c) 2009, Ben Stewart
# 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 utility 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 Ben Stewart "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 Ben Stewart 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.


import sys
import re
import os


## Dummy class for any option parser-related errors.
#
# @see parse_options()
#
class ParseException(Exception):
    pass


## Parse the command-line options
#
# @param  arguments  Command-line arguments, excluding the program name.
#
# @return  Returns a 4-tuple consisting of test-only mode, source regular
#          expression, destination regular expression, and an array of filenames
#          to consider for the rename. 
#        
def parse_options(arguments):
    test_mode = False
    regex_src = ''
    regex_dst = ''
    files = []

    # Check argc
    if len(arguments) < 3:
        raise ParseException('Not enough arguments provided.')
    
    # Check for test-only mode, trim -t from arguments if required.
    if arguments[0] == '-t':
        if len(arguments) < 4:
            raise ParseException('Not enough arguments provided.')
        
        test_mode = True
        arguments = arguments[1:]

    regex_src = arguments[0]
    regex_dst = arguments[1]
    files = arguments[2:]

    # Test the regular expressions before proceeding
    try:
        re.compile(regex_src)
    except:
        raise ParseException('Source regular expression "%s" is not valid.' % regex_src)

    try:
        re.compile(regex_dst)
    except:
        raise ParseException('Destination regular expression "%s" is not valid.' % regex_dst)

    return (test_mode, regex_src, regex_dst, files)


## Actually perform the rename operation.
#
# Any errors will be put to standard error.
#
#
# @param  test_mode  Boolean; set to True if should be run in a mode that will
#                    only display proposed changes and not execute them.
#
# @param  regex_src  Source regular expression, string. Any of these matches
#                    will be replaced with the 'destination' regular expression.
#
# @param  regex_dst  Destination regular expression. Any matches of the source
#                    regular expression will be replaced with this.
#                    Backreferences of the form \1 through \9 are permitted, as
#                    per the documentation for re.sub().
#
# @param  files      Array of filenames to consider for a rename.
#
# @return  Returns True upon successful completion.
# @return  Returns False if one or more files failed to rename.
#
def rename_sed(test_mode, regex_src, regex_dst, files):
    success = True

    for file in files:
        destFile = re.sub(regex_src, regex_dst, file)
        if destFile != file:
            try:    
                if not os.path.exists(destFile):
                    if test_mode:
                        print "%s => %s" % (file, destFile)
                    else:
                        os.rename(file, destFile)
                else:
                    print >> sys.stderr, "Cannot rename %s to %s; destination exists." % (file, destFile)
                    success = False

            except OSError, e:
                print >>sys.stderr, "Cannot rename %s to %s; %s" % (file, destFile, e.strerror)
                success = False


## Display usage information for the script to standard error.
#
# @param  programName  Command name, as per user invocation of the tool.
#
def usage(programName = 'mvsed.py'):
    print >>sys.stderr, "Usage:"
    print >>sys.stderr, "  %s [-t] source-pattern dest-pattern file1 [file2] [file3] ... [fileN]" % programName


# The actual program code..
#
if __name__ == '__main__':
    try:
        (test_mode, regex_src, regex_dst, files) = parse_options(sys.argv[1:])
        rename_sed(test_mode, regex_src, regex_dst, files)

    except ParseException, e:
        print >>sys.stderr, "Error: %s" % e.message
        usage(sys.argv[0])
        exit(1)
