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
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 "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)))
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 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`
78 If an annotation offers mutiple types,
79 the first one will be used for the command line.
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
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)
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)
119 # end
120 return parser
123def create_cli_argument(parser, param, doc, names, positional):
124 """
125 Adds an argument for :epkg:`*py:argparse:ArgumentParser`.
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
133 If an annotation offers mutiple types,
134 the first one will be used for the command line.
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))
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
159 if p.name in names:
160 raise ValueError( # pragma: no cover
161 "You should change the name of parameter '{0}'".format(p.name))
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
171 if isinstance(typ, list):
172 # Multiple options for the same parameter
173 typ = typ[0]
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))
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.
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
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:
219 ::
221 class TextMyCommandLine(unittest.TestCase):
223 def test_mycommand_line_help(self):
224 fLOG(
225 __file__,
226 self._testMethodName,
227 OutputPrint=__name__ == "__main__")
229 rows = []
231 def flog(*l):
232 rows.append(l)
234 mycommand_line(args=['-h'], fLOG=flog)
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
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
290def guess_module_name(fct):
291 """
292 Guesses the module name based on a function.
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]
305def cli_main_helper(dfct, args, fLOG=print):
306 """
307 Implements the main commmand line for a module.
309 @param dfct dictionary ``{ key: fct }``
310 @param args arguments
311 @param fLOG logging function
312 @return the output of the wrapped function
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:
320 ::
322 import sys
325 def main(args, fLOG=print):
326 '''
327 Implements ``python -m pyquickhelper <command> <args>``.
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
341 fcts = dict(df2rst=df2rst, clean_files=clean_files)
342 cli_main_helper(fcts, args=args, fLOG=fLOG)
345 if __name__ == "__main__":
346 main(sys.argv[1:])
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:
356 .. cmdref::
357 :title: Clean files
358 :cmd: -m pyquickhelper clean_files --help
360 The command line cleans files in a folder.
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.
366 ::
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
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)
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
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`.
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
431 This GUI can be triggered with the following command line:
433 ::
435 python -m <module> --GUI
437 If one of your function prints out some information or
438 raises an exception, option ``-u`` should be added:
440 ::
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