eric7/QScintilla/DocstringGenerator/PyDocstringGenerator.py

branch
eric7
changeset 8312
800c432b34c8
parent 8229
6fa22aa4fc4a
child 8881
54e42bc2437a
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a docstring generator for Python.
8 """
9
10 import re
11 import collections
12
13 from .BaseDocstringGenerator import (
14 BaseDocstringGenerator, FunctionInfo, getIndentStr
15 )
16
17
18 class 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
462 class 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 = ""

eric ide

mercurial