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