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
« 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
12def clean_documentation_for_cli(doc, cleandoc):
13 """
14 Cleans the documentation before integrating
15 into a command line documentation.
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
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)}")
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).
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`
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
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)
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)
118 # end
119 return parser
122def create_cli_argument(parser, param, doc, names, positional):
123 """
124 Adds an argument for :epkg:`*py:argparse:ArgumentParser`.
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
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})")
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
155 if p.name in names:
156 raise ValueError( # pragma: no cover
157 f"You should change the name of parameter '{p.name}'")
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
167 if isinstance(typ, list):
168 # Multiple options for the same parameter
169 typ = typ[0]
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))
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.
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
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:
215 ::
217 class TextMyCommandLine(unittest.TestCase):
219 def test_mycommand_line_help(self):
220 fLOG(
221 __file__,
222 self._testMethodName,
223 OutputPrint=__name__ == "__main__")
225 rows = []
227 def flog(*l):
228 rows.append(l)
230 mycommand_line(args=['-h'], fLOG=flog)
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
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
286def guess_module_name(fct):
287 """
288 Guesses the module name based on a function.
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]
301def cli_main_helper(dfct, args, fLOG=print):
302 """
303 Implements the main commmand line for a module.
305 @param dfct dictionary ``{ key: fct }``
306 @param args arguments
307 @param fLOG logging function
308 @return the output of the wrapped function
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:
316 ::
318 import sys
321 def main(args, fLOG=print):
322 '''
323 Implements ``python -m pyquickhelper <command> <args>``.
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
337 fcts = dict(df2rst=df2rst, clean_files=clean_files)
338 cli_main_helper(fcts, args=args, fLOG=fLOG)
341 if __name__ == "__main__":
342 main(sys.argv[1:])
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:
352 .. cmdref::
353 :title: Clean files
354 :cmd: -m pyquickhelper clean_files --help
356 The command line cleans files in a folder.
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.
362 ::
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
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)
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
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`.
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
427 This GUI can be triggered with the following command line:
429 ::
431 python -m <module> --GUI
433 If one of your function prints out some information or
434 raises an exception, option ``-u`` should be added:
436 ::
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