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