"""
Automate the creation of a parser based on a function.
:githublink:`%|py|5`
"""
from __future__ import print_function
import argparse
import inspect
import re
from fire.docstrings import parse
[docs]def clean_documentation_for_cli(doc, cleandoc):
"""
Cleans the documentation before integrating
into a command line documentation.
:param doc: documentation
:param cleandoc: a string which tells how to clean,
or a function which takes a function and
returns a string
The function removes everything after ``.. cmdref::`` and ``.. cmdreflist``
as it creates an infinite loop of processus if this command
is part of the documentation of the command line itself.
:githublink:`%|py|25`
"""
for st in ('.. versionchanged::', '.. versionadded::', '.. cmdref::', '.. cmdreflist::'):
if st in doc:
doc = doc.split(st)[0]
if isinstance(cleandoc, (list, tuple)):
for cl in cleandoc:
doc = clean_documentation_for_cli(doc, cl)
return doc
else:
if isinstance(cleandoc, str):
if cleandoc == 'epkg':
reg = re.compile('(:epkg:(`[0-9a-zA-Z_:.*]+`))')
fall = reg.findall(doc)
for c in fall:
doc = doc.replace(c[0], c[1].replace(':', '.'))
return doc
elif cleandoc == 'link':
reg = re.compile('(`(.+?) <.+?>`_)')
fall = reg.findall(doc)
for c in fall:
doc = doc.replace(c[0], c[1].replace(':', '.'))
return doc
else:
raise ValueError( # pragma: no cover
"cleandoc='{0}' is not implemented, only 'epkg'.".format(cleandoc))
elif callable(cleandoc):
return cleandoc(doc)
else:
raise ValueError( # pragma: no cover
"cleandoc is not a string or a callable object but {0}".format(type(cleandoc)))
[docs]def create_cli_parser(f, prog=None, layout="sphinx", skip_parameters=('fLOG',),
cleandoc=("epkg", "link"), positional=None, cls=None, **options):
"""
Automatically creates a parser based on a function,
its signature with annotation and its documentation (assuming
this documentation is written using :epkg:`Sphinx` syntax).
:param f: function
:param prog: to give the parser a different name than the function name
:param use_sphinx: simple documentation only requires :epkg:`docutils`,
richer requires :epkg:`sphinx`
:param skip_parameters: do not expose these parameters
:param cleandoc: cleans the documentation before converting it into text,
:func:`clean_documentation_for_cli <pyquickhelper.cli.cli_helper.clean_documentation_for_cli>`
:param options: additional :epkg:`Sphinx` options
:param positional: positional argument
:param cls: parser class, :epkg:`*py:argparse:ArgumentParser`
by default
:return: :epkg:`*py:argparse:ArgumentParser`
If an annotation offers mutiple types,
the first one will be used for the command line.
.. versionchanged:: 1.9
Parameters *cls*, *positional* were added.
:githublink:`%|py|82`
"""
# delayed import to speed up import.
# from ..helpgen import docstring2html
if "@param" in f.__doc__:
raise RuntimeError( # pragma: no cover
"@param is not allowed in documentation for function '{}' in '{}'.".format(
f, f.__module__))
docf = clean_documentation_for_cli(f.__doc__, cleandoc)
fulldocinfo = parse(docf)
docparams = {}
for arg in fulldocinfo.args:
if arg.name in docparams:
raise ValueError( # pragma: no cover
"Parameter '{0}' is documented twice.\n{1}".format(
arg.name, docf))
docparams[arg.name] = arg.description
# add arguments with the signature
signature = inspect.signature(f)
parameters = signature.parameters
if cls is None:
cls = argparse.ArgumentParser
parser = cls(prog=prog or f.__name__, description=fulldocinfo.summary,
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
if skip_parameters is None:
skip_parameters = []
names = {"h": "already taken"}
for k, p in parameters.items():
if k in skip_parameters:
continue
if k not in docparams:
raise ValueError( # pragma: no cover
"Parameter '{0}' is not documented in\n{1}.".format(k, docf))
create_cli_argument(parser, p, docparams[k], names, positional)
# end
return parser
[docs]def create_cli_argument(parser, param, doc, names, positional):
"""
Adds an argument for :epkg:`*py:argparse:ArgumentParser`.
:param parser: :epkg:`*py:argparse:ArgumentParser`
:param param: parameter (from the signature)
:param doc: documentation for this parameter
:param names: for shortnames
:param positional: positional arguments
If an annotation offers mutiple types,
the first one will be used for the command line.
.. versionchanged:: 1.9
Parameter *positional* was added.
:githublink:`%|py|137`
"""
p = param
if p.annotation and p.annotation != inspect._empty:
typ = p.annotation
else:
typ = type(p.default)
if typ is None:
raise ValueError( # pragma: no cover
"Unable to infer type of '{0}' ({1})".format(p.name, p))
if len(p.name) > 3:
shortname = p.name[0]
if shortname in names:
shortname = p.name[0:2]
if shortname in names:
shortname = p.name[0:3]
if shortname in names:
shortname = None
else:
shortname = None
if p.name in names:
raise ValueError( # pragma: no cover
"You should change the name of parameter '{0}'".format(p.name))
if positional is not None and p.name in positional:
pnames = [p.name]
else:
pnames = ["--" + p.name]
if shortname:
pnames.insert(0, "-" + shortname)
names[shortname] = p.name
if isinstance(typ, list):
# Multiple options for the same parameter
typ = typ[0]
if typ in (int, str, float, bool):
default = None if p.default == inspect._empty else p.default
if typ == bool:
# see https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse
def typ_(s):
return s.lower() in {'true', 't', 'yes', '1'}
typ = typ_
if default is not None:
parser.add_argument(*pnames, type=typ, help=doc, default=default)
else:
parser.add_argument(*pnames, type=typ, help=doc)
elif typ is None or str(typ) == "<class 'NoneType'>":
parser.add_argument(*pnames, type=str, help=doc, default="")
elif str(typ) == "<class 'type'>":
# Positional argument
parser.add_argument(*pnames, help=doc)
else:
raise NotImplementedError( # pragma: no cover
"typ='{0}' not supported (parameter '{1}'). \n"
"None should be replaced by an empty string \n"
"as empty value are received that way.".format(typ, p))
[docs]def call_cli_function(f, args=None, parser=None, fLOG=print, skip_parameters=('fLOG',),
cleandoc=("epkg", 'link'), prog=None, **options):
"""
Calls a function *f* given parsed arguments.
:param f: function to call
:param args: arguments to parse (if None, it considers sys.argv)
:param parser: parser (can be None, in that case, :func:`create_cli_parser <pyquickhelper.cli.cli_helper.create_cli_parser>`
is called)
:param fLOG: logging function
:param skip_parameters: see :func:`create_cli_parser <pyquickhelper.cli.cli_helper.create_cli_parser>`
:param cleandoc: cleans the documentation before converting it into text,
:func:`clean_documentation_for_cli <pyquickhelper.cli.cli_helper.clean_documentation_for_cli>`
:param prog: to give the parser a different name than the function name
:param options: additional :epkg:`Sphinx` options
:return: the output of the wrapped function
This function is used in command line :func:`pyq_sync <pyquickhelper.cli.pyq_sync_cli.pyq_sync>`.
Its code can can be used as an example.
The command line can be tested as:
::
class TextMyCommandLine(unittest.TestCase):
def test_mycommand_line_help(self):
fLOG(
__file__,
self._testMethodName,
OutputPrint=__name__ == "__main__")
rows = []
def flog(*l):
rows.append(l)
mycommand_line(args=['-h'], fLOG=flog)
r = rows[0][0]
if not r.startswith("usage: mycommand_line ..."):
raise Exception(r)
:githublink:`%|py|238`
"""
if parser is None:
parser = create_cli_parser(f, prog=prog, skip_parameters=skip_parameters,
cleandoc=cleandoc, **options)
if args is not None and (args == ['--help'] or args == ['-h']): # pylint: disable=R1714
fLOG(parser.format_help())
else:
try:
args = parser.parse_args(args=args)
except SystemExit as e: # pragma: no cover
if fLOG:
fLOG("Unable to parse argument due to '{0}':".format(e))
fLOG(" ", " ".join(args))
fLOG("")
fLOG(parser.format_usage())
args = None
if args is not None:
signature = inspect.signature(f)
parameters = signature.parameters
kwargs = {}
has_flog = False
for k in parameters:
if k == "fLOG":
has_flog = True
continue
if hasattr(args, k):
val = getattr(args, k)
if val == '':
val = None
kwargs[k] = val
if has_flog:
res = f(fLOG=fLOG, **kwargs)
else:
res = f(**kwargs)
if res is not None:
if isinstance(res, str):
fLOG(res)
elif isinstance(res, list):
for el in res:
fLOG(el)
elif isinstance(res, dict):
for k, v in sorted(res.items()):
fLOG("{0}: {1}".format(k, v))
return res
return None
[docs]def guess_module_name(fct):
"""
Guesses the module name based on a function.
:param fct: function
:return: module name
:githublink:`%|py|292`
"""
mod = fct.__module__
spl = mod.split('.')
name = spl[0]
if name == 'src':
return spl[1]
return spl[0]
[docs]def cli_main_helper(dfct, args, fLOG=print):
"""
Implements the main commmand line for a module.
:param dfct: dictionary ``{ key: fct }``
:param args: arguments
:param fLOG: logging function
:return: the output of the wrapped function
The function makes it quite simple to write a file
``__main__.py`` which implements the syntax
``python -m <module> <command> <arguments>``.
Here is an example of implementation based on this
function:
::
import sys
def main(args, fLOG=print):
'''
Implements ``python -m pyquickhelper <command> <args>``.
:param args: command line arguments
:param fLOG: logging function
'''
try:
from .pandashelper import df2rst
from .pycode import clean_files
from .cli import cli_main_helper
except ImportError:
from pyquickhelper.pandashelper import df2rst
from pyquickhelper.pycode import clean_files
from pyquickhelper.cli import cli_main_helper
fcts = dict(df2rst=df2rst, clean_files=clean_files)
cli_main_helper(fcts, args=args, fLOG=fLOG)
if __name__ == "__main__":
main(sys.argv[1:])
The function takes care of the parsing of the command line by
leveraging the signature and the documentation of the function
if its docstring is written in :epkg:`rst` format.
For example, function :func:`clean_files <pyquickhelper.pycode.clean_helper.clean_files>` is automatically wrapped
with function :func:`call_cli_function <pyquickhelper.cli.cli_helper.call_cli_function>`. The command
``python -m pyquickhelper clean_files --help`` produces
the following output:
.. cmdref::
:title: Clean files
:cmd: -m pyquickhelper clean_files --help
The command line cleans files in a folder.
The command line can be replaced by a GUI triggered
with the following command line. It relies on module
:epkg`tkinterquickhelper`. See :func:`call_gui_function <pyquickhelper.cli.cli_helper.call_gui_function>`.
::
python -u -m <module> --GUI
:githublink:`%|py|365`
"""
if fLOG is None:
raise ValueError("fLOG must be defined.") # pragma: no cover
first = None
for _, v in dfct.items():
first = v
break
if not first:
raise ValueError("dictionary must not be empty.") # pragma: no cover
def print_available():
maxlen = max(map(len, dfct)) + 3
fLOG("Available commands:")
fLOG("")
for a, fct in sorted(dfct.items()):
doc = fct.__doc__.strip("\r\n ").split("\n")[0]
fLOG(" " + a + " " * (maxlen - len(a)) + doc)
modname = guess_module_name(first)
if len(args) < 1:
fLOG("Usage:")
fLOG("")
fLOG(" python -m {0} <command>".format(modname))
fLOG("")
fLOG("To get help:")
fLOG("")
fLOG(" python -m {0} <command> --help".format(modname))
fLOG("")
print_available()
return None
else:
cmd = args[0]
cp = args.copy()
del cp[0]
if cmd in dfct:
fct = dfct[cmd]
sig = inspect.signature(fct)
if 'args' not in sig.parameters or 'fLOG' not in sig.parameters:
return call_cli_function(fct, prog=cmd, args=cp, fLOG=fLOG,
skip_parameters=('fLOG', ))
else:
return fct(args=cp, fLOG=fLOG)
elif cmd in ('--GUI', '-G', "--GUITEST"):
return call_gui_function(dfct, fLOG=fLOG, utest=cmd == "--GUITEST")
else:
fLOG("Command not found: '{0}'.".format(cmd))
fLOG("")
print_available()
return None
[docs]def call_gui_function(dfct, fLOG=print, utest=False):
"""
Opens a GUI based on :epkg:`tkinter` which allows the
user to run a command line through a windows.
The function requires :epkg:`tkinterquickhelper`.
:param dfct: dictionary ``{ key: fct }``
:param args: arguments
:param utest: for unit test purposes,
does not start the main loop if True
This GUI can be triggered with the following command line:
::
python -m <module> --GUI
If one of your function prints out some information or
raises an exception, option ``-u`` should be added:
::
python -u -m <module> --GUI
:githublink:`%|py|439`
"""
try:
import tkinterquickhelper
except ImportError: # pragma: no cover
print("Option --GUI requires module tkinterquickhelper to be installed.")
tkinterquickhelper = None
if tkinterquickhelper:
memo = dfct
dfct = {}
for k, v in memo.items():
sig = inspect.signature(v)
pars = list(sorted(sig.parameters))
if pars == ["args", "fLOG"]:
continue
dfct[k] = v
from tkinterquickhelper.funcwin import main_loop_functions
first = None
for _, v in dfct.items():
first = v
break
modname = guess_module_name(first)
win = main_loop_functions(dfct, title="{0} command line".format(modname),
mainloop=not utest)
return win
return None