-
Notifications
You must be signed in to change notification settings - Fork 0
/
command_opts.py
executable file
·422 lines (353 loc) · 15.1 KB
/
command_opts.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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
#!/usr/bin/env python3
import inspect
import os
import sys
import textwrap
__version__ = 26
SAMPLE_CODE = """
# --------------------------------------------------------------------------
# This module is not meant to be run directly. To use it, add code like
# this to a script to have this module parse the command line directly
# and call the function the user wants to invoke:
# --------------------------------------------------------------------------
#!/usr/bin/env python""" + str(sys.version_info.major) + """
from command_opts import opt, main_entry
@opt("Example function!")
def example():
print("You passed 'example' on the command line!")
if __name__ == "__main__":
main_entry('func')
# --------------------------------------------------------------------------
"""
_g_options = []
def opt_to_bool(value):
# Helper to turn an opt into a bool, can return None if the value is empty
if isinstance(value, bool):
return value
if not isinstance(value, str):
value = str(value)
if len(value) == 0:
return None
return value.lower() in {"true", "yes", "y"}
def opt_to_int(value):
# Helper to turn an opt into an int, can return None if the value is empty
# Also returns None if the value isn't parsable as a number
if isinstance(value, int):
return value
if not isinstance(value, str):
value = str(value)
if len(value) == 0:
return None
try:
return int(value)
except:
return None
def opt_to_float(value):
# Helper to turn an opt into a float, can return None if the value is empty
# Also returns None if the value isn't parsable as a number
if isinstance(value, float):
return value
if not isinstance(value, str):
value = str(value)
if len(value) == 0:
return None
try:
return float(value)
except:
return None
class OptMethod:
# Internal data structure to keep track a single option
def __init__(self, help):
self.func = None # The function to call when the option is picked
self.func_names = [] # List of all strings that this option can can be called by
self.hidden_args = [] # List of all args to hide in the help display
self.help = help # The help description for this option
self.args = [] # List of arguments for this option
self.parsers = [] # Optional parser to use to convert the type of each arg
self.hidden = False # Is this option hidden on the help screen?
self.other = None # The default name for this option (synthesized from .func_names)
self.aka = None # List of all other names for this option (synthesized from .func_names)
self.special = None # Optional key to control the sort order for 'sort' sort order
self.module_name = "" # The module name the option came from
self.group_name = "" # Optional group name for this option
self.default = False # Is option selected automatically when no option picked?
def create_clones(self):
# Helper to create a clone of a valid option
if len(self.func_names) >= 1:
ret = OptMethod(self.help)
ret.func = self.func
ret.args = self.args[:]
ret.hidden = self.hidden
ret.other = self.func_names[0]
ret.aka = self.func_names[1:]
ret.special = self.special
ret.module_name = self.module_name
ret.group_name = self.group_name
ret.default = self.default
ret.parsers = self.parsers[:]
ret.hidden_args = self.hidden_args[:]
yield ret
def opt(help_string, hidden=False, name:str=None, names:list=None, sort:str=None, group="", default=False, hidden_args:list=None):
"""
Decorator for the function to bubble it up as a command line option
:param help_string: Help description string
:param hidden: This option isn't shown in the help
:param name: The name to use for this option instead of the function name
:param names: A list of valid names to use for this option
:param sort: Key to use for 'special' sort mode
:param group: Group to cluster this option in
:param default: Treat this option as the default option to run
:param hidden_args: Args to hide from being shown in the help
"""
global _g_options
method = OptMethod(help_string)
_g_options.append(method)
# Store the various options
method.hidden = hidden
if name is not None:
method.func_names.append(name)
if names is not None:
method.func_names.extend(names)
if hidden_args is not None:
method.hidden_args.extend(hidden_args)
if sort is not None:
method.special = sort
method.group_name = group
method.default = default
# Create a bounce function that's actually called by scripts
# This exists to let us get a pointer to the real function
def real_opts(func):
if len(method.func_names) == 0:
method.func_names.append(func.__name__)
method.module_name = func.__module__
method.func = func
arg_spec = inspect.getfullargspec(func)
method.args += arg_spec.args
for i, arg in enumerate(arg_spec.args):
target_type = None
if arg_spec.defaults is not None:
offset = -len(arg_spec.args) + i + len(arg_spec.defaults)
if offset >= 0:
target_type = type(arg_spec.defaults[offset])
if arg in arg_spec.annotations:
target_type = arg_spec.annotations[arg]
if target_type is bool:
method.parsers.append(opt_to_bool)
elif target_type is int:
method.parsers.append(opt_to_int)
elif target_type is float:
method.parsers.append(opt_to_float)
else:
method.parsers.append(None)
for i in range(len(method.args)):
if method.args[i] in method.hidden_args:
method.args[i] = None
if inspect.getfullargspec(func)[3] is not None:
for i in range(len(inspect.getfullargspec(func)[3])):
i = -(i + 1)
if method.args[i] is not None:
method.args[i] = "(" + method.args[i] + ")"
method.args = [x for x in method.args if x]
def wrapper(*args2, **kwargs):
return func(*args2, **kwargs)
return wrapper
return real_opts
def main_entry(order_by='none', include_other=False, program_desc=None, default_action=None, picker=None):
"""
Main entry responsible for parsing command line args and running
picked option.
:param order_by: String representing the order conditions, 'none' = No order to the options,
'func' = Order by the function name, 'desc' = Order by the help text, 'special' = Order by
the 'sort' flag to @opt
:param include_other: Include @opt methods in modules outside of the main module
:param program_desc: Text to show before the main options help display
:param default_action: Default action to run if no options are present. Can be a
string or function
:param picker: Function to call to pick an option to run with a list of ("desc", "object") tuples
to pick an option if no options are selected. Expected to return "object" for the user's choice.
"""
global _g_options
temp = sys.argv[1:]
good = False
if not include_other:
_g_options = [x for x in _g_options if x.module_name == "__main__"]
if default_action is None:
for cur in _g_options:
if cur.default:
default_action = cur.func
break
all_names = set()
for arg in _g_options:
for name_arg in arg.func_names:
if name_arg in all_names:
raise Exception("The name %s is used more than once!" % (name_arg,))
else:
all_names.add(name_arg)
# Run through and crack each arg and run it in turn
while temp:
handled = False
good = True
for arg in _g_options:
found = False
for func_name in arg.func_names:
if temp[0].replace("-", "_") == func_name:
params = len(arg.args)
while True:
if len(temp) - 1 >= params:
temp_args = temp[1:params + 1]
for i in range(len(temp_args)):
if callable(arg.parsers[i]):
temp_args[i] = arg.parsers[i](temp_args[i])
arg.func(*temp_args)
temp = temp[params + 1:]
handled = True
break
else:
if arg.args[params-1].startswith("("):
params -= 1
else:
break
found = True
break
if found:
break
if not handled:
good = False
break
# Something wasn't right, so dump the help
if not good:
# Look for an explicit help request
help_found = False
for cur in sys.argv[1:]:
if cur.lower() in {"-h", "/h", "--help", "-?", "/?"}:
help_found = True
break
# If there wasn't an explicit ask for help, and there's a
# default action to call, go ahead and use it
if default_action is not None and not help_found:
# Allow either passing in a string, or function to call
if isinstance(default_action, str):
for arg in _g_options:
for func_name in arg.func_names:
if default_action.replace("-", "_") == func_name:
default_action = arg.func
default_action(*sys.argv[1:])
return
# Ok, if we get here, we're going to show help, but first
# see if there's a picker to call instead, only if there
# isn't an explicit ask for help
if picker is not None and not help_found:
# We've been told to use a picker as a backup method of
# selecting an action, so call it and see if it runs
options = []
for cur in _g_options:
if not cur.hidden:
options.append((cur.help, cur))
picked = picker(options)
if picked is not None:
picked.func()
return
options = []
for cur in _g_options:
for sub in cur.create_clones():
options.append(sub)
max_len = 0
for arg in options:
if not arg.hidden:
optional = " ".join(arg.args)
if len(arg.other) + len(optional) <= 25:
max_len = max(len(arg.other) + len(optional), max_len)
dec_len = 70
for arg in options:
if not arg.hidden:
optional = " ".join(arg.args)
if len(arg.other) + len(optional) <= max_len:
dec_len = max(dec_len, len("%s %s%s = %s" % (
arg.other,
optional,
" " * (max_len - (len(arg.other) + len(optional))),
arg.help)))
else:
dec_len = max(dec_len, len("%s %s%s" % (
arg.other,
optional,
" " * (max_len - (len(arg.other) + len(optional))))))
dec_len = max(dec_len, len("%s = %s" % (
" " * max_len,
arg.help)))
if program_desc is not None:
program_desc = textwrap.dedent(program_desc)
program_desc = program_desc.strip(" \r\n")
program_desc = program_desc.replace("\r\n", "\n")
program_desc = program_desc.split("\n")
for cur in program_desc:
dec_len = max(dec_len, len(cur))
print("-" * dec_len)
for cur in program_desc:
print(cur)
print("-" * dec_len)
if order_by == 'func':
options.sort(key=lambda x: x.other)
elif order_by == 'desc':
options.sort(key=lambda x: x.help)
elif order_by == 'special':
options.sort(key=lambda x: x.special)
elif order_by == 'none':
pass
else:
raise Exception("Sort expected to be one of 'func', 'desc', 'special', 'none'")
groups = set([x.group_name for x in options])
groups = sorted(groups)
for group in groups:
if len(groups) > 1:
group_pretty = "(Misc)" if len(group) == 0 else group
print(" %s %s %s" % (
"-" * 10,
group_pretty,
"-" * (50 - len(group_pretty)),
))
for arg in options:
if not arg.hidden and arg.group_name == group:
optional = " ".join(arg.args)
if len(arg.other) + len(optional) <= max_len:
print("%s %s%s = %s" % (
arg.other,
optional,
" " * (max_len - (len(arg.other) + len(optional))),
arg.help))
else:
print("%s %s%s" % (
arg.other,
optional,
" " * (max_len - (len(arg.other) + len(optional)))))
print("%s = %s" % (
" " * max_len,
arg.help))
if len(arg.aka) > 0:
print("%s Or: %s" % (" " * (max_len + 3), ", ".join(arg.aka)))
print("-" * dec_len)
sys.exit(1)
def get_sample_code():
# See what this script should be called, basically
# if this is in a package, add the package name,
# otherwise the name is just "command_opts", but only
# support for Python3, since the python2 "version" of
# this script rarely gets new features
import_name = "command_opts"
if __name__ != "__main__":
import_name = __name__
else:
# Only check for the package version on Python 3
if sys.version_info.major >= 3:
from pathlib import Path
temp = Path(__file__).parent.joinpath(Path("__init__.py"))
if os.path.isfile(temp):
import_name = str(temp.parent.name) + "." + import_name
return SAMPLE_CODE.replace("command_opts", import_name)
def main():
# If called as a script, just dump a little help screen
# showing how to embed this script in another script
print(get_sample_code())
sys.exit(1)
if __name__ == "__main__":
main()