Hide keyboard shortcuts

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 

18 

19 

20class autosignature_node(nodes.Structural, nodes.Element): 

21 

22 """ 

23 Defines *autosignature* node. 

24 """ 

25 pass 

26 

27 

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*. 

32 

33 @param doc text to parse 

34 @param max_args maximum number of parameters 

35 @return iterator of found signatures 

36 

37 :: 

38 

39 __init__(self: cpyquickhelper.numbers.weighted_number.WeightedDouble, 

40 value: float, weight: float=1.0) -> None 

41 

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 

52 

53 

54def enumerate_cleaned_signature(doc, max_args=20): 

55 """ 

56 Removes annotation from a signature extracted with 

57 @see fn enumerate_extract_signature. 

58 

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)) 

77 

78 

79class AutoSignatureDirective(Directive): 

80 """ 

81 This directive displays a shorter signature than 

82 :epkg:`sphinx.ext.autodoc`. Available options: 

83 

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 

96 

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>`_. 

100 

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 

109 

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 } 

120 

121 has_content = True 

122 autosignature_class = autosignature_node 

123 

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 

129 

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) 

139 

140 if opt_debug: 

141 keep_logged = [] 

142 

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 

148 

149 try: 

150 source, lineno = self.reporter.get_source_and_line(self.lineno) 

151 except AttributeError: # pragma: no cover 

152 source = lineno = None 

153 

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:] 

176 

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 

183 

184 # build node 

185 node = self.__class__.autosignature_class(rawsource=object_name, 

186 source=source, lineno=lineno, 

187 objectname=object_name) 

188 

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 

214 

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 

271 

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) 

286 

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" 

303 

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" 

310 

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] 

319 

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 

347 

348 if signature is not None: 

349 parameters = signature.parameters 

350 else: 

351 parameters = [] # pragma: no cover 

352 

353 if signature is None: 

354 continue # pragma: no cover 

355 

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) 

359 

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" 

371 

372 rows.append(text) 

373 

374 return "\n".join(rows) 

375 

376 def build_summary(self, docstring): 

377 """ 

378 Extracts the part of the docstring before the parameters. 

379 

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 

409 

410 def build_parameters_list(self, parameters, annotation): 

411 """ 

412 Builds the list of parameters. 

413 

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) 

433 

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. 

439 

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) 

452 

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 

477 

478 

479def visit_autosignature_node(self, node): 

480 """ 

481 What to do when visiting a node @see cl autosignature_node. 

482 """ 

483 pass 

484 

485 

486def depart_autosignature_node(self, node): 

487 """ 

488 What to do when leaving a node @see cl autosignature_node. 

489 """ 

490 pass 

491 

492 

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)) 

506 

507 app.add_directive('autosignature', AutoSignatureDirective) 

508 return {'version': sphinx.__display_version__, 'parallel_read_safe': True}