Update instructions in containers.rst
[gromacs.git] / admin / copyright.py
blobe54d602851034a93132373b1ebb92738d7e8d610
1 #!/usr/bin/env python3
3 # This file is part of the GROMACS molecular simulation package.
5 # Copyright (c) 2013,2014,2015,2016,2018 by the GROMACS development team.
6 # Copyright (c) 2019,2020, by the GROMACS development team, led by
7 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
8 # and including many others, as listed in the AUTHORS file in the
9 # top-level source directory and at http://www.gromacs.org.
11 # GROMACS is free software; you can redistribute it and/or
12 # modify it under the terms of the GNU Lesser General Public License
13 # as published by the Free Software Foundation; either version 2.1
14 # of the License, or (at your option) any later version.
16 # GROMACS is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19 # Lesser General Public License for more details.
21 # You should have received a copy of the GNU Lesser General Public
22 # License along with GROMACS; if not, see
23 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
24 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
26 # If you want to redistribute modifications to GROMACS, please
27 # consider that scientific software is very special. Version
28 # control is crucial - bugs must be traceable. We will be happy to
29 # consider code for inclusion in the official distribution, but
30 # derived work must not be called official GROMACS. Details are found
31 # in the README & COPYING files - if they are missing, get the
32 # official version at http://www.gromacs.org.
34 # To help us fund GROMACS development, we humbly ask that you cite
35 # the research papers on the package. Check out http://www.gromacs.org.
37 """Checks and/or updates copyright headers in GROMACS source files.
39 It is used internally by several bash scripts to do copyright-relates tasks,
40 but can also be invoked directly for some rare use cases.
42 See docs/dev-manual/code-formatting.rst for more details.
43 """
45 import datetime
46 import os.path
47 import re
48 import sys
50 from optparse import OptionParser
52 class CopyrightState(object):
54 """Information about an existing (or non-existing) copyright header."""
56 def __init__(self, has_copyright, is_correct, is_newstyle, years, other_copyrights):
57 self.has_copyright = has_copyright
58 self.is_correct = is_correct
59 self.is_newstyle = is_newstyle
60 self.years = years
61 self.other_copyrights = other_copyrights
63 class CopyrightChecker(object):
65 """Logic for analyzing existing copyright headers and generating new ones."""
67 _header = ["", "This file is part of the GROMACS molecular simulation package.", ""]
68 _copyright = "Copyright (c) {0}, by the GROMACS development team, led by"
69 _footer = """
70 Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
71 and including many others, as listed in the AUTHORS file in the
72 top-level source directory and at http://www.gromacs.org.
74 GROMACS is free software; you can redistribute it and/or
75 modify it under the terms of the GNU Lesser General Public License
76 as published by the Free Software Foundation; either version 2.1
77 of the License, or (at your option) any later version.
79 GROMACS is distributed in the hope that it will be useful,
80 but WITHOUT ANY WARRANTY; without even the implied warranty of
81 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
82 Lesser General Public License for more details.
84 You should have received a copy of the GNU Lesser General Public
85 License along with GROMACS; if not, see
86 http://www.gnu.org/licenses, or write to the Free Software Foundation,
87 Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
89 If you want to redistribute modifications to GROMACS, please
90 consider that scientific software is very special. Version
91 control is crucial - bugs must be traceable. We will be happy to
92 consider code for inclusion in the official distribution, but
93 derived work must not be called official GROMACS. Details are found
94 in the README & COPYING files - if they are missing, get the
95 official version at http://www.gromacs.org.
97 To help us fund GROMACS development, we humbly ask that you cite
98 the research papers on the package. Check out http://www.gromacs.org.
99 """.strip().splitlines()
101 def check_copyright(self, comment_block):
102 """Analyze existing copyright header for correctness and extract information."""
103 copyright_re = r'Copyright \(c\) (([0-9]{4}[,-])*[0-9]{4}),? by the GROMACS development team,'
104 has_copyright = False
105 is_newstyle = True
106 is_correct = True
107 next_header_line = 0
108 next_footer_line = 0
109 append_next_line_to_other_copyrights = False
110 existing_years = ''
111 other_copyrights = []
112 for line in comment_block:
113 if append_next_line_to_other_copyrights:
114 other_copyrights[-1] += ' ' + line
115 append_next_line_to_other_copyrights = False
116 continue
117 if 'Copyright' in line:
118 has_copyright = True
119 match = re.match(copyright_re, line)
120 if match:
121 existing_years = match.group(1)
122 new_line = self._copyright.format(existing_years)
123 if line != new_line:
124 is_correct = False
125 else:
126 other_copyrights.append(line[line.find('Copyright'):])
127 if not line.startswith('Copyright'):
128 append_next_line_to_other_copyrights = True
129 if next_header_line != -1 or next_footer_line != 0:
130 is_correct = False
131 continue
132 if line.startswith('Written by the Gromacs development team'):
133 has_copyright = True
134 if next_header_line >= 0:
135 if line == self._header[next_header_line]:
136 next_header_line += 1
137 if next_header_line >= len(self._header):
138 next_header_line = -1
139 else:
140 is_correct = False
141 is_newstyle = False
142 elif next_footer_line >= 0:
143 if line == self._footer[next_footer_line]:
144 next_footer_line += 1
145 if next_footer_line >= len(self._footer):
146 next_footer_line = -1
147 else:
148 is_correct = False
149 else:
150 is_correct = False
151 if next_header_line != -1 or next_footer_line != -1:
152 is_correct = False
154 return CopyrightState(has_copyright, is_correct, is_newstyle, existing_years, other_copyrights)
156 def process_copyright(self, state, options, current_years, reporter):
157 """Determine whether a copyrigth header needs to be updated and report issues."""
158 need_update = False
160 if state.years:
161 if options.replace_years:
162 if state.years != current_years:
163 need_update = True
164 reporter.report('copyright years replaced')
165 new_years = current_years
166 else:
167 new_years = state.years
168 if not new_years.endswith(current_years):
169 if options.update_year:
170 need_update = True
171 new_years += ',' + current_years
172 if options.check or not need_update:
173 reporter.report('copyright year outdated')
174 else:
175 reporter.report('copyright year added')
176 else:
177 new_years = current_years
179 if not state.has_copyright:
180 if options.add_missing:
181 need_update = True
182 if options.check or not need_update:
183 reporter.report('copyright header missing')
184 elif options.add_missing:
185 reporter.report('copyright header added')
186 else:
187 if not state.is_newstyle:
188 if options.replace_header:
189 need_update = True
190 if options.check or not need_update:
191 reporter.report('copyright header incorrect')
192 else:
193 reporter.report('copyright header replaced')
194 elif not state.is_correct:
195 if options.update_header:
196 need_update = True
197 if options.check or not need_update:
198 reporter.report('copyright header outdated')
199 else:
200 reporter.report('copyright header updated')
202 return need_update, new_years
204 def get_copyright_text(self, years, other_copyrights):
205 """Construct a new copyright header."""
206 output = []
207 output.extend(self._header)
208 if other_copyrights:
209 for line in other_copyrights:
210 outline = line.rstrip()
211 if outline.endswith(','):
212 outline = outline[:-1]
213 if not outline.endswith('.'):
214 outline += '.'
215 output.append(outline)
216 output.append(self._copyright.format(years))
217 output.extend(self._footer)
218 return output
220 class Reporter(object):
222 """Wrapper for reporting issues in a file."""
224 def __init__(self, reportfile, filename):
225 self._reportfile = reportfile
226 self._filename = filename
228 def report(self, text):
229 self._reportfile.write(self._filename + ': ' + text + '\n');
231 class CommentHandlerC(object):
233 """Handler for extracting and creating C-style comments."""
235 def extract_first_comment_block(self, content_lines):
236 if not content_lines or not content_lines[0].startswith('/*'):
237 return ([], 0)
238 comment_block = [content_lines[0][2:].strip()]
239 line_index = 1
240 while line_index < len(content_lines):
241 line = content_lines[line_index]
242 if '*/' in content_lines[line_index]:
243 break
244 comment_block.append(line.lstrip('* ').rstrip())
245 line_index += 1
246 return (comment_block, line_index + 1)
248 def create_comment_block(self, lines):
249 output = []
250 output.append(('/* ' + lines[0]).rstrip())
251 output.extend([(' * ' + x).rstrip() for x in lines[1:]])
252 output.append(' */')
253 return output
255 class CommentHandlerSimple(object):
257 """Handler for extracting and creating sh-style comments.
259 Also other comments of the same type, but with a different comment
260 character are supported."""
262 def __init__(self, comment_char):
263 self._comment_char = comment_char
265 def extract_first_comment_block(self, content_lines):
266 if not content_lines or not content_lines[0].startswith(self._comment_char):
267 return ([], 0)
268 comment_block = []
269 line_index = 0
270 while line_index < len(content_lines):
271 line = content_lines[line_index]
272 if not line.startswith(self._comment_char):
273 break
274 comment_block.append(line.lstrip(self._comment_char + ' ').rstrip())
275 line_index += 1
276 if line == self._comment_char + ' the research papers on the package. Check out http://www.gromacs.org.':
277 break
278 while line_index < len(content_lines):
279 line = content_lines[line_index].rstrip()
280 if len(line) > 0 and line != self._comment_char:
281 break
282 line_index += 1
283 return (comment_block, line_index)
285 def create_comment_block(self, lines):
286 output = []
287 output.extend([(self._comment_char + ' ' + x).rstrip() for x in lines])
288 output.append('')
289 return output
291 comment_handlers = {
292 'c': CommentHandlerC(),
293 'tex': CommentHandlerSimple('%'),
294 'sh': CommentHandlerSimple('#')
297 def select_comment_handler(override, filename):
298 """Select comment handler for a file based on file name and input options."""
299 filetype = override
300 if not filetype and filename != '-':
301 basename = os.path.basename(filename)
302 root, ext = os.path.splitext(basename)
303 if ext == '.cmakein':
304 dummy, ext2 = os.path.splitext(root)
305 if ext2:
306 ext = ext2
307 if ext in ('.c', '.cu', '.cpp', '.cl', '.h', '.cuh', '.clh', '.y', '.l', '.pre', '.bm'):
308 filetype = 'c'
309 elif ext in ('.tex',):
310 filetype = 'tex'
311 elif basename in ('CMakeLists.txt', 'GMXRC', 'git-pre-commit') or \
312 ext in ('.cmake', '.cmakein', '.py', '.sh', '.bash', '.csh', '.zsh'):
313 filetype = 'sh'
314 if filetype in comment_handlers:
315 return comment_handlers[filetype]
316 if filetype:
317 sys.stderr.write("Unsupported input format: {0}\n".format(filetype))
318 elif filename != '-':
319 sys.stderr.write("Unsupported input format: {0}\n".format(filename))
320 else:
321 sys.stderr.write("No file name or file type provided.\n")
322 sys.exit(1)
324 def create_copyright_header(years, other_copyrights=None, language='c'):
325 if language not in comment_handlers:
326 sys.strerr.write("Unsupported language: {0}\n".format(language))
327 sys.exit(1)
328 copyright_checker = CopyrightChecker()
329 comment_handler = comment_handlers[language]
330 copyright_lines = copyright_checker.get_copyright_text(years, other_copyrights)
331 comment_lines = comment_handler.create_comment_block(copyright_lines)
332 return '\n'.join(comment_lines) + '\n'
334 def process_options():
335 """Process input options."""
336 parser = OptionParser()
337 parser.add_option('-l', '--lang',
338 help='Comment type to use (c or sh)')
339 parser.add_option('-y', '--years',
340 help='Comma-separated list of years')
341 parser.add_option('-F', '--files',
342 help='File to read list of files from')
343 parser.add_option('--check', action='store_true',
344 help='Do not modify the files, only check the copyright (default action). ' +
345 'If specified together with --update, do the modifications ' +
346 'but produce output as if only --check was provided.')
347 parser.add_option('--update-year', action='store_true',
348 help='Update the copyright year if outdated')
349 parser.add_option('--replace-years', action='store_true',
350 help='Replace the copyright years with those given with --years')
351 parser.add_option('--update-header', action='store_true',
352 help='Update the copyright header if outdated')
353 parser.add_option('--replace-header', action='store_true',
354 help='Replace any copyright header with the current one')
355 parser.add_option('--remove-old-copyrights', action='store_true',
356 help='Remove copyright statements not in the new format')
357 parser.add_option('--add-missing', action='store_true',
358 help='Add missing copyright headers')
359 options, args = parser.parse_args()
361 filenames = args
362 if options.files:
363 with open(options.files, 'r') as filelist:
364 filenames = [x.strip() for x in filelist.read().splitlines()]
365 elif not filenames:
366 filenames = ['-']
368 # Default is --check if nothing provided.
369 if not options.check and not options.update_year and \
370 not options.update_header and not options.replace_header and \
371 not options.add_missing:
372 options.check = True
374 return options, filenames
376 def main():
377 """Do processing as a stand-alone script."""
378 options, filenames = process_options()
379 years = options.years
380 if not years:
381 years = str(datetime.date.today().year)
382 if years.endswith(','):
383 years = years[:-1]
385 checker = CopyrightChecker()
387 # Process each input file in turn.
388 for filename in filenames:
389 comment_handler = select_comment_handler(options.lang, filename)
391 # Read the input file. We are doing an in-place operation, so can't
392 # operate in pass-through mode.
393 if filename == '-':
394 contents = sys.stdin.read().splitlines()
395 reporter = Reporter(sys.stderr, '<stdin>')
396 else:
397 with open(filename, 'r', encoding='utf-8') as inputfile:
398 contents = inputfile.read().splitlines()
399 reporter = Reporter(sys.stdout, filename)
401 output = []
402 # Keep lines that must be at the beginning of the file and skip them in
403 # the check.
404 if contents and (contents[0].startswith('#!/') or \
405 contents[0].startswith('%code requires') or \
406 contents[0].startswith('/* #if')):
407 output.append(contents[0])
408 contents = contents[1:]
409 # Remove and skip empty lines at the beginning.
410 while contents and len(contents[0]) == 0:
411 contents = contents[1:]
413 # Analyze the first comment block in the file.
414 comment_block, line_count = comment_handler.extract_first_comment_block(contents)
415 state = checker.check_copyright(comment_block)
416 need_update, file_years = checker.process_copyright(state, options, years, reporter)
417 if state.other_copyrights and options.remove_old_copyrights:
418 need_update = True
419 state.other_copyrights = []
420 reporter.report('old copyrights removed')
422 if need_update:
423 # Remove the original comment if it was a copyright comment.
424 if state.has_copyright:
425 contents = contents[line_count:]
426 new_block = checker.get_copyright_text(file_years, state.other_copyrights)
427 output.extend(comment_handler.create_comment_block(new_block))
429 # Write the output file if required.
430 if need_update or filename == '-':
431 # Append the rest of the input file as it was.
432 output.extend(contents)
433 output = '\n'.join(output) + '\n'
434 if filename == '-':
435 sys.stdout.write(output)
436 else:
437 with open(filename, 'w') as outputfile:
438 outputfile.write(output)
440 if __name__ == "__main__":
441 main()