5270ac521faef194a5b32eef0b208685519ecc86
[update-copyright.git] / update_copyright.py
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18
19 import os.path
20 import re
21 import sys
22 import time
23
24 import os
25 import sys
26 import select
27 from threading import Thread
28
29 from libbe.subproc import Pipe
30
31 COPYRIGHT_TEXT="""#
32 # This program is free software; you can redistribute it and/or modify
33 # it under the terms of the GNU General Public License as published by
34 # the Free Software Foundation; either version 2 of the License, or
35 # (at your option) any later version.
36 #
37 # This program is distributed in the hope that it will be useful,
38 # but WITHOUT ANY WARRANTY; without even the implied warranty of
39 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
40 # GNU General Public License for more details.
41 #
42 # You should have received a copy of the GNU General Public License along
43 # with this program; if not, write to the Free Software Foundation, Inc.,
44 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA."""
45
46 COPYRIGHT_TAG='-xyz-COPYRIGHT-zyx-' # unlikely to occur in the wild :p
47
48 ALIASES = [
49     ['Ben Finney <benf@cybersource.com.au>',
50      'Ben Finney <ben+python@benfinney.id.au>',
51      'John Doe <jdoe@example.com>'],
52     ['Chris Ball <cjb@laptop.org>',
53      'Chris Ball <cjb@thunk.printf.net>'],
54     ['Gianluca Montecchi <gian@grys.it>',
55      'gian <gian@li82-39>',
56      'gianluca <gian@galactica>'],
57     ['W. Trevor King <wking@drexel.edu>',
58      'wking <wking@mjolnir>'],
59     [None,
60      'j^ <j@oil21.org>'],
61     ]
62 COPYRIGHT_ALIASES = [
63     ['Aaron Bentley and Panometrics, Inc.',
64      'Aaron Bentley <abentley@panoramicfeedback.com>'],
65     ]
66 EXCLUDES = [
67     ['Aaron Bentley and Panometrics, Inc.',
68      'Aaron Bentley <aaron.bentley@utoronto.ca>',]
69     ]
70
71
72 IGNORED_PATHS = ['./.be/', './.bzr/', './build/']
73 IGNORED_FILES = ['COPYING', 'update_copyright.py', 'catmutt']
74
75 def _strip_email(*args):
76     """
77     >>> _strip_email('J Doe <jdoe@a.com>')
78     ['J Doe']
79     >>> _strip_email('J Doe <jdoe@a.com>', 'JJJ Smith <jjjs@a.com>')
80     ['J Doe', 'JJJ Smith']
81     """
82     args = list(args)
83     for i,arg in enumerate(args):
84         if arg == None:
85             continue
86         index = arg.find('<')
87         if index > 0:
88             args[i] = arg[:index].rstrip()
89     return args
90
91 def _replace_aliases(authors, with_email=True, aliases=None,
92                      excludes=None):
93     """
94     >>> aliases = [['J Doe and C, Inc.', 'J Doe <jdoe@c.com>'],
95     ...            ['J Doe <jdoe@a.com>', 'Johnny <jdoe@b.edu>'],
96     ...            ['JJJ Smith <jjjs@a.com>', 'Jingly <jjjs@b.edu>'],
97     ...            [None, 'Anonymous <a@a.com>']]
98     >>> excludes = [['J Doe and C, Inc.', 'J Doe <jdoe@a.com>']]
99     >>> _replace_aliases(['JJJ Smith <jjjs@a.com>', 'Johnny <jdoe@b.edu>',
100     ...                   'Jingly <jjjs@b.edu>', 'Anonymous <a@a.com>'],
101     ...                  with_email=True, aliases=aliases, excludes=excludes)
102     ['J Doe <jdoe@a.com>', 'JJJ Smith <jjjs@a.com>']
103     >>> _replace_aliases(['JJJ Smith', 'Johnny', 'Jingly', 'Anonymous'],
104     ...                  with_email=False, aliases=aliases, excludes=excludes)
105     ['J Doe', 'JJJ Smith']
106     >>> _replace_aliases(['JJJ Smith <jjjs@a.com>', 'Johnny <jdoe@b.edu>',
107     ...                   'Jingly <jjjs@b.edu>', 'J Doe <jdoe@c.com>'],
108     ...                  with_email=True, aliases=aliases, excludes=excludes)
109     ['J Doe and C, Inc.', 'JJJ Smith <jjjs@a.com>']
110     """
111     if aliases == None:
112         aliases = ALIASES
113     if excludes == None:
114         excludes = EXCLUDES
115     if with_email == False:
116         aliases = [_strip_email(*alias) for alias in aliases]
117         exclude = [_strip_email(*exclude) for exclude in excludes]
118     for i,author in enumerate(authors):
119         for alias in aliases:
120             if author in alias[1:]:
121                 authors[i] = alias[0]
122                 break
123     for i,author in enumerate(authors):
124         for exclude in excludes:
125             if author in exclude[1:] and exclude[0] in authors:
126                 authors[i] = None
127     authors = sorted(set(authors))
128     if None in authors:
129         authors.remove(None)
130     return authors
131
132 def authors_list():
133     p = Pipe([['bzr', 'log', '-n0'],
134               ['grep', '^ *committer\|^ *author'],
135               ['cut', '-d:', '-f2'],
136               ['sed', 's/ <.*//;s/^ *//'],
137               ['sort'],
138               ['uniq']])
139     assert p.status == 0, p.statuses
140     authors = p.stdout.rstrip().split('\n')
141     return _replace_aliases(authors, with_email=False)
142
143 def update_authors(verbose=True):
144     print "updating AUTHORS"
145     f = file('AUTHORS', 'w')
146     authors_text = 'Bugs Everywhere was written by:\n%s\n' % '\n'.join(authors_list())
147     f.write(authors_text)
148     f.close()
149
150 def ignored_file(filename, ignored_paths=None, ignored_files=None):
151     """
152     >>> ignored_paths = ['./a/', './b/']
153     >>> ignored_files = ['x', 'y']
154     >>> ignored_file('./a/z', ignored_paths, ignored_files)
155     True
156     >>> ignored_file('./ab/z', ignored_paths, ignored_files)
157     False
158     >>> ignored_file('./ab/x', ignored_paths, ignored_files)
159     True
160     >>> ignored_file('./ab/xy', ignored_paths, ignored_files)
161     False
162     >>> ignored_file('./z', ignored_paths, ignored_files)
163     False
164     """
165     if ignored_paths == None:
166         ignored_paths = IGNORED_PATHS
167     if ignored_files == None:
168         ignored_files = IGNORED_FILES
169     for path in ignored_paths:
170         if filename.startswith(path):
171             return True
172     if os.path.basename(filename) in ignored_files:
173         return True
174     if os.path.abspath(filename) != os.path.realpath(filename):
175         return True # symink somewhere in path...
176     return False
177
178 def _copyright_string(orig_year, final_year, authors):
179     """
180     >>> print _copyright_string(orig_year=2005,
181     ...                         final_year=2005,
182     ...                         authors=['A <a@a.com>', 'B <b@b.edu>']
183     ...                        ) # doctest: +ELLIPSIS
184     # Copyright (C) 2005 A <a@a.com>
185     #                    B <b@b.edu>
186     #
187     # This program...
188     >>> print _copyright_string(orig_year=2005,
189     ...                         final_year=2009,
190     ...                         authors=['A <a@a.com>', 'B <b@b.edu>']
191     ...                        ) # doctest: +ELLIPSIS
192     # Copyright (C) 2005-2009 A <a@a.com>
193     #                         B <b@b.edu>
194     #
195     # This program...
196     """
197     if orig_year == final_year:
198         date_range = '%s' % orig_year
199     else:
200         date_range = '%s-%s' % (orig_year, final_year)
201     lines = ['# Copyright (C) %s %s' % (date_range, authors[0])]
202     for author in authors[1:]:
203         lines.append('#' +
204                      ' '*(len(' Copyright (C) ')+len(date_range)+1) +
205                      author)
206     return '%s\n%s' % ('\n'.join(lines), COPYRIGHT_TEXT)
207
208 def _tag_copyright(contents):
209     """
210     >>> contents = '''Some file
211     ... bla bla
212     ... # Copyright (copyright begins)
213     ... # (copyright continues)
214     ... # bla bla bla
215     ... (copyright ends)
216     ... bla bla bla
217     ... '''
218     >>> print _tag_copyright(contents),
219     Some file
220     bla bla
221     -xyz-COPYRIGHT-zyx-
222     (copyright ends)
223     bla bla bla
224     """
225     lines = []
226     incopy = False
227     for line in contents.splitlines():
228         if incopy == False and line.startswith('# Copyright'):
229             incopy = True
230             lines.append(COPYRIGHT_TAG)
231         elif incopy == True and not line.startswith('#'):
232             incopy = False
233         if incopy == False:
234             lines.append(line.rstrip('\n'))
235     return '\n'.join(lines)+'\n'
236
237 def _update_copyright(contents, orig_year, authors):
238     current_year = time.gmtime()[0]
239     copyright_string = _copyright_string(orig_year, current_year, authors)
240     contents = _tag_copyright(contents)
241     return contents.replace(COPYRIGHT_TAG, copyright_string)
242
243 def update_file(filename, verbose=True):
244     if verbose == True:
245         print "updating", filename
246     contents = file(filename, 'r').read()
247
248     p = Pipe([['bzr', 'log', '-n0', filename],
249               ['grep', '^ *timestamp: '],
250               ['tail', '-n1'],
251               ['sed', 's/^ *//;'],
252               ['cut', '-b', '16-19']])
253     if p.status != 0:
254         assert p.statuses[0] == 3, p.statuses
255         return # bzr doesn't version that file
256     assert p.status == 0, p.statuses
257     orig_year = int(p.stdout.strip())
258
259     p = Pipe([['bzr', 'log', '-n0', filename],
260               ['grep', '^ *author: \|^ *committer: '],
261               ['cut', '-d:', '-f2'],
262               ['sed', 's/^ *//;s/ *$//'],
263               ['sort'],
264               ['uniq']])
265     assert p.status == 0, p.statuses
266     authors = p.stdout.rstrip().split('\n')
267     authors = _replace_aliases(authors, with_email=True,
268                                aliases=ALIASES+COPYRIGHT_ALIASES)
269
270     contents = _update_copyright(contents, orig_year, authors)
271     f = file(filename, 'w')
272     f.write(contents)
273     f.close()
274
275 def update_files(files=None):
276     if files == None or len(files) == 0:
277         p = Pipe([['grep', '-rc', '# Copyright', '.'],
278                   ['grep', '-v', ':0$'],
279                   ['cut', '-d:', '-f1']])
280         assert p.status == 0
281         files = p.stdout.rstrip().split('\n')
282
283     for filename in files:
284         if ignored_file(filename) == True:
285             continue
286         update_file(filename)
287
288 def test():
289     import doctest
290     doctest.testmod()
291
292 if __name__ == '__main__':
293     import optparse
294     usage = """%prog [options] [file ...]
295
296 Update copyright information in source code with information from
297 the bzr repository.  Run from the BE repository root.
298
299 Replaces every line starting with '^# Copyright' and continuing with
300 '^#' with an auto-generated copyright blurb.  If you want to add
301 #-commented material after a copyright blurb, please insert a blank
302 line between the blurb and your comment (as in this file), so the
303 next run of update_copyright.py doesn't clobber your comment.
304
305 If no files are given, a list of files to update is generated
306 automatically.
307 """
308     p = optparse.OptionParser(usage)
309     p.add_option('--test', dest='test', default=False,
310                  action='store_true', help='Run internal tests and exit')
311     options,args = p.parse_args()
312
313     if options.test == True:
314         test()
315         sys.exit(0)
316
317     update_authors()
318     update_files(files=args)