eric6/QScintilla/DocstringGenerator/PyDocstringGenerator.py

changeset 7998
cd41c844862f
child 8000
47b15df088e4
equal deleted inserted replaced
7997:2ca23396c25c 7998:cd41c844862f
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(PyDocstringGenerator, self).__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 = self.__generateDocstringFromStart()
110 else:
111 docstring, insertPos = self.__generateDocstringFromBelow(
112 cursorPosition)
113
114 if docstring:
115 self.editor.beginUndoAction()
116 self.editor.insertAt(docstring, *insertPos)
117 self.editor.endUndoAction()
118
119 def __getIndentationInsertString(self, text):
120 """
121 Private method to create the indentation string for the docstring.
122
123 @param text text to based the indentation on
124 @type str
125 @return indentation string for docstring
126 @rtype str
127 """
128 indent = getIndentStr(text)
129 indentWidth = self.editor.indentationWidth()
130 if indentWidth == 0:
131 indentWidth = self.editor.tabWidth()
132
133 return indent + indentWidth * " "
134
135 #######################################################################
136 ## Methods to generate the docstring when the text cursor is on the
137 ## line starting the function definition.
138 #######################################################################
139
140 def __generateDocstringFromStart(self):
141 """
142 Private method to generate a docstring based on the cursor being
143 placed on the first line of the definition.
144
145 @return tuple containing the docstring and a tuple containing the
146 insertion line and index
147 @rtype tuple of (str, tuple(int, int))
148 """
149 result = self.__getFunctionDefinitionFromStart()
150 if result:
151 functionDefinition, functionDefinitionLength = result
152
153 insertLine = self.__functionStartLine + functionDefinitionLength
154 indentation = self.__getIndentationInsertString(functionDefinition)
155 sep = self.editor.getLineSeparator()
156 bodyStart = insertLine
157
158 docstringList = self.__generateDocstring(
159 '"', functionDefinition, bodyStart
160 )
161 if docstringList:
162 if self.getDocstringType() == "ericdoc":
163 docstringList.insert(0, self.__quote3)
164 else:
165 docstringList[0] = self.__quote3 + docstringList[0]
166 docstringList.append(self.__quote3)
167 return (
168 indentation +
169 "{0}{1}".format(sep, indentation).join(docstringList) +
170 sep
171 ), (insertLine, 0)
172
173 return "", (0, 0)
174
175 def __getFunctionDefinitionFromStart(self):
176 """
177 Private method to extract the function definition based on the cursor
178 being placed on the first line of the definition.
179
180 @return text containing the function definition
181 @rtype str
182 """
183 startLine = self.__functionStartLine
184 endLine = startLine + min(
185 self.editor.lines() - startLine,
186 20 # max. 20 lines of definition allowed
187 )
188 isFirstLine = True
189 functionIndent = ""
190 functionTextList = []
191
192 for lineNo in range(startLine, endLine):
193 text = self.editor.text(lineNo).rstrip()
194 if isFirstLine:
195 if not self.isFunctionStart(text):
196 return None
197
198 functionIndent = getIndentStr(text)
199 isFirstLine = False
200 else:
201 currentIndent = getIndentStr(text)
202 if (
203 currentIndent <= functionIndent or
204 self.isFunctionStart(text)
205 ):
206 # no function body exists
207 return None
208 if text.strip() == "":
209 # empty line, illegal/incomplete function definition
210 return None
211
212 if text.endswith("\\"):
213 text = text[:-1]
214
215 functionTextList.append(text)
216
217 if text.endswith(":"):
218 # end of function definition reached
219 functionDefinitionLength = len(functionTextList)
220
221 # check, if function is decorated with a supported one
222 if startLine > 0:
223 decoratorLine = self.editor.text(startLine - 1)
224 if (
225 "@classmethod" in decoratorLine or
226 "@staticmethod" in decoratorLine or
227 "pyqtSlot" in decoratorLine or # PyQt slot
228 "Slot" in decoratorLine # PySide slot
229 ):
230 functionTextList.insert(0, decoratorLine)
231
232 return "".join(functionTextList), functionDefinitionLength
233
234 return None
235
236 #######################################################################
237 ## Methods to generate the docstring when the text cursor is on the
238 ## line after the function definition (e.g. after a triple quote).
239 #######################################################################
240
241 def __generateDocstringFromBelow(self, cursorPosition):
242 """
243 Private method to generate a docstring when the gicen position is on
244 the line below the end of the definition.
245
246 @param cursorPosition position of the cursor (line and index)
247 @type tuple of (int, int)
248 @return tuple containing the docstring and a tuple containing the
249 insertion line and index
250 @rtype tuple of (str, tuple(int, int))
251 """
252 functionDefinition = self.__getFunctionDefinitionFromBelow(
253 cursorPosition)
254 if functionDefinition:
255 lineTextToCursor = (
256 self.editor.text(cursorPosition[0])[:cursorPosition[1]]
257 )
258 insertLine = cursorPosition[0]
259 indentation = self.__getIndentationInsertString(functionDefinition)
260 sep = self.editor.getLineSeparator()
261 bodyStart = insertLine
262
263 docstringList = self.__generateDocstring(
264 '"', functionDefinition, bodyStart
265 )
266 if docstringList:
267 if self.__isTripleQuotesStart(lineTextToCursor):
268 if self.getDocstringType() == "ericdoc":
269 docstringList.insert(0, "")
270 docstringList.append("")
271 else:
272 if self.getDocstringType() == "ericdoc":
273 docstringList.insert(0, self.__quote3)
274 else:
275 docstringList[0] = self.__quote3 + docstringList[0]
276 docstringList.append(self.__quote3)
277 docstring = (
278 "{0}{1}".format(sep, indentation).join(docstringList)
279 )
280 return docstring, cursorPosition
281
282 return "", (0, 0)
283
284 def __getFunctionDefinitionFromBelow(self, cursorPosition):
285 """
286 Private method to extract the function definition based on the cursor
287 being placed on the first line after the definition.
288
289 @param cursorPosition current cursor position (line and column)
290 @type tuple of (int, int)
291 @return text containing the function definition
292 @rtype str
293 """
294 startLine = cursorPosition[0] - 1
295 endLine = startLine - min(startLine, 20)
296 # max. 20 lines of definition allowed
297 isFirstLine = True
298 functionTextList = []
299
300 for lineNo in range(startLine, endLine, -1):
301 text = self.editor.text(lineNo).rstrip()
302 if isFirstLine:
303 if not text.endswith(":"):
304 return None
305 isFirstLine = False
306 elif text.endswith(":") or text == "":
307 return None
308
309 if text.endswith("\\"):
310 text = text[:-1]
311
312 functionTextList.insert(0, text)
313
314 if self.isFunctionStart(text):
315 # start of function definition reached
316 # check, if function is decorated with a supported one
317 if lineNo > 0:
318 decoratorLine = self.editor.text(lineNo - 1)
319 if (
320 "@classmethod" in decoratorLine or
321 "@staticmethod" in decoratorLine or
322 "pyqtSlot" in decoratorLine or # PyQt slot
323 "Slot" in decoratorLine # PySide slot
324 ):
325 functionTextList.insert(0, decoratorLine)
326
327 return "".join(functionTextList)
328
329 return None
330
331 #######################################################################
332 ## Methods to generate the docstring contents.
333 #######################################################################
334
335 def __getFunctionBody(self, functionIndent, startLine):
336 """
337 Private method to get the function body.
338
339 @param functionIndent indentation string of the function definition
340 @type str
341 @param startLine starting line for the extraction process
342 @type int
343 @return text containing the function body
344 @rtype str
345 """
346 bodyList = []
347
348 for line in range(startLine, self.editor.lines()):
349 text = self.editor.text(line)
350 textIndent = getIndentStr(text)
351
352 if text.strip() == "":
353 pass
354 elif len(textIndent) <= len(functionIndent):
355 break
356
357 bodyList.append(text)
358
359 return "".join(bodyList)
360
361 def __generateDocstring(self, quote, functionDef, bodyStartLine):
362 """
363 Private method to generate the list of docstring lines.
364
365 @param quote quote string
366 @type str
367 @param functionDef text containing the function definition
368 @type str
369 @param bodyStartLine starting line of the function body
370 @type int
371 @return list of docstring lines
372 @rtype list of str
373 """
374 quote3 = 3 * quote
375 if quote == '"':
376 quote3replace = 3 * "'"
377 elif quote == "'":
378 quote3replace = 3 * '"'
379 functionInfo = PyFunctionInfo()
380 functionInfo.parseDefinition(functionDef, quote3, quote3replace)
381
382 if functionInfo.hasInfo:
383 functionBody = self.__getFunctionBody(functionInfo.functionIndent,
384 bodyStartLine)
385
386 if functionBody:
387 functionInfo.parseBody(functionBody)
388
389 docstringType = self.getDocstringType()
390 return self._generateDocstringList(functionInfo, docstringType)
391
392 return []
393
394
395 class PyFunctionInfo(FunctionInfo):
396 """
397 Class implementing an object to extract and store function information.
398 """
399 def __init__(self):
400 """
401 Constructor
402 """
403 super(PyFunctionInfo, self).__init__()
404
405 def __isCharInPairs(self, posChar, pairs):
406 """
407 Private method to test, if the given character position is between
408 pairs of brackets or quotes.
409
410 @param posChar character position to be tested
411 @type int
412 @param pairs list containing pairs of positions
413 @type list of tuple of (int, int)
414 @return flag indicating the position is in between
415 @rtype bool
416 """
417 for posLeft, posRight in pairs:
418 if posLeft < posChar < posRight:
419 return True
420
421 return False
422
423 def __findQuotePosition(self, text):
424 """
425 Private method to find the start and end position of pairs of quotes.
426
427 @param text text to be parsed
428 @type str
429 @return list of tuple with start and end position of pairs of quotes
430 @rtype list of tuple of (int, int)
431 @exception IndexError raised when a matching close quote is missing
432 """
433 pos = []
434 foundLeftQuote = False
435
436 for index, character in enumerate(text):
437 if foundLeftQuote is False:
438 if character == "'" or character == '"':
439 foundLeftQuote = True
440 quote = character
441 leftPos = index
442 else:
443 if character == quote and text[index - 1] != "\\":
444 pos.append((leftPos, index))
445 foundLeftQuote = False
446
447 if foundLeftQuote:
448 raise IndexError("No matching close quote at: {0}".format(leftPos))
449
450 return pos
451
452 def __findBracketPosition(self, text, bracketLeft, bracketRight, posQuote):
453 """
454 Private method to find the start and end position of pairs of brackets.
455
456 https://stackoverflow.com/questions/29991917/
457 indices-of-matching-parentheses-in-python
458
459 @param text text to be parsed
460 @type str
461 @param bracketLeft character of the left bracket
462 @type str
463 @param bracketRight character of the right bracket
464 @type str
465 @param posQuote list of tuple with start and end position of pairs
466 of quotes
467 @type list of tuple of (int, int)
468 @return list of tuple with start and end position of pairs of brackets
469 @rtype list of tuple of (int, int)
470 @exception IndexError raised when a closing or opening bracket is
471 missing
472 """
473 pos = []
474 pstack = []
475
476 for index, character in enumerate(text):
477 if (
478 character == bracketLeft and
479 not self.__isCharInPairs(index, posQuote)
480 ):
481 pstack.append(index)
482 elif (
483 character == bracketRight and
484 not self.__isCharInPairs(index, posQuote)
485 ):
486 if len(pstack) == 0:
487 raise IndexError(
488 "No matching closing parens at: {0}".format(index))
489 pos.append((pstack.pop(), index))
490
491 if len(pstack) > 0:
492 raise IndexError(
493 "No matching opening parens at: {0}".format(pstack.pop()))
494
495 return pos
496
497 def __splitArgumentToNameTypeValue(self, argumentsList,
498 quote, quoteReplace):
499 """
500 Private method to split some argument text to name, type and value.
501
502 @param argumentsList list of function argument definitions
503 @type list of str
504 @param quote quote string to be replaced
505 @type str
506 @param quoteReplace quote string to replace the original
507 @type str
508 """
509 for arg in argumentsList:
510 hasType = False
511 hasValue = False
512
513 colonPosition = arg.find(":")
514 equalPosition = arg.find("=")
515
516 if equalPosition > -1:
517 hasValue = True
518
519 if colonPosition > -1:
520 if not hasValue:
521 hasType = True
522 elif equalPosition > colonPosition:
523 # exception for def foo(arg1=":")
524 hasType = True
525
526 if hasValue and hasType:
527 argName = arg[0:colonPosition].strip()
528 argType = arg[colonPosition + 1:equalPosition].strip()
529 argValue = arg[equalPosition + 1:].strip()
530 elif not hasValue and hasType:
531 argName = arg[0:colonPosition].strip()
532 argType = arg[colonPosition + 1:].strip()
533 argValue = None
534 elif hasValue and not hasType:
535 argName = arg[0:equalPosition].strip()
536 argType = None
537 argValue = arg[equalPosition + 1:].strip()
538 else:
539 argName = arg.strip()
540 argType = None
541 argValue = None
542 if argValue and quote:
543 # sanitize argValue with respect to quotes
544 argValue = argValue.replace(quote, quoteReplace)
545
546 self.argumentsList.append((argName, argType, argValue))
547
548 def __splitArgumentsTextToList(self, argumentsText):
549 """
550 Private method to split the given arguments text into a list of
551 arguments.
552
553 This function uses a comma to separate arguments and ignores a comma in
554 brackets and quotes.
555
556 @param argumentsText text containing the list of arguments
557 @type str
558 @return list of individual argument texts
559 @rtype list of str
560 """
561 argumentsList = []
562 indexFindStart = 0
563 indexArgStart = 0
564
565 try:
566 posQuote = self.__findQuotePosition(argumentsText)
567 posRound = self.__findBracketPosition(
568 argumentsText, "(", ")", posQuote)
569 posCurly = self.__findBracketPosition(
570 argumentsText, "{", "}", posQuote)
571 posSquare = self.__findBracketPosition(
572 argumentsText, "[", "]", posQuote)
573 except IndexError:
574 return None
575
576 while True:
577 posComma = argumentsText.find(",", indexFindStart)
578
579 if posComma == -1:
580 break
581
582 indexFindStart = posComma + 1
583
584 if (
585 self.__isCharInPairs(posComma, posRound) or
586 self.__isCharInPairs(posComma, posCurly) or
587 self.__isCharInPairs(posComma, posSquare) or
588 self.__isCharInPairs(posComma, posQuote)
589 ):
590 continue
591
592 argumentsList.append(argumentsText[indexArgStart:posComma])
593 indexArgStart = posComma + 1
594
595 if indexArgStart < len(argumentsText):
596 argumentsList.append(argumentsText[indexArgStart:])
597
598 return argumentsList
599
600 def parseDefinition(self, text, quote, quoteReplace):
601 """
602 Public method to parse the function definition text.
603
604 @param text text containing the function definition
605 @type str
606 @param quote quote string to be replaced
607 @type str
608 @param quoteReplace quote string to replace the original
609 @type str
610 """
611 self.functionIndent = getIndentStr(text)
612
613 textList = text.splitlines()
614 if textList[0].lstrip().startswith("@"):
615 # first line of function definition is a decorator
616 decorator = textList.pop(0).strip()
617 if decorator == "@staticmethod":
618 self.functionType = "staticmethod"
619 elif decorator == "@classmethod":
620 self.functionType = "classmethod"
621 elif re.match(r"@(PyQt[456]\.)?(QtCore\.)?pyqtSlot", decorator):
622 self.functionType = "qtslot"
623 elif re.match(r"@(PySide[26]\.)?(QtCore\.)?Slot", decorator):
624 self.functionType = "qtslot"
625
626 text = "".join(textList).strip()
627
628 if text.startswith("async def "):
629 self.isAsync = True
630
631 returnType = re.search(r"->[ ]*([a-zA-Z0-9_,()\[\] ]*):$", text)
632 if returnType:
633 self.returnTypeAnnotated = returnType.group(1)
634 textEnd = text.rfind(returnType.group(0))
635 else:
636 self.returnTypeAnnotated = None
637 textEnd = len(text)
638
639 positionArgumentsStart = text.find("(") + 1
640 positionArgumentsEnd = text.rfind(")", positionArgumentsStart,
641 textEnd)
642
643 self.argumentsText = text[positionArgumentsStart:positionArgumentsEnd]
644
645 argumentsList = self.__splitArgumentsTextToList(self.argumentsText)
646 if argumentsList is not None:
647 self.hasInfo = True
648 self.__splitArgumentToNameTypeValue(
649 argumentsList, quote, quoteReplace)
650
651 functionName = (
652 text[:positionArgumentsStart - 1]
653 .replace("async def ", "")
654 .replace("def ", "")
655 )
656 if functionName == "__init__":
657 self.functionType = "constructor"
658 elif functionName.startswith("__"):
659 if functionName.endswith("__"):
660 self.visibility = "special"
661 else:
662 self.visibility = "private"
663 elif functionName.startswith("_"):
664 self.visibility = "protected"
665 else:
666 self.visibility = "public"
667
668 def parseBody(self, text):
669 """
670 Public method to parse the function body text.
671
672 @param text function body text
673 @type str
674 """
675 raiseRe = re.findall(r"[ \t]raise ([a-zA-Z0-9_]*)", text)
676 if len(raiseRe) > 0:
677 self.raiseList = [x.strip() for x in raiseRe]
678 # remove duplicates from list while keeping it in the order
679 # stackoverflow.com/questions/7961363/removing-duplicates-in-lists
680 self.raiseList = list(collections.OrderedDict.fromkeys(
681 self.raiseList))
682
683 yieldRe = re.search(r"[ \t]yield ", text)
684 if yieldRe:
685 self.hasYield = True
686
687 # get return value
688 returnPattern = r"return |yield "
689 lineList = text.splitlines()
690 returnFound = False
691 returnTmpLine = ""
692
693 for line in lineList:
694 line = line.strip()
695
696 if returnFound is False:
697 if re.match(returnPattern, line):
698 returnFound = True
699
700 if returnFound:
701 returnTmpLine += line
702 # check the integrity of line
703 try:
704 quotePos = self.__findQuotePosition(returnTmpLine)
705
706 if returnTmpLine.endswith("\\"):
707 returnTmpLine = returnTmpLine[:-1]
708 continue
709
710 self.__findBracketPosition(
711 returnTmpLine, "(", ")", quotePos)
712 self.__findBracketPosition(
713 returnTmpLine, "{", "}", quotePos)
714 self.__findBracketPosition(
715 returnTmpLine, "[", "]", quotePos)
716 except IndexError:
717 continue
718
719 returnValue = re.sub(returnPattern, "", returnTmpLine)
720 self.returnValueInBody.append(returnValue)
721
722 returnFound = False
723 returnTmpLine = ""

eric ide

mercurial