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

2@file 

3@brief Automate the creation of a parser based on a function. 

4""" 

5from __future__ import print_function 

6import argparse 

7import inspect 

8import re 

9from fire.docstrings import parse 

10 

11 

12def clean_documentation_for_cli(doc, cleandoc): 

13 """ 

14 Cleans the documentation before integrating 

15 into a command line documentation. 

16 

17 @param doc documentation 

18 @param cleandoc a string which tells how to clean, 

19 or a function which takes a function and 

20 returns a string 

21 

22 The function removes everything after ``.. cmdref::`` and ``.. cmdreflist`` 

23 as it creates an infinite loop of processus if this command 

24 is part of the documentation of the command line itself. 

25 """ 

26 for st in ('.. versionchanged::', '.. versionadded::', 

27 '.. cmdref::', '.. cmdreflist::'): 

28 if st in doc: 

29 doc = doc.split(st)[0] 

30 if isinstance(cleandoc, (list, tuple)): 

31 for cl in cleandoc: 

32 doc = clean_documentation_for_cli(doc, cl) 

33 return doc 

34 else: 

35 if isinstance(cleandoc, str): 

36 if cleandoc == 'epkg': 

37 reg = re.compile('(:epkg:(`[0-9a-zA-Z_:.*]+`))') 

38 fall = reg.findall(doc) 

39 for c in fall: 

40 doc = doc.replace(c[0], c[1].replace(':', '.')) 

41 return doc 

42 elif cleandoc == 'link': 

43 reg = re.compile('(`(.+?) <.+?>`_)') 

44 fall = reg.findall(doc) 

45 for c in fall: 

46 doc = doc.replace(c[0], c[1].replace(':', '.')) 

47 return doc 

48 else: 

49 raise ValueError( # pragma: no cover 

50 "cleandoc='{0}' is not implemented, only 'epkg'.".format(cleandoc)) 

51 elif callable(cleandoc): 

52 return cleandoc(doc) 

53 else: 

54 raise ValueError( # pragma: no cover 

55 "cleandoc is not a string or a callable object but {0}".format(type(cleandoc))) 

56 

57 

58def create_cli_parser(f, prog=None, layout="sphinx", skip_parameters=('fLOG',), 

59 cleandoc=("epkg", "link"), positional=None, cls=None, **options): 

60 """ 

61 Automatically creates a parser based on a function, 

62 its signature with annotation and its documentation (assuming 

63 this documentation is written using :epkg:`Sphinx` syntax). 

64 

65 @param f function 

66 @param prog to give the parser a different name than the function name 

67 @param use_sphinx simple documentation only requires :epkg:`docutils`, 

68 richer requires :epkg:`sphinx` 

69 @param skip_parameters do not expose these parameters 

70 @param cleandoc cleans the documentation before converting it into text, 

71 @see fn clean_documentation_for_cli 

72 @param options additional :epkg:`Sphinx` options 

73 @param positional positional argument 

74 @param cls parser class, :epkg:`*py:argparse:ArgumentParser` 

75 by default 

76 @return :epkg:`*py:argparse:ArgumentParser` 

77 

78 If an annotation offers mutiple types, 

79 the first one will be used for the command line. 

80 

81 .. versionchanged:: 1.9 

82 Parameters *cls*, *positional* were added. 

83 """ 

84 # delayed import to speed up import. 

85 # from ..helpgen import docstring2html 

86 if "@param" in f.__doc__: 

87 raise RuntimeError( # pragma: no cover 

88 "@param is not allowed in documentation for function '{}' in '{}'.".format( 

89 f, f.__module__)) 

90 docf = clean_documentation_for_cli(f.__doc__, cleandoc) 

91 fulldocinfo = parse(docf) 

92 docparams = {} 

93 for arg in fulldocinfo.args: 

94 if arg.name in docparams: 

95 raise ValueError( # pragma: no cover 

96 "Parameter '{0}' is documented twice.\n{1}".format( 

97 arg.name, docf)) 

98 docparams[arg.name] = arg.description 

99 

100 # add arguments with the signature 

101 signature = inspect.signature(f) 

102 parameters = signature.parameters 

103 if cls is None: 

104 cls = argparse.ArgumentParser 

105 parser = cls(prog=prog or f.__name__, description=fulldocinfo.summary, 

106 formatter_class=argparse.ArgumentDefaultsHelpFormatter) 

107 

108 if skip_parameters is None: 

109 skip_parameters = [] 

110 names = {"h": "already taken"} 

111 for k, p in parameters.items(): 

112 if k in skip_parameters: 

113 continue 

114 if k not in docparams: 

115 raise ValueError( # pragma: no cover 

116 "Parameter '{0}' is not documented in\n{1}.".format(k, docf)) 

117 create_cli_argument(parser, p, docparams[k], names, positional) 

118 

119 # end 

120 return parser 

121 

122 

123def create_cli_argument(parser, param, doc, names, positional): 

124 """ 

125 Adds an argument for :epkg:`*py:argparse:ArgumentParser`. 

126 

127 @param parser :epkg:`*py:argparse:ArgumentParser` 

128 @param param parameter (from the signature) 

129 @param doc documentation for this parameter 

130 @param names for shortnames 

131 @param positional positional arguments 

132 

133 If an annotation offers mutiple types, 

134 the first one will be used for the command line. 

135 

136 .. versionchanged:: 1.9 

137 Parameter *positional* was added. 

138 """ 

139 p = param 

140 if p.annotation and p.annotation != inspect._empty: 

141 typ = p.annotation 

142 else: 

143 typ = type(p.default) 

144 if typ is None: 

145 raise ValueError( # pragma: no cover 

146 "Unable to infer type of '{0}' ({1})".format(p.name, p)) 

147 

148 if len(p.name) > 3: 

149 shortname = p.name[0] 

150 if shortname in names: 

151 shortname = p.name[0:2] 

152 if shortname in names: 

153 shortname = p.name[0:3] 

154 if shortname in names: 

155 shortname = None 

156 else: 

157 shortname = None 

158 

159 if p.name in names: 

160 raise ValueError( # pragma: no cover 

161 "You should change the name of parameter '{0}'".format(p.name)) 

162 

163 if positional is not None and p.name in positional: 

164 pnames = [p.name] 

165 else: 

166 pnames = ["--" + p.name] 

167 if shortname: 

168 pnames.insert(0, "-" + shortname) 

169 names[shortname] = p.name 

170 

171 if isinstance(typ, list): 

172 # Multiple options for the same parameter 

173 typ = typ[0] 

174 

175 if typ in (int, str, float, bool): 

176 default = None if p.default == inspect._empty else p.default 

177 if typ == bool: 

178 # see https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse 

179 def typ_(s): 

180 return s.lower() in {'true', 't', 'yes', '1'} 

181 typ = typ_ 

182 if default is not None: 

183 parser.add_argument(*pnames, type=typ, help=doc, default=default) 

184 else: 

185 parser.add_argument(*pnames, type=typ, help=doc) 

186 elif typ is None or str(typ) == "<class 'NoneType'>": 

187 parser.add_argument(*pnames, type=str, help=doc, default="") 

188 elif str(typ) == "<class 'type'>": 

189 # Positional argument 

190 parser.add_argument(*pnames, help=doc) 

191 else: 

192 raise NotImplementedError( # pragma: no cover 

193 "typ='{0}' not supported (parameter '{1}'). \n" 

194 "None should be replaced by an empty string \n" 

195 "as empty value are received that way.".format(typ, p)) 

196 

197 

198def call_cli_function(f, args=None, parser=None, fLOG=print, skip_parameters=('fLOG',), 

199 cleandoc=("epkg", 'link'), prog=None, **options): 

200 """ 

201 Calls a function *f* given parsed arguments. 

202 

203 @param f function to call 

204 @param args arguments to parse (if None, it considers sys.argv) 

205 @param parser parser (can be None, in that case, @see fn create_cli_parser 

206 is called) 

207 @param fLOG logging function 

208 @param skip_parameters see @see fn create_cli_parser 

209 @param cleandoc cleans the documentation before converting it into text, 

210 @see fn clean_documentation_for_cli 

211 @param prog to give the parser a different name than the function name 

212 @param options additional :epkg:`Sphinx` options 

213 @return the output of the wrapped function 

214 

215 This function is used in command line @see fn pyq_sync. 

216 Its code can can be used as an example. 

217 The command line can be tested as: 

218 

219 :: 

220 

221 class TextMyCommandLine(unittest.TestCase): 

222 

223 def test_mycommand_line_help(self): 

224 fLOG( 

225 __file__, 

226 self._testMethodName, 

227 OutputPrint=__name__ == "__main__") 

228 

229 rows = [] 

230 

231 def flog(*l): 

232 rows.append(l) 

233 

234 mycommand_line(args=['-h'], fLOG=flog) 

235 

236 r = rows[0][0] 

237 if not r.startswith("usage: mycommand_line ..."): 

238 raise Exception(r) 

239 """ 

240 if parser is None: 

241 parser = create_cli_parser(f, prog=prog, skip_parameters=skip_parameters, 

242 cleandoc=cleandoc, **options) 

243 if args is not None and (args == ['--help'] or args == ['-h']): # pylint: disable=R1714 

244 fLOG(parser.format_help()) 

245 else: 

246 try: 

247 args = parser.parse_args(args=args) 

248 except SystemExit as e: # pragma: no cover 

249 exit_code = e.args[0] 

250 if exit_code != 0: 

251 if fLOG: 

252 fLOG("Unable to parse argument due to '{0}':".format(e)) 

253 if args: 

254 fLOG(" ", " ".join(args)) 

255 fLOG("") 

256 fLOG(parser.format_usage()) 

257 args = None 

258 

259 if args is not None: 

260 signature = inspect.signature(f) 

261 parameters = signature.parameters 

262 kwargs = {} 

263 has_flog = False 

264 for k in parameters: 

265 if k == "fLOG": 

266 has_flog = True 

267 continue 

268 if hasattr(args, k): 

269 val = getattr(args, k) 

270 if val == '': 

271 val = None 

272 kwargs[k] = val 

273 if has_flog: 

274 res = f(fLOG=fLOG, **kwargs) 

275 else: 

276 res = f(**kwargs) 

277 if res is not None: 

278 if isinstance(res, str): 

279 fLOG(res) 

280 elif isinstance(res, list): 

281 for el in res: 

282 fLOG(el) 

283 elif isinstance(res, dict): 

284 for k, v in sorted(res.items()): 

285 fLOG("{0}: {1}".format(k, v)) 

286 return res 

287 return None 

288 

289 

290def guess_module_name(fct): 

291 """ 

292 Guesses the module name based on a function. 

293 

294 @param fct function 

295 @return module name 

296 """ 

297 mod = fct.__module__ 

298 spl = mod.split('.') 

299 name = spl[0] 

300 if name == 'src': 

301 return spl[1] 

302 return spl[0] 

303 

304 

305def cli_main_helper(dfct, args, fLOG=print): 

306 """ 

307 Implements the main commmand line for a module. 

308 

309 @param dfct dictionary ``{ key: fct }`` 

310 @param args arguments 

311 @param fLOG logging function 

312 @return the output of the wrapped function 

313 

314 The function makes it quite simple to write a file 

315 ``__main__.py`` which implements the syntax 

316 ``python -m <module> <command> <arguments>``. 

317 Here is an example of implementation based on this 

318 function: 

319 

320 :: 

321 

322 import sys 

323 

324 

325 def main(args, fLOG=print): 

326 ''' 

327 Implements ``python -m pyquickhelper <command> <args>``. 

328 

329 @param args command line arguments 

330 @param fLOG logging function 

331 ''' 

332 try: 

333 from .pandashelper import df2rst 

334 from .pycode import clean_files 

335 from .cli import cli_main_helper 

336 except ImportError: 

337 from pyquickhelper.pandashelper import df2rst 

338 from pyquickhelper.pycode import clean_files 

339 from pyquickhelper.cli import cli_main_helper 

340 

341 fcts = dict(df2rst=df2rst, clean_files=clean_files) 

342 cli_main_helper(fcts, args=args, fLOG=fLOG) 

343 

344 

345 if __name__ == "__main__": 

346 main(sys.argv[1:]) 

347 

348 The function takes care of the parsing of the command line by 

349 leveraging the signature and the documentation of the function 

350 if its docstring is written in :epkg:`rst` format. 

351 For example, function @see fn clean_files is automatically wrapped 

352 with function @see fn call_cli_function. The command 

353 ``python -m pyquickhelper clean_files --help`` produces 

354 the following output: 

355 

356 .. cmdref:: 

357 :title: Clean files 

358 :cmd: -m pyquickhelper clean_files --help 

359 

360 The command line cleans files in a folder. 

361 

362 The command line can be replaced by a GUI triggered 

363 with the following command line. It relies on module 

364 :epkg`tkinterquickhelper`. See @see fn call_gui_function. 

365 

366 :: 

367 

368 python -u -m <module> --GUI 

369 """ 

370 if fLOG is None: 

371 raise ValueError("fLOG must be defined.") # pragma: no cover 

372 first = None 

373 for _, v in dfct.items(): 

374 first = v 

375 break 

376 if not first: 

377 raise ValueError("dictionary must not be empty.") # pragma: no cover 

378 

379 def print_available(): 

380 maxlen = max(map(len, dfct)) + 3 

381 fLOG("Available commands:") 

382 fLOG("") 

383 for a, fct in sorted(dfct.items()): 

384 doc = fct.__doc__.strip("\r\n ").split("\n")[0] 

385 fLOG(" " + a + " " * (maxlen - len(a)) + doc) 

386 

387 modname = guess_module_name(first) 

388 if len(args) < 1: 

389 fLOG("Usage:") 

390 fLOG("") 

391 fLOG(" python -m {0} <command>".format(modname)) 

392 fLOG("") 

393 fLOG("To get help:") 

394 fLOG("") 

395 fLOG(" python -m {0} <command> --help".format(modname)) 

396 fLOG("") 

397 print_available() 

398 return None 

399 else: 

400 cmd = args[0] 

401 cp = args.copy() 

402 del cp[0] 

403 if cmd in dfct: 

404 fct = dfct[cmd] 

405 sig = inspect.signature(fct) 

406 if 'args' not in sig.parameters or 'fLOG' not in sig.parameters: 

407 return call_cli_function(fct, prog=cmd, args=cp, fLOG=fLOG, 

408 skip_parameters=('fLOG', )) 

409 else: 

410 return fct(args=cp, fLOG=fLOG) 

411 elif cmd in ('--GUI', '-G', "--GUITEST"): 

412 return call_gui_function(dfct, fLOG=fLOG, utest=cmd == "--GUITEST") 

413 else: 

414 fLOG("Command not found: '{0}'.".format(cmd)) 

415 fLOG("") 

416 print_available() 

417 return None 

418 

419 

420def call_gui_function(dfct, fLOG=print, utest=False): 

421 """ 

422 Opens a GUI based on :epkg:`tkinter` which allows the 

423 user to run a command line through a windows. 

424 The function requires :epkg:`tkinterquickhelper`. 

425 

426 @param dfct dictionary ``{ key: fct }`` 

427 @param args arguments 

428 @param utest for unit test purposes, 

429 does not start the main loop if True 

430 

431 This GUI can be triggered with the following command line: 

432 

433 :: 

434 

435 python -m <module> --GUI 

436 

437 If one of your function prints out some information or 

438 raises an exception, option ``-u`` should be added: 

439 

440 :: 

441 

442 python -u -m <module> --GUI 

443 """ 

444 try: 

445 import tkinterquickhelper 

446 except ImportError: # pragma: no cover 

447 print("Option --GUI requires module tkinterquickhelper to be installed.") 

448 tkinterquickhelper = None 

449 if tkinterquickhelper: 

450 memo = dfct 

451 dfct = {} 

452 for k, v in memo.items(): 

453 sig = inspect.signature(v) 

454 pars = list(sorted(sig.parameters)) 

455 if pars == ["args", "fLOG"]: 

456 continue 

457 dfct[k] = v 

458 from tkinterquickhelper.funcwin import main_loop_functions 

459 first = None 

460 for _, v in dfct.items(): 

461 first = v 

462 break 

463 modname = guess_module_name(first) 

464 win = main_loop_functions(dfct, title="{0} command line".format(modname), 

465 mainloop=not utest) 

466 return win 

467 return None # pragma: no cover