Add support for loading author_hacks, year_hacks, and aliases from the config.
[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 import ConfigParser as _configparser
22 import fnmatch as _fnmatch
23 import os.path as _os_path
24 import sys
25 import time as _time
26
27 from . import LOG as _LOG
28 from . import utils as _utils
29 from .vcs.git import GitBackend as _GitBackend
30 try:
31     from .vcs.bazaar import BazaarBackend as _BazaarBackend
32 except ImportError, _bazaar_import_error:
33     _BazaarBackend = None
34 try:
35     from .vcs.mercurial import MercurialBackend as _MercurialBackend
36 except ImportError, _mercurial_import_error:
37     _MercurialBackend = None
38
39
40 class Project (object):
41     def __init__(self, name=None, vcs=None, copyright=None,
42                  short_copyright=None):
43         self._name = name
44         self._vcs = vcs
45         self._author_hacks = None
46         self._year_hacks = None
47         self._aliases = None
48         self._copyright = None
49         self._short_copyright = None
50         self.with_authors = False
51         self.with_files = False
52         self._ignored_paths = None
53         self._pyfile = None
54         self._encoding = None
55         self._width = 79
56
57         # unlikely to occur in the wild :p
58         self._copyright_tag = u'-xyz-COPY' + u'-RIGHT-zyx-'
59
60     def load_config(self, stream):
61         parser = _configparser.RawConfigParser()
62         parser.readfp(stream)
63         for section in parser.sections():
64             clean_section = section.replace('-', '_')
65             try:
66                 loader = getattr(self, '_load_{}_conf'.format(clean_section))
67             except AttributeError, e:
68                 _LOG.error('invalid {} section'.format(section))
69                 raise
70             loader(parser=parser)
71
72     def _load_project_conf(self, parser):
73         try:
74             self._name = parser.get('project', 'name')
75         except _configparser.NoOptionError:
76             pass
77         try:
78             vcs = parser.get('project', 'vcs')
79         except _configparser.NoOptionError:
80             pass
81         else:
82             kwargs = {
83                 'author_hacks': self._author_hacks,
84                 'year_hacks': self._year_hacks,
85                 'aliases': self._aliases,
86                 }
87             if vcs == 'Git':
88                 self._vcs = _GitBackend(**kwargs)
89             elif vcs == 'Bazaar':
90                 if _BazaarBackend is None:
91                     raise _bazaar_import_error
92                 self._vcs = _BazaarBackend(**kwargs)
93             elif vcs == 'Mercurial':
94                 if _MercurialBackend is None:
95                     raise _mercurial_import_error
96                 self._vcs = _MercurialBackend(**kwargs)
97             else:
98                 raise NotImplementedError('vcs: {}'.format(vcs))
99
100     def _load_copyright_conf(self, parser):
101         try:
102             self._copyright = parser.get('copyright', 'long').splitlines()
103         except _configparser.NoOptionError:
104             pass
105         try:
106             self._short_copyright = parser.get(
107                 'copyright', 'short').splitlines()
108         except _configparser.NoOptionError:
109             pass
110
111     def _load_files_conf(self, parser):
112         try:
113             self.with_authors = parser.get('files', 'authors')
114         except _configparser.NoOptionError:
115             pass
116         try:
117             self.with_files = parser.get('files', 'files')
118         except _configparser.NoOptionError:
119             pass
120         try:
121             ignored = parser.get('files', 'ignored')
122         except _configparser.NoOptionError:
123             pass
124         else:
125             self._ignored_paths = [pth.strip() for pth in ignored.split(',')]
126         try:
127             self._pyfile = parser.get('files', 'pyfile')
128         except _configparser.NoOptionError:
129             pass
130
131     def _load_author_hacks_conf(self, parser, encoding=None):
132         if encoding is None:
133             encoding = self._encoding or _utils.ENCODING
134         author_hacks = {}
135         for path in parser.options('author-hacks'):
136             authors = parser.get('author-hacks', path)
137             author_hacks[tuple(path.split('/'))] = set(
138                 unicode(a.strip(), encoding) for a in authors.split(','))
139         self._author_hacks = author_hacks
140         if self._vcs is not None:
141             self._vcs._author_hacks = self._author_hacks
142
143     def _load_year_hacks_conf(self, parser):
144         year_hacks = {}
145         for path in parser.options('year-hacks'):
146             year = parser.get('year-hacks', path)
147             year_hacks[tuple(path.split('/'))] = int(year)
148         self._year_hacks = year_hacks
149         if self._vcs is not None:
150             self._vcs._year_hacks = self._year_hacks
151
152     def _load_aliases_conf(self, parser, encoding=None):
153         if encoding is None:
154             encoding = self._encoding or _utils.ENCODING
155         aliases = {}
156         for author in parser.options('aliases'):
157             _aliases = parser.get('aliases', author)
158             aliases[author] = set(
159                 unicode(a.strip(), encoding) for a in _aliases.split(','))
160         self._aliases = aliases
161         if self._vcs is not None:
162             self._vcs._aliases = self._aliases
163
164     def _info(self):
165         return {
166             'project': self._name,
167             'vcs': self._vcs.name,
168             }
169
170     def update_authors(self, dry_run=False):
171         _LOG.info('update AUTHORS')
172         authors = self._vcs.authors()
173         new_contents = u'{} was written by:\n{}\n'.format(
174             self._name, u'\n'.join(authors))
175         _utils.set_contents(
176             'AUTHORS', new_contents, unicode=True, encoding=self._encoding,
177             dry_run=dry_run)
178
179     def update_file(self, filename, dry_run=False):
180         _LOG.info('update {}'.format(filename))
181         contents = _utils.get_contents(
182             filename=filename, unicode=True, encoding=self._encoding)
183         original_year = self._vcs.original_year(filename=filename)
184         authors = self._vcs.authors(filename=filename)
185         new_contents = _utils.update_copyright(
186             contents=contents, original_year=original_year, authors=authors,
187             text=self._copyright, info=self._info(), prefix='# ',
188             width=self._width, tag=self._copyright_tag)
189         _utils.set_contents(
190             filename=filename, contents=new_contents,
191             original_contents=contents, unicode=True, encoding=self._encoding,
192             dry_run=dry_run)
193
194     def update_files(self, files=None, dry_run=False):
195         if files is None or len(files) == 0:
196             files = _utils.list_files(root='.')
197         for filename in files:
198             if self._ignored_file(filename=filename):
199                 continue
200             self.update_file(filename=filename, dry_run=dry_run)
201
202     def update_pyfile(self, dry_run=False):
203         if self._pyfile is None:
204             _LOG.info('no pyfile location configured, skip `update_pyfile`')
205             return
206         _LOG.info('update pyfile at {}'.format(self._pyfile))
207         current_year = _time.gmtime()[0]
208         original_year = self._vcs.original_year()
209         authors = self._vcs.authors()
210         lines = [
211             _utils.copyright_string(
212                 original_year=original_year, final_year=current_year,
213                 authors=authors, text=self._copyright, info=self._info(),
214                 prefix=u'# ', width=self._width),
215             u'', u'import textwrap as _textwrap', u'', u'',
216             u'LICENSE = """',
217             _utils.copyright_string(
218                 original_year=original_year, final_year=current_year,
219                 authors=authors, text=self._copyright, info=self._info(),
220                 prefix=u'', width=self._width),
221             u'""".strip()',
222             u'',
223             u'def short_license(info, wrap=True, **kwargs):',
224             u'    paragraphs = [',
225             ]
226         paragraphs = _utils.copyright_string(
227             original_year=original_year, final_year=current_year,
228             authors=authors, text=self._short_copyright, info=self._info(),
229             author_format_fn=_utils.short_author_formatter, wrap=False,
230             ).split(u'\n\n')
231         for p in paragraphs:
232             lines.append(u"        '{}' % info,".format(
233                     p.replace(u"'", ur"\'")))
234         lines.extend([
235                 u'        ]',
236                 u'    if wrap:',
237                 u'        for i,p in enumerate(paragraphs):',
238                 u'            paragraphs[i] = _textwrap.fill(p, **kwargs)',
239                 ur"    return '\n\n'.join(paragraphs)",
240                 u'',  # for terminal endline
241                 ])
242         new_contents = u'\n'.join(lines)
243         _utils.set_contents(
244             filename=self._pyfile, contents=new_contents, unicode=True,
245             encoding=self._encoding, dry_run=dry_run)
246
247     def _ignored_file(self, filename):
248         """
249         >>> ignored_paths = ['./a/', './b/']
250         >>> ignored_files = ['x', 'y']
251         >>> ignored_file('./a/z', ignored_paths, ignored_files, False, False)
252         True
253         >>> ignored_file('./ab/z', ignored_paths, ignored_files, False, False)
254         False
255         >>> ignored_file('./ab/x', ignored_paths, ignored_files, False, False)
256         True
257         >>> ignored_file('./ab/xy', ignored_paths, ignored_files, False, False)
258         False
259         >>> ignored_file('./z', ignored_paths, ignored_files, False, False)
260         False
261         """
262         if self._ignored_paths is not None:
263             for path in self._ignored_paths:
264                 if _fnmatch.fnmatch(filename, path):
265                     _LOG.debug('ignoring {} (matched {})'.format(
266                             filename, path))
267                     return True
268         if self._vcs and not self._vcs.is_versioned(filename):
269             _LOG.debug('ignoring {} (not versioned))'.format(filename))
270             return True
271         return False