1#! /usr/bin/env python3
2
3"""cleanfuture [-d][-r][-v] path ...
4
5-d  Dry run.  Analyze, but don't make any changes to, files.
6-r  Recurse.  Search for all .py files in subdirectories too.
7-v  Verbose.  Print informative msgs.
8
9Search Python (.py) files for future statements, and remove the features
10from such statements that are already mandatory in the version of Python
11you're using.
12
13Pass one or more file and/or directory paths.  When a directory path, all
14.py files within the directory will be examined, and, if the -r option is
15given, likewise recursively for subdirectories.
16
17Overwrites files in place, renaming the originals with a .bak extension. If
18cleanfuture finds nothing to change, the file is left alone.  If cleanfuture
19does change a file, the changed file is a fixed-point (i.e., running
20cleanfuture on the resulting .py file won't change it again, at least not
21until you try it again with a later Python release).
22
23Limitations:  You can do these things, but this tool won't help you then:
24
25+ A future statement cannot be mixed with any other statement on the same
26  physical line (separated by semicolon).
27
28+ A future statement cannot contain an "as" clause.
29
30Example:  Assuming you're using Python 2.2, if a file containing
31
32from __future__ import nested_scopes, generators
33
34is analyzed by cleanfuture, the line is rewritten to
35
36from __future__ import generators
37
38because nested_scopes is no longer optional in 2.2 but generators is.
39"""
40
41import __future__
42import tokenize
43import os
44import sys
45
46dryrun  = 0
47recurse = 0
48verbose = 0
49
50def errprint(*args):
51    strings = map(str, args)
52    msg = ' '.join(strings)
53    if msg[-1:] != '\n':
54        msg += '\n'
55    sys.stderr.write(msg)
56
57def main():
58    import getopt
59    global verbose, recurse, dryrun
60    try:
61        opts, args = getopt.getopt(sys.argv[1:], "drv")
62    except getopt.error as msg:
63        errprint(msg)
64        return
65    for o, a in opts:
66        if o == '-d':
67            dryrun += 1
68        elif o == '-r':
69            recurse += 1
70        elif o == '-v':
71            verbose += 1
72    if not args:
73        errprint("Usage:", __doc__)
74        return
75    for arg in args:
76        check(arg)
77
78def check(file):
79    if os.path.isdir(file) and not os.path.islink(file):
80        if verbose:
81            print("listing directory", file)
82        names = os.listdir(file)
83        for name in names:
84            fullname = os.path.join(file, name)
85            if ((recurse and os.path.isdir(fullname) and
86                 not os.path.islink(fullname))
87                or name.lower().endswith(".py")):
88                check(fullname)
89        return
90
91    if verbose:
92        print("checking", file, "...", end=' ')
93    try:
94        f = open(file)
95    except IOError as msg:
96        errprint("%r: I/O Error: %s" % (file, str(msg)))
97        return
98
99    with f:
100        ff = FutureFinder(f, file)
101        changed = ff.run()
102        if changed:
103            ff.gettherest()
104    if changed:
105        if verbose:
106            print("changed.")
107            if dryrun:
108                print("But this is a dry run, so leaving it alone.")
109        for s, e, line in changed:
110            print("%r lines %d-%d" % (file, s+1, e+1))
111            for i in range(s, e+1):
112                print(ff.lines[i], end=' ')
113            if line is None:
114                print("-- deleted")
115            else:
116                print("-- change to:")
117                print(line, end=' ')
118        if not dryrun:
119            bak = file + ".bak"
120            if os.path.exists(bak):
121                os.remove(bak)
122            os.rename(file, bak)
123            if verbose:
124                print("renamed", file, "to", bak)
125            with open(file, "w") as g:
126                ff.write(g)
127            if verbose:
128                print("wrote new", file)
129    else:
130        if verbose:
131            print("unchanged.")
132
133class FutureFinder:
134
135    def __init__(self, f, fname):
136        self.f = f
137        self.fname = fname
138        self.ateof = 0
139        self.lines = [] # raw file lines
140
141        # List of (start_index, end_index, new_line) triples.
142        self.changed = []
143
144    # Line-getter for tokenize.
145    def getline(self):
146        if self.ateof:
147            return ""
148        line = self.f.readline()
149        if line == "":
150            self.ateof = 1
151        else:
152            self.lines.append(line)
153        return line
154
155    def run(self):
156        STRING = tokenize.STRING
157        NL = tokenize.NL
158        NEWLINE = tokenize.NEWLINE
159        COMMENT = tokenize.COMMENT
160        NAME = tokenize.NAME
161        OP = tokenize.OP
162
163        changed = self.changed
164        get = tokenize.generate_tokens(self.getline).__next__
165        type, token, (srow, scol), (erow, ecol), line = get()
166
167        # Chew up initial comments and blank lines (if any).
168        while type in (COMMENT, NL, NEWLINE):
169            type, token, (srow, scol), (erow, ecol), line = get()
170
171        # Chew up docstring (if any -- and it may be implicitly catenated!).
172        while type is STRING:
173            type, token, (srow, scol), (erow, ecol), line = get()
174
175        # Analyze the future stmts.
176        while 1:
177            # Chew up comments and blank lines (if any).
178            while type in (COMMENT, NL, NEWLINE):
179                type, token, (srow, scol), (erow, ecol), line = get()
180
181            if not (type is NAME and token == "from"):
182                break
183            startline = srow - 1    # tokenize is one-based
184            type, token, (srow, scol), (erow, ecol), line = get()
185
186            if not (type is NAME and token == "__future__"):
187                break
188            type, token, (srow, scol), (erow, ecol), line = get()
189
190            if not (type is NAME and token == "import"):
191                break
192            type, token, (srow, scol), (erow, ecol), line = get()
193
194            # Get the list of features.
195            features = []
196            while type is NAME:
197                features.append(token)
198                type, token, (srow, scol), (erow, ecol), line = get()
199
200                if not (type is OP and token == ','):
201                    break
202                type, token, (srow, scol), (erow, ecol), line = get()
203
204            # A trailing comment?
205            comment = None
206            if type is COMMENT:
207                comment = token
208                type, token, (srow, scol), (erow, ecol), line = get()
209
210            if type is not NEWLINE:
211                errprint("Skipping file %r; can't parse line %d:\n%s" %
212                         (self.fname, srow, line))
213                return []
214
215            endline = srow - 1
216
217            # Check for obsolete features.
218            okfeatures = []
219            for f in features:
220                object = getattr(__future__, f, None)
221                if object is None:
222                    # A feature we don't know about yet -- leave it in.
223                    # They'll get a compile-time error when they compile
224                    # this program, but that's not our job to sort out.
225                    okfeatures.append(f)
226                else:
227                    released = object.getMandatoryRelease()
228                    if released is None or released <= sys.version_info:
229                        # Withdrawn or obsolete.
230                        pass
231                    else:
232                        okfeatures.append(f)
233
234            # Rewrite the line if at least one future-feature is obsolete.
235            if len(okfeatures) < len(features):
236                if len(okfeatures) == 0:
237                    line = None
238                else:
239                    line = "from __future__ import "
240                    line += ', '.join(okfeatures)
241                    if comment is not None:
242                        line += ' ' + comment
243                    line += '\n'
244                changed.append((startline, endline, line))
245
246            # Loop back for more future statements.
247
248        return changed
249
250    def gettherest(self):
251        if self.ateof:
252            self.therest = ''
253        else:
254            self.therest = self.f.read()
255
256    def write(self, f):
257        changed = self.changed
258        assert changed
259        # Prevent calling this again.
260        self.changed = []
261        # Apply changes in reverse order.
262        changed.reverse()
263        for s, e, line in changed:
264            if line is None:
265                # pure deletion
266                del self.lines[s:e+1]
267            else:
268                self.lines[s:e+1] = [line]
269        f.writelines(self.lines)
270        # Copy over the remainder of the file.
271        if self.therest:
272            f.write(self.therest)
273
274if __name__ == '__main__':
275    main()
276