eric6/QScintilla/DocstringGenerator/PyDocstringGenerator.py

Fri, 22 Jan 2021 16:48:43 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Fri, 22 Jan 2021 16:48:43 +0100
changeset 7998
cd41c844862f
child 8000
47b15df088e4
permissions
-rw-r--r--

Editor
- added functionality to insert docstring templates via the context menu (cursor placed on first line of function definition) or after entering the docstring start string (e.g. """ for Python)

# -*- coding: utf-8 -*-

# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a docstring generator for Python.
"""

import re
import collections

from .BaseDocstringGenerator import (
    BaseDocstringGenerator, FunctionInfo, getIndentStr
)


class PyDocstringGenerator(BaseDocstringGenerator):
    """
    Class implementing a docstring generator for Python.
    """
    def __init__(self, editor):
        """
        Constructor
        
        @param editor reference to the editor widget
        @type Editor
        """
        super(PyDocstringGenerator, self).__init__(editor)
        
        self.__quote3 = '"""'
        self.__quote3Alternate = "'''"
    
    def isFunctionStart(self, text):
        """
        Public method to test, if a text is the start of a function or method
        definition.
        
        @param text line of text to be tested
        @type str
        @return flag indicating that the given text starts a function or
            method definition
        @rtype bool
        """
        if isinstance(text, str):
            text = text.lstrip()
            if text.startswith(("def", "async def")):
                return True
        
        return False
    
    def hasFunctionDefinition(self, cursorPosition):
        """
        Public method to test, if the cursor is right below a function
        definition.
        
        @param cursorPosition current cursor position (line and column)
        @type tuple of (int, int)
        @return flag indicating cursor is right below a function definition
        @rtype bool
        """
        return (
            self.__getFunctionDefinitionFromBelow(cursorPosition) is not None
        )
    
    def isDocstringIntro(self, cursorPosition):
        """
        Public function to test, if the line up to the cursor position might be
        introducing a docstring.
        
        @param cursorPosition current cursor position (line and column)
        @type tuple of (int, int)
        @return flag indicating a potential start of a docstring
        @rtype bool
        """
        cline, cindex = cursorPosition
        lineToCursor = self.editor.text(cline)[:cindex]
        return self.__isTripleQuotesStart(lineToCursor)
    
    def __isTripleQuotesStart(self, text):
        """
        Private method to test, if the given text is the start of a triple
        quoted string.
        
        @param text text to be inspected
        @type str
        @return flag indicating a triple quote start
        @rtype bool
        """
        docstringTriggers = ('"""', 'r"""', "'''", "r'''")
        if text.lstrip() in docstringTriggers:
            return True

        return False
    
    def insertDocstring(self, cursorPosition, fromStart=True):
        """
        Public method to insert a docstring for the function at the cursor
        position.
        
        @param cursorPosition position of the cursor (line and index)
        @type tuple of (int, int)
        @param fromStart flag indicating that the editor text cursor is placed
            on the line starting the function definition
        @type bool
        """
        if fromStart:
            self.__functionStartLine = cursorPosition[0]
            docstring, insertPos = self.__generateDocstringFromStart()
        else:
            docstring, insertPos = self.__generateDocstringFromBelow(
                cursorPosition)
        
        if docstring:
            self.editor.beginUndoAction()
            self.editor.insertAt(docstring, *insertPos)
            self.editor.endUndoAction()
    
    def __getIndentationInsertString(self, text):
        """
        Private method to create the indentation string for the docstring.
        
        @param text text to based the indentation on
        @type str
        @return indentation string for docstring
        @rtype str
        """
        indent = getIndentStr(text)
        indentWidth = self.editor.indentationWidth()
        if indentWidth == 0:
            indentWidth = self.editor.tabWidth()
        
        return indent + indentWidth * " "
    
    #######################################################################
    ## Methods to generate the docstring when the text cursor is on the
    ## line starting the function definition.
    #######################################################################
    
    def __generateDocstringFromStart(self):
        """
        Private method to generate a docstring based on the cursor being
        placed on the first line of the definition.
        
        @return tuple containing the docstring and a tuple containing the
            insertion line and index
        @rtype tuple of (str, tuple(int, int))
        """
        result = self.__getFunctionDefinitionFromStart()
        if result:
            functionDefinition, functionDefinitionLength = result
            
            insertLine = self.__functionStartLine + functionDefinitionLength
            indentation = self.__getIndentationInsertString(functionDefinition)
            sep = self.editor.getLineSeparator()
            bodyStart = insertLine
            
            docstringList = self.__generateDocstring(
                '"', functionDefinition, bodyStart
            )
            if docstringList:
                if self.getDocstringType() == "ericdoc":
                    docstringList.insert(0, self.__quote3)
                else:
                    docstringList[0] = self.__quote3 + docstringList[0]
                docstringList.append(self.__quote3)
                return (
                    indentation +
                    "{0}{1}".format(sep, indentation).join(docstringList) +
                    sep
                ), (insertLine, 0)
        
        return "", (0, 0)
    
    def __getFunctionDefinitionFromStart(self):
        """
        Private method to extract the function definition based on the cursor
        being placed on the first line of the definition.
        
        @return text containing the function definition
        @rtype str
        """
        startLine = self.__functionStartLine
        endLine = startLine + min(
            self.editor.lines() - startLine,
            20          # max. 20 lines of definition allowed
        )
        isFirstLine = True
        functionIndent = ""
        functionTextList = []
        
        for lineNo in range(startLine, endLine):
            text = self.editor.text(lineNo).rstrip()
            if isFirstLine:
                if not self.isFunctionStart(text):
                    return None
                
                functionIndent = getIndentStr(text)
                isFirstLine = False
            else:
                currentIndent = getIndentStr(text)
                if (
                    currentIndent <= functionIndent or
                    self.isFunctionStart(text)
                ):
                    # no function body exists
                    return None
                if text.strip() == "":
                    # empty line, illegal/incomplete function definition
                    return None
            
            if text.endswith("\\"):
                text = text[:-1]
            
            functionTextList.append(text)
            
            if text.endswith(":"):
                # end of function definition reached
                functionDefinitionLength = len(functionTextList)
                
                # check, if function is decorated with a supported one
                if startLine > 0:
                    decoratorLine = self.editor.text(startLine - 1)
                    if (
                        "@classmethod" in decoratorLine or
                        "@staticmethod" in decoratorLine or
                        "pyqtSlot" in decoratorLine or          # PyQt slot
                        "Slot" in decoratorLine                 # PySide slot
                    ):
                        functionTextList.insert(0, decoratorLine)
                
                return "".join(functionTextList), functionDefinitionLength
        
        return None
    
    #######################################################################
    ## Methods to generate the docstring when the text cursor is on the
    ## line after the function definition (e.g. after a triple quote).
    #######################################################################
    
    def __generateDocstringFromBelow(self, cursorPosition):
        """
        Private method to generate a docstring when the gicen position is on
        the line below the end of the definition.
        
        @param cursorPosition position of the cursor (line and index)
        @type tuple of (int, int)
        @return tuple containing the docstring and a tuple containing the
            insertion line and index
        @rtype tuple of (str, tuple(int, int))
        """
        functionDefinition = self.__getFunctionDefinitionFromBelow(
            cursorPosition)
        if functionDefinition:
            lineTextToCursor = (
                self.editor.text(cursorPosition[0])[:cursorPosition[1]]
            )
            insertLine = cursorPosition[0]
            indentation = self.__getIndentationInsertString(functionDefinition)
            sep = self.editor.getLineSeparator()
            bodyStart = insertLine
            
            docstringList = self.__generateDocstring(
                '"', functionDefinition, bodyStart
            )
            if docstringList:
                if self.__isTripleQuotesStart(lineTextToCursor):
                    if self.getDocstringType() == "ericdoc":
                        docstringList.insert(0, "")
                    docstringList.append("")
                else:
                    if self.getDocstringType() == "ericdoc":
                        docstringList.insert(0, self.__quote3)
                    else:
                        docstringList[0] = self.__quote3 + docstringList[0]
                    docstringList.append(self.__quote3)
                docstring = (
                    "{0}{1}".format(sep, indentation).join(docstringList)
                )
                return docstring, cursorPosition
        
        return "", (0, 0)
    
    def __getFunctionDefinitionFromBelow(self, cursorPosition):
        """
        Private method to extract the function definition based on the cursor
        being placed on the first line after the definition.
        
        @param cursorPosition current cursor position (line and column)
        @type tuple of (int, int)
        @return text containing the function definition
        @rtype str
        """
        startLine = cursorPosition[0] - 1
        endLine = startLine - min(startLine, 20)
        # max. 20 lines of definition allowed
        isFirstLine = True
        functionTextList = []
        
        for lineNo in range(startLine, endLine, -1):
            text = self.editor.text(lineNo).rstrip()
            if isFirstLine:
                if not text.endswith(":"):
                    return None
                isFirstLine = False
            elif text.endswith(":") or text == "":
                return None
            
            if text.endswith("\\"):
                text = text[:-1]
            
            functionTextList.insert(0, text)
            
            if self.isFunctionStart(text):
                # start of function definition reached
                # check, if function is decorated with a supported one
                if lineNo > 0:
                    decoratorLine = self.editor.text(lineNo - 1)
                    if (
                        "@classmethod" in decoratorLine or
                        "@staticmethod" in decoratorLine or
                        "pyqtSlot" in decoratorLine or          # PyQt slot
                        "Slot" in decoratorLine                 # PySide slot
                    ):
                        functionTextList.insert(0, decoratorLine)
                
                return "".join(functionTextList)
        
        return None
    
    #######################################################################
    ## Methods to generate the docstring contents.
    #######################################################################
    
    def __getFunctionBody(self, functionIndent, startLine):
        """
        Private method to get the function body.
        
        @param functionIndent indentation string of the function definition
        @type str
        @param startLine starting line for the extraction process
        @type int
        @return text containing the function body
        @rtype str
        """
        bodyList = []
        
        for line in range(startLine, self.editor.lines()):
            text = self.editor.text(line)
            textIndent = getIndentStr(text)
            
            if text.strip() == "":
                pass
            elif len(textIndent) <= len(functionIndent):
                break
            
            bodyList.append(text)
        
        return "".join(bodyList)
    
    def __generateDocstring(self, quote, functionDef, bodyStartLine):
        """
        Private method to generate the list of docstring lines.
        
        @param quote quote string
        @type str
        @param functionDef text containing the function definition
        @type str
        @param bodyStartLine starting line of the function body
        @type int
        @return list of docstring lines
        @rtype list of str
        """
        quote3 = 3 * quote
        if quote == '"':
            quote3replace = 3 * "'"
        elif quote == "'":
            quote3replace = 3 * '"'
        functionInfo = PyFunctionInfo()
        functionInfo.parseDefinition(functionDef, quote3, quote3replace)
        
        if functionInfo.hasInfo:
            functionBody = self.__getFunctionBody(functionInfo.functionIndent,
                                                  bodyStartLine)
            
            if functionBody:
                functionInfo.parseBody(functionBody)
            
            docstringType = self.getDocstringType()
            return self._generateDocstringList(functionInfo, docstringType)
        
        return []


class PyFunctionInfo(FunctionInfo):
    """
    Class implementing an object to extract and store function information.
    """
    def __init__(self):
        """
        Constructor
        """
        super(PyFunctionInfo, self).__init__()
    
    def __isCharInPairs(self, posChar, pairs):
        """
        Private method to test, if the given character position is between
        pairs of brackets or quotes.
        
        @param posChar character position to be tested
        @type int
        @param pairs list containing pairs of positions
        @type list of tuple of (int, int)
        @return flag indicating the position is in between
        @rtype bool
        """
        for posLeft, posRight in pairs:
            if posLeft < posChar < posRight:
                return True
        
        return False
    
    def __findQuotePosition(self, text):
        """
        Private method to find the start and end position of pairs of quotes.
        
        @param text text to be parsed
        @type str
        @return list of tuple with start and end position of pairs of quotes
        @rtype list of tuple of (int, int)
        @exception IndexError raised when a matching close quote is missing
        """
        pos = []
        foundLeftQuote = False
        
        for index, character in enumerate(text):
            if foundLeftQuote is False:
                if character == "'" or character == '"':
                    foundLeftQuote = True
                    quote = character
                    leftPos = index
            else:
                if character == quote and text[index - 1] != "\\":
                    pos.append((leftPos, index))
                    foundLeftQuote = False
        
        if foundLeftQuote:
            raise IndexError("No matching close quote at: {0}".format(leftPos))
        
        return pos
    
    def __findBracketPosition(self, text, bracketLeft, bracketRight, posQuote):
        """
        Private method to find the start and end position of pairs of brackets.

        https://stackoverflow.com/questions/29991917/
        indices-of-matching-parentheses-in-python
        
        @param text text to be parsed
        @type str
        @param bracketLeft character of the left bracket
        @type str
        @param bracketRight character of the right bracket
        @type str
        @param posQuote list of tuple with start and end position of pairs
            of quotes
        @type list of tuple of (int, int)
        @return list of tuple with start and end position of pairs of brackets
        @rtype list of tuple of (int, int)
        @exception IndexError raised when a closing or opening bracket is
            missing
        """
        pos = []
        pstack = []
        
        for index, character in enumerate(text):
            if (
                character == bracketLeft and
                not self.__isCharInPairs(index, posQuote)
            ):
                pstack.append(index)
            elif (
                character == bracketRight and
                not self.__isCharInPairs(index, posQuote)
            ):
                if len(pstack) == 0:
                    raise IndexError(
                        "No matching closing parens at: {0}".format(index))
                pos.append((pstack.pop(), index))
        
        if len(pstack) > 0:
            raise IndexError(
                "No matching opening parens at: {0}".format(pstack.pop()))
        
        return pos
    
    def __splitArgumentToNameTypeValue(self, argumentsList,
                                       quote, quoteReplace):
        """
        Private method to split some argument text to name, type and value.
        
        @param argumentsList list of function argument definitions
        @type list of str
        @param quote quote string to be replaced
        @type str
        @param quoteReplace quote string to replace the original
        @type str
        """
        for arg in argumentsList:
            hasType = False
            hasValue = False
            
            colonPosition = arg.find(":")
            equalPosition = arg.find("=")
            
            if equalPosition > -1:
                hasValue = True
            
            if colonPosition > -1:
                if not hasValue:
                    hasType = True
                elif equalPosition > colonPosition:
                    # exception for def foo(arg1=":")
                    hasType = True
            
            if hasValue and hasType:
                argName = arg[0:colonPosition].strip()
                argType = arg[colonPosition + 1:equalPosition].strip()
                argValue = arg[equalPosition + 1:].strip()
            elif not hasValue and hasType:
                argName = arg[0:colonPosition].strip()
                argType = arg[colonPosition + 1:].strip()
                argValue = None
            elif hasValue and not hasType:
                argName = arg[0:equalPosition].strip()
                argType = None
                argValue = arg[equalPosition + 1:].strip()
            else:
                argName = arg.strip()
                argType = None
                argValue = None
            if argValue and quote:
                # sanitize argValue with respect to quotes
                argValue = argValue.replace(quote, quoteReplace)
            
            self.argumentsList.append((argName, argType, argValue))
    
    def __splitArgumentsTextToList(self, argumentsText):
        """
        Private method to split the given arguments text into a list of
        arguments.
        
        This function uses a comma to separate arguments and ignores a comma in
        brackets and quotes.
        
        @param argumentsText text containing the list of arguments
        @type str
        @return list of individual argument texts
        @rtype list of str
        """
        argumentsList = []
        indexFindStart = 0
        indexArgStart = 0
        
        try:
            posQuote = self.__findQuotePosition(argumentsText)
            posRound = self.__findBracketPosition(
                argumentsText, "(", ")", posQuote)
            posCurly = self.__findBracketPosition(
                argumentsText, "{", "}", posQuote)
            posSquare = self.__findBracketPosition(
                argumentsText, "[", "]", posQuote)
        except IndexError:
            return None
        
        while True:
            posComma = argumentsText.find(",", indexFindStart)
            
            if posComma == -1:
                break
            
            indexFindStart = posComma + 1
            
            if (
                self.__isCharInPairs(posComma, posRound) or
                self.__isCharInPairs(posComma, posCurly) or
                self.__isCharInPairs(posComma, posSquare) or
                self.__isCharInPairs(posComma, posQuote)
            ):
                continue
            
            argumentsList.append(argumentsText[indexArgStart:posComma])
            indexArgStart = posComma + 1
        
        if indexArgStart < len(argumentsText):
            argumentsList.append(argumentsText[indexArgStart:])
        
        return argumentsList
    
    def parseDefinition(self, text, quote, quoteReplace):
        """
        Public method to parse the function definition text.
        
        @param text text containing the function definition
        @type str
        @param quote quote string to be replaced
        @type str
        @param quoteReplace quote string to replace the original
        @type str
        """
        self.functionIndent = getIndentStr(text)
        
        textList = text.splitlines()
        if textList[0].lstrip().startswith("@"):
            # first line of function definition is a decorator
            decorator = textList.pop(0).strip()
            if decorator == "@staticmethod":
                self.functionType = "staticmethod"
            elif decorator == "@classmethod":
                self.functionType = "classmethod"
            elif re.match(r"@(PyQt[456]\.)?(QtCore\.)?pyqtSlot", decorator):
                self.functionType = "qtslot"
            elif re.match(r"@(PySide[26]\.)?(QtCore\.)?Slot", decorator):
                self.functionType = "qtslot"
        
        text = "".join(textList).strip()
        
        if text.startswith("async def "):
            self.isAsync = True
        
        returnType = re.search(r"->[ ]*([a-zA-Z0-9_,()\[\] ]*):$", text)
        if returnType:
            self.returnTypeAnnotated = returnType.group(1)
            textEnd = text.rfind(returnType.group(0))
        else:
            self.returnTypeAnnotated = None
            textEnd = len(text)
        
        positionArgumentsStart = text.find("(") + 1
        positionArgumentsEnd = text.rfind(")", positionArgumentsStart,
                                          textEnd)
        
        self.argumentsText = text[positionArgumentsStart:positionArgumentsEnd]
        
        argumentsList = self.__splitArgumentsTextToList(self.argumentsText)
        if argumentsList is not None:
            self.hasInfo = True
            self.__splitArgumentToNameTypeValue(
                argumentsList, quote, quoteReplace)
        
        functionName = (
            text[:positionArgumentsStart - 1]
            .replace("async def ", "")
            .replace("def ", "")
        )
        if functionName == "__init__":
            self.functionType = "constructor"
        elif functionName.startswith("__"):
            if functionName.endswith("__"):
                self.visibility = "special"
            else:
                self.visibility = "private"
        elif functionName.startswith("_"):
            self.visibility = "protected"
        else:
            self.visibility = "public"
    
    def parseBody(self, text):
        """
        Public method to parse the function body text.
        
        @param text function body text
        @type str
        """
        raiseRe = re.findall(r"[ \t]raise ([a-zA-Z0-9_]*)", text)
        if len(raiseRe) > 0:
            self.raiseList = [x.strip() for x in raiseRe]
            # remove duplicates from list while keeping it in the order
            # stackoverflow.com/questions/7961363/removing-duplicates-in-lists
            self.raiseList = list(collections.OrderedDict.fromkeys(
                self.raiseList))

        yieldRe = re.search(r"[ \t]yield ", text)
        if yieldRe:
            self.hasYield = True

        # get return value
        returnPattern = r"return |yield "
        lineList = text.splitlines()
        returnFound = False
        returnTmpLine = ""

        for line in lineList:
            line = line.strip()

            if returnFound is False:
                if re.match(returnPattern, line):
                    returnFound = True

            if returnFound:
                returnTmpLine += line
                # check the integrity of line
                try:
                    quotePos = self.__findQuotePosition(returnTmpLine)

                    if returnTmpLine.endswith("\\"):
                        returnTmpLine = returnTmpLine[:-1]
                        continue

                    self.__findBracketPosition(
                        returnTmpLine, "(", ")", quotePos)
                    self.__findBracketPosition(
                        returnTmpLine, "{", "}", quotePos)
                    self.__findBracketPosition(
                        returnTmpLine, "[", "]", quotePos)
                except IndexError:
                    continue

                returnValue = re.sub(returnPattern, "", returnTmpLine)
                self.returnValueInBody.append(returnValue)

                returnFound = False
                returnTmpLine = ""

eric ide

mercurial