-
Notifications
You must be signed in to change notification settings - Fork 29
/
cli_gen.py
executable file
·270 lines (231 loc) · 9.23 KB
/
cli_gen.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
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors
#
# SPDX-License-Identifier: Apache-2.0
import argparse
import enum
import functools
import inspect
import itertools
import os
import pkgutil
import sys
import types
try:
import ci.util
except ModuleNotFoundError:
repo_dir = os.path.abspath(
os.path.join(
os.path.dirname(__name__),
os.pardir,
os.pardir,
)
)
sys.path.insert(1, repo_dir)
import ci.util
import ci.log
# to overwrite cli.py log level, call
# "configure_default_logging(force=True, stdout_level=logging.DEBUG)" in specific module cli
ci.log.configure_default_logging(force=True)
import ctx # noqa: E402
cfg = ctx.cfg
terminal_cfg = cfg.terminal
if terminal_cfg and terminal_cfg.output_columns is not None:
column_width = terminal_cfg.output_columns
# Create a custom width formatter by fixing two arguments for the default formatter class,
# namely 'width' (defaults to 80 - 2) and 'max_help_position' (defaults to 24)
FORMATTER_CLASS = functools.partial(
argparse.RawDescriptionHelpFormatter,
max_help_position=24,
width=column_width
)
else:
FORMATTER_CLASS = argparse.RawDescriptionHelpFormatter
def _subcommand_modules() -> dict[str, str]:
'''
returns a dict where keys are subcommand-names, and values are module-names
(module-names are sometimes prefixed with an underscore in this directory to avoid
name conflicts)
'''
fnames = [
n for n in os.listdir(os.path.dirname(os.path.realpath(__file__)))
if n.endswith('.py') and not n == os.path.basename(__file__) and not n == '__init__.py'
]
return {
n.removeprefix('_').removesuffix('.py'): n.removesuffix('.py')
for n in fnames
}
def main():
'''
Creates a command line parser (using argparse) for each python module found in this
directory (except for _this_ module). For each module, a sub-command named as the
module name is added. Each function defined in a given module is again added as a
sub-sub-command. Based on the function signature, optional arguments are added.
This parser is then used to parse the given ARGV. Provided that parsing succeeds,
the thus specified function is executed.
'''
subcommand_modules = _subcommand_modules()
# as an optimisation, try to parse chosen subcommand (if any). If there is one, we can skip
# loading any other modules (which saves a lot of startup time).
parser = argparse.ArgumentParser()
parser.add_argument('subcommand', nargs=1, choices=subcommand_modules.keys())
add_global_args(parser)
parsed, _ = parser.parse_known_args()
if parsed.subcommand:
subcommand = parsed.subcommand[0]
chosen_module_name = subcommand_modules[subcommand]
else:
subcommand = None
chosen_module_name = None
parser = argparse.ArgumentParser(formatter_class=FORMATTER_CLASS)
add_global_args(parser)
sub_command_parsers = parser.add_subparsers()
cli_module_dir = os.path.dirname(os.path.abspath(os.path.realpath(__file__)))
sys.path.insert(0, cli_module_dir)
for _, module_name, _ in pkgutil.iter_modules([cli_module_dir]):
# if subcommand was given, only load module for chosen subcommand
if chosen_module_name and module_name != chosen_module_name:
continue
# skip own module name
if module_name == os.path.splitext(os.path.basename(__file__))[0]:
continue
if module_name == 'setup': # skip setup.py
continue
add_module(module_name, sub_command_parsers)
if len(sys.argv) == 1:
parser.print_usage()
sys.exit(1)
parsed = parser.parse_args()
# write parsed args to global ctx module so called module functions may
# retrieve if (see ci.util.ctx)
ctx.args = parsed
ctx.load_config()
# mark 'cli' mode
ci.util._set_cli(True)
if hasattr(parsed, 'module'):
parsed.module.args = parsed
parsed.func(parsed)
def add_global_args(parser):
parser.add_argument('--quiet', action='store_true')
parser.add_argument('--verbose', action='store_true')
parser.add_argument('--cfg-dir', default=None)
def add_module(module_name, parser):
module = __import__(module_name)
# skip if module defines a symbol 'main'
if hasattr(module, 'main'):
return
if hasattr(module, '__cmd_name__'):
cmd_name = module.__cmd_name__
else:
cmd_name = module_name
module_parser = parser.add_parser(cmd_name, formatter_class=FORMATTER_CLASS)
module_parser.set_defaults(
func=display_usage_function(module_parser),
module=module
)
# add module-specific arguments
if hasattr(module, '__add_module_command_args'):
getattr(module, '__add_module_command_args')(module_parser)
function_parsers = module_parser.add_subparsers()
for fname, function in inspect.getmembers(module, predicate=inspect.isfunction):
if fname.startswith('_'):
continue # skip "private" functions
function_docstring = inspect.getdoc(function)
function_parser = function_parsers.add_parser(
fname,
description=function_docstring,
formatter_class=FORMATTER_CLASS,
)
fspec = inspect.getfullargspec(function)
function_parser.set_defaults(func=run_function(function))
action = None
# defaults are filled "from the end", so reverse both argnames and defaults
for argname, default in reversed(list(
itertools.zip_longest(
reversed(fspec.args),
reversed(fspec.defaults or []),
fillvalue=NotImplemented # workaround to be able to discriminate from None
)
)):
cl_arg = '--' + argname.replace('_', '-')
annotation = fspec.annotations.get(argname, None)
argtype = None
action = None
kwargs = {}
if annotation:
from ci.util import CliHint
# special case: CliHint
if type(annotation) == CliHint:
typehint = annotation.typehint
kwargs.update(annotation.argparse_args)
else:
typehint = annotation
# handle type-specific actions (lists, booleans, ..)
if type(typehint) == type: # primitives (str, bool, int, ..)
argtype = typehint
if typehint == bool:
action = 'store_true'
argtype = None # type must not be set for store_true/store_false actions
elif type(typehint) == list:
action = 'append'
elif isinstance(typehint, types.GenericAlias):
# assume it is a list/sequence
type_args = typehint.__args__
if len(type_args) == 1:
argtype = type_args[0]
action = 'append'
else:
print('Error: GenericAlias must have exactly one type-parameter')
exit(1)
elif callable(typehint):
argtype = typehint
if inspect.isclass(typehint) and issubclass(typehint, enum.Enum):
# XXX: improve online-help
kwargs['choices'] = [
e for e in typehint
]
if default != NotImplemented:
required = False
else:
required = True
default = None # set back to None to not have argparser behave strangely :-)
# add_argument does not allow 'type' as a parameter in some cases;
# workaround this by omitting it in all cases where it is None anyway
if argtype is not None and 'type' not in kwargs:
kwargs['type'] = argtype
if action:
kwargs['action'] = action
if default:
help_text = kwargs.get('help', '')
help_text += '(default: %(default)s)'
kwargs['help'] = help_text
function_parser.add_argument(
cl_arg,
required=required,
default=default,
**kwargs
)
if annotation == bool and not argname.startswith('no'):
kwargs['help'] = '(default: False)'
cl_arg = '--no-' + argname.replace('_', '-')
kwargs['action'] = 'store_false'
function_parser.add_argument(
cl_arg,
required=False,
dest=argname.replace('-', '_'),
**kwargs
)
def run_function(function):
def function_runner(args):
fspec = inspect.getfullargspec(function)
function_args = []
for argname in fspec.args:
function_args.append(getattr(args, argname))
function(*function_args)
return function_runner
def display_usage_function(parser):
def display_usage(_):
parser.print_usage()
return display_usage
if __name__ == '__main__':
main()