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