1# mako/pygen.py
2# Copyright 2006-2023 the Mako authors and contributors <see AUTHORS file>
3#
4# This module is part of Mako and is released under
5# the MIT License: http://www.opensource.org/licenses/mit-license.php
6
7"""utilities for generating and formatting literal Python code."""
8
9import re
10
11from mako import exceptions
12
13
14class PythonPrinter:
15    def __init__(self, stream):
16        # indentation counter
17        self.indent = 0
18
19        # a stack storing information about why we incremented
20        # the indentation counter, to help us determine if we
21        # should decrement it
22        self.indent_detail = []
23
24        # the string of whitespace multiplied by the indent
25        # counter to produce a line
26        self.indentstring = "    "
27
28        # the stream we are writing to
29        self.stream = stream
30
31        # current line number
32        self.lineno = 1
33
34        # a list of lines that represents a buffered "block" of code,
35        # which can be later printed relative to an indent level
36        self.line_buffer = []
37
38        self.in_indent_lines = False
39
40        self._reset_multi_line_flags()
41
42        # mapping of generated python lines to template
43        # source lines
44        self.source_map = {}
45
46        self._re_space_comment = re.compile(r"^\s*#")
47        self._re_space = re.compile(r"^\s*$")
48        self._re_indent = re.compile(r":[ \t]*(?:#.*)?$")
49        self._re_compound = re.compile(r"^\s*(if|try|elif|while|for|with)")
50        self._re_indent_keyword = re.compile(
51            r"^\s*(def|class|else|elif|except|finally)"
52        )
53        self._re_unindentor = re.compile(r"^\s*(else|elif|except|finally).*\:")
54
55    def _update_lineno(self, num):
56        self.lineno += num
57
58    def start_source(self, lineno):
59        if self.lineno not in self.source_map:
60            self.source_map[self.lineno] = lineno
61
62    def write_blanks(self, num):
63        self.stream.write("\n" * num)
64        self._update_lineno(num)
65
66    def write_indented_block(self, block, starting_lineno=None):
67        """print a line or lines of python which already contain indentation.
68
69        The indentation of the total block of lines will be adjusted to that of
70        the current indent level."""
71        self.in_indent_lines = False
72        for i, l in enumerate(re.split(r"\r?\n", block)):
73            self.line_buffer.append(l)
74            if starting_lineno is not None:
75                self.start_source(starting_lineno + i)
76            self._update_lineno(1)
77
78    def writelines(self, *lines):
79        """print a series of lines of python."""
80        for line in lines:
81            self.writeline(line)
82
83    def writeline(self, line):
84        """print a line of python, indenting it according to the current
85        indent level.
86
87        this also adjusts the indentation counter according to the
88        content of the line.
89
90        """
91
92        if not self.in_indent_lines:
93            self._flush_adjusted_lines()
94            self.in_indent_lines = True
95
96        if (
97            line is None
98            or self._re_space_comment.match(line)
99            or self._re_space.match(line)
100        ):
101            hastext = False
102        else:
103            hastext = True
104
105        is_comment = line and len(line) and line[0] == "#"
106
107        # see if this line should decrease the indentation level
108        if (
109            not is_comment
110            and (not hastext or self._is_unindentor(line))
111            and self.indent > 0
112        ):
113            self.indent -= 1
114            # if the indent_detail stack is empty, the user
115            # probably put extra closures - the resulting
116            # module wont compile.
117            if len(self.indent_detail) == 0:
118                # TODO: no coverage here
119                raise exceptions.MakoException("Too many whitespace closures")
120            self.indent_detail.pop()
121
122        if line is None:
123            return
124
125        # write the line
126        self.stream.write(self._indent_line(line) + "\n")
127        self._update_lineno(len(line.split("\n")))
128
129        # see if this line should increase the indentation level.
130        # note that a line can both decrase (before printing) and
131        # then increase (after printing) the indentation level.
132
133        if self._re_indent.search(line):
134            # increment indentation count, and also
135            # keep track of what the keyword was that indented us,
136            # if it is a python compound statement keyword
137            # where we might have to look for an "unindent" keyword
138            match = self._re_compound.match(line)
139            if match:
140                # its a "compound" keyword, so we will check for "unindentors"
141                indentor = match.group(1)
142                self.indent += 1
143                self.indent_detail.append(indentor)
144            else:
145                indentor = None
146                # its not a "compound" keyword.  but lets also
147                # test for valid Python keywords that might be indenting us,
148                # else assume its a non-indenting line
149                m2 = self._re_indent_keyword.match(line)
150                if m2:
151                    self.indent += 1
152                    self.indent_detail.append(indentor)
153
154    def close(self):
155        """close this printer, flushing any remaining lines."""
156        self._flush_adjusted_lines()
157
158    def _is_unindentor(self, line):
159        """return true if the given line is an 'unindentor',
160        relative to the last 'indent' event received.
161
162        """
163
164        # no indentation detail has been pushed on; return False
165        if len(self.indent_detail) == 0:
166            return False
167
168        indentor = self.indent_detail[-1]
169
170        # the last indent keyword we grabbed is not a
171        # compound statement keyword; return False
172        if indentor is None:
173            return False
174
175        # if the current line doesnt have one of the "unindentor" keywords,
176        # return False
177        match = self._re_unindentor.match(line)
178        # if True, whitespace matches up, we have a compound indentor,
179        # and this line has an unindentor, this
180        # is probably good enough
181        return bool(match)
182
183        # should we decide that its not good enough, heres
184        # more stuff to check.
185        # keyword = match.group(1)
186
187        # match the original indent keyword
188        # for crit in [
189        #   (r'if|elif', r'else|elif'),
190        #   (r'try', r'except|finally|else'),
191        #   (r'while|for', r'else'),
192        # ]:
193        #   if re.match(crit[0], indentor) and re.match(crit[1], keyword):
194        #        return True
195
196        # return False
197
198    def _indent_line(self, line, stripspace=""):
199        """indent the given line according to the current indent level.
200
201        stripspace is a string of space that will be truncated from the
202        start of the line before indenting."""
203        if stripspace == "":
204            # Fast path optimization.
205            return self.indentstring * self.indent + line
206
207        return re.sub(
208            r"^%s" % stripspace, self.indentstring * self.indent, line
209        )
210
211    def _reset_multi_line_flags(self):
212        """reset the flags which would indicate we are in a backslashed
213        or triple-quoted section."""
214
215        self.backslashed, self.triplequoted = False, False
216
217    def _in_multi_line(self, line):
218        """return true if the given line is part of a multi-line block,
219        via backslash or triple-quote."""
220
221        # we are only looking for explicitly joined lines here, not
222        # implicit ones (i.e. brackets, braces etc.).  this is just to
223        # guard against the possibility of modifying the space inside of
224        # a literal multiline string with unfortunately placed
225        # whitespace
226
227        current_state = self.backslashed or self.triplequoted
228
229        self.backslashed = bool(re.search(r"\\$", line))
230        triples = len(re.findall(r"\"\"\"|\'\'\'", line))
231        if triples == 1 or triples % 2 != 0:
232            self.triplequoted = not self.triplequoted
233
234        return current_state
235
236    def _flush_adjusted_lines(self):
237        stripspace = None
238        self._reset_multi_line_flags()
239
240        for entry in self.line_buffer:
241            if self._in_multi_line(entry):
242                self.stream.write(entry + "\n")
243            else:
244                entry = entry.expandtabs()
245                if stripspace is None and re.search(r"^[ \t]*[^# \t]", entry):
246                    stripspace = re.match(r"^([ \t]*)", entry).group(1)
247                self.stream.write(self._indent_line(entry, stripspace) + "\n")
248
249        self.line_buffer = []
250        self._reset_multi_line_flags()
251
252
253def adjust_whitespace(text):
254    """remove the left-whitespace margin of a block of Python code."""
255
256    state = [False, False]
257    (backslashed, triplequoted) = (0, 1)
258
259    def in_multi_line(line):
260        start_state = state[backslashed] or state[triplequoted]
261
262        if re.search(r"\\$", line):
263            state[backslashed] = True
264        else:
265            state[backslashed] = False
266
267        def match(reg, t):
268            m = re.match(reg, t)
269            if m:
270                return m, t[len(m.group(0)) :]
271            else:
272                return None, t
273
274        while line:
275            if state[triplequoted]:
276                m, line = match(r"%s" % state[triplequoted], line)
277                if m:
278                    state[triplequoted] = False
279                else:
280                    m, line = match(r".*?(?=%s|$)" % state[triplequoted], line)
281            else:
282                m, line = match(r"#", line)
283                if m:
284                    return start_state
285
286                m, line = match(r"\"\"\"|\'\'\'", line)
287                if m:
288                    state[triplequoted] = m.group(0)
289                    continue
290
291                m, line = match(r".*?(?=\"\"\"|\'\'\'|#|$)", line)
292
293        return start_state
294
295    def _indent_line(line, stripspace=""):
296        return re.sub(r"^%s" % stripspace, "", line)
297
298    lines = []
299    stripspace = None
300
301    for line in re.split(r"\r?\n", text):
302        if in_multi_line(line):
303            lines.append(line)
304        else:
305            line = line.expandtabs()
306            if stripspace is None and re.search(r"^[ \t]*[^# \t]", line):
307                stripspace = re.match(r"^([ \t]*)", line).group(1)
308            lines.append(_indent_line(line, stripspace))
309    return "\n".join(lines)
310