Sun, 26 Jun 2022 17:21:24 +0200
Removed an obsolete comment.
# -*- coding: utf-8 -*- # Copyright (c) 2021 - 2022 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 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 = ""