This repository has been archived by the owner on Jan 13, 2024. It is now read-only.
/
cli_helper.py
248 lines (214 loc) · 8.8 KB
/
cli_helper.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
"""
@file
@brief Automate the creation of a parser based on a function.
"""
from __future__ import print_function
import argparse
import inspect
import re
from docutils import nodes
from ..helpgen import docstring2html
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
"""
for st in ('.. versionchanged::', '.. versionadded'):
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(
"cleandoc='{0}' is not implemented, only 'epkg'.".format(cleandoc))
elif callable(cleandoc):
return cleandoc(doc)
else:
raise ValueError(
"cleandoc is not a string or a callable object but {0}".format(type(cleandoc)))
def create_cli_parser(f, prog=None, layout="sphinx", skip_parameters=('fLOG',),
cleandoc=("epkg", "link"), **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,
see @fn clean_documentation_for_cli
@param options additional :epkg:`Sphinx` options
@return :epkg:`*py:argparse:ArgumentParser`
If an annotation offers mutiple types,
the first one will be used for the command line.
"""
docf = clean_documentation_for_cli(f.__doc__, cleandoc)
doctree = docstring2html(docf, writer="doctree",
layout=layout, ret_doctree=True, **options)
# documentation
docparams = {}
for node_list in doctree.traverse(nodes.field_list):
for node in node_list.traverse(nodes.field):
text = list(filter(lambda c: c.astext().startswith(
"param "), node.traverse(nodes.Text)))
body = list(node.traverse(nodes.field_body))
if len(text) == 1 and len(body) == 1:
text = text[0]
body = body[0]
name = text.astext()
name = name[5:].strip()
doc = body.astext()
if name in docparams:
raise ValueError(
"Parameter '{0}' is documented twice.\n{1}".format(name, docf))
docparams[name] = doc
def clear_node_list(doctree):
"local function"
for node_list in doctree.traverse(nodes.field_list):
node_list.clear()
# create the parser
fulldoc = docstring2html(docf, writer="rst", layout='sphinx',
filter_nodes=clear_node_list, **options)
# add arguments with the signature
signature = inspect.signature(f)
parameters = signature.parameters
parser = argparse.ArgumentParser(prog=prog or f.__name__, description=fulldoc,
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(
"Parameter '{0}' is not documented in\n{1}.".format(k, docf))
create_cli_argument(parser, p, docparams[k], names)
# end
return parser
def create_cli_argument(parser, param, doc, names):
"""
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
If an annotation offers mutiple types,
the first one will be used for the command line.
"""
p = param
if p.annotation and p.annotation != inspect._empty:
typ = p.annotation
else:
typ = type(p.default)
if typ is None:
raise ValueError(
"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(
"You should change the name of parameter '{0}'".format(p.name))
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 default is not None:
parser.add_argument(*pnames, type=typ, help=doc, default=default)
else:
parser.add_argument(*pnames, type=typ, help=doc)
else:
raise NotImplementedError(
"typ='{0}' not supported (parameter '{1}')".format(typ, p))
def call_cli_function(f, args=None, parser=None, fLOG=print, skip_parameters=('fLOG',),
cleandoc=("epkg", 'link'), **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, @see fn create_cli_parser is called)
@param fLOG logging function
@param skip_parameters see @see fn create_cli_parser
@param cleandoc cleans the documentation before converting it into text,
see @fn clean_documentation_for_cli
@param options additional :epkg:`Sphinx` options
This function is used in command line @see fn 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)
"""
if parser is None:
parser = create_cli_parser(
f, 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:
if 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):
kwargs[k] = getattr(args, k)
if has_flog:
f(fLOG=fLOG, **kwargs)
else:
f(**kwargs)