Coverage for pyquickhelper/sphinxext/sphinx_autosignature.py: 98%
225 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-03 02:21 +0200
1# -*- coding: utf-8 -*-
2"""
3@file
4@brief Defines a :epkg:`sphinx` extension to describe a function,
5inspired from `autofunction <https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/autodoc/__init__.py#L1082>`_
6and `AutoDirective <https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/autodoc/__init__.py#L1480>`_.
7"""
8import inspect
9import re
10import sys
11import sphinx
12from docutils import nodes
13from docutils.parsers.rst import Directive, directives
14from docutils.statemachine import StringList
15from sphinx.util.nodes import nested_parse_with_titles
16from sphinx.util import logging
17from .import_object_helper import import_any_object, import_path
20class autosignature_node(nodes.Structural, nodes.Element):
22 """
23 Defines *autosignature* node.
24 """
25 pass
28def enumerate_extract_signature(doc, max_args=20):
29 """
30 Looks for substring like the following and clean the signature
31 to be able to use function *_signature_fromstr*.
33 @param doc text to parse
34 @param max_args maximum number of parameters
35 @return iterator of found signatures
37 ::
39 __init__(self: cpyquickhelper.numbers.weighted_number.WeightedDouble,
40 value: float, weight: float=1.0) -> None
42 It is limited to 20 parameters.
43 """
44 el = "((?P<p%d>[*a-zA-Z_][*a-zA-Z_0-9]*) *(?P<a%d>: *[a-zA-Z_][\\[\\]0-9a-zA-Z_.]+)? *(?P<d%d>= *[^ ]+?)?)"
45 els = [el % (i, i, i) for i in range(0, max_args)]
46 par = els[0] + "?" + "".join(["( *, *" + e + ")?" for e in els[1:]])
47 exp = f"(?P<name>[a-zA-Z_][0-9a-zA-Z_]*) *[(] *(?P<sig>{par}) *[)]"
48 reg = re.compile(exp)
49 for func in reg.finditer(doc.replace("\n", " ")):
50 yield func
53def enumerate_cleaned_signature(doc, max_args=20):
54 """
55 Removes annotation from a signature extracted with
56 @see fn enumerate_extract_signature.
58 @param doc text to parse
59 @param max_args maximum number of parameters
60 @return iterator of found signatures
61 """
62 for sig in enumerate_extract_signature(doc, max_args=max_args):
63 dic = sig.groupdict()
64 name = sig["name"]
65 args = []
66 for i in range(0, max_args):
67 p = dic.get('p%d' % i, None)
68 if p is None:
69 break
70 d = dic.get('d%d' % i, None)
71 if d is None:
72 args.append(p)
73 else:
74 args.append(f"{p}{d}")
75 yield f"{name}({', '.join(args)})"
78class AutoSignatureDirective(Directive):
79 """
80 This directive displays a shorter signature than
81 :epkg:`sphinx.ext.autodoc`. Available options:
83 * *nosummary*: do not display a summary (shorten)
84 * *annotation*: shows annotation
85 * *nolink*: if False, add a link to a full documentation (produced by
86 :epkg:`sphinx.ext.autodoc`)
87 * *members*: shows members of a class
88 * *path*: three options, *full* displays the full path including
89 submodules, *name* displays the last name,
90 *import* displays the shortest syntax to import it
91 (default).
92 * *debug*: diplays debug information
93 * *syspath*: additional paths to add to ``sys.path`` before importing,
94 ';' separated list
96 The signature is not always available for builtin functions
97 or :epkg:`C++` functions depending on the way to bind them to :epkg:`Python`.
98 See `Set the __text_signature__ attribute of callables <https://github.com/pybind/pybind11/issues/945>`_.
100 The signature may not be infered by module ``inspect``
101 if the function is a compiled C function. In that case,
102 the signature must be added to the documentation. It will
103 parsed by *autosignature* with by function
104 @see fn enumerate_extract_signature with regular expressions.
105 """
106 required_arguments = 0
107 optional_arguments = 0
109 final_argument_whitespace = True
110 option_spec = {
111 'nosummary': directives.unchanged,
112 'annotation': directives.unchanged,
113 'nolink': directives.unchanged,
114 'members': directives.unchanged,
115 'path': directives.unchanged,
116 'debug': directives.unchanged,
117 'syspath': directives.unchanged,
118 }
120 has_content = True
121 autosignature_class = autosignature_node
123 def run(self):
124 self.filename_set = set()
125 # a set of dependent filenames
126 self.reporter = self.state.document.reporter
127 self.env = self.state.document.settings.env
129 opt_summary = 'nosummary' not in self.options
130 opt_annotation = 'annotation' in self.options
131 opt_link = 'nolink' not in self.options
132 opt_members = self.options.get('members', None)
133 opt_debug = 'debug' in self.options
134 if opt_members in (None, '') and 'members' in self.options:
135 opt_members = "all"
136 opt_path = self.options.get('path', 'import')
137 opt_syspath = self.options.get('syspath', None)
139 if opt_debug:
140 keep_logged = []
142 def keep_logging(*els):
143 keep_logged.append(" ".join(str(_) for _ in els))
144 logging_function = keep_logging
145 else:
146 logging_function = None
148 try:
149 source, lineno = self.reporter.get_source_and_line(self.lineno)
150 except AttributeError: # pragma: no cover
151 source = lineno = None
153 # object name
154 object_name = " ".join(_.strip("\n\r\t ") for _ in self.content)
155 if opt_syspath:
156 syslength = len(sys.path)
157 sys.path.extend(opt_syspath.split(';'))
158 try:
159 obj, _, kind = import_any_object(
160 object_name, use_init=False, fLOG=logging_function)
161 except ImportError as e:
162 mes = f"[autosignature] unable to import '{object_name}' due to '{e}'"
163 logger = logging.getLogger("autosignature")
164 logger.warning(mes)
165 if logging_function:
166 logging_function(mes) # pragma: no cover
167 if lineno is not None:
168 logger.warning(' File "%s", line %r', source, lineno)
169 obj = None
170 kind = None
171 if opt_syspath:
172 del sys.path[syslength:]
174 if opt_members is not None and kind != "class": # pragma: no cover
175 logger = logging.getLogger("autosignature")
176 logger.warning(
177 "[autosignature] option members is specified but %r "
178 "is not a class (kind=%r).", object_name, kind)
179 obj = None
181 # build node
182 node = self.__class__.autosignature_class(rawsource=object_name,
183 source=source, lineno=lineno,
184 objectname=object_name)
186 if opt_path == 'import':
187 if obj is None:
188 logger = logging.getLogger("autosignature")
189 logger.warning("[autosignature] object %r cannot be imported.",
190 object_name)
191 anchor = object_name
192 elif kind == "staticmethod":
193 cl, fu = object_name.split(".")[-2:]
194 pimp = import_path(obj, class_name=cl, fLOG=logging_function)
195 anchor = f'{pimp}.{cl}.{fu}'
196 else:
197 pimp = import_path(
198 obj, err_msg=f"object name: '{object_name}'")
199 anchor = f"{pimp}.{object_name.rsplit('.', maxsplit=1)[-1]}"
200 elif opt_path == 'full':
201 anchor = object_name
202 elif opt_path == 'name':
203 anchor = object_name.rsplit('.', maxsplit=1)[-1]
204 else: # pragma: no cover
205 logger = logging.getLogger("autosignature")
206 logger.warning(
207 "[autosignature] options path is %r, it should be in "
208 "(import, name, full) for object %r.", opt_path, object_name)
209 anchor = object_name
211 if obj is None:
212 if opt_link:
213 text = f"\n:py:func:`{anchor} <{object_name}>`\n\n"
214 else:
215 text = f"\n``{anchor}``\n\n" # pragma: no cover
216 else:
217 obj_sig = obj.__init__ if kind == "class" else obj
218 try:
219 signature = inspect.signature(obj_sig)
220 parameters = signature.parameters
221 except TypeError as e: # pragma: no cover
222 mes = "[autosignature](1) unable to get signature of '{0}' - {1}.".format(
223 object_name, str(e).replace("\n", "\\n"))
224 logger = logging.getLogger("autosignature")
225 logger.warning(mes)
226 if logging_function:
227 logging_function(mes)
228 signature = None
229 parameters = None
230 except ValueError as e: # pragma: no cover
231 # Backup plan, no __text_signature__, this happen
232 # when a function was created with pybind11.
233 doc = obj_sig.__doc__
234 sigs = set(enumerate_cleaned_signature(doc))
235 if len(sigs) == 0:
236 mes = "[autosignature](2) unable to get signature of '{0}' - {1}.".format(
237 object_name, str(e).replace("\n", "\\n"))
238 logger = logging.getLogger("autosignature")
239 logger.warning(mes)
240 if logging_function:
241 logging_function(mes)
242 signature = None
243 parameters = None
244 elif len(sigs) > 1:
245 mes = "[autosignature](2) too many signatures for '{0}' - {1} - {2}.".format(
246 object_name, str(e).replace("\n", "\\n"), " *** ".join(sigs))
247 logger = logging.getLogger("autosignature")
248 logger.warning(mes)
249 if logging_function:
250 logging_function(mes)
251 signature = None
252 parameters = None
253 else:
254 try:
255 signature = inspect._signature_fromstr(
256 inspect.Signature, obj_sig, list(sigs)[0])
257 parameters = signature.parameters
258 except TypeError as ee:
259 mes = "[autosignature](3) unable to get signature of '{0}' - {1}.".format(
260 object_name, str(ee).replace("\n", "\\n"))
261 logger = logging.getLogger("autosignature")
262 logger.warning(mes)
263 if logging_function:
264 logging_function(mes)
265 signature = None
266 parameters = None
268 domkind = {'meth': 'func', 'function': 'func', 'method': 'meth',
269 'class': 'class', 'staticmethod': 'meth',
270 'property': 'meth'}[kind]
271 if signature is None:
272 if opt_link: # pragma: no cover
273 text = f"\n:py:{domkind}:`{anchor} <{object_name}>`\n\n"
274 else: # pragma: no cover
275 text = f"\n``{kind} {object_name}``\n\n"
276 else:
277 signature = self.build_parameters_list(
278 parameters, opt_annotation)
279 text = f"\n:py:{domkind}:`{anchor} <{object_name}>` ({signature})\n\n"
281 if obj is not None and opt_summary:
282 # Documentation.
283 doc = obj.__doc__ # if kind != "class" else obj.__class__.__doc__
284 if doc is None: # pragma: no cover
285 mes = f"[autosignature] docstring empty for '{object_name}'."
286 logger = logging.getLogger("autosignature")
287 logger.warning(mes)
288 if logging_function:
289 logging_function(mes)
290 else:
291 if "type(object_or_name, bases, dict)" in doc:
292 raise TypeError( # pragma: no cover
293 f"issue with {obj}\n{doc}")
294 docstring = self.build_summary(doc)
295 text += docstring + "\n\n"
297 if opt_members is not None and kind == "class":
298 docstring = self.build_members(obj, opt_members, object_name,
299 opt_annotation, opt_summary)
300 docstring = "\n".join(
301 map(lambda s: " " + s, docstring.split("\n")))
302 text += docstring + "\n\n"
304 text_lines = text.split("\n")
305 if logging_function:
306 text_lines.extend([' ::', '', ' [debug]', ''])
307 text_lines.extend(' ' + li for li in keep_logged)
308 text_lines.append('')
309 st = StringList(text_lines)
310 nested_parse_with_titles(self.state, st, node)
311 return [node]
313 def build_members(self, obj, members, object_name, annotation, summary):
314 """
315 Extracts methods of a class and document them.
316 """
317 if members != "all":
318 members = {_.strip() for _ in members.split(",")}
319 else:
320 members = None
321 rows = []
322 cl = obj
323 methods = inspect.getmembers(cl)
324 for name, value in methods:
325 if name[0] == "_" or (members is not None and name not in members):
326 continue
327 if name not in cl.__dict__:
328 # Not a method of this class.
329 continue # pragma: no cover
330 try:
331 signature = inspect.signature(value)
332 except TypeError as e: # pragma: no cover
333 logger = logging.getLogger("autosignature")
334 logger.warning(
335 "[autosignature](2) unable to get signature of "
336 "'%s.%s - %s'.", object_name, name, str(e).replace("\n", "\\n"))
337 signature = None
338 except ValueError: # pragma: no cover
339 signature = None
341 if signature is not None:
342 parameters = signature.parameters
343 else:
344 parameters = [] # pragma: no cover
346 if signature is None:
347 continue # pragma: no cover
349 signature = self.build_parameters_list(parameters, annotation)
350 text = "\n:py:meth:`{0} <{1}.{0}>` ({2})\n\n".format(
351 name, object_name, signature)
353 if value is not None and summary:
354 doc = value.__doc__
355 if doc is None: # pragma: no cover
356 logger = logging.getLogger("autosignature")
357 logger.warning("[autosignature] docstring empty for '%s.%s'.",
358 object_name, name)
359 else:
360 docstring = self.build_summary(doc)
361 lines = "\n".join(
362 map(lambda s: " " + s, docstring.split("\n")))
363 text += "\n" + lines + "\n\n"
365 rows.append(text)
367 return "\n".join(rows)
369 def build_summary(self, docstring):
370 """
371 Extracts the part of the docstring before the parameters.
373 @param docstring document string
374 @return string
375 """
376 lines = docstring.split("\n")
377 keep = []
378 for line in lines:
379 sline = line.strip(" \r\t")
380 if sline.startswith(":param") or sline.startswith("@param"):
381 break
382 if sline.startswith("Parameters"):
383 break
384 if sline.startswith(":returns:") or sline.startswith(":return:"):
385 break # pragma: no cover
386 if sline.startswith(":rtype:") or sline.startswith(":raises:"):
387 break # pragma: no cover
388 if sline.startswith(".. ") and "::" in sline:
389 break
390 if sline == "::":
391 break # pragma: no cover
392 if sline.startswith(":githublink:"):
393 break # pragma: no cover
394 if sline.startswith("@warning") or sline.startswith(".. warning::"):
395 break # pragma: no cover
396 keep.append(line)
397 res = "\n".join(keep).rstrip("\n\r\t ")
398 if res.endswith(":"):
399 res = res[:-1] + "..." # pragma: no cover
400 res = AutoSignatureDirective.reformat(res)
401 return res
403 def build_parameters_list(self, parameters, annotation):
404 """
405 Builds the list of parameters.
407 @param parameters list of `Parameters <https://docs.python.org/3/library/inspect.html#inspect.Parameter>`_
408 @param annotation add annotation
409 @return string (RST format)
410 """
411 pieces = []
412 for name, value in parameters.items():
413 if len(pieces) > 0:
414 pieces.append(", ")
415 pieces.append(f"*{name}*")
416 if annotation and value.annotation is not inspect._empty:
417 pieces.append(f":{value.annotation}")
418 if value.default is not inspect._empty:
419 pieces.append(" = ")
420 if isinstance(value.default, str):
421 de = "'{0}'".format(value.default.replace("'", "\\'"))
422 else:
423 de = str(value.default)
424 pieces.append(f"`{de}`")
425 return "".join(pieces)
427 @staticmethod
428 def reformat(text, indent=4):
429 """
430 Formats the number of spaces in front every line
431 to be equal to a specific value.
433 @param text text to analyse
434 @param indent specify the expected indentation for the result
435 @return number
436 """
437 mins = None
438 spl = text.split("\n")
439 for line in spl:
440 wh = line.strip("\r\t ")
441 if len(wh) > 0:
442 wh = line.lstrip(" \t")
443 m = len(line) - len(wh)
444 mins = m if mins is None else min(mins, m)
446 if mins is None:
447 return text
448 dec = indent - mins
449 if dec > 0:
450 res = []
451 ins = " " * dec
452 for line in spl:
453 wh = line.strip("\r\t ")
454 if len(wh) > 0:
455 res.append(ins + line)
456 else:
457 res.append(wh)
458 text = "\n".join(res)
459 elif dec < 0:
460 res = []
461 dec = -dec
462 for line in spl:
463 wh = line.strip("\r\t ")
464 if len(wh) > 0:
465 res.append(line[dec:])
466 else:
467 res.append(wh)
468 text = "\n".join(res)
469 return text
472def visit_autosignature_node(self, node):
473 """
474 What to do when visiting a node @see cl autosignature_node.
475 """
476 pass
479def depart_autosignature_node(self, node):
480 """
481 What to do when leaving a node @see cl autosignature_node.
482 """
483 pass
486def setup(app):
487 """
488 Create a new directive called *autosignature* which
489 displays the signature of the function.
490 """
491 app.add_node(autosignature_node,
492 html=(visit_autosignature_node, depart_autosignature_node),
493 epub=(visit_autosignature_node, depart_autosignature_node),
494 latex=(visit_autosignature_node, depart_autosignature_node),
495 elatex=(visit_autosignature_node, depart_autosignature_node),
496 text=(visit_autosignature_node, depart_autosignature_node),
497 md=(visit_autosignature_node, depart_autosignature_node),
498 rst=(visit_autosignature_node, depart_autosignature_node))
500 app.add_directive('autosignature', AutoSignatureDirective)
501 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}