Updated update_copyright to work with git/hg/bzr.
[be.git] / update_copyright.py
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu>
4 #
5 # This file is part of Bugs Everywhere.
6 #
7 # Bugs Everywhere is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by the
9 # Free Software Foundation, either version 2 of the License, or (at your
10 # option) any later version.
11 #
12 # Bugs Everywhere is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 # General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
19
20 """Automatically update copyright boilerplate.
21
22 This script is adapted from one written for `Bugs Everywhere`_. and
23 later modified for `Hooke`_ before returning to `Bugs Everywhere`_.
24
25 .. _Bugs Everywhere: http://bugseverywhere.org/
26 .. _Hooke: http://code.google.com/p/hooke/
27 """
28
29 import difflib
30 import email.utils
31 import os
32 import os.path
33 import re
34 import sys
35 import time
36
37
38 PROJECT_INFO = {
39     'project': 'Bugs Everywhere',
40     'vcs': 'Git',
41     }
42
43 # Break "copyright" into "copy" and "right" to avoid matching the
44 # REGEXP.
45 COPY_RIGHT_TEXT="""
46 This file is part of %(project)s.
47
48 %(project)s is free software; you can redistribute it and/or modify it
49 under the terms of the GNU General Public License as published by the
50 Free Software Foundation, either version 2 of the License, or (at your
51 option) any later version.
52
53 %(project)s is distributed in the hope that it will be useful, but
54 WITHOUT ANY WARRANTY; without even the implied warranty of
55 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
56 General Public License for more details.
57
58 You should have received a copy of the GNU General Public License
59 along with %(project)s.  If not, see <http://www.gnu.org/licenses/>.
60 """.strip()
61
62 COPY_RIGHT_TAG='-xyz-COPY' + '-RIGHT-zyx-' # unlikely to occur in the wild :p
63
64 # Convert author names to canonical forms.
65 # ALIASES[<canonical name>] = <list of aliases>
66 # for example,
67 # ALIASES = {
68 #     'John Doe <jdoe@a.com>':
69 #         ['John Doe', 'jdoe', 'J. Doe <j@doe.net>'],
70 #     }
71 # Git-based projects are encouraged to use .mailmap instead of
72 # ALIASES.  See git-shortlog(1) for details.
73 ALIASES = {
74     'Aaron Bentley <abentley@panoramicfeedback.com>':
75         ['Aaron Bentley <aaron.bentley@utoronto.ca>'],
76     'Panometrics, Inc.':
77         ['Aaron Bentley and Panometrics, Inc.'],
78     'Ben Finney <benf@cybersource.com.au>':
79         ['Ben Finney <ben+python@benfinney.id.au>',
80          'John Doe <jdoe@example.com>'],
81     'Chris Ball <cjb@laptop.org>':
82         ['Chris Ball <cjb@thunk.printf.net>'],
83     'Gianluca Montecchi <gian@grys.it>':
84         ['gian <gian@li82-39>',
85          'gianluca <gian@galactica>'],
86     'W. Trevor King <wking@drexel.edu>':
87         ['wking <wking@mjolnir>',
88          'wking <wking@thialfi>'],
89     None:
90         ['j^ <j@oil21.org>'],
91     }
92
93 # List of paths that should not be scanned for copyright updates.
94 # IGNORED_PATHS = ['./.git/']
95 IGNORED_PATHS = ['./.be/', './.git/', './build/', './doc/.build/']
96 # List of files that should not be scanned for copyright updates.
97 # IGNORED_FILES = ['COPYING']
98 IGNORED_FILES = ['COPYING', 'catmutt']
99
100 # Work around missing author holes in the VCS history.
101 # AUTHOR_HACKS[<path tuple>] = [<missing authors]
102 # for example, if John Doe contributed to module.py but wasn't listed
103 # in the VCS history of that file:
104 # AUTHOR_HACKS = {
105 #     ('path', 'to', 'module.py'):['John Doe'],
106 #     }
107 AUTHOR_HACKS = {}
108
109 # Work around missing year holes in the VCS history.
110 # YEAR_HACKS[<path tuple>] = <original year>
111 # for example, if module.py was published in 2008 but the VCS history
112 # only goes back to 2010:
113 # YEAR_HACKS = {
114 #     ('path', 'to', 'module.py'):2008,
115 #     }
116 YEAR_HACKS = {}
117
118 # Helpers for VCS-specific commands
119
120 def splitpath(path):
121     """Recursively split a path into elements.
122
123     Examples
124     --------
125
126     >>> splitpath(os.path.join('a', 'b', 'c'))
127     ('a', 'b', 'c')
128     >>> splitpath(os.path.join('.', 'a', 'b', 'c'))
129     ('a', 'b', 'c')
130     """
131     path = os.path.normpath(path)
132     elements = []
133     while True:
134         dirname,basename = os.path.split(path)
135         elements.insert(0,basename)
136         if dirname in ['', '.']:
137             break
138         path = dirname
139     return tuple(elements)
140
141 # VCS-specific commands
142
143 if PROJECT_INFO['vcs'] == 'Git':
144
145     import subprocess
146
147     _MSWINDOWS = sys.platform == 'win32'
148     _POSIX = not _MSWINDOWS
149
150     def invoke(args, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, expect=(0,)):
151         """
152         expect should be a tuple of allowed exit codes.
153         """
154         try :
155             if _POSIX:
156                 q = subprocess.Popen(args, stdin=subprocess.PIPE,
157                                      stdout=stdout, stderr=stderr)
158             else:
159                 assert _MSWINDOWS == True, 'invalid platform'
160                 # win32 don't have os.execvp() so run the command in a shell
161                 q = subprocess.Popen(args, stdin=subprocess.PIPE,
162                                      stdout=stdout, stderr=stderr, shell=True)
163         except OSError, e:
164             raise ValueError([args, e])
165         stdout,stderr = q.communicate(input=stdin)
166         status = q.wait()
167         if status not in expect:
168             raise ValueError([args, status, stdout, stderr])
169         return status, stdout, stderr
170
171     def git_cmd(*args):
172         status,stdout,stderr = invoke(['git'] + list(args))
173         return stdout.rstrip('\n')
174
175     version = git_cmd('--version').split(' ')[-1]
176     if version.startswith('1.5.'):
177         # Author name <author email>
178         author_format = '--pretty=format:%an <%ae>'
179         year_format = ['--pretty=format:%ai']  # Author date
180         # YYYY-MM-DD HH:MM:SS Z
181         # Earlier versions of Git don't seem to recognize --date=short
182     else:
183         author_format = '--pretty=format:%aN <%aE>'
184         year_format = ['--pretty=format:%ad',  # Author date
185                        '--date=short']         # YYYY-MM-DD
186
187     def original_year(filename, year_hacks=YEAR_HACKS):
188         output = git_cmd(*(['log', '--follow']
189                            + year_format
190                            + [filename]))
191         if version.startswith('1.5.'):
192             output = '\n'.join([x.split()[0] for x in output.splitlines()])
193         years = [int(line.split('-', 1)[0]) for line in output.splitlines()]
194         if splitpath(filename) in year_hacks:
195             years.append(year_hacks[splitpath(filename)])
196         years.sort()
197         return years[0]
198
199     def authors(filename, author_hacks=AUTHOR_HACKS):
200         output = git_cmd('log', '--follow', author_format,
201                          filename)
202         ret = list(set(output.splitlines()))
203         if splitpath(filename) in author_hacks:
204             ret.extend(author_hacks[splitpath(filename)])
205         return ret
206
207     def authors_list(author_hacks=AUTHOR_HACKS):
208         output = git_cmd('log', author_format)
209         ret = list(set(output.splitlines()))
210         for path,authors in author_hacks.items():
211             ret.extend(authors)
212         return ret
213
214     def is_versioned(filename):
215         output = git_cmd('log', '--follow', filename)
216         if len(output) == 0:
217             return False
218         return True
219
220 elif PROJECT_INFO['vcs'] == 'Mercurial':
221
222     import StringIO
223     import mercurial
224     import mercurial.dispatch
225
226     def mercurial_cmd(*args):
227         cwd = os.getcwd()
228         stdout = sys.stdout
229         stderr = sys.stderr
230         tmp_stdout = StringIO.StringIO()
231         tmp_stderr = StringIO.StringIO()
232         sys.stdout = tmp_stdout
233         sys.stderr = tmp_stderr
234         try:
235             mercurial.dispatch.dispatch(list(args))
236         finally:
237             os.chdir(cwd)
238             sys.stdout = stdout
239             sys.stderr = stderr
240         return (tmp_stdout.getvalue().rstrip('\n'),
241                 tmp_stderr.getvalue().rstrip('\n'))
242
243     def original_year(filename, year_hacks=YEAR_HACKS):
244         # shortdate filter: YEAR-MONTH-DAY
245         output,error = mercurial_cmd('log', '--follow',
246                                      '--template', '{date|shortdate}\n',
247                                      filename)
248         years = [int(line.split('-', 1)[0]) for line in output.splitlines()]
249         if splitpath(filename) in year_hacks:
250             years.append(year_hacks[splitpath(filename)])
251         years.sort()
252         return years[0]
253
254     def authors(filename, author_hacks=AUTHOR_HACKS):
255         output,error = mercurial_cmd('log', '--follow',
256                                      '--template', '{author}\n',
257                                      filename)
258         ret = list(set(output.splitlines()))
259         if splitpath(filename) in author_hacks:
260             ret.extend(author_hacks[splitpath(filename)])
261         return ret
262
263     def authors_list(author_hacks=AUTHOR_HACKS):
264         output,error = mercurial_cmd('log', '--template', '{author}\n')
265         ret = list(set(output.splitlines()))
266         for path,authors in author_hacks.items():
267             ret.extend(authors)
268         return ret
269
270     def is_versioned(filename):
271         output,error = mercurial_cmd('log', '--follow', filename)
272         if len(error) > 0:
273             return False
274         return True
275
276 elif PROJECT_INFO['vcs'] == 'Bazaar':
277
278     import StringIO
279     import bzrlib
280     import bzrlib.builtins
281     import bzrlib.log
282
283     class LogFormatter (bzrlib.log.LogFormatter):
284         supports_merge_revisions = True
285         preferred_levels = 0
286         supports_deta = False
287         supports_tags = False
288         supports_diff = False
289
290         def log_revision(self, revision):
291             raise NotImplementedError
292
293     class YearLogFormatter (LogFormatter):
294         def log_revision(self, revision):
295             self.to_file.write(
296                 time.strftime('%Y', time.gmtime(revision.rev.timestamp))
297                 +'\n')
298
299     class AuthorLogFormatter (LogFormatter):
300         def log_revision(self, revision):
301             authors = revision.rev.get_apparent_authors()
302             self.to_file.write('\n'.join(authors)+'\n')
303
304     def original_year(filename, year_hacks=YEAR_HACKS):
305         cmd = bzrlib.builtins.cmd_log()
306         cmd.outf = StringIO.StringIO()
307         cmd.run(file_list=[filename], log_format=YearLogFormatter, levels=0)
308         years = [int(year) for year in set(cmd.outf.getvalue().splitlines())]
309         if splitpath(filename) in year_hacks:
310             years.append(year_hacks[splitpath(filename)])
311         years.sort()
312         return years[0]
313
314     def authors(filename, author_hacks=AUTHOR_HACKS):
315         cmd = bzrlib.builtins.cmd_log()
316         cmd.outf = StringIO.StringIO()
317         cmd.run(file_list=[filename], log_format=AuthorLogFormatter, levels=0)
318         ret = list(set(cmd.outf.getvalue().splitlines()))
319         if splitpath(filename) in author_hacks:
320             ret.extend(author_hacks[splitpath(filename)])
321         return ret
322
323     def authors_list(author_hacks=AUTHOR_HACKS):
324         cmd = bzrlib.builtins.cmd_log()
325         cmd.outf = StringIO.StringIO()
326         cmd.run(log_format=AuthorLogFormatter, levels=0)
327         output = cmd.outf.getvalue()
328         ret = list(set(cmd.outf.getvalue().splitlines()))
329         for path,authors in author_hacks.items():
330             ret.extend(authors)
331         return ret
332
333     def is_versioned(filename):
334         cmd = bzrlib.builtins.cmd_log()
335         cmd.outf = StringIO.StringIO()
336         cmd.run(file_list=[filename])
337         return True
338
339 else:
340     raise NotImplementedError('Unrecognized VCS: %(vcs)s' % PROJECT_INFO)
341
342 # General utility commands
343
344 def _strip_email(*args):
345     """Remove email addresses from a series of names.
346
347     Examples
348     --------
349
350     >>> _strip_email('J Doe <jdoe@a.com>')
351     ['J Doe']
352     >>> _strip_email('J Doe <jdoe@a.com>', 'JJJ Smith <jjjs@a.com>')
353     ['J Doe', 'JJJ Smith']
354     """
355     args = list(args)
356     for i,arg in enumerate(args):
357         if arg == None:
358             continue
359         author,addr = email.utils.parseaddr(arg)
360         args[i] = author
361     return args
362
363 def _reverse_aliases(aliases):
364     """Reverse an `aliases` dict.
365
366     Input:   key: canonical name,  value: list of aliases
367     Output:  key: alias,           value: canonical name
368
369     Examples
370     --------
371
372     >>> aliases = {
373     ...     'J Doe <jdoe@a.com>':['Johnny <jdoe@b.edu>', 'J'],
374     ...     'JJJ Smith <jjjs@a.com>':['Jingly <jjjs@b.edu>'],
375     ...     None:['Anonymous <a@a.com>'],
376     ...     }
377     >>> r = _reverse_aliases(aliases)
378     >>> for item in sorted(r.items()):
379     ...     print item
380     ('Anonymous <a@a.com>', None)
381     ('J', 'J Doe <jdoe@a.com>')
382     ('Jingly <jjjs@b.edu>', 'JJJ Smith <jjjs@a.com>')
383     ('Johnny <jdoe@b.edu>', 'J Doe <jdoe@a.com>')
384     """
385     output = {}
386     for canonical_name,_aliases in aliases.items():
387         for alias in _aliases:
388             output[alias] = canonical_name
389     return output
390
391 def _replace_aliases(authors, with_email=True, aliases=None):
392     """Consolidate and sort `authors`.
393
394     Make the replacements listed in the `aliases` dict (key: canonical
395     name, value: list of aliases).  If `aliases` is ``None``, default
396     to ``ALIASES``.
397
398     >>> aliases = {
399     ...     'J Doe <jdoe@a.com>':['Johnny <jdoe@b.edu>'],
400     ...     'JJJ Smith <jjjs@a.com>':['Jingly <jjjs@b.edu>'],
401     ...     None:['Anonymous <a@a.com>'],
402     ...     }
403     >>> _replace_aliases(['JJJ Smith <jjjs@a.com>', 'Johnny <jdoe@b.edu>',
404     ...                   'Jingly <jjjs@b.edu>', 'Anonymous <a@a.com>'],
405     ...                  with_email=True, aliases=aliases)
406     ['J Doe <jdoe@a.com>', 'JJJ Smith <jjjs@a.com>']
407     >>> _replace_aliases(['JJJ Smith', 'Johnny', 'Jingly', 'Anonymous'],
408     ...                  with_email=False, aliases=aliases)
409     ['J Doe', 'JJJ Smith']
410     >>> _replace_aliases(['JJJ Smith <jjjs@a.com>', 'Johnny <jdoe@b.edu>',
411     ...                   'Jingly <jjjs@b.edu>', 'J Doe <jdoe@a.com>'],
412     ...                  with_email=True, aliases=aliases)
413     ['J Doe <jdoe@a.com>', 'JJJ Smith <jjjs@a.com>']
414     """
415     if aliases == None:
416         aliases = ALIASES
417     if with_email == False:
418         aliases = dict([(_strip_email(author)[0], _strip_email(*_aliases))
419                         for author,_aliases in aliases.items()])
420     rev_aliases = _reverse_aliases(aliases)
421     for i,author in enumerate(authors):
422         if author in rev_aliases:
423             authors[i] = rev_aliases[author]
424     authors = sorted(list(set(authors)))
425     if None in authors:
426         authors.remove(None)
427     return authors
428
429 def _copyright_string(original_year, final_year, authors, prefix=''):
430     """
431     >>> print _copyright_string(original_year=2005,
432     ...                         final_year=2005,
433     ...                         authors=['A <a@a.com>', 'B <b@b.edu>'],
434     ...                         prefix='# '
435     ...                        ) # doctest: +ELLIPSIS
436     # Copyright (C) 2005 A <a@a.com>
437     #                    B <b@b.edu>
438     #
439     # This file...
440     >>> print _copyright_string(original_year=2005,
441     ...                         final_year=2009,
442     ...                         authors=['A <a@a.com>', 'B <b@b.edu>']
443     ...                        ) # doctest: +ELLIPSIS
444     Copyright (C) 2005-2009 A <a@a.com>
445                             B <b@b.edu>
446     <BLANKLINE>
447     This file...
448     """
449     if original_year == final_year:
450         date_range = '%s' % original_year
451     else:
452         date_range = '%s-%s' % (original_year, final_year)
453     lines = ['Copyright (C) %s %s' % (date_range, authors[0])]
454     for author in authors[1:]:
455         lines.append(' '*(len('Copyright (C) ')+len(date_range)+1) +
456                      author)
457     lines.append('')
458     lines.extend((COPY_RIGHT_TEXT % PROJECT_INFO).splitlines())
459     for i,line in enumerate(lines):
460         lines[i] = (prefix + line).rstrip()
461     return '\n'.join(lines)
462
463 def _tag_copyright(contents):
464     """
465     >>> contents = '''Some file
466     ... bla bla
467     ... # Copyright (copyright begins)
468     ... # (copyright continues)
469     ... # bla bla bla
470     ... (copyright ends)
471     ... bla bla bla
472     ... '''
473     >>> print _tag_copyright(contents).replace('COPY-RIGHT', 'CR')
474     Some file
475     bla bla
476     -xyz-CR-zyx-
477     (copyright ends)
478     bla bla bla
479     <BLANKLINE>
480     """
481     lines = []
482     incopy = False
483     for line in contents.splitlines():
484         if incopy == False and line.startswith('# Copyright'):
485             incopy = True
486             lines.append(COPY_RIGHT_TAG)
487         elif incopy == True and not line.startswith('#'):
488             incopy = False
489         if incopy == False:
490             lines.append(line.rstrip('\n'))
491     return '\n'.join(lines)+'\n'
492
493 def _update_copyright(contents, original_year, authors):
494     """
495     >>> contents = '''Some file
496     ... bla bla
497     ... # Copyright (copyright begins)
498     ... # (copyright continues)
499     ... # bla bla bla
500     ... (copyright ends)
501     ... bla bla bla
502     ... '''
503     >>> print _update_copyright(contents, 2008, ['Jack', 'Jill']
504     ...     ) # doctest: +ELLIPSIS, +REPORT_UDIFF
505     Some file
506     bla bla
507     # Copyright (C) 2008-... Jack
508     #                         Jill
509     #
510     # This file...
511     (copyright ends)
512     bla bla bla
513     <BLANKLINE>
514     """
515     current_year = time.gmtime()[0]
516     copyright_string = _copyright_string(
517         original_year, current_year, authors, prefix='# ')
518     contents = _tag_copyright(contents)
519     return contents.replace(COPY_RIGHT_TAG, copyright_string)
520
521 def ignored_file(filename, ignored_paths=None, ignored_files=None,
522                  check_disk=True, check_vcs=True):
523     """
524     >>> ignored_paths = ['./a/', './b/']
525     >>> ignored_files = ['x', 'y']
526     >>> ignored_file('./a/z', ignored_paths, ignored_files, False, False)
527     True
528     >>> ignored_file('./ab/z', ignored_paths, ignored_files, False, False)
529     False
530     >>> ignored_file('./ab/x', ignored_paths, ignored_files, False, False)
531     True
532     >>> ignored_file('./ab/xy', ignored_paths, ignored_files, False, False)
533     False
534     >>> ignored_file('./z', ignored_paths, ignored_files, False, False)
535     False
536     """
537     if ignored_paths == None:
538         ignored_paths = IGNORED_PATHS
539     if ignored_files == None:
540         ignored_files = IGNORED_FILES
541     if check_disk == True and os.path.isfile(filename) == False:
542         return True
543     for path in ignored_paths:
544         if filename.startswith(path):
545             return True
546     if os.path.basename(filename) in ignored_files:
547         return True
548     if check_vcs == True and is_versioned(filename) == False:
549         return True
550     return False
551
552 def _set_contents(filename, contents, original_contents=None, dry_run=False,
553                   verbose=0):
554     if original_contents == None and os.path.isfile(filename):
555         f = open(filename, 'r')
556         original_contents = f.read()
557         f.close()
558     if verbose > 0:
559         print "checking %s ... " % filename,
560     if contents != original_contents:
561         if verbose > 0:
562             if original_contents == None:
563                 print "[creating]"
564             else:
565                 print "[updating]"
566         if verbose > 1 and original_contents != None:
567             print '\n'.join(
568                 difflib.unified_diff(
569                     original_contents.splitlines(), contents.splitlines(),
570                     fromfile=os.path.normpath(os.path.join('a', filename)),
571                     tofile=os.path.normpath(os.path.join('b', filename)),
572                     n=3, lineterm=''))
573         if dry_run == False:
574             f = file(filename, 'w')
575             f.write(contents)
576             f.close()
577     elif verbose > 0:
578         print "[no change]"
579
580 # Update commands
581
582 def update_authors(authors_fn=authors_list, dry_run=False, verbose=0):
583     authors = authors_fn()
584     authors = _replace_aliases(authors, with_email=True, aliases=ALIASES)
585     new_contents = '%s was written by:\n%s\n' % (
586         PROJECT_INFO['project'],
587         '\n'.join(authors)
588         )
589     _set_contents('AUTHORS', new_contents, dry_run=dry_run, verbose=verbose)
590
591 def update_file(filename, original_year_fn=original_year, authors_fn=authors,
592                 dry_run=False, verbose=0):
593     f = file(filename, 'r')
594     contents = f.read()
595     f.close()
596
597     original_year = original_year_fn(filename)
598     authors = authors_fn(filename)
599     authors = _replace_aliases(authors, with_email=True, aliases=ALIASES)
600
601     new_contents = _update_copyright(contents, original_year, authors)
602     _set_contents(filename, contents=new_contents, original_contents=contents,
603                   dry_run=dry_run, verbose=verbose)
604
605 def update_files(files=None, dry_run=False, verbose=0):
606     if files == None or len(files) == 0:
607         files = []
608         for dirpath,dirnames,filenames in os.walk('.'):
609             for filename in filenames:
610                 files.append(os.path.join(dirpath, filename))
611
612     for filename in files:
613         if ignored_file(filename) == True:
614             continue
615         update_file(filename, dry_run=dry_run, verbose=verbose)
616
617 def test():
618     import doctest
619     doctest.testmod()
620
621 if __name__ == '__main__':
622     import optparse
623     import sys
624
625     usage = """%%prog [options] [file ...]
626
627 Update copyright information in source code with information from
628 the %(vcs)s repository.  Run from the %(project)s repository root.
629
630 Replaces every line starting with '^# Copyright' and continuing with
631 '^#' with an auto-generated copyright blurb.  If you want to add
632 #-commented material after a copyright blurb, please insert a blank
633 line between the blurb and your comment, so the next run of
634 ``update_copyright.py`` doesn't clobber your comment.
635
636 If no files are given, a list of files to update is generated
637 automatically.
638 """ % PROJECT_INFO
639     p = optparse.OptionParser(usage)
640     p.add_option('--test', dest='test', default=False,
641                  action='store_true', help='Run internal tests and exit')
642     p.add_option('--dry-run', dest='dry_run', default=False,
643                  action='store_true', help="Don't make any changes")
644     p.add_option('-v', '--verbose', dest='verbose', default=0,
645                  action='count', help='Increment verbosity')
646     options,args = p.parse_args()
647
648     if options.test == True:
649         test()
650         sys.exit(0)
651
652     update_authors(dry_run=options.dry_run, verbose=options.verbose)
653     update_files(files=args, dry_run=options.dry_run, verbose=options.verbose)