Ran update-copyright.py
[update-copyright.git] / update_copyright / project.py
1 # Copyright (C) 2012-2013 W. Trevor King <wking@tremily.us>
2 #
3 # This file is part of update-copyright.
4 #
5 # update-copyright is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option) any
8 # later version.
9 #
10 # update-copyright is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
13 # more details.
14 #
15 # You should have received a copy of the GNU General Public License along with
16 # update-copyright.  If not, see <http://www.gnu.org/licenses/>.
17
18 """Project-specific configuration."""
19
20 import ConfigParser as _configparser
21 import fnmatch as _fnmatch
22 import os.path as _os_path
23 import sys
24 import time as _time
25
26 from . import LOG as _LOG
27 from . import utils as _utils
28 from .vcs.git import GitBackend as _GitBackend
29 try:
30     from .vcs.bazaar import BazaarBackend as _BazaarBackend
31 except ImportError as _bazaar_import_error:
32     _BazaarBackend = None
33 try:
34     from .vcs.mercurial import MercurialBackend as _MercurialBackend
35 except ImportError as _mercurial_import_error:
36     _MercurialBackend = None
37
38
39 class Project (object):
40     def __init__(self, root='.', name=None, vcs=None, copyright=None,
41                  short_copyright=None):
42         self._root = _os_path.normpath(_os_path.abspath(root))
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.optionxform = str
63         parser.readfp(stream)
64         for section in parser.sections():
65             clean_section = section.replace('-', '_')
66             try:
67                 loader = getattr(self, '_load_{}_conf'.format(clean_section))
68             except AttributeError as e:
69                 _LOG.error('invalid {} section'.format(section))
70                 raise
71             loader(parser=parser)
72
73     def _load_project_conf(self, parser):
74         try:
75             self._name = parser.get('project', 'name')
76         except _configparser.NoOptionError:
77             pass
78         try:
79             vcs = parser.get('project', 'vcs')
80         except _configparser.NoOptionError:
81             pass
82         else:
83             kwargs = {
84                 'root': self._root,
85                 'author_hacks': self._author_hacks,
86                 'year_hacks': self._year_hacks,
87                 'aliases': self._aliases,
88                 }
89             if vcs == 'Git':
90                 self._vcs = _GitBackend(**kwargs)
91             elif vcs == 'Bazaar':
92                 if _BazaarBackend is None:
93                     raise _bazaar_import_error
94                 self._vcs = _BazaarBackend(**kwargs)
95             elif vcs == 'Mercurial':
96                 if _MercurialBackend is None:
97                     raise _mercurial_import_error
98                 self._vcs = _MercurialBackend(**kwargs)
99             else:
100                 raise NotImplementedError('vcs: {}'.format(vcs))
101
102     def _load_copyright_conf(self, parser, encoding=None):
103         if encoding is None:
104             encoding = self._encoding or _utils.ENCODING
105         try:
106             self._copyright = self._split_paragraphs(
107                 unicode(parser.get('copyright', 'long'), encoding))
108         except _configparser.NoOptionError:
109             pass
110         try:
111             self._short_copyright = self._split_paragraphs(
112                 unicode(parser.get('copyright', 'short'), encoding))
113         except _configparser.NoOptionError:
114             pass
115
116     def _split_paragraphs(self, text):
117         return [p.strip() for p in text.split(u'\n')]
118
119     def _load_files_conf(self, parser):
120         try:
121             self.with_authors = parser.getboolean('files', 'authors')
122         except _configparser.NoOptionError:
123             pass
124         try:
125             self.with_files = parser.getboolean('files', 'files')
126         except _configparser.NoOptionError:
127             pass
128         try:
129             ignored = parser.get('files', 'ignored')
130         except _configparser.NoOptionError:
131             pass
132         else:
133             self._ignored_paths = [pth.strip() for pth in ignored.split('|')]
134         try:
135             pyfile = parser.get('files', 'pyfile')
136         except _configparser.NoOptionError:
137             pass
138         else:
139             self._pyfile = _os_path.join(self._root, pyfile)
140
141     def _load_author_hacks_conf(self, parser, encoding=None):
142         if encoding is None:
143             encoding = self._encoding or _utils.ENCODING
144         author_hacks = {}
145         for path in parser.options('author-hacks'):
146             authors = parser.get('author-hacks', path)
147             author_hacks[tuple(path.split('/'))] = set(
148                 unicode(a.strip(), encoding) for a in authors.split('|'))
149         self._author_hacks = author_hacks
150         if self._vcs is not None:
151             self._vcs._author_hacks = self._author_hacks
152
153     def _load_year_hacks_conf(self, parser):
154         year_hacks = {}
155         for path in parser.options('year-hacks'):
156             year = parser.get('year-hacks', path)
157             year_hacks[tuple(path.split('/'))] = int(year)
158         self._year_hacks = year_hacks
159         if self._vcs is not None:
160             self._vcs._year_hacks = self._year_hacks
161
162     def _load_aliases_conf(self, parser, encoding=None):
163         if encoding is None:
164             encoding = self._encoding or _utils.ENCODING
165         aliases = {}
166         for author in parser.options('aliases'):
167             _aliases = parser.get('aliases', author)
168             author = unicode(author, encoding)
169             aliases[author] = set(
170                 unicode(a.strip(), encoding) for a in _aliases.split('|'))
171         self._aliases = aliases
172         if self._vcs is not None:
173             self._vcs._aliases = self._aliases
174
175     def _info(self):
176         return {
177             'project': self._name,
178             'vcs': self._vcs.name,
179             }
180
181     def update_authors(self, dry_run=False):
182         _LOG.info('update AUTHORS')
183         authors = self._vcs.authors()
184         new_contents = u'{} was written by:\n{}\n'.format(
185             self._name, u'\n'.join(authors))
186         _utils.set_contents(
187             _os_path.join(self._root, 'AUTHORS'),
188             new_contents, unicode=True, encoding=self._encoding,
189             dry_run=dry_run)
190
191     def update_file(self, filename, dry_run=False):
192         _LOG.info('update {}'.format(filename))
193         contents = _utils.get_contents(
194             filename=filename, unicode=True, encoding=self._encoding)
195         original_year = self._vcs.original_year(filename=filename)
196         authors = self._vcs.authors(filename=filename)
197         new_contents = _utils.update_copyright(
198             contents=contents, original_year=original_year, authors=authors,
199             text=self._copyright, info=self._info(), prefix=('# ', '# ', None),
200             width=self._width, tag=self._copyright_tag)
201         new_contents = _utils.update_copyright(
202             contents=new_contents, original_year=original_year,
203             authors=authors, text=self._copyright, info=self._info(),
204             prefix=('/* ', ' * ', ' */'), width=self._width,
205             tag=self._copyright_tag)
206         _utils.set_contents(
207             filename=filename, contents=new_contents,
208             original_contents=contents, unicode=True, encoding=self._encoding,
209             dry_run=dry_run)
210
211     def update_files(self, files=None, dry_run=False):
212         if files is None or len(files) == 0:
213             files = _utils.list_files(root=self._root)
214         for filename in files:
215             if self._ignored_file(filename=filename):
216                 continue
217             self.update_file(filename=filename, dry_run=dry_run)
218
219     def update_pyfile(self, dry_run=False):
220         if self._pyfile is None:
221             _LOG.info('no pyfile location configured, skip `update_pyfile`')
222             return
223         _LOG.info('update pyfile at {}'.format(self._pyfile))
224         current_year = _time.gmtime()[0]
225         original_year = self._vcs.original_year()
226         authors = self._vcs.authors()
227         lines = [
228             _utils.copyright_string(
229                 original_year=original_year, final_year=current_year,
230                 authors=authors, text=self._copyright, info=self._info(),
231                 prefix=(u'# ', u'# ', None), width=self._width),
232             u'', u'import textwrap as _textwrap', u'', u'',
233             u'LICENSE = """',
234             _utils.copyright_string(
235                 original_year=original_year, final_year=current_year,
236                 authors=authors, text=self._copyright, info=self._info(),
237                 prefix=(u'', u'', None), width=self._width),
238             u'""".strip()',
239             u'',
240             u'def short_license(info, wrap=True, **kwargs):',
241             u'    paragraphs = [',
242             ]
243         paragraphs = _utils.copyright_string(
244             original_year=original_year, final_year=current_year,
245             authors=authors, text=self._short_copyright, info=self._info(),
246             author_format_fn=_utils.short_author_formatter, wrap=False,
247             ).split(u'\n\n')
248         for p in paragraphs:
249             lines.append(u"        '{}'.format(**info),".format(
250                     p.replace(u"'", ur"\'")))
251         lines.extend([
252                 u'        ]',
253                 u'    if wrap:',
254                 u'        for i,p in enumerate(paragraphs):',
255                 u'            paragraphs[i] = _textwrap.fill(p, **kwargs)',
256                 ur"    return '\n\n'.join(paragraphs)",
257                 u'',  # for terminal endline
258                 ])
259         new_contents = u'\n'.join(lines)
260         _utils.set_contents(
261             filename=self._pyfile, contents=new_contents, unicode=True,
262             encoding=self._encoding, dry_run=dry_run)
263
264     def _ignored_file(self, filename):
265         """
266         >>> p = Project()
267         >>> p._ignored_paths = ['a', './b/']
268         >>> p._ignored_file('./a/')
269         True
270         >>> p._ignored_file('b')
271         True
272         >>> p._ignored_file('a/z')
273         True
274         >>> p._ignored_file('ab/z')
275         False
276         >>> p._ignored_file('./ab/a')
277         False
278         >>> p._ignored_file('./z')
279         False
280         """
281         filename = _os_path.relpath(filename, self._root)
282         if self._ignored_paths is not None:
283             base = filename
284             while base not in ['', '.', '..']:
285                 for path in self._ignored_paths:
286                     if _fnmatch.fnmatch(base, _os_path.normpath(path)):
287                         _LOG.debug('ignoring {} (matched {})'.format(
288                                 filename, path))
289                         return True
290                 base = _os_path.split(base)[0]
291         if self._vcs and not self._vcs.is_versioned(filename):
292             _LOG.debug('ignoring {} (not versioned))'.format(filename))
293             return True
294         return False