| #!/usr/bin/env python3 |
| # |
| # Copyright (c) 2019 Collabora, Ltd. |
| # |
| # SPDX-License-Identifier: Apache-2.0 |
| # |
| # Author(s): Ryan Pavlik <[email protected]> |
| # |
| # Purpose: This script converts leading comments on some Python |
| # classes and functions into docstrings. |
| # It doesn't attempt to deal with line continuations, etc. |
| # so you may want to "join line" on your def statements |
| # temporarily before running. |
| |
| import re |
| |
| from spec_tools.file_process import LinewiseFileProcessor |
| |
| COMMENT_RE = re.compile(r" *#(!.*| (?P<content>.*))?") |
| CONVERTIBLE_DEF_RE = re.compile(r"(?P<indentation> *)(def|class) .*:") |
| |
| |
| class CommentConverter(LinewiseFileProcessor): |
| def __init__(self, single_line_quotes=False, allow_blank_lines=False): |
| super().__init__() |
| self.comment_lines = [] |
| "Temporary storage for contiguous comment lines." |
| |
| self.trailing_empty_lines = [] |
| "Temporary storage for empty lines following a comment." |
| |
| self.output_lines = [] |
| "Fully-processed output lines." |
| |
| self.single_line_quotes = single_line_quotes |
| "Whether we generate simple, single-line quotes for single line comments." |
| |
| self.allow_blank_lines = allow_blank_lines |
| "Whether we allow blank lines between a comment and the thing it's considered to document." |
| |
| self.done_with_initial_comment = False |
| "Have we read our first non-comment line yet?" |
| |
| def output_line(self, line=None): |
| if line: |
| self.output_lines.append(line) |
| else: |
| self.output_lines.append("") |
| |
| def output_normal_line(self, line): |
| # flush any comment lines we had stored and output this line. |
| self.dump_comment_lines() |
| self.output_line(line) |
| |
| def dump_comment_lines(self): |
| # Early out for empty |
| if not self.comment_lines: |
| return |
| |
| for line in self.comment_lines: |
| self.output_line(line) |
| self.comment_lines = [] |
| |
| for line in self.trailing_empty_lines: |
| self.output_line(line) |
| self.trailing_empty_lines = [] |
| |
| def dump_converted_comment_lines(self, indent): |
| # Early out for empty |
| if not self.comment_lines: |
| return |
| |
| for line in self.trailing_empty_lines: |
| self.output_line(line) |
| self.trailing_empty_lines = [] |
| |
| indent = indent + ' ' |
| |
| def extract(line): |
| match = COMMENT_RE.match(line) |
| content = match.group('content') |
| if content: |
| return content |
| return "" |
| |
| # Extract comment content |
| lines = [extract(line) for line in self.comment_lines] |
| |
| # Drop leading empty comments. |
| while lines and not lines[0].strip(): |
| lines.pop(0) |
| |
| # Drop trailing empty comments. |
| while lines and not lines[-1].strip(): |
| lines.pop() |
| |
| # Add single- or multi-line-string quote |
| if self.single_line_quotes \ |
| and len(lines) == 1 \ |
| and '"' not in lines[0]: |
| quote = '"' |
| else: |
| quote = '"""' |
| lines[0] = quote + lines[0] |
| lines[-1] = lines[-1] + quote |
| |
| # Output lines, indenting content as required. |
| for line in lines: |
| if line: |
| self.output_line(indent + line) |
| else: |
| # Don't indent empty comment lines |
| self.output_line() |
| |
| # Clear stored comment lines since we processed them |
| self.comment_lines = [] |
| |
| def queue_comment_line(self, line): |
| if self.trailing_empty_lines: |
| # If we had blank lines between comment lines, they are separate blocks |
| self.dump_comment_lines() |
| self.comment_lines.append(line) |
| |
| def handle_empty_line(self, line): |
| """Handle an empty line. |
| |
| Contiguous empty lines between a comment and something documentable do not |
| disassociate the comment from the documentable thing. |
| We have someplace else to store these lines in case there isn't something |
| documentable coming up.""" |
| if self.comment_lines and self.allow_blank_lines: |
| self.trailing_empty_lines.append(line) |
| else: |
| self.output_normal_line(line) |
| |
| def is_next_line_doc_comment(self): |
| next_line = self.next_line_rstripped |
| if next_line is None: |
| return False |
| |
| return next_line.strip().startswith('"') |
| |
| def process_line(self, line_num, line): |
| line = line.rstrip() |
| comment_match = COMMENT_RE.match(line) |
| def_match = CONVERTIBLE_DEF_RE.match(line) |
| |
| # First check if this is a comment line. |
| if comment_match: |
| if self.done_with_initial_comment: |
| self.queue_comment_line(line) |
| else: |
| self.output_line(line) |
| else: |
| # If not a comment line, then by definition we're done with the comment header. |
| self.done_with_initial_comment = True |
| if not line.strip(): |
| self.handle_empty_line(line) |
| elif def_match and not self.is_next_line_doc_comment(): |
| # We got something we can make a docstring for: |
| # print the thing the docstring is for first, |
| # then the converted comment. |
| |
| indent = def_match.group('indentation') |
| self.output_line(line) |
| self.dump_converted_comment_lines(indent) |
| else: |
| # Can't make a docstring for this line: |
| self.output_normal_line(line) |
| |
| def process(self, fn, write=False): |
| self.process_file(fn) |
| |
| if write: |
| with open(fn, 'w', encoding='utf-8') as fp: |
| for line in self.output_lines: |
| fp.write(line) |
| fp.write('\n') |
| |
| # Reset state |
| self.__init__(self.single_line_quotes, self.allow_blank_lines) |
| |
| |
| def main(): |
| import argparse |
| |
| parser = argparse.ArgumentParser() |
| parser.add_argument('filenames', metavar='filename', |
| type=str, nargs='+', |
| help='A Python file to transform.') |
| parser.add_argument('-b', '--blanklines', action='store_true', |
| help='Allow blank lines between a comment and a define and still convert that comment.') |
| |
| args = parser.parse_args() |
| |
| converter = CommentConverter(allow_blank_lines=args.blanklines) |
| for fn in args.filenames: |
| print("Processing", fn) |
| converter.process(fn, write=True) |
| |
| |
| if __name__ == "__main__": |
| main() |