Log reason for ignoring paths in Project._ignore_file().
[update-copyright.git] / update_copyright / project.py
1 # Copyright (C) 2012 W. Trevor King
2 #
3 # This file is part of update-copyright.
4 #
5 # update-copyright is free software: you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # update-copyright 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 GNU
13 # General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with update-copyright.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """Project-specific configuration.
20
21 # Convert author names to canonical forms.
22 # ALIASES[<canonical name>] = <list of aliases>
23 # for example,
24 # ALIASES = {
25 #     'John Doe <jdoe@a.com>':
26 #         ['John Doe', 'jdoe', 'J. Doe <j@doe.net>'],
27 #     }
28 # Git-based projects are encouraged to use .mailmap instead of
29 # ALIASES.  See git-shortlog(1) for details.
30
31 # List of paths that should not be scanned for copyright updates.
32 # IGNORED_PATHS = ['./.git/']
33 IGNORED_PATHS = ['./.git']
34 # List of files that should not be scanned for copyright updates.
35 # IGNORED_FILES = ['COPYING']
36 IGNORED_FILES = ['COPYING']
37
38 # Work around missing author holes in the VCS history.
39 # AUTHOR_HACKS[<path tuple>] = [<missing authors]
40 # for example, if John Doe contributed to module.py but wasn't listed
41 # in the VCS history of that file:
42 # AUTHOR_HACKS = {
43 #     ('path', 'to', 'module.py'):['John Doe'],
44 #     }
45 AUTHOR_HACKS = {}
46
47 # Work around missing year holes in the VCS history.
48 # YEAR_HACKS[<path tuple>] = <original year>
49 # for example, if module.py was published in 2008 but the VCS history
50 # only goes back to 2010:
51 # YEAR_HACKS = {
52 #     ('path', 'to', 'module.py'):2008,
53 #     }
54 YEAR_HACKS = {}
55 """
56
57 import ConfigParser as _configparser
58 import fnmatch as _fnmatch
59 import os.path as _os_path
60 import sys
61 import time as _time
62
63 from . import LOG as _LOG
64 from . import utils as _utils
65 from .vcs.git import GitBackend as _GitBackend
66 try:
67     from .vcs.bazaar import BazaarBackend as _BazaarBackend
68 except ImportError, _bazaar_import_error:
69     _BazaarBackend = None
70 try:
71     from .vcs.mercurial import MercurialBackend as _MercurialBackend
72 except ImportError, _mercurial_import_error:
73     _MercurialBackend = None
74
75
76 class Project (object):
77     def __init__(self, name=None, vcs=None, copyright=None,
78                  short_copyright=None):
79         self._name = name
80         self._vcs = vcs
81         self._copyright = None
82         self._short_copyright = None
83         self.with_authors = False
84         self.with_files = False
85         self._ignored_paths = None
86         self._pyfile = None
87
88         # unlikely to occur in the wild :p
89         self._copyright_tag = '-xyz-COPY' + '-RIGHT-zyx-'
90
91     def load_config(self, stream):
92         p = _configparser.RawConfigParser()
93         p.readfp(stream)
94         try:
95             self._name = p.get('project', 'name')
96         except _configparser.NoOptionError:
97             pass
98         try:
99             vcs = p.get('project', 'vcs')
100         except _configparser.NoOptionError:
101             pass
102         else:
103             if vcs == 'Git':
104                 self._vcs = _GitBackend()
105             elif vcs == 'Bazaar':
106                 self._vcs = _BazaarBackend()
107             elif vcs == 'Mercurial':
108                 self._vcs = _MercurialBackend()
109             else:
110                 raise NotImplementedError('vcs: {}'.format(vcs))
111         try:
112             self._copyright = p.get('copyright', 'long').splitlines()
113         except _configparser.NoOptionError:
114             pass
115         try:
116             self._short_copyright = p.get('copyright', 'short').splitlines()
117         except _configparser.NoOptionError:
118             pass
119         try:
120             self.with_authors = p.get('files', 'authors')
121         except _configparser.NoOptionError:
122             pass
123         try:
124             self.with_files = p.get('files', 'files')
125         except _configparser.NoOptionError:
126             pass
127         try:
128             ignored = p.get('files', 'ignored')
129         except _configparser.NoOptionError:
130             pass
131         else:
132             self._ignored_paths = [p.strip() for p in ignored.split(',')]
133         try:
134             self._pyfile = p.get('files', 'pyfile')
135         except _configparser.NoOptionError:
136             pass
137
138     def _info(self):
139         return {
140             'project': self._name,
141             'vcs': self._vcs.name,
142             }
143
144     def update_authors(self, dry_run=False):
145         _LOG.info('update AUTHORS')
146         authors = self._vcs.authors()
147         new_contents = u'{} was written by:\n{}\n'.format(
148             self._name, u'\n'.join(authors))
149         _utils.set_contents('AUTHORS', new_contents, dry_run=dry_run)
150
151     def update_file(self, filename, dry_run=False):
152         _LOG.info('update {}'.format(filename))
153         contents = _utils.get_contents(filename=filename)
154         original_year = self._vcs.original_year(filename=filename)
155         authors = self._vcs.authors(filename=filename)
156         new_contents = _utils.update_copyright(
157             contents=contents, original_year=original_year, authors=authors,
158             text=self._copyright, info=self._info(), prefix='# ',
159             tag=self._copyright_tag)
160         _utils.set_contents(
161             filename=filename, contents=new_contents,
162             original_contents=contents, dry_run=dry_run)
163
164     def update_files(self, files=None, dry_run=False):
165         if files is None or len(files) == 0:
166             files = _utils.list_files(root='.')
167         for filename in files:
168             if self._ignored_file(filename=filename):
169                 continue
170             self.update_file(filename=filename, dry_run=dry_run)
171
172     def update_pyfile(self, dry_run=False):
173         if self._pyfile is None:
174             _LOG.info('no pyfile location configured, skip `update_pyfile`')
175             return
176         _LOG.info('update pyfile at {}'.format(self._pyfile))
177         current_year = _time.gmtime()[0]
178         original_year = self._vcs.original_year()
179         authors = self._vcs.authors()
180         lines = [
181             _utils.copyright_string(
182                 original_year=original_year, final_year=current_year,
183                 authors=authors, text=self._copyright, info=self._info(),
184                 prefix='# '),
185             '', 'import textwrap as _textwrap', '', '',
186             'LICENSE = """',
187             _utils.copyright_string(
188                 original_year=original_year, final_year=current_year,
189                 authors=authors, text=self._copyright, info=self._info(),
190                 prefix=''),
191             '""".strip()',
192             '',
193             'def short_license(info, wrap=True, **kwargs):',
194             '    paragraphs = [',
195             ]
196         paragraphs = _utils.copyright_string(
197             original_year=original_year, final_year=current_year,
198             authors=authors, text=self._short_copyright, info=self._info(),
199             author_format_fn=_utils.short_author_formatter, wrap=False,
200             ).split('\n\n')
201         for p in paragraphs:
202             lines.append("        '{}' % info,".format(
203                     p.replace("'", r"\'")))
204         lines.extend([
205                 '        ]',
206                 '    if wrap:',
207                 '        for i,p in enumerate(paragraphs):',
208                 '            paragraphs[i] = _textwrap.fill(p, **kwargs)',
209                 r"    return '\n\n'.join(paragraphs)",
210                 '',  # for terminal endline
211                 ])
212         new_contents = '\n'.join(lines)
213         _utils.set_contents(
214             filename=self._pyfile, contents=new_contents, dry_run=dry_run)
215
216     def _ignored_file(self, filename):
217         """
218         >>> ignored_paths = ['./a/', './b/']
219         >>> ignored_files = ['x', 'y']
220         >>> ignored_file('./a/z', ignored_paths, ignored_files, False, False)
221         True
222         >>> ignored_file('./ab/z', ignored_paths, ignored_files, False, False)
223         False
224         >>> ignored_file('./ab/x', ignored_paths, ignored_files, False, False)
225         True
226         >>> ignored_file('./ab/xy', ignored_paths, ignored_files, False, False)
227         False
228         >>> ignored_file('./z', ignored_paths, ignored_files, False, False)
229         False
230         """
231         if self._ignored_paths is not None:
232             for path in self._ignored_paths:
233                 if _fnmatch.fnmatch(filename, path):
234                     _LOG.debug('ignoring {} (matched {})'.format(
235                             filename, path))
236                     return True
237         if self._vcs and not self._vcs.is_versioned(filename):
238             _LOG.debug('ignoring {} (not versioned))'.format(filename))
239             return True
240         return False