#! /usr/bin/env python

"""cleanfuture [-d][-r][-v] path ...

-d  Dry run.  Analyze, but don't make any changes to, files.
-r  Recurse.  Search for all .py files in subdirectories too.
-v  Verbose.  Print informative msgs.

Search Python (.py) files for future statements, and remove the features
from such statements that are already mandatory in the version of Python
you're using.

Pass one or more file and/or directory paths.  When a directory path, all
py files within the directory will be examined, and, if the -r option is
given, likewise recursively for subdirectories.

Overwrites files in place, renaming the originals with a .bak extension. If
cleanfuture finds nothing to change, the file is left alone.  If cleanfuture
does change a file, the changed file is a fixed-point (i.e., running
cleanfuture on the resulting .py file won't change it again, at least not
until you try it again with a later Python release).

Limitations:  You can do these things, but this tool won't help you then:

+ A future statement cannot be mixed with any other statement on the same
 physical line (separated by semicolon).

+ A future statement cannot contain an "as" clause.

Example:  Assuming you're using Python 2.2, if a file containing

from __future__ import nested_scopes, generators

is analyzed by cleanfuture, the line is rewritten to

from __future__ import generators

because nested_scopes is no longer optional in 2.2 but generators is.
"""

import __future__
import tokenize
import os
import sys

dryrun  = 0
recurse = 0
verbose = 0

def errprint(*args):
   strings = map(str, args)
   msg = ' '.join(strings)
   if msg[-1:] != '\n':
       msg += '\n'
   sys.stderr.write(msg)

def main():
   import getopt
   global verbose, recurse, dryrun
   try:
       opts, args = getopt.getopt(sys.argv[1:], "drv")
   except getopt.error, msg:
       errprint(msg)
       return
   for o, a in opts:
       if o == '-d':
           dryrun += 1
       elif o == '-r':
           recurse += 1
       elif o == '-v':
           verbose += 1
   if not args:
       errprint("Usage:", __doc__)
       return
   for arg in args:
       check(arg)

def check(file):
   if os.path.isdir(file) and not os.path.islink(file):
       if verbose:
           print "listing directory", file
       names = os.listdir(file)
       for name in names:
           fullname = os.path.join(file, name)
           if ((recurse and os.path.isdir(fullname) and
                not os.path.islink(fullname))
               or name.lower().endswith(".py")):
               check(fullname)
       return

   if verbose:
       print "checking", file, "...",
   try:
       f = open(file)
   except IOError, msg:
       errprint("%r: I/O Error: %s" % (file, str(msg)))
       return

   ff = FutureFinder(f, file)
   changed = ff.run()
   if changed:
       ff.gettherest()
   f.close()
   if changed:
       if verbose:
           print "changed."
           if dryrun:
               print "But this is a dry run, so leaving it alone."
       for s, e, line in changed:
           print "%r lines %d-%d" % (file, s+1, e+1)
           for i in range(s, e+1):
               print ff.lines[i],
           if line is None:
               print "-- deleted"
           else:
               print "-- change to:"
               print line,
       if not dryrun:
           bak = file + ".bak"
           if os.path.exists(bak):
               os.remove(bak)
           os.rename(file, bak)
           if verbose:
               print "renamed", file, "to", bak
           g = open(file, "w")
           ff.write(g)
           g.close()
           if verbose:
               print "wrote new", file
   else:
       if verbose:
           print "unchanged."

class FutureFinder:

   def __init__(self, f, fname):
       self.f = f
       self.fname = fname
       self.ateof = 0
       self.lines = [] # raw file lines

       # List of (start_index, end_index, new_line) triples.
       self.changed = []

   # Line-getter for tokenize.
   def getline(self):
       if self.ateof:
           return ""
       line = self.f.readline()
       if line == "":
           self.ateof = 1
       else:
           self.lines.append(line)
       return line

   def run(self):
       STRING = tokenize.STRING
       NL = tokenize.NL
       NEWLINE = tokenize.NEWLINE
       COMMENT = tokenize.COMMENT
       NAME = tokenize.NAME
       OP = tokenize.OP

       changed = self.changed
       get = tokenize.generate_tokens(self.getline).next
       type, token, (srow, scol), (erow, ecol), line = get()

       # Chew up initial comments and blank lines (if any).
       while type in (COMMENT, NL, NEWLINE):
           type, token, (srow, scol), (erow, ecol), line = get()

       # Chew up docstring (if any -- and it may be implicitly catenated!).
       while type is STRING:
           type, token, (srow, scol), (erow, ecol), line = get()

       # Analyze the future stmts.
       while 1:
           # Chew up comments and blank lines (if any).
           while type in (COMMENT, NL, NEWLINE):
               type, token, (srow, scol), (erow, ecol), line = get()

           if not (type is NAME and token == "from"):
               break
           startline = srow - 1    # tokenize is one-based
           type, token, (srow, scol), (erow, ecol), line = get()

           if not (type is NAME and token == "__future__"):
               break
           type, token, (srow, scol), (erow, ecol), line = get()

           if not (type is NAME and token == "import"):
               break
           type, token, (srow, scol), (erow, ecol), line = get()

           # Get the list of features.
           features = []
           while type is NAME:
               features.append(token)
               type, token, (srow, scol), (erow, ecol), line = get()

               if not (type is OP and token == ','):
                   break
               type, token, (srow, scol), (erow, ecol), line = get()

           # A trailing comment?
           comment = None
           if type is COMMENT:
               comment = token
               type, token, (srow, scol), (erow, ecol), line = get()

           if type is not NEWLINE:
               errprint("Skipping file %r; can't parse line %d:\n%s" %
                        (self.fname, srow, line))
               return []

           endline = srow - 1

           # Check for obsolete features.
           okfeatures = []
           for f in features:
               object = getattr(__future__, f, None)
               if object is None:
                   # A feature we don't know about yet -- leave it in.
                   # They'll get a compile-time error when they compile
                   # this program, but that's not our job to sort out.
                   okfeatures.append(f)
               else:
                   released = object.getMandatoryRelease()
                   if released is None or released <= sys.version_info:
                       # Withdrawn or obsolete.
                       pass
                   else:
                       okfeatures.append(f)

           # Rewrite the line if at least one future-feature is obsolete.
           if len(okfeatures) < len(features):
               if len(okfeatures) == 0:
                   line = None
               else:
                   line = "from __future__ import "
                   line += ', '.join(okfeatures)
                   if comment is not None:
                       line += ' ' + comment
                   line += '\n'
               changed.append((startline, endline, line))

           # Loop back for more future statements.

       return changed

   def gettherest(self):
       if self.ateof:
           self.therest = ''
       else:
           self.therest = self.f.read()

   def write(self, f):
       changed = self.changed
       assert changed
       # Prevent calling this again.
       self.changed = []
       # Apply changes in reverse order.
       changed.reverse()
       for s, e, line in changed:
           if line is None:
               # pure deletion
               del self.lines[s:e+1]
           else:
               self.lines[s:e+1] = [line]
       f.writelines(self.lines)
       # Copy over the remainder of the file.
       if self.therest:
           f.write(self.therest)

if __name__ == '__main__':
   main()