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

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

51 

52 

53def enumerate_cleaned_signature(doc, max_args=20): 

54 """ 

55 Removes annotation from a signature extracted with 

56 @see fn enumerate_extract_signature. 

57 

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

76 

77 

78class AutoSignatureDirective(Directive): 

79 """ 

80 This directive displays a shorter signature than 

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

82 

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 

95 

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

99 

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 

108 

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 } 

119 

120 has_content = True 

121 autosignature_class = autosignature_node 

122 

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 

128 

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) 

138 

139 if opt_debug: 

140 keep_logged = [] 

141 

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 

147 

148 try: 

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

150 except AttributeError: # pragma: no cover 

151 source = lineno = None 

152 

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

173 

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 

180 

181 # build node 

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

183 source=source, lineno=lineno, 

184 objectname=object_name) 

185 

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 

210 

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 

267 

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" 

280 

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" 

296 

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" 

303 

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] 

312 

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 

340 

341 if signature is not None: 

342 parameters = signature.parameters 

343 else: 

344 parameters = [] # pragma: no cover 

345 

346 if signature is None: 

347 continue # pragma: no cover 

348 

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) 

352 

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" 

364 

365 rows.append(text) 

366 

367 return "\n".join(rows) 

368 

369 def build_summary(self, docstring): 

370 """ 

371 Extracts the part of the docstring before the parameters. 

372 

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 

402 

403 def build_parameters_list(self, parameters, annotation): 

404 """ 

405 Builds the list of parameters. 

406 

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) 

426 

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. 

432 

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) 

445 

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 

470 

471 

472def visit_autosignature_node(self, node): 

473 """ 

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

475 """ 

476 pass 

477 

478 

479def depart_autosignature_node(self, node): 

480 """ 

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

482 """ 

483 pass 

484 

485 

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

499 

500 app.add_directive('autosignature', AutoSignatureDirective) 

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