1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a docstring generator for Python.
8"""
9
10import re
11import collections
12
13from .BaseDocstringGenerator import (
14    BaseDocstringGenerator, FunctionInfo, getIndentStr
15)
16
17
18class PyDocstringGenerator(BaseDocstringGenerator):
19    """
20    Class implementing a docstring generator for Python.
21    """
22    def __init__(self, editor):
23        """
24        Constructor
25
26        @param editor reference to the editor widget
27        @type Editor
28        """
29        super().__init__(editor)
30
31        self.__quote3 = '"""'
32        self.__quote3Alternate = "'''"
33
34    def isFunctionStart(self, text):
35        """
36        Public method to test, if a text is the start of a function or method
37        definition.
38
39        @param text line of text to be tested
40        @type str
41        @return flag indicating that the given text starts a function or
42            method definition
43        @rtype bool
44        """
45        if isinstance(text, str):
46            text = text.lstrip()
47            if text.startswith(("def", "async def")):
48                return True
49
50        return False
51
52    def hasFunctionDefinition(self, cursorPosition):
53        """
54        Public method to test, if the cursor is right below a function
55        definition.
56
57        @param cursorPosition current cursor position (line and column)
58        @type tuple of (int, int)
59        @return flag indicating cursor is right below a function definition
60        @rtype bool
61        """
62        return (
63            self.__getFunctionDefinitionFromBelow(cursorPosition) is not None
64        )
65
66    def isDocstringIntro(self, cursorPosition):
67        """
68        Public function to test, if the line up to the cursor position might be
69        introducing a docstring.
70
71        @param cursorPosition current cursor position (line and column)
72        @type tuple of (int, int)
73        @return flag indicating a potential start of a docstring
74        @rtype bool
75        """
76        cline, cindex = cursorPosition
77        lineToCursor = self.editor.text(cline)[:cindex]
78        return self.__isTripleQuotesStart(lineToCursor)
79
80    def __isTripleQuotesStart(self, text):
81        """
82        Private method to test, if the given text is the start of a triple
83        quoted string.
84
85        @param text text to be inspected
86        @type str
87        @return flag indicating a triple quote start
88        @rtype bool
89        """
90        docstringTriggers = ('"""', 'r"""', "'''", "r'''")
91        if text.lstrip() in docstringTriggers:
92            return True
93
94        return False
95
96    def insertDocstring(self, cursorPosition, fromStart=True):
97        """
98        Public method to insert a docstring for the function at the cursor
99        position.
100
101        @param cursorPosition position of the cursor (line and index)
102        @type tuple of (int, int)
103        @param fromStart flag indicating that the editor text cursor is placed
104            on the line starting the function definition
105        @type bool
106        """
107        if fromStart:
108            self.__functionStartLine = cursorPosition[0]
109            docstring, insertPos, newCursorLine = (
110                self.__generateDocstringFromStart()
111            )
112        else:
113            docstring, insertPos, newCursorLine = (
114                self.__generateDocstringFromBelow(cursorPosition)
115            )
116
117        if docstring:
118            self.editor.beginUndoAction()
119            self.editor.insertAt(docstring, *insertPos)
120
121            if not fromStart:
122                # correct triple quote indentation if neccessary
123                functionIndent = self.editor.indentation(
124                    self.__functionStartLine)
125                quoteIndent = self.editor.indentation(insertPos[0])
126
127                # step 1: unindent quote line until indentation is zero
128                while quoteIndent > 0:
129                    self.editor.unindent(insertPos[0])
130                    quoteIndent = self.editor.indentation(insertPos[0])
131
132                # step 2: indent quote line until indentation is one greater
133                # than function definition line
134                while quoteIndent <= functionIndent:
135                    self.editor.indent(insertPos[0])
136                    quoteIndent = self.editor.indentation(insertPos[0])
137
138            self.editor.endUndoAction()
139            self.editor.setCursorPosition(
140                newCursorLine, len(self.editor.text(newCursorLine)) - 1
141            )
142
143    def insertDocstringFromShortcut(self, cursorPosition):
144        """
145        Public method to insert a docstring for the function at the cursor
146        position initiated via a keyboard shortcut.
147
148        @param cursorPosition position of the cursor (line and index)
149        @type tuple of (int, int)
150        """
151        result = self.__getFunctionDefinitionFromBelow(cursorPosition)
152        if result is not None:
153            # cursor is on the line after the function definition
154            cline = cursorPosition[0] - 1
155            while not self.isFunctionStart(self.editor.text(cline)):
156                cline -= 1
157            self.__functionStartLine = cline
158        elif self.isFunctionStart(self.editor.text(cursorPosition[0])):
159            # cursor is on the start line of the function definition
160            self.__functionStartLine = cursorPosition[0]
161        else:
162            # neither after the function definition nor at the start
163            # just do nothing
164            return
165
166        docstring, insertPos, newCursorLine = (
167            self.__generateDocstringFromStart()
168        )
169        if docstring:
170            self.editor.beginUndoAction()
171            self.editor.insertAt(docstring, *insertPos)
172            self.editor.endUndoAction()
173            self.editor.setCursorPosition(
174                newCursorLine, len(self.editor.text(newCursorLine)) - 1
175            )
176
177    def __getIndentationInsertString(self, text):
178        """
179        Private method to create the indentation string for the docstring.
180
181        @param text text to based the indentation on
182        @type str
183        @return indentation string for docstring
184        @rtype str
185        """
186        indent = getIndentStr(text)
187        indentWidth = self.editor.indentationWidth()
188        if indentWidth == 0:
189            indentWidth = self.editor.tabWidth()
190
191        return indent + indentWidth * " "
192
193    #######################################################################
194    ## Methods to generate the docstring when the text cursor is on the
195    ## line starting the function definition.
196    #######################################################################
197
198    def __generateDocstringFromStart(self):
199        """
200        Private method to generate a docstring based on the cursor being
201        placed on the first line of the definition.
202
203        @return tuple containing the docstring and a tuple containing the
204            insertion line and index
205        @rtype tuple of (str, tuple(int, int))
206        """
207        result = self.__getFunctionDefinitionFromStart()
208        if result:
209            functionDefinition, functionDefinitionLength = result
210
211            insertLine = self.__functionStartLine + functionDefinitionLength
212            indentation = self.__getIndentationInsertString(functionDefinition)
213            sep = self.editor.getLineSeparator()
214            bodyStart = insertLine
215
216            docstringList = self.__generateDocstring(
217                '"', functionDefinition, bodyStart
218            )
219            if docstringList:
220                if self.getDocstringType() == "ericdoc":
221                    docstringList.insert(0, self.__quote3)
222                    newCursorLine = insertLine + 1
223                else:
224                    docstringList[0] = self.__quote3 + docstringList[0]
225                    newCursorLine = insertLine
226                docstringList.append(self.__quote3)
227                return (
228                    indentation +
229                    "{0}{1}".format(sep, indentation).join(docstringList) +
230                    sep
231                ), (insertLine, 0), newCursorLine
232
233        return "", (0, 0), 0
234
235    def __getFunctionDefinitionFromStart(self):
236        """
237        Private method to extract the function definition based on the cursor
238        being placed on the first line of the definition.
239
240        @return text containing the function definition
241        @rtype str
242        """
243        startLine = self.__functionStartLine
244        endLine = startLine + min(
245            self.editor.lines() - startLine,
246            20          # max. 20 lines of definition allowed
247        )
248        isFirstLine = True
249        functionIndent = ""
250        functionTextList = []
251
252        for lineNo in range(startLine, endLine):
253            text = self.editor.text(lineNo).rstrip()
254            if isFirstLine:
255                if not self.isFunctionStart(text):
256                    return None
257
258                functionIndent = getIndentStr(text)
259                isFirstLine = False
260            else:
261                currentIndent = getIndentStr(text)
262                if (
263                    currentIndent <= functionIndent or
264                    self.isFunctionStart(text)
265                ):
266                    # no function body exists
267                    return None
268                if text.strip() == "":
269                    # empty line, illegal/incomplete function definition
270                    return None
271
272            if text.endswith("\\"):
273                text = text[:-1]
274
275            functionTextList.append(text)
276
277            if text.endswith(":"):
278                # end of function definition reached
279                functionDefinitionLength = len(functionTextList)
280
281                # check, if function is decorated with a supported one
282                if startLine > 0:
283                    decoratorLine = self.editor.text(startLine - 1)
284                    if (
285                        "@classmethod" in decoratorLine or
286                        "@staticmethod" in decoratorLine or
287                        "pyqtSlot" in decoratorLine or          # PyQt slot
288                        "Slot" in decoratorLine                 # PySide slot
289                    ):
290                        functionTextList.insert(0, decoratorLine)
291
292                return "".join(functionTextList), functionDefinitionLength
293
294        return None
295
296    #######################################################################
297    ## Methods to generate the docstring when the text cursor is on the
298    ## line after the function definition (e.g. after a triple quote).
299    #######################################################################
300
301    def __generateDocstringFromBelow(self, cursorPosition):
302        """
303        Private method to generate a docstring when the gicen position is on
304        the line below the end of the definition.
305
306        @param cursorPosition position of the cursor (line and index)
307        @type tuple of (int, int)
308        @return tuple containing the docstring and a tuple containing the
309            insertion line and index
310        @rtype tuple of (str, tuple(int, int))
311        """
312        functionDefinition = self.__getFunctionDefinitionFromBelow(
313            cursorPosition)
314        if functionDefinition:
315            lineTextToCursor = (
316                self.editor.text(cursorPosition[0])[:cursorPosition[1]]
317            )
318            insertLine = cursorPosition[0]
319            indentation = self.__getIndentationInsertString(functionDefinition)
320            sep = self.editor.getLineSeparator()
321            bodyStart = insertLine
322
323            docstringList = self.__generateDocstring(
324                '"', functionDefinition, bodyStart
325            )
326            if docstringList:
327                if self.__isTripleQuotesStart(lineTextToCursor):
328                    if self.getDocstringType() == "ericdoc":
329                        docstringList.insert(0, "")
330                        newCursorLine = cursorPosition[0] + 1
331                    else:
332                        newCursorLine = cursorPosition[0]
333                    docstringList.append("")
334                else:
335                    if self.getDocstringType() == "ericdoc":
336                        docstringList.insert(0, self.__quote3)
337                        newCursorLine = cursorPosition[0] + 1
338                    else:
339                        docstringList[0] = self.__quote3 + docstringList[0]
340                        newCursorLine = cursorPosition[0]
341                    docstringList.append(self.__quote3)
342                docstring = (
343                    "{0}{1}".format(sep, indentation).join(docstringList)
344                )
345                return docstring, cursorPosition, newCursorLine
346
347        return "", (0, 0), 0
348
349    def __getFunctionDefinitionFromBelow(self, cursorPosition):
350        """
351        Private method to extract the function definition based on the cursor
352        being placed on the first line after the definition.
353
354        @param cursorPosition current cursor position (line and column)
355        @type tuple of (int, int)
356        @return text containing the function definition
357        @rtype str
358        """
359        startLine = cursorPosition[0] - 1
360        endLine = startLine - min(startLine, 20)
361        # max. 20 lines of definition allowed
362        isFirstLine = True
363        functionTextList = []
364
365        for lineNo in range(startLine, endLine, -1):
366            text = self.editor.text(lineNo).rstrip()
367            if isFirstLine:
368                if not text.endswith(":"):
369                    return None
370                isFirstLine = False
371            elif text.endswith(":") or text == "":
372                return None
373
374            if text.endswith("\\"):
375                text = text[:-1]
376
377            functionTextList.insert(0, text)
378
379            if self.isFunctionStart(text):
380                # start of function definition reached
381                self.__functionStartLine = lineNo
382
383                # check, if function is decorated with a supported one
384                if lineNo > 0:
385                    decoratorLine = self.editor.text(lineNo - 1)
386                    if (
387                        "@classmethod" in decoratorLine or
388                        "@staticmethod" in decoratorLine or
389                        "pyqtSlot" in decoratorLine or          # PyQt slot
390                        "Slot" in decoratorLine                 # PySide slot
391                    ):
392                        functionTextList.insert(0, decoratorLine)
393
394                return "".join(functionTextList)
395
396        return None
397
398    #######################################################################
399    ## Methods to generate the docstring contents.
400    #######################################################################
401
402    def __getFunctionBody(self, functionIndent, startLine):
403        """
404        Private method to get the function body.
405
406        @param functionIndent indentation string of the function definition
407        @type str
408        @param startLine starting line for the extraction process
409        @type int
410        @return text containing the function body
411        @rtype str
412        """
413        bodyList = []
414
415        for line in range(startLine, self.editor.lines()):
416            text = self.editor.text(line)
417            textIndent = getIndentStr(text)
418
419            if text.strip() == "":
420                pass
421            elif len(textIndent) <= len(functionIndent):
422                break
423
424            bodyList.append(text)
425
426        return "".join(bodyList)
427
428    def __generateDocstring(self, quote, functionDef, bodyStartLine):
429        """
430        Private method to generate the list of docstring lines.
431
432        @param quote quote string
433        @type str
434        @param functionDef text containing the function definition
435        @type str
436        @param bodyStartLine starting line of the function body
437        @type int
438        @return list of docstring lines
439        @rtype list of str
440        """
441        quote3 = 3 * quote
442        if quote == '"':
443            quote3replace = 3 * "'"
444        elif quote == "'":
445            quote3replace = 3 * '"'
446        functionInfo = PyFunctionInfo()
447        functionInfo.parseDefinition(functionDef, quote3, quote3replace)
448
449        if functionInfo.hasInfo:
450            functionBody = self.__getFunctionBody(functionInfo.functionIndent,
451                                                  bodyStartLine)
452
453            if functionBody:
454                functionInfo.parseBody(functionBody)
455
456            docstringType = self.getDocstringType()
457            return self._generateDocstringList(functionInfo, docstringType)
458
459        return []
460
461
462class PyFunctionInfo(FunctionInfo):
463    """
464    Class implementing an object to extract and store function information.
465    """
466    def __init__(self):
467        """
468        Constructor
469        """
470        super().__init__()
471
472    def __isCharInPairs(self, posChar, pairs):
473        """
474        Private method to test, if the given character position is between
475        pairs of brackets or quotes.
476
477        @param posChar character position to be tested
478        @type int
479        @param pairs list containing pairs of positions
480        @type list of tuple of (int, int)
481        @return flag indicating the position is in between
482        @rtype bool
483        """
484        return any(posLeft < posChar < posRight
485                   for (posLeft, posRight) in pairs)
486
487    def __findQuotePosition(self, text):
488        """
489        Private method to find the start and end position of pairs of quotes.
490
491        @param text text to be parsed
492        @type str
493        @return list of tuple with start and end position of pairs of quotes
494        @rtype list of tuple of (int, int)
495        @exception IndexError raised when a matching close quote is missing
496        """
497        pos = []
498        foundLeftQuote = False
499
500        for index, character in enumerate(text):
501            if foundLeftQuote is False:
502                if character in ("'", '"'):
503                    foundLeftQuote = True
504                    quote = character
505                    leftPos = index
506            else:
507                if character == quote and text[index - 1] != "\\":
508                    pos.append((leftPos, index))
509                    foundLeftQuote = False
510
511        if foundLeftQuote:
512            raise IndexError("No matching close quote at: {0}".format(leftPos))
513
514        return pos
515
516    def __findBracketPosition(self, text, bracketLeft, bracketRight, posQuote):
517        """
518        Private method to find the start and end position of pairs of brackets.
519
520        https://stackoverflow.com/questions/29991917/
521        indices-of-matching-parentheses-in-python
522
523        @param text text to be parsed
524        @type str
525        @param bracketLeft character of the left bracket
526        @type str
527        @param bracketRight character of the right bracket
528        @type str
529        @param posQuote list of tuple with start and end position of pairs
530            of quotes
531        @type list of tuple of (int, int)
532        @return list of tuple with start and end position of pairs of brackets
533        @rtype list of tuple of (int, int)
534        @exception IndexError raised when a closing or opening bracket is
535            missing
536        """
537        pos = []
538        pstack = []
539
540        for index, character in enumerate(text):
541            if (
542                character == bracketLeft and
543                not self.__isCharInPairs(index, posQuote)
544            ):
545                pstack.append(index)
546            elif (
547                character == bracketRight and
548                not self.__isCharInPairs(index, posQuote)
549            ):
550                if len(pstack) == 0:
551                    raise IndexError(
552                        "No matching closing parens at: {0}".format(index))
553                pos.append((pstack.pop(), index))
554
555        if len(pstack) > 0:
556            raise IndexError(
557                "No matching opening parens at: {0}".format(pstack.pop()))
558
559        return pos
560
561    def __splitArgumentToNameTypeValue(self, argumentsList,
562                                       quote, quoteReplace):
563        """
564        Private method to split some argument text to name, type and value.
565
566        @param argumentsList list of function argument definitions
567        @type list of str
568        @param quote quote string to be replaced
569        @type str
570        @param quoteReplace quote string to replace the original
571        @type str
572        """
573        for arg in argumentsList:
574            hasType = False
575            hasValue = False
576
577            colonPosition = arg.find(":")
578            equalPosition = arg.find("=")
579
580            if equalPosition > -1:
581                hasValue = True
582
583            if (
584                colonPosition > -1 and
585                (not hasValue or equalPosition > colonPosition)
586            ):
587                # exception for def foo(arg1=":")
588                hasType = True
589
590            if hasValue and hasType:
591                argName = arg[0:colonPosition].strip()
592                argType = arg[colonPosition + 1:equalPosition].strip()
593                argValue = arg[equalPosition + 1:].strip()
594            elif not hasValue and hasType:
595                argName = arg[0:colonPosition].strip()
596                argType = arg[colonPosition + 1:].strip()
597                argValue = None
598            elif hasValue and not hasType:
599                argName = arg[0:equalPosition].strip()
600                argType = None
601                argValue = arg[equalPosition + 1:].strip()
602            else:
603                argName = arg.strip()
604                argType = None
605                argValue = None
606            if argValue and quote:
607                # sanitize argValue with respect to quotes
608                argValue = argValue.replace(quote, quoteReplace)
609
610            self.argumentsList.append((argName, argType, argValue))
611
612    def __splitArgumentsTextToList(self, argumentsText):
613        """
614        Private method to split the given arguments text into a list of
615        arguments.
616
617        This function uses a comma to separate arguments and ignores a comma in
618        brackets and quotes.
619
620        @param argumentsText text containing the list of arguments
621        @type str
622        @return list of individual argument texts
623        @rtype list of str
624        """
625        argumentsList = []
626        indexFindStart = 0
627        indexArgStart = 0
628
629        try:
630            posQuote = self.__findQuotePosition(argumentsText)
631            posRound = self.__findBracketPosition(
632                argumentsText, "(", ")", posQuote)
633            posCurly = self.__findBracketPosition(
634                argumentsText, "{", "}", posQuote)
635            posSquare = self.__findBracketPosition(
636                argumentsText, "[", "]", posQuote)
637        except IndexError:
638            return None
639
640        while True:
641            posComma = argumentsText.find(",", indexFindStart)
642
643            if posComma == -1:
644                break
645
646            indexFindStart = posComma + 1
647
648            if (
649                self.__isCharInPairs(posComma, posRound) or
650                self.__isCharInPairs(posComma, posCurly) or
651                self.__isCharInPairs(posComma, posSquare) or
652                self.__isCharInPairs(posComma, posQuote)
653            ):
654                continue
655
656            argumentsList.append(argumentsText[indexArgStart:posComma])
657            indexArgStart = posComma + 1
658
659        if indexArgStart < len(argumentsText):
660            argumentsList.append(argumentsText[indexArgStart:])
661
662        return argumentsList
663
664    def parseDefinition(self, text, quote, quoteReplace):
665        """
666        Public method to parse the function definition text.
667
668        @param text text containing the function definition
669        @type str
670        @param quote quote string to be replaced
671        @type str
672        @param quoteReplace quote string to replace the original
673        @type str
674        """
675        self.functionIndent = getIndentStr(text)
676
677        textList = text.splitlines()
678        if textList[0].lstrip().startswith("@"):
679            # first line of function definition is a decorator
680            decorator = textList.pop(0).strip()
681            if decorator == "@staticmethod":
682                self.functionType = "staticmethod"
683            elif decorator == "@classmethod":
684                self.functionType = "classmethod"
685            elif (
686                re.match(r"@(PyQt[456]\.)?(QtCore\.)?pyqtSlot", decorator) or
687                re.match(r"@(PySide[26]\.)?(QtCore\.)?Slot", decorator)
688            ):
689                self.functionType = "qtslot"
690
691        text = "".join(textList).strip()
692
693        if text.startswith("async def "):
694            self.isAsync = True
695
696        returnType = re.search(r"->[ ]*([a-zA-Z0-9_,()\[\] ]*):$", text)
697        if returnType:
698            self.returnTypeAnnotated = returnType.group(1)
699            textEnd = text.rfind(returnType.group(0))
700        else:
701            self.returnTypeAnnotated = None
702            textEnd = len(text)
703
704        positionArgumentsStart = text.find("(") + 1
705        positionArgumentsEnd = text.rfind(")", positionArgumentsStart,
706                                          textEnd)
707
708        self.argumentsText = text[positionArgumentsStart:positionArgumentsEnd]
709
710        argumentsList = self.__splitArgumentsTextToList(self.argumentsText)
711        if argumentsList is not None:
712            self.hasInfo = True
713            self.__splitArgumentToNameTypeValue(
714                argumentsList, quote, quoteReplace)
715
716        functionName = (
717            text[:positionArgumentsStart - 1]
718            .replace("async def ", "")
719            .replace("def ", "")
720        )
721        if functionName == "__init__":
722            self.functionType = "constructor"
723        elif functionName.startswith("__"):
724            if functionName.endswith("__"):
725                self.visibility = "special"
726            else:
727                self.visibility = "private"
728        elif functionName.startswith("_"):
729            self.visibility = "protected"
730        else:
731            self.visibility = "public"
732
733    def parseBody(self, text):
734        """
735        Public method to parse the function body text.
736
737        @param text function body text
738        @type str
739        """
740        raiseRe = re.findall(r"[ \t]raise ([a-zA-Z0-9_]*)", text)
741        if len(raiseRe) > 0:
742            self.raiseList = [x.strip() for x in raiseRe]
743            # remove duplicates from list while keeping it in the order
744            # stackoverflow.com/questions/7961363/removing-duplicates-in-lists
745            self.raiseList = list(collections.OrderedDict.fromkeys(
746                self.raiseList))
747
748        yieldRe = re.search(r"[ \t]yield ", text)
749        if yieldRe:
750            self.hasYield = True
751
752        # get return value
753        returnPattern = r"return |yield "
754        lineList = text.splitlines()
755        returnFound = False
756        returnTmpLine = ""
757
758        for line in lineList:
759            line = line.strip()
760
761            if (
762                returnFound is False and
763                re.match(returnPattern, line)
764            ):
765                returnFound = True
766
767            if returnFound:
768                returnTmpLine += line
769                # check the integrity of line
770                try:
771                    quotePos = self.__findQuotePosition(returnTmpLine)
772
773                    if returnTmpLine.endswith("\\"):
774                        returnTmpLine = returnTmpLine[:-1]
775                        continue
776
777                    self.__findBracketPosition(
778                        returnTmpLine, "(", ")", quotePos)
779                    self.__findBracketPosition(
780                        returnTmpLine, "{", "}", quotePos)
781                    self.__findBracketPosition(
782                        returnTmpLine, "[", "]", quotePos)
783                except IndexError:
784                    continue
785
786                returnValue = re.sub(returnPattern, "", returnTmpLine)
787                self.returnValueInBody.append(returnValue)
788
789                returnFound = False
790                returnTmpLine = ""
791