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