1#!/usr/bin/env python3 2# 3# Copyright (c) 2019 Collabora, Ltd. 4# 5# SPDX-License-Identifier: Apache-2.0 6# 7# Author(s): Ryan Pavlik <ryan.pavlik@collabora.com> 8# 9# Purpose: This script converts leading comments on some Python 10# classes and functions into docstrings. 11# It doesn't attempt to deal with line continuations, etc. 12# so you may want to "join line" on your def statements 13# temporarily before running. 14 15import re 16 17from spec_tools.file_process import LinewiseFileProcessor 18 19COMMENT_RE = re.compile(r" *#(!.*| (?P<content>.*))?") 20CONVERTIBLE_DEF_RE = re.compile(r"(?P<indentation> *)(def|class) .*:") 21 22 23class CommentConverter(LinewiseFileProcessor): 24 def __init__(self, single_line_quotes=False, allow_blank_lines=False): 25 super().__init__() 26 self.comment_lines = [] 27 "Temporary storage for contiguous comment lines." 28 29 self.trailing_empty_lines = [] 30 "Temporary storage for empty lines following a comment." 31 32 self.output_lines = [] 33 "Fully-processed output lines." 34 35 self.single_line_quotes = single_line_quotes 36 "Whether we generate simple, single-line quotes for single line comments." 37 38 self.allow_blank_lines = allow_blank_lines 39 "Whether we allow blank lines between a comment and the thing it's considered to document." 40 41 self.done_with_initial_comment = False 42 "Have we read our first non-comment line yet?" 43 44 def output_line(self, line=None): 45 if line: 46 self.output_lines.append(line) 47 else: 48 self.output_lines.append("") 49 50 def output_normal_line(self, line): 51 # flush any comment lines we had stored and output this line. 52 self.dump_comment_lines() 53 self.output_line(line) 54 55 def dump_comment_lines(self): 56 # Early out for empty 57 if not self.comment_lines: 58 return 59 60 for line in self.comment_lines: 61 self.output_line(line) 62 self.comment_lines = [] 63 64 for line in self.trailing_empty_lines: 65 self.output_line(line) 66 self.trailing_empty_lines = [] 67 68 def dump_converted_comment_lines(self, indent): 69 # Early out for empty 70 if not self.comment_lines: 71 return 72 73 for line in self.trailing_empty_lines: 74 self.output_line(line) 75 self.trailing_empty_lines = [] 76 77 indent = indent + ' ' 78 79 def extract(line): 80 match = COMMENT_RE.match(line) 81 content = match.group('content') 82 if content: 83 return content 84 return "" 85 86 # Extract comment content 87 lines = [extract(line) for line in self.comment_lines] 88 89 # Drop leading empty comments. 90 while lines and not lines[0].strip(): 91 lines.pop(0) 92 93 # Drop trailing empty comments. 94 while lines and not lines[-1].strip(): 95 lines.pop() 96 97 # Add single- or multi-line-string quote 98 if self.single_line_quotes \ 99 and len(lines) == 1 \ 100 and '"' not in lines[0]: 101 quote = '"' 102 else: 103 quote = '"""' 104 lines[0] = quote + lines[0] 105 lines[-1] = lines[-1] + quote 106 107 # Output lines, indenting content as required. 108 for line in lines: 109 if line: 110 self.output_line(indent + line) 111 else: 112 # Don't indent empty comment lines 113 self.output_line() 114 115 # Clear stored comment lines since we processed them 116 self.comment_lines = [] 117 118 def queue_comment_line(self, line): 119 if self.trailing_empty_lines: 120 # If we had blank lines between comment lines, they are separate blocks 121 self.dump_comment_lines() 122 self.comment_lines.append(line) 123 124 def handle_empty_line(self, line): 125 """Handle an empty line. 126 127 Contiguous empty lines between a comment and something documentable do not 128 disassociate the comment from the documentable thing. 129 We have someplace else to store these lines in case there isn't something 130 documentable coming up.""" 131 if self.comment_lines and self.allow_blank_lines: 132 self.trailing_empty_lines.append(line) 133 else: 134 self.output_normal_line(line) 135 136 def is_next_line_doc_comment(self): 137 next_line = self.next_line_rstripped 138 if next_line is None: 139 return False 140 141 return next_line.strip().startswith('"') 142 143 def process_line(self, line_num, line): 144 line = line.rstrip() 145 comment_match = COMMENT_RE.match(line) 146 def_match = CONVERTIBLE_DEF_RE.match(line) 147 148 # First check if this is a comment line. 149 if comment_match: 150 if self.done_with_initial_comment: 151 self.queue_comment_line(line) 152 else: 153 self.output_line(line) 154 else: 155 # If not a comment line, then by definition we're done with the comment header. 156 self.done_with_initial_comment = True 157 if not line.strip(): 158 self.handle_empty_line(line) 159 elif def_match and not self.is_next_line_doc_comment(): 160 # We got something we can make a docstring for: 161 # print the thing the docstring is for first, 162 # then the converted comment. 163 164 indent = def_match.group('indentation') 165 self.output_line(line) 166 self.dump_converted_comment_lines(indent) 167 else: 168 # Can't make a docstring for this line: 169 self.output_normal_line(line) 170 171 def process(self, fn, write=False): 172 self.process_file(fn) 173 174 if write: 175 with open(fn, 'w', encoding='utf-8') as fp: 176 for line in self.output_lines: 177 fp.write(line) 178 fp.write('\n') 179 180 # Reset state 181 self.__init__(self.single_line_quotes, self.allow_blank_lines) 182 183 184def main(): 185 import argparse 186 187 parser = argparse.ArgumentParser() 188 parser.add_argument('filenames', metavar='filename', 189 type=str, nargs='+', 190 help='A Python file to transform.') 191 parser.add_argument('-b', '--blanklines', action='store_true', 192 help='Allow blank lines between a comment and a define and still convert that comment.') 193 194 args = parser.parse_args() 195 196 converter = CommentConverter(allow_blank_lines=args.blanklines) 197 for fn in args.filenames: 198 print("Processing", fn) 199 converter.process(fn, write=True) 200 201 202if __name__ == "__main__": 203 main() 204