• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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