Coverage for pyquickhelper/cli/cli_helper.py: 97%

191 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-03 02:21 +0200

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 f"cleandoc='{cleandoc}' is not implemented, only 'epkg'.") 

51 elif callable(cleandoc): 

52 return cleandoc(doc) 

53 else: 

54 raise ValueError( # pragma: no cover 

55 f"cleandoc is not a string or a callable object but {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 

71 converting it into text, 

72 @see fn clean_documentation_for_cli 

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

74 @param positional positional argument 

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

76 by default 

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

78 

79 If an annotation offers mutiple types, 

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

81 """ 

82 # delayed import to speed up import. 

83 # from ..helpgen import docstring2html 

84 if "@param" in f.__doc__: 

85 raise RuntimeError( # pragma: no cover 

86 "@param is not allowed in documentation for function " 

87 "'{}' in '{}'.".format( 

88 f, f.__module__)) 

89 docf = clean_documentation_for_cli(f.__doc__, cleandoc) 

90 fulldocinfo = parse(docf) 

91 docparams = {} 

92 if fulldocinfo.args is not None: 

93 for arg in fulldocinfo.args: 

94 if arg.name in docparams: 

95 raise ValueError( # pragma: no cover 

96 f"Parameter '{arg.name}' is documented twice.\n{docf}") 

97 docparams[arg.name] = arg.description 

98 

99 # add arguments with the signature 

100 signature = inspect.signature(f) 

101 parameters = signature.parameters 

102 if cls is None: 

103 cls = argparse.ArgumentParser 

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

105 formatter_class=argparse.ArgumentDefaultsHelpFormatter) 

106 

107 if skip_parameters is None: 

108 skip_parameters = [] 

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

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

111 if k in skip_parameters: 

112 continue 

113 if k not in docparams: 

114 raise ValueError( # pragma: no cover 

115 f"Parameter '{k}' is not documented in\n{docf}.") 

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

117 

118 # end 

119 return parser 

120 

121 

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

123 """ 

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

125 

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

127 @param param parameter (from the signature) 

128 @param doc documentation for this parameter 

129 @param names for shortnames 

130 @param positional positional arguments 

131 

132 If an annotation offers mutiple types, 

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

134 """ 

135 p = param 

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

137 typ = p.annotation 

138 else: 

139 typ = type(p.default) 

140 if typ is None: 

141 raise ValueError( # pragma: no cover 

142 f"Unable to infer type of '{p.name}' ({p})") 

143 

144 if len(p.name) > 3: 

145 shortname = p.name[0] 

146 if shortname in names: 

147 shortname = p.name[0:2] 

148 if shortname in names: 

149 shortname = p.name[0:3] 

150 if shortname in names: 

151 shortname = None 

152 else: 

153 shortname = None 

154 

155 if p.name in names: 

156 raise ValueError( # pragma: no cover 

157 f"You should change the name of parameter '{p.name}'") 

158 

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

160 pnames = [p.name] 

161 else: 

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

163 if shortname: 

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

165 names[shortname] = p.name 

166 

167 if isinstance(typ, list): 

168 # Multiple options for the same parameter 

169 typ = typ[0] 

170 

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

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

173 if typ == bool: 

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

175 def typ_(s): 

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

177 typ = typ_ 

178 if default is not None: 

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

180 else: 

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

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

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

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

185 # Positional argument 

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

187 else: 

188 raise NotImplementedError( # pragma: no cover 

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

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

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

192 

193 

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

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

196 """ 

197 Calls a function *f* given parsed arguments. 

198 

199 @param f function to call 

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

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

202 is called) 

203 @param fLOG logging function 

204 @param skip_parameters see @see fn create_cli_parser 

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

206 @see fn clean_documentation_for_cli 

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

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

209 @return the output of the wrapped function 

210 

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

212 Its code can can be used as an example. 

213 The command line can be tested as: 

214 

215 :: 

216 

217 class TextMyCommandLine(unittest.TestCase): 

218 

219 def test_mycommand_line_help(self): 

220 fLOG( 

221 __file__, 

222 self._testMethodName, 

223 OutputPrint=__name__ == "__main__") 

224 

225 rows = [] 

226 

227 def flog(*l): 

228 rows.append(l) 

229 

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

231 

232 r = rows[0][0] 

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

234 raise RuntimeError(r) 

235 """ 

236 if parser is None: 

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

238 cleandoc=cleandoc, **options) 

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

240 fLOG(parser.format_help()) 

241 else: 

242 try: 

243 args = parser.parse_args(args=args) 

244 except SystemExit as e: # pragma: no cover 

245 exit_code = e.args[0] 

246 if exit_code != 0: 

247 if fLOG: 

248 fLOG(f"Unable to parse argument due to '{e}':") 

249 if args: 

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

251 fLOG("") 

252 fLOG(parser.format_usage()) 

253 args = None 

254 

255 if args is not None: 

256 signature = inspect.signature(f) 

257 parameters = signature.parameters 

258 kwargs = {} 

259 has_flog = False 

260 for k in parameters: 

261 if k == "fLOG": 

262 has_flog = True 

263 continue 

264 if hasattr(args, k): 

265 val = getattr(args, k) 

266 if val == '': 

267 val = None 

268 kwargs[k] = val 

269 if has_flog: 

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

271 else: 

272 res = f(**kwargs) 

273 if res is not None: 

274 if isinstance(res, str): 

275 fLOG(res) 

276 elif isinstance(res, list): 

277 for el in res: 

278 fLOG(el) 

279 elif isinstance(res, dict): 

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

281 fLOG(f"{k}: {v}") 

282 return res 

283 return None 

284 

285 

286def guess_module_name(fct): 

287 """ 

288 Guesses the module name based on a function. 

289 

290 @param fct function 

291 @return module name 

292 """ 

293 mod = fct.__module__ 

294 spl = mod.split('.') 

295 name = spl[0] 

296 if name == 'src': 

297 return spl[1] 

298 return spl[0] 

299 

300 

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

302 """ 

303 Implements the main commmand line for a module. 

304 

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

306 @param args arguments 

307 @param fLOG logging function 

308 @return the output of the wrapped function 

309 

310 The function makes it quite simple to write a file 

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

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

313 Here is an example of implementation based on this 

314 function: 

315 

316 :: 

317 

318 import sys 

319 

320 

321 def main(args, fLOG=print): 

322 ''' 

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

324 

325 @param args command line arguments 

326 @param fLOG logging function 

327 ''' 

328 try: 

329 from .pandashelper import df2rst 

330 from .pycode import clean_files 

331 from .cli import cli_main_helper 

332 except ImportError: 

333 from pyquickhelper.pandashelper import df2rst 

334 from pyquickhelper.pycode import clean_files 

335 from pyquickhelper.cli import cli_main_helper 

336 

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

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

339 

340 

341 if __name__ == "__main__": 

342 main(sys.argv[1:]) 

343 

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

345 leveraging the signature and the documentation of the function 

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

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

348 with function @see fn call_cli_function. The command 

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

350 the following output: 

351 

352 .. cmdref:: 

353 :title: Clean files 

354 :cmd: -m pyquickhelper clean_files --help 

355 

356 The command line cleans files in a folder. 

357 

358 The command line can be replaced by a GUI triggered 

359 with the following command line. It relies on module 

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

361 

362 :: 

363 

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

365 """ 

366 if fLOG is None: 

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

368 first = None 

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

370 first = v 

371 break 

372 if not first: 

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

374 

375 def print_available(): 

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

377 fLOG("Available commands:") 

378 fLOG("") 

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

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

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

382 

383 modname = guess_module_name(first) 

384 if len(args) < 1: 

385 fLOG("Usage:") 

386 fLOG("") 

387 fLOG(f" python -m {modname} <command>") 

388 fLOG("") 

389 fLOG("To get help:") 

390 fLOG("") 

391 fLOG(f" python -m {modname} <command> --help") 

392 fLOG("") 

393 print_available() 

394 return None 

395 else: 

396 cmd = args[0] 

397 cp = args.copy() 

398 del cp[0] 

399 if cmd in dfct: 

400 fct = dfct[cmd] 

401 sig = inspect.signature(fct) 

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

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

404 skip_parameters=('fLOG', )) 

405 else: 

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

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

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

409 else: 

410 fLOG(f"Command not found: '{cmd}'.") 

411 fLOG("") 

412 print_available() 

413 return None 

414 

415 

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

417 """ 

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

419 user to run a command line through a windows. 

420 The function requires :epkg:`tkinterquickhelper`. 

421 

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

423 @param args arguments 

424 @param utest for unit test purposes, 

425 does not start the main loop if True 

426 

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

428 

429 :: 

430 

431 python -m <module> --GUI 

432 

433 If one of your function prints out some information or 

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

435 

436 :: 

437 

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

439 """ 

440 try: 

441 import tkinterquickhelper 

442 except ImportError: # pragma: no cover 

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

444 tkinterquickhelper = None 

445 if tkinterquickhelper: 

446 memo = dfct 

447 dfct = {} 

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

449 sig = inspect.signature(v) 

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

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

452 continue 

453 dfct[k] = v 

454 from tkinterquickhelper.funcwin import main_loop_functions 

455 first = None 

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

457 first = v 

458 break 

459 modname = guess_module_name(first) 

460 win = main_loop_functions(dfct, title=f"{modname} command line", 

461 mainloop=not utest) 

462 return win 

463 return None # pragma: no cover