--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/QScintilla/DocstringGenerator/PyDocstringGenerator.py Sat May 15 18:45:04 2021 +0200 @@ -0,0 +1,790 @@ +# -*- 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().__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, newCursorLine = ( + self.__generateDocstringFromStart() + ) + else: + docstring, insertPos, newCursorLine = ( + self.__generateDocstringFromBelow(cursorPosition) + ) + + if docstring: + self.editor.beginUndoAction() + self.editor.insertAt(docstring, *insertPos) + + if not fromStart: + # correct triple quote indentation if neccessary + functionIndent = self.editor.indentation( + self.__functionStartLine) + quoteIndent = self.editor.indentation(insertPos[0]) + + # step 1: unindent quote line until indentation is zero + while quoteIndent > 0: + self.editor.unindent(insertPos[0]) + quoteIndent = self.editor.indentation(insertPos[0]) + + # step 2: indent quote line until indentation is one greater + # than function definition line + while quoteIndent <= functionIndent: + self.editor.indent(insertPos[0]) + quoteIndent = self.editor.indentation(insertPos[0]) + + self.editor.endUndoAction() + self.editor.setCursorPosition( + newCursorLine, len(self.editor.text(newCursorLine)) - 1 + ) + + def insertDocstringFromShortcut(self, cursorPosition): + """ + Public method to insert a docstring for the function at the cursor + position initiated via a keyboard shortcut. + + @param cursorPosition position of the cursor (line and index) + @type tuple of (int, int) + """ + result = self.__getFunctionDefinitionFromBelow(cursorPosition) + if result is not None: + # cursor is on the line after the function definition + cline = cursorPosition[0] - 1 + while not self.isFunctionStart(self.editor.text(cline)): + cline -= 1 + self.__functionStartLine = cline + elif self.isFunctionStart(self.editor.text(cursorPosition[0])): + # cursor is on the start line of the function definition + self.__functionStartLine = cursorPosition[0] + else: + # neither after the function definition nor at the start + # just do nothing + return + + docstring, insertPos, newCursorLine = ( + self.__generateDocstringFromStart() + ) + if docstring: + self.editor.beginUndoAction() + self.editor.insertAt(docstring, *insertPos) + self.editor.endUndoAction() + self.editor.setCursorPosition( + newCursorLine, len(self.editor.text(newCursorLine)) - 1 + ) + + 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) + newCursorLine = insertLine + 1 + else: + docstringList[0] = self.__quote3 + docstringList[0] + newCursorLine = insertLine + docstringList.append(self.__quote3) + return ( + indentation + + "{0}{1}".format(sep, indentation).join(docstringList) + + sep + ), (insertLine, 0), newCursorLine + + return "", (0, 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, "") + newCursorLine = cursorPosition[0] + 1 + else: + newCursorLine = cursorPosition[0] + docstringList.append("") + else: + if self.getDocstringType() == "ericdoc": + docstringList.insert(0, self.__quote3) + newCursorLine = cursorPosition[0] + 1 + else: + docstringList[0] = self.__quote3 + docstringList[0] + newCursorLine = cursorPosition[0] + docstringList.append(self.__quote3) + docstring = ( + "{0}{1}".format(sep, indentation).join(docstringList) + ) + return docstring, cursorPosition, newCursorLine + + return "", (0, 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 + self.__functionStartLine = lineNo + + # 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().__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 + """ + return any(posLeft < posChar < posRight + for (posLeft, posRight) in pairs) + + 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 in ("'", '"'): + 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 and + (not hasValue or 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) or + 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 and + 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 = ""