cs-cmdutils 20250306__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cs/cmdutils.py +2054 -0
- cs_cmdutils-20250306.dist-info/METADATA +1097 -0
- cs_cmdutils-20250306.dist-info/RECORD +4 -0
- cs_cmdutils-20250306.dist-info/WHEEL +5 -0
cs/cmdutils.py
ADDED
|
@@ -0,0 +1,2054 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Command line stuff. - Cameron Simpson <cs@cskk.id.au> 03sep2015
|
|
4
|
+
#
|
|
5
|
+
# pylint: disable=too-many-lines
|
|
6
|
+
|
|
7
|
+
''' Convenience functions for working with the cmd stdlib module,
|
|
8
|
+
the BaseCommand class for constructing command line programmes,
|
|
9
|
+
and other command line related stuff.
|
|
10
|
+
|
|
11
|
+
This module provides the following main items:
|
|
12
|
+
- `@docmd`: a decorator for command methods of a `cmd.Cmd` class
|
|
13
|
+
providing better quality of service
|
|
14
|
+
- `BaseCommand`: a base class for creating command line programmes
|
|
15
|
+
with easier setup and usage than libraries like `optparse` or `argparse`
|
|
16
|
+
- `@popopts`: a decorator which works with `BaseCommand` command
|
|
17
|
+
methods to parse their command line options
|
|
18
|
+
|
|
19
|
+
Editorial: why not arparse?
|
|
20
|
+
I find the whole argparse `add_argument` thing very cumbersome
|
|
21
|
+
and hard to use and remember.
|
|
22
|
+
Also, when incorrectly invoked an argparse command line prints
|
|
23
|
+
the help/usage messgae and aborts the whole programme with
|
|
24
|
+
`SystemExit`.
|
|
25
|
+
'''
|
|
26
|
+
|
|
27
|
+
from cmd import Cmd
|
|
28
|
+
from code import interact
|
|
29
|
+
from collections import ChainMap
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from dataclasses import dataclass, field, fields
|
|
32
|
+
try:
|
|
33
|
+
from functools import cache # 3.9 onward
|
|
34
|
+
except ImportError:
|
|
35
|
+
from functools import lru_cache
|
|
36
|
+
cache = lru_cache(maxsize=None)
|
|
37
|
+
try:
|
|
38
|
+
from functools import cached_property # 3.8 onward
|
|
39
|
+
except ImportError:
|
|
40
|
+
cached_property = lambda func: property(cache(func))
|
|
41
|
+
|
|
42
|
+
from getopt import getopt, GetoptError
|
|
43
|
+
from inspect import isclass
|
|
44
|
+
import os
|
|
45
|
+
from os.path import basename
|
|
46
|
+
from pprint import pformat
|
|
47
|
+
# this enables readline support in the docmd stuff
|
|
48
|
+
try:
|
|
49
|
+
import readline # pylint: disable=unused-import
|
|
50
|
+
except ImportError:
|
|
51
|
+
pass
|
|
52
|
+
import shlex
|
|
53
|
+
from signal import SIGHUP, SIGINT, SIGQUIT, SIGTERM
|
|
54
|
+
import sys
|
|
55
|
+
from textwrap import dedent
|
|
56
|
+
from typing import Any, Callable, List, Mapping, Optional, Tuple, Union
|
|
57
|
+
|
|
58
|
+
from typeguard import typechecked
|
|
59
|
+
|
|
60
|
+
from cs.context import stackattrs
|
|
61
|
+
from cs.deco import decorator, OBSOLETE, uses_cmd_options
|
|
62
|
+
from cs.lex import (
|
|
63
|
+
cutprefix,
|
|
64
|
+
cutsuffix,
|
|
65
|
+
indent,
|
|
66
|
+
is_identifier,
|
|
67
|
+
r,
|
|
68
|
+
stripped_dedent,
|
|
69
|
+
tabulate,
|
|
70
|
+
)
|
|
71
|
+
from cs.logutils import setup_logging, warning, error, exception
|
|
72
|
+
from cs.pfx import Pfx, pfx_call, pfx_method
|
|
73
|
+
from cs.py.doc import obj_docstring
|
|
74
|
+
from cs.resources import RunState, uses_runstate
|
|
75
|
+
from cs.result import CancellationError
|
|
76
|
+
from cs.threads import HasThreadState, ThreadState
|
|
77
|
+
from cs.typingutils import subtype
|
|
78
|
+
from cs.upd import Upd, uses_upd, print # pylint: disable=redefined-builtin
|
|
79
|
+
|
|
80
|
+
__version__ = '20250306'
|
|
81
|
+
|
|
82
|
+
DISTINFO = {
|
|
83
|
+
'keywords': ["python2", "python3"],
|
|
84
|
+
'classifiers': [
|
|
85
|
+
"Programming Language :: Python",
|
|
86
|
+
"Programming Language :: Python :: 3",
|
|
87
|
+
],
|
|
88
|
+
'install_requires': [
|
|
89
|
+
'cs.context',
|
|
90
|
+
'cs.deco',
|
|
91
|
+
'cs.lex',
|
|
92
|
+
'cs.logutils',
|
|
93
|
+
'cs.pfx',
|
|
94
|
+
'cs.py.doc',
|
|
95
|
+
'cs.resources',
|
|
96
|
+
'cs.result',
|
|
97
|
+
'cs.threads',
|
|
98
|
+
'cs.typingutils',
|
|
99
|
+
'cs.upd',
|
|
100
|
+
'typeguard',
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def docmd(dofunc):
|
|
105
|
+
''' Decorator for `cmd.Cmd` subclass methods
|
|
106
|
+
to supply some basic quality of service.
|
|
107
|
+
|
|
108
|
+
This decorator:
|
|
109
|
+
- wraps the function call in a `cs.pfx.Pfx` for context
|
|
110
|
+
- intercepts `getopt.GetoptError`s, issues a `warning`
|
|
111
|
+
and runs `self.do_help` with the method name,
|
|
112
|
+
then returns `None`
|
|
113
|
+
- intercepts other `Exception`s,
|
|
114
|
+
issues an `exception` log message
|
|
115
|
+
and returns `None`
|
|
116
|
+
|
|
117
|
+
The intended use is to decorate `cmd.Cmd` `do_`* methods:
|
|
118
|
+
|
|
119
|
+
from cmd import Cmd
|
|
120
|
+
from cs.cmdutils import docmd
|
|
121
|
+
...
|
|
122
|
+
class MyCmd(Cmd):
|
|
123
|
+
@docmd
|
|
124
|
+
def do_something(...):
|
|
125
|
+
... do something ...
|
|
126
|
+
'''
|
|
127
|
+
funcname = dofunc.__name__
|
|
128
|
+
|
|
129
|
+
def docmd_wrapper(self, *a, **kw):
|
|
130
|
+
''' Run a `Cmd` "do" method with some context and handling.
|
|
131
|
+
'''
|
|
132
|
+
if not funcname.startswith('do_'):
|
|
133
|
+
raise ValueError("function does not start with 'do_': %s" % (funcname,))
|
|
134
|
+
argv0 = funcname[3:]
|
|
135
|
+
with Pfx(argv0):
|
|
136
|
+
try:
|
|
137
|
+
return dofunc(self, *a, **kw)
|
|
138
|
+
except GetoptError as e:
|
|
139
|
+
warning("%s", e)
|
|
140
|
+
self.do_help(argv0)
|
|
141
|
+
return None
|
|
142
|
+
except Exception as e: # pylint: disable=broad-except
|
|
143
|
+
exception("%s", e)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
docmd_wrapper.__name__ = '@docmd(%s)' % (funcname,)
|
|
147
|
+
docmd_wrapper.__doc__ = dofunc.__doc__
|
|
148
|
+
return docmd_wrapper
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class OptionSpec:
|
|
152
|
+
''' A class to support parsing an option value.
|
|
153
|
+
'''
|
|
154
|
+
|
|
155
|
+
UNVALIDATED_MESSAGE_DEFAULT = "invalid value"
|
|
156
|
+
|
|
157
|
+
# the option name, eg 'n' for -n or 'dry-run' for --dry-run
|
|
158
|
+
opt_name: str
|
|
159
|
+
# whether an argument is expected
|
|
160
|
+
# the argument usage name or None
|
|
161
|
+
# eg "username"
|
|
162
|
+
arg_name: Optional[str] = None
|
|
163
|
+
# the name of the options field/attribute
|
|
164
|
+
field_name: Optional[str] = None
|
|
165
|
+
# the initial value of the option
|
|
166
|
+
field_default: Optional[Any] = None
|
|
167
|
+
# the help text
|
|
168
|
+
help_text: Optional[str] = None
|
|
169
|
+
# optional callable to convert the argument to the value
|
|
170
|
+
parse: Optional[Callable[[str], Any]] = None
|
|
171
|
+
# optional callable to validate the value
|
|
172
|
+
validate: Optional[Callable[[Any], bool]] = None
|
|
173
|
+
# optional message for invalid values
|
|
174
|
+
unvalidated_message: str = UNVALIDATED_MESSAGE_DEFAULT
|
|
175
|
+
|
|
176
|
+
def __post_init__(self):
|
|
177
|
+
''' Infer `field_name` and `help_text` if unspecified.
|
|
178
|
+
'''
|
|
179
|
+
if self.field_name is None:
|
|
180
|
+
self.field_name = self.opt_name.replace('-', '_')
|
|
181
|
+
if self.help_text is None:
|
|
182
|
+
self.help_text = self.help_text_from_field_name(self.field_name)
|
|
183
|
+
|
|
184
|
+
def parse_value(self, value):
|
|
185
|
+
''' Parse `value` according to the spec.
|
|
186
|
+
Raises `GetoptError` for invalid values.
|
|
187
|
+
'''
|
|
188
|
+
if self.parse is None:
|
|
189
|
+
return value
|
|
190
|
+
with Pfx("%s %r", self.help_text, value):
|
|
191
|
+
try:
|
|
192
|
+
value = pfx_call(self.parse, value)
|
|
193
|
+
if self.validate is not None:
|
|
194
|
+
try:
|
|
195
|
+
if not pfx_call(self.validate, value):
|
|
196
|
+
raise GetoptError(self.unvalidated_message)
|
|
197
|
+
except ValueError as e:
|
|
198
|
+
raise GetoptError(
|
|
199
|
+
f'{self.unvalidated_message}: {e.__class__.__name__}:{e}'
|
|
200
|
+
) from e
|
|
201
|
+
except ValueError as e:
|
|
202
|
+
raise GetoptError(str(e)) from e # pylint: disable=raise-missing-from
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
@pfx_method
|
|
207
|
+
def from_opt_kw(cls, opt_k: str, specs: Union[str, List, Tuple, None]):
|
|
208
|
+
''' Factory to produce an `OptionSpec` from a `(key,specs)` 2-tuple
|
|
209
|
+
as from the `items()` from a `popopts()` call.
|
|
210
|
+
|
|
211
|
+
The `specs` is normally a list or tuple, but a bare string
|
|
212
|
+
will be promoted to a 1-element list containing the string.
|
|
213
|
+
|
|
214
|
+
The elements of `specs` are considered in order for:
|
|
215
|
+
- an identifier specifying the `arg_name`,
|
|
216
|
+
optionally prepended with a dash to indicate an inverted option
|
|
217
|
+
- a help text about the option
|
|
218
|
+
- a callable to parse the option string value to obtain the actual value
|
|
219
|
+
- a callable to validate the option value
|
|
220
|
+
- a message for use when validation fails
|
|
221
|
+
'''
|
|
222
|
+
# produce needs_arg and cleaned up opt_name from the opt_k
|
|
223
|
+
needs_arg = False
|
|
224
|
+
# leading underscore for numeric options like -1
|
|
225
|
+
if opt_k.startswith('_'):
|
|
226
|
+
opt_k = opt_k[1:]
|
|
227
|
+
if is_identifier(opt_k):
|
|
228
|
+
warning("unnecessary leading underscore on valid identifier option")
|
|
229
|
+
# trailing underscore indicates that the option expected an argument
|
|
230
|
+
if opt_k.endswith('_'):
|
|
231
|
+
needs_arg = True
|
|
232
|
+
opt_k = opt_k[:-1]
|
|
233
|
+
opt_name = opt_k.replace('_', '-')
|
|
234
|
+
field_name = opt_k.replace('-', '_')
|
|
235
|
+
field_default = None
|
|
236
|
+
help_text = None
|
|
237
|
+
parse = None
|
|
238
|
+
validate = None
|
|
239
|
+
unvalidated_message = None
|
|
240
|
+
# apply the provided specifications
|
|
241
|
+
if specs is None:
|
|
242
|
+
specs = [field_name]
|
|
243
|
+
elif isinstance(specs, str):
|
|
244
|
+
# bare field name or help text
|
|
245
|
+
specs = [specs]
|
|
246
|
+
elif isinstance(specs, (list, tuple)):
|
|
247
|
+
specs = list(specs)
|
|
248
|
+
elif callable(specs):
|
|
249
|
+
# bare conversion function (or a type eg int)
|
|
250
|
+
specs = [specs]
|
|
251
|
+
else:
|
|
252
|
+
raise TypeError(
|
|
253
|
+
f'expected str or list or tuple for specs, got {r(specs)}'
|
|
254
|
+
)
|
|
255
|
+
spec0 = specs.pop(0) if specs else None
|
|
256
|
+
# first: optional field_name
|
|
257
|
+
# field_name, an identifier
|
|
258
|
+
if isinstance(spec0, str) and is_identifier(spec0):
|
|
259
|
+
field_name = spec0
|
|
260
|
+
spec0 = specs.pop(0) if specs else None
|
|
261
|
+
# -field_name, a dash followed by an identifier
|
|
262
|
+
elif (isinstance(spec0, str) and spec0.startswith('-')
|
|
263
|
+
and is_identifier(spec0[1:])):
|
|
264
|
+
field_name = spec0[1:]
|
|
265
|
+
if needs_arg:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f'field name {field_name!r} expects an aegument'
|
|
268
|
+
': inverted options only make sense for Boolean options'
|
|
269
|
+
)
|
|
270
|
+
field_default = True
|
|
271
|
+
spec0 = specs.pop(0) if specs else None
|
|
272
|
+
# optional help text
|
|
273
|
+
if isinstance(spec0, str):
|
|
274
|
+
help_text = spec0
|
|
275
|
+
spec0 = specs.pop(0) if specs else None
|
|
276
|
+
# optional parse callable
|
|
277
|
+
if callable(spec0):
|
|
278
|
+
parse = spec0
|
|
279
|
+
spec0 = specs.pop(0) if specs else None
|
|
280
|
+
# optional validate callable
|
|
281
|
+
if callable(spec0):
|
|
282
|
+
validate = spec0
|
|
283
|
+
spec0 = specs.pop(0) if specs else None
|
|
284
|
+
# optional unvalidated_message
|
|
285
|
+
if isinstance(spec0, str):
|
|
286
|
+
if not parse and not validate:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f'unexpected unvalidated_message {spec0!r} when there is no parse or validate callable'
|
|
289
|
+
)
|
|
290
|
+
unvalidated_message = spec0
|
|
291
|
+
spec0 = specs.pop(0) if specs else None
|
|
292
|
+
if spec0 is not None:
|
|
293
|
+
raise ValueError(f'unhandled specifications: {[spec0]+specs!r}')
|
|
294
|
+
if not needs_arg:
|
|
295
|
+
# sanity check Boolean option
|
|
296
|
+
if field_default is None:
|
|
297
|
+
field_default = False
|
|
298
|
+
elif not isinstance(field_default, bool):
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f'non-Booolean specified for the field default: {r(field_default)}'
|
|
301
|
+
)
|
|
302
|
+
if field_default is None and not needs_arg:
|
|
303
|
+
field_default = False
|
|
304
|
+
self = cls(
|
|
305
|
+
opt_name=opt_name,
|
|
306
|
+
arg_name=(field_name.replace('_', '-') if needs_arg else None),
|
|
307
|
+
field_name=field_name,
|
|
308
|
+
field_default=field_default,
|
|
309
|
+
help_text=help_text,
|
|
310
|
+
parse=parse,
|
|
311
|
+
validate=validate,
|
|
312
|
+
unvalidated_message=unvalidated_message,
|
|
313
|
+
)
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def needs_arg(self):
|
|
318
|
+
''' Whether we expect an argument: we have a `self.arg_name`.
|
|
319
|
+
'''
|
|
320
|
+
return bool(self.arg_name)
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def getopt_opt(self):
|
|
324
|
+
''' The `opt` we expect from `opt,val=getopt(argv,...)`.
|
|
325
|
+
'''
|
|
326
|
+
return (
|
|
327
|
+
f'-{self.opt_name}'
|
|
328
|
+
if len(self.opt_name) == 1 else f'--{self.opt_name}'
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def getopt_short(self):
|
|
333
|
+
''' The option specification for a getopt short option.
|
|
334
|
+
Return `''` if `self.opt_name` is longer than 1 character.
|
|
335
|
+
'''
|
|
336
|
+
if len(self.opt_name) > 1:
|
|
337
|
+
return ''
|
|
338
|
+
opt_char = self.opt_name[0]
|
|
339
|
+
return f'{opt_char}:' if self.needs_arg else opt_char
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def getopt_long(self):
|
|
343
|
+
''' The option specification for a getopt long option.
|
|
344
|
+
Return `None` if `self.opt_name` is only 1 character.
|
|
345
|
+
'''
|
|
346
|
+
if len(self.opt_name) < 2:
|
|
347
|
+
return None
|
|
348
|
+
return f'{self.opt_name}=' if self.needs_arg else f'{self.opt_name}'
|
|
349
|
+
|
|
350
|
+
def option_terse(self):
|
|
351
|
+
''' Return the `"-x"` or `"--name"` option string (with the arg name if expected).
|
|
352
|
+
'''
|
|
353
|
+
return f'{self.getopt_opt} {self.arg_name}' if self.needs_arg else self.getopt_opt
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def help_text_from_field_name(field_name):
|
|
357
|
+
''' Compute an inferred `help_text` from an option `field_name`.
|
|
358
|
+
'''
|
|
359
|
+
help_text = field_name.replace('_', ' ')
|
|
360
|
+
return help_text[0].upper() + help_text[1:] + '.'
|
|
361
|
+
|
|
362
|
+
def option_usage(self):
|
|
363
|
+
''' A 2 line usage entry for this option.
|
|
364
|
+
Example:
|
|
365
|
+
|
|
366
|
+
-j jobs
|
|
367
|
+
Job limit.
|
|
368
|
+
'''
|
|
369
|
+
line1 = self.option_terse()
|
|
370
|
+
# TODO: allow multiline help_text, indent it here
|
|
371
|
+
return f'{line1}\n {self.help_text}'
|
|
372
|
+
|
|
373
|
+
def add_argument(self, parser, options=None):
|
|
374
|
+
''' Add this option to an `argparser`-style option parser.
|
|
375
|
+
The optional `options` parameter may be used to supply an
|
|
376
|
+
`Options` instance to provide a default value.
|
|
377
|
+
'''
|
|
378
|
+
parser.add_argument(
|
|
379
|
+
self.getopt_opt,
|
|
380
|
+
action=('store' if self.arg_name else 'store_true'),
|
|
381
|
+
dest=self.field_name,
|
|
382
|
+
help=self.help_text,
|
|
383
|
+
default=(
|
|
384
|
+
(None if self.arg_name else False)
|
|
385
|
+
if options is None else getattr(options, self.field_name, None)
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def split_usage(doc: Union[str, None],
|
|
390
|
+
usage_marker="Usage:") -> Tuple[str, str, str]:
|
|
391
|
+
''' Extract a `"Usage:"`paragraph from a docstring
|
|
392
|
+
and return a 3-tuple of `(preusage,usage,postusage)`.
|
|
393
|
+
|
|
394
|
+
If the usage paragraph is not present `''` is returned as
|
|
395
|
+
the middle comonpent, otherwise it is the unindented usage
|
|
396
|
+
without the leading `"Usage:"`.
|
|
397
|
+
'''
|
|
398
|
+
if not doc:
|
|
399
|
+
# no doc, return unchanged
|
|
400
|
+
return '', '', ''
|
|
401
|
+
try:
|
|
402
|
+
pre_usage, usage_onward = doc.split(usage_marker, 1)
|
|
403
|
+
except ValueError:
|
|
404
|
+
# no usage: paragraph
|
|
405
|
+
return doc, '', ''
|
|
406
|
+
try:
|
|
407
|
+
usage_format, post_usage = usage_onward.split("\n\n", 1)
|
|
408
|
+
except ValueError:
|
|
409
|
+
usage_format, post_usage = usage_onward.rstrip(), ''
|
|
410
|
+
usage_format = stripped_dedent(usage_format)
|
|
411
|
+
# indent the second and following lines
|
|
412
|
+
try:
|
|
413
|
+
top_line, post_lines = usage_format.split("\n", 1)
|
|
414
|
+
except ValueError:
|
|
415
|
+
# single line usage only
|
|
416
|
+
pass
|
|
417
|
+
else:
|
|
418
|
+
usage_format = f'{top_line}\n{indent(post_lines)}'
|
|
419
|
+
return pre_usage, usage_format, post_usage
|
|
420
|
+
|
|
421
|
+
@dataclass
|
|
422
|
+
class SubCommand:
|
|
423
|
+
''' An implementation for a subcommand.
|
|
424
|
+
'''
|
|
425
|
+
|
|
426
|
+
# the BaseCommand instance with which we're associated
|
|
427
|
+
command: "BaseCommand"
|
|
428
|
+
# a method or a subclass of BaseCommand
|
|
429
|
+
method: Callable
|
|
430
|
+
# the notional name of the command/subcommand
|
|
431
|
+
cmd: str = None
|
|
432
|
+
# optional additional usage keyword mapping
|
|
433
|
+
usage_mapping: Mapping[str, Any] = field(default_factory=dict)
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def instance(self):
|
|
437
|
+
''' An instance of the class for `self.method`.
|
|
438
|
+
'''
|
|
439
|
+
return self.method(...) if isclass(self.method) else self.method.__self__
|
|
440
|
+
|
|
441
|
+
def get_cmd(self) -> str:
|
|
442
|
+
''' Return the `cmd` string for this `SubCommand`,
|
|
443
|
+
derived from the subcommand's method name or class name
|
|
444
|
+
if `self.cmd` is not set.
|
|
445
|
+
'''
|
|
446
|
+
if self.cmd is None:
|
|
447
|
+
method = self.method
|
|
448
|
+
if isclass(method):
|
|
449
|
+
return cutsuffix(method.__name__, 'Command').lower()
|
|
450
|
+
return cutprefix(method.__name__, self.command.SUBCOMMAND_METHOD_PREFIX)
|
|
451
|
+
return self.cmd
|
|
452
|
+
|
|
453
|
+
@typechecked
|
|
454
|
+
def __call__(self, argv: List[str]):
|
|
455
|
+
''' Run the subcommand.
|
|
456
|
+
|
|
457
|
+
Parameters:
|
|
458
|
+
* `argv`: the command line arguments after the subcommand name
|
|
459
|
+
'''
|
|
460
|
+
method = self.method
|
|
461
|
+
if isclass(method):
|
|
462
|
+
# plumb self.command.options through to the subcommand
|
|
463
|
+
updates = self.command.options.as_dict()
|
|
464
|
+
updates.update(cmd=self.get_cmd())
|
|
465
|
+
return pfx_call(method, argv, **updates).run()
|
|
466
|
+
return method(argv)
|
|
467
|
+
|
|
468
|
+
@cached_property
|
|
469
|
+
def usage_default(self):
|
|
470
|
+
''' The fallback usage line if nothing specified:
|
|
471
|
+
`'{cmd} [options...]'` or `'{cmd} subcommand [options...]'`.
|
|
472
|
+
'''
|
|
473
|
+
if isclass(self.method):
|
|
474
|
+
has_subcommands_test = getattr(
|
|
475
|
+
self.instance, 'has_subcommands', lambda: False
|
|
476
|
+
)
|
|
477
|
+
else:
|
|
478
|
+
has_subcommands_test = getattr(
|
|
479
|
+
self.method, 'has_subcommands', lambda: False
|
|
480
|
+
)
|
|
481
|
+
return (
|
|
482
|
+
'{cmd} subcommand [options...]'
|
|
483
|
+
if has_subcommands_test() else '{cmd} [options...]'
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
@cached_property
|
|
487
|
+
def usage_commonopts_format(self):
|
|
488
|
+
''' The `Common options:` format string paragraph
|
|
489
|
+
or `None` if there are no common options.
|
|
490
|
+
'''
|
|
491
|
+
return self.command.Options.usage_options_format(
|
|
492
|
+
"Common options:", **self.command.options.COMMON_OPT_SPECS
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
@cached_property
|
|
496
|
+
def usage_format(self) -> str:
|
|
497
|
+
''' The usage format string for this subcommand.
|
|
498
|
+
*Note*: no leading "Usage:" prefix.
|
|
499
|
+
|
|
500
|
+
This first tries the legacy `self.method.USAGE_FORMAT`,
|
|
501
|
+
falling back to deriving it from `obj_docstring(self.method)`.
|
|
502
|
+
|
|
503
|
+
When deriving from the docstring we look for a paragraph
|
|
504
|
+
commencing with the string `Usage:` and otherwise fall back
|
|
505
|
+
to its first parapgraph.
|
|
506
|
+
'''
|
|
507
|
+
method = self.method
|
|
508
|
+
try:
|
|
509
|
+
# the old way
|
|
510
|
+
usage_format = method.USAGE_FORMAT
|
|
511
|
+
except AttributeError:
|
|
512
|
+
# the preferred way
|
|
513
|
+
# derive from the docstring or from self.usage_default
|
|
514
|
+
doc = obj_docstring(method)
|
|
515
|
+
pre_usage, usage_format, post_usage = split_usage(doc)
|
|
516
|
+
if not usage_format:
|
|
517
|
+
# No "Usage:" paragraph - use default usage line and first paragraph.
|
|
518
|
+
usage_format = self.usage_default
|
|
519
|
+
paragraph1 = stripped_dedent(pre_usage.split('\n\n', 1)[0])
|
|
520
|
+
if paragraph1:
|
|
521
|
+
usage_format += "\n" + indent(paragraph1)
|
|
522
|
+
else:
|
|
523
|
+
# The existing USAGE_FORMAT based usages have the word "Usage:"
|
|
524
|
+
# at the front but this is supplied at print time now.
|
|
525
|
+
usage_format = indent(
|
|
526
|
+
stripped_dedent(cutprefix(usage_format.lstrip(), 'Usage:'))
|
|
527
|
+
).lstrip()
|
|
528
|
+
return usage_format
|
|
529
|
+
|
|
530
|
+
@cached_property
|
|
531
|
+
def usage_format_parts(
|
|
532
|
+
self
|
|
533
|
+
) -> Tuple[str, Union[str, None], Union[str, None]]:
|
|
534
|
+
''' The usage description format string brokoen into:
|
|
535
|
+
- the usage line (or lines if slosh extended)
|
|
536
|
+
- the first line of the description, or `None`
|
|
537
|
+
- the trailing lines of the description, or `None`
|
|
538
|
+
'''
|
|
539
|
+
lines = self.usage_format.split('\n')
|
|
540
|
+
usage_lines = [lines.pop(0)]
|
|
541
|
+
while usage_lines[-1].endswith('\\'):
|
|
542
|
+
usage_lines.append(lines.pop(0))
|
|
543
|
+
return (
|
|
544
|
+
"\n".join(usage_lines),
|
|
545
|
+
lines.pop(0).lstrip() if lines and lines[0].endswith('.') else None,
|
|
546
|
+
dedent("\n".join(lines)) if lines else None,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
@cached_property
|
|
550
|
+
def usage_format_usage(self) -> str:
|
|
551
|
+
''' The usage line(s) part of the format string.
|
|
552
|
+
'''
|
|
553
|
+
return self.usage_format_parts[0]
|
|
554
|
+
|
|
555
|
+
@cached_property
|
|
556
|
+
def usage_format_desc1(self) -> str:
|
|
557
|
+
''' The leading usage line part of the format string.
|
|
558
|
+
'''
|
|
559
|
+
return self.usage_format_parts[1]
|
|
560
|
+
|
|
561
|
+
def get_usage_format(self, show_common=False) -> str:
|
|
562
|
+
''' Return the usage format string for this subcommand.
|
|
563
|
+
*Note*: no leading "Usage:" prefix.
|
|
564
|
+
If `show_common` is true, include the `Common options:` paragraph.
|
|
565
|
+
'''
|
|
566
|
+
usage_format = self.usage_format
|
|
567
|
+
if show_common:
|
|
568
|
+
copts_format = self.usage_commonopts_format
|
|
569
|
+
if copts_format:
|
|
570
|
+
usage_format += "\n" + indent(copts_format)
|
|
571
|
+
return usage_format
|
|
572
|
+
|
|
573
|
+
def get_usage_keywords(
|
|
574
|
+
self,
|
|
575
|
+
*,
|
|
576
|
+
cmd: Optional[str] = None,
|
|
577
|
+
usage_mapping: Optional[Mapping] = None,
|
|
578
|
+
) -> str:
|
|
579
|
+
''' Return a mapping to be used when formatting the usage format string.
|
|
580
|
+
|
|
581
|
+
This is an elaborate `ChainMap` of:
|
|
582
|
+
- the optional `cmd` or `self.get_cmd()`
|
|
583
|
+
- the optional `usage_mapping`
|
|
584
|
+
- `self.usage_mapping`
|
|
585
|
+
- `self.method.USAGE_KEYWORDS` if present
|
|
586
|
+
- the attributes of `self.command`
|
|
587
|
+
- the attributes of `type(self.command)`
|
|
588
|
+
- the attributes of the module for `type(self.command)`
|
|
589
|
+
'''
|
|
590
|
+
# TODO maybe this should return the ChainMap used in usage_text
|
|
591
|
+
# elaborate search path for symbols in the usage format string
|
|
592
|
+
# TODO: should this _be_ get_usage_keywords? pretty verbose
|
|
593
|
+
format_cmd = cmd or self.get_cmd().replace('_', '-')
|
|
594
|
+
if self.command.options.COMMON_OPT_SPECS:
|
|
595
|
+
format_cmd += ' [common-options...]'
|
|
596
|
+
return ChainMap(
|
|
597
|
+
# normalised cmd name
|
|
598
|
+
{'cmd': format_cmd},
|
|
599
|
+
# supplied usage_mapping, if any
|
|
600
|
+
usage_mapping or {},
|
|
601
|
+
# direct .usage_mapping attribute
|
|
602
|
+
self.usage_mapping or {},
|
|
603
|
+
# usage mapping via the method
|
|
604
|
+
dict(getattr(self.method, 'USAGE_KEYWORDS', {})),
|
|
605
|
+
# the names in the command
|
|
606
|
+
self.command.__dict__,
|
|
607
|
+
# ... and its class
|
|
608
|
+
self.command.__class__.__dict__,
|
|
609
|
+
# ... and its module's top level
|
|
610
|
+
sys.modules[self.command.__module__].__dict__,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def get_subcommands(self):
|
|
614
|
+
''' Return `self.method`'s mapping of subcommand name to `SubCommand`.
|
|
615
|
+
'''
|
|
616
|
+
method = self.method
|
|
617
|
+
if isclass(method):
|
|
618
|
+
method = method(...)
|
|
619
|
+
try:
|
|
620
|
+
get_subcommands = method.subcommands
|
|
621
|
+
except AttributeError:
|
|
622
|
+
return {}
|
|
623
|
+
return get_subcommands()
|
|
624
|
+
|
|
625
|
+
@property
|
|
626
|
+
def has_subcommands(self):
|
|
627
|
+
''' Whether this `SubCommand`'s `.method` has subcommands.
|
|
628
|
+
'''
|
|
629
|
+
return bool(self.get_subcommands())
|
|
630
|
+
|
|
631
|
+
def get_subcmds(self):
|
|
632
|
+
''' Return the names of `self.method`'s subcommands in lexical order.
|
|
633
|
+
'''
|
|
634
|
+
return sorted(self.get_subcommands().keys())
|
|
635
|
+
|
|
636
|
+
def subusage_table(self, subcmds: List[str], *, recurse=False, short=False):
|
|
637
|
+
''' Return rows for use with `cs.lex.tabulate`
|
|
638
|
+
for the short subusage listing.
|
|
639
|
+
'''
|
|
640
|
+
rows = []
|
|
641
|
+
subcommands = self.get_subcommands()
|
|
642
|
+
for subcmd in subcmds:
|
|
643
|
+
subcommand = subcommands[subcmd]
|
|
644
|
+
rows.append(
|
|
645
|
+
[
|
|
646
|
+
subcmd.replace('_', '-'),
|
|
647
|
+
(
|
|
648
|
+
# TODO: full desc if not short
|
|
649
|
+
subcommand.usage_format_desc1
|
|
650
|
+
or f'{subcmd.title()} subcommand.'
|
|
651
|
+
)
|
|
652
|
+
]
|
|
653
|
+
)
|
|
654
|
+
if recurse and subcommand.has_subcommands:
|
|
655
|
+
rows.extend(
|
|
656
|
+
[indent(subc), indent(subd)]
|
|
657
|
+
for subc, subd in subcommand.subusage_table(
|
|
658
|
+
sorted(subcommand.get_subcommands().keys()),
|
|
659
|
+
recurse=recurse,
|
|
660
|
+
short=short,
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
return rows
|
|
664
|
+
|
|
665
|
+
def short_subusages(self, subcmds: List[str], *, recurse=False, short=False):
|
|
666
|
+
''' Return a list of tabulated one line subcommand summaries.
|
|
667
|
+
'''
|
|
668
|
+
table = self.subusage_table(subcmds, recurse=recurse, short=short)
|
|
669
|
+
return list(tabulate(*table))
|
|
670
|
+
|
|
671
|
+
@typechecked
|
|
672
|
+
def usage_text(
|
|
673
|
+
self,
|
|
674
|
+
*,
|
|
675
|
+
cmd=None,
|
|
676
|
+
short: bool,
|
|
677
|
+
recurse: bool = False,
|
|
678
|
+
show_common: bool = False,
|
|
679
|
+
show_subcmds: Optional[Union[bool, str, List[str]]] = None,
|
|
680
|
+
usage_mapping: Optional[Mapping] = None,
|
|
681
|
+
seen_subcommands: Optional[Mapping] = None,
|
|
682
|
+
) -> str:
|
|
683
|
+
''' Return the filled out usage text for this subcommand.
|
|
684
|
+
'''
|
|
685
|
+
if show_subcmds is None:
|
|
686
|
+
show_subcmds = True
|
|
687
|
+
if seen_subcommands is None:
|
|
688
|
+
seen_subcommands = {}
|
|
689
|
+
subcommands = self.get_subcommands()
|
|
690
|
+
if show_subcmds:
|
|
691
|
+
# compute those already seen and those new
|
|
692
|
+
common_subcmds = subcommands.keys() & seen_subcommands.keys()
|
|
693
|
+
additional_subcommands = subcommands.keys() - common_subcmds
|
|
694
|
+
# turn show_subcmds into the list of subcommand names to show in the usage
|
|
695
|
+
if isinstance(show_subcmds, bool):
|
|
696
|
+
# all the subcommands winnowed by the sub_seen_subcommands
|
|
697
|
+
assert show_subcmds is True
|
|
698
|
+
show_subcmds = sorted(additional_subcommands)
|
|
699
|
+
elif isinstance(show_subcmds, str):
|
|
700
|
+
# show a single subcommand
|
|
701
|
+
show_subcmds = [show_subcmds]
|
|
702
|
+
# the seen_subcommands for our subcommands
|
|
703
|
+
sub_seen_subcommands = dict(seen_subcommands)
|
|
704
|
+
sub_seen_subcommands.update(subcommands)
|
|
705
|
+
# normalise the subcommand names to match the subcommands mapping
|
|
706
|
+
show_subcmds = [subcmd.replace('-', '_') for subcmd in show_subcmds]
|
|
707
|
+
if short:
|
|
708
|
+
usage_line, desc1, _ = self.usage_format_parts
|
|
709
|
+
usage_format = usage_line
|
|
710
|
+
if desc1:
|
|
711
|
+
usage_format += '\n' + indent(desc1)
|
|
712
|
+
else:
|
|
713
|
+
usage_format = self.usage_format
|
|
714
|
+
if show_common:
|
|
715
|
+
copts_format = self.usage_commonopts_format
|
|
716
|
+
if copts_format:
|
|
717
|
+
usage_format += "\n" + indent(copts_format)
|
|
718
|
+
# the elaborate search path for symbols in the usage format string
|
|
719
|
+
mapping = self.get_usage_keywords(cmd=cmd, usage_mapping=usage_mapping)
|
|
720
|
+
with Pfx("format %r using %r", usage_format, mapping):
|
|
721
|
+
usage = usage_format.format_map(mapping)
|
|
722
|
+
if short:
|
|
723
|
+
# the terse one subcommand-per-line listing
|
|
724
|
+
subusages = self.short_subusages(
|
|
725
|
+
show_subcmds, recurse=recurse, short=short
|
|
726
|
+
)
|
|
727
|
+
else:
|
|
728
|
+
# the longer descriptions
|
|
729
|
+
subusages = []
|
|
730
|
+
for subcmd in show_subcmds:
|
|
731
|
+
try:
|
|
732
|
+
subcommand = subcommands[subcmd]
|
|
733
|
+
except KeyError:
|
|
734
|
+
warning("unknown subcommand %r", subcmd)
|
|
735
|
+
else:
|
|
736
|
+
# recursive long listing
|
|
737
|
+
subusages.append(
|
|
738
|
+
subcommand.usage_text(
|
|
739
|
+
short=short,
|
|
740
|
+
recurse=recurse,
|
|
741
|
+
seen_subcommands=sub_seen_subcommands,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
subusage_listing = []
|
|
745
|
+
if common_subcmds:
|
|
746
|
+
common_subcmds_line = f'Common subcommands: {", ".join(sorted(common_subcmds))}.'
|
|
747
|
+
if subusages:
|
|
748
|
+
subcmds_header = (
|
|
749
|
+
'Subcommands'
|
|
750
|
+
if show_subcmds is None or len(show_subcmds) > 1 else 'Subcommand'
|
|
751
|
+
)
|
|
752
|
+
subusage_listing.append(f'{subcmds_header}:')
|
|
753
|
+
if common_subcmds:
|
|
754
|
+
subusage_listing.append(indent(common_subcmds_line))
|
|
755
|
+
subusage_listing.extend(map(indent, subusages))
|
|
756
|
+
else:
|
|
757
|
+
if common_subcmds:
|
|
758
|
+
subusage_listing.append(common_subcmds_line)
|
|
759
|
+
if subusage_listing:
|
|
760
|
+
subusage = "\n".join(subusage_listing)
|
|
761
|
+
usage = f'{usage}\n{indent(subusage)}'
|
|
762
|
+
return usage
|
|
763
|
+
|
|
764
|
+
# exposed outside the class for @fmtdoc
|
|
765
|
+
SSH_EXE_DEFAULT = 'ssh'
|
|
766
|
+
SSH_EXE_ENVVAR = 'SSH_EXE'
|
|
767
|
+
|
|
768
|
+
# gimmicked name to support @fmtdoc on BaseCommandOptions.popopts
|
|
769
|
+
_COMMON_OPT_SPECS = dict(
|
|
770
|
+
dry_run=('dry_run', 'Dry run, aka no action.'),
|
|
771
|
+
e_=(
|
|
772
|
+
'ssh_exe',
|
|
773
|
+
''' An ssh-like command to use for remote command execution.
|
|
774
|
+
The string is a shell-like command string parsable by shlex.split.''',
|
|
775
|
+
),
|
|
776
|
+
n=('dry_run', 'No action, aka dry run.'),
|
|
777
|
+
q='quiet',
|
|
778
|
+
v='verbose',
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
@dataclass
|
|
782
|
+
class BaseCommandOptions(HasThreadState):
|
|
783
|
+
''' A base class for the `BaseCommand` `options` object.
|
|
784
|
+
|
|
785
|
+
This is the default class for the `self.options` object
|
|
786
|
+
available during `BaseCommand.run()`,
|
|
787
|
+
and available as the `BaseCommand.Options` attribute.
|
|
788
|
+
|
|
789
|
+
Any keyword arguments are applied as field updates to the instance.
|
|
790
|
+
|
|
791
|
+
It comes prefilled with:
|
|
792
|
+
* `.dry_run=False`
|
|
793
|
+
* `.force=False`
|
|
794
|
+
* `.quiet=False`
|
|
795
|
+
* `.ssh_exe='ssh'`
|
|
796
|
+
* `.verbose=False`
|
|
797
|
+
and a `.doit` property which is the inverse of `.dry_run`.
|
|
798
|
+
|
|
799
|
+
It is recommended that if `BaseCommand` subclasses use a
|
|
800
|
+
different type for their `Options` that it should be a
|
|
801
|
+
subclass of `BaseCommandOptions`.
|
|
802
|
+
Since `BaseCommandOptions` is a data class, this typically looks like:
|
|
803
|
+
|
|
804
|
+
@dataclass
|
|
805
|
+
class Options(BaseCommand.Options):
|
|
806
|
+
... optional extra fields etc ...
|
|
807
|
+
'''
|
|
808
|
+
|
|
809
|
+
INFO_SKIP_NAMES = ('runstate', 'runstate_signals')
|
|
810
|
+
DEFAULT_SIGNALS = SIGHUP, SIGINT, SIGQUIT, SIGTERM
|
|
811
|
+
COMMON_OPT_SPECS = _COMMON_OPT_SPECS
|
|
812
|
+
|
|
813
|
+
# the cmd prefix while a command runs
|
|
814
|
+
cmd: Optional[str] = None
|
|
815
|
+
# dry run, no action
|
|
816
|
+
dry_run: bool = False
|
|
817
|
+
force: bool = False
|
|
818
|
+
quiet: bool = False
|
|
819
|
+
runstate: Optional[RunState] = None
|
|
820
|
+
runstate_signals: Tuple[int] = DEFAULT_SIGNALS
|
|
821
|
+
ssh_exe: str = field(
|
|
822
|
+
default_factory=lambda: (
|
|
823
|
+
os.environ.get(SSH_EXE_ENVVAR, os.environ.get('RSYNC_RSH', '')) or
|
|
824
|
+
SSH_EXE_DEFAULT
|
|
825
|
+
)
|
|
826
|
+
)
|
|
827
|
+
verbose: bool = False
|
|
828
|
+
|
|
829
|
+
opt_spec_class = OptionSpec
|
|
830
|
+
|
|
831
|
+
perthread_state = ThreadState()
|
|
832
|
+
|
|
833
|
+
def as_dict(self):
|
|
834
|
+
''' Return the options as a `dict`.
|
|
835
|
+
This contains all the public attributes of `self`.
|
|
836
|
+
'''
|
|
837
|
+
return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
|
|
838
|
+
|
|
839
|
+
def fields_as_dict(self):
|
|
840
|
+
''' Return the options' fields as a `dict`.
|
|
841
|
+
This contains all the field values of `self`.
|
|
842
|
+
'''
|
|
843
|
+
return {f.name: getattr(self, f.name) for f in fields(self)}
|
|
844
|
+
|
|
845
|
+
def copy(self, **updates):
|
|
846
|
+
''' Return a new instance of `BaseCommandOptions` (well, `type(self)`)
|
|
847
|
+
which is a shallow copy of the public attributes from `self.__dict__`.
|
|
848
|
+
|
|
849
|
+
Any keyword arguments are applied as attribute updates to the copy.
|
|
850
|
+
'''
|
|
851
|
+
# instantiate copied with the fields
|
|
852
|
+
copied = pfx_call(type(self), **self.fields_as_dict())
|
|
853
|
+
# infill any attributes which were not fields
|
|
854
|
+
for k, v in self.as_dict().items():
|
|
855
|
+
setattr(copied, k, v)
|
|
856
|
+
# apply the supplied updates
|
|
857
|
+
for k, v in updates.items():
|
|
858
|
+
setattr(copied, k, v)
|
|
859
|
+
return copied
|
|
860
|
+
|
|
861
|
+
def update(self, **updates):
|
|
862
|
+
''' Modify the options in place with the mapping `updates`.
|
|
863
|
+
It would be more normal to call the options in a `with` statement
|
|
864
|
+
as shown for `__call__`.
|
|
865
|
+
'''
|
|
866
|
+
for k, v in updates.items():
|
|
867
|
+
setattr(self, k, v)
|
|
868
|
+
|
|
869
|
+
# TODO: remove this - the overt make-a-copy-and-with-the-copy is clearer
|
|
870
|
+
@contextmanager
|
|
871
|
+
def __call__(self, **updates):
|
|
872
|
+
''' Calling the options object returns a context manager whose
|
|
873
|
+
value is a shallow copy of the options with any `suboptions` applied.
|
|
874
|
+
|
|
875
|
+
Example showing the semantics:
|
|
876
|
+
|
|
877
|
+
>>> from cs.cmdutils import BaseCommandOptions
|
|
878
|
+
>>> @dataclass
|
|
879
|
+
... class DemoOptions(BaseCommandOptions):
|
|
880
|
+
... x: int = 0
|
|
881
|
+
...
|
|
882
|
+
>>> options = DemoOptions(x=1)
|
|
883
|
+
>>> assert options.x == 1
|
|
884
|
+
>>> assert not options.verbose
|
|
885
|
+
>>> with options(verbose=True) as subopts:
|
|
886
|
+
... assert options is not subopts
|
|
887
|
+
... assert options.x == 1
|
|
888
|
+
... assert not options.verbose
|
|
889
|
+
... assert subopts.x == 1
|
|
890
|
+
... assert subopts.verbose
|
|
891
|
+
...
|
|
892
|
+
>>> assert options.x == 1
|
|
893
|
+
>>> assert not options.verbose
|
|
894
|
+
|
|
895
|
+
'''
|
|
896
|
+
suboptions = self.copy(**updates)
|
|
897
|
+
yield suboptions
|
|
898
|
+
|
|
899
|
+
@property
|
|
900
|
+
def doit(self):
|
|
901
|
+
''' I usually use a `doit` flag, the inverse of `dry_run`.
|
|
902
|
+
'''
|
|
903
|
+
return not self.dry_run
|
|
904
|
+
|
|
905
|
+
@doit.setter
|
|
906
|
+
def doit(self, new_doit):
|
|
907
|
+
''' Set `dry_run` to the inverse of `new_doit`.
|
|
908
|
+
'''
|
|
909
|
+
self.dry_run = not new_doit
|
|
910
|
+
|
|
911
|
+
@classmethod
|
|
912
|
+
def getopt_spec_map(cls, opt_specs_kw: Mapping, common_opt_specs=None):
|
|
913
|
+
''' Return a 3-tuple of (shortopts,longopts,getopt_spec_map)` being:
|
|
914
|
+
- `shortopts`: the `getopt()` short options specification string
|
|
915
|
+
- `longopts`: the `getopts()` long option specification list
|
|
916
|
+
- `getopt_spec_map`: a mapping of `opt`->`OptionSpec`
|
|
917
|
+
where `opt` is as from `opt,val` from `getopt()`
|
|
918
|
+
and `opt_spec` is the associated `OptionSpec` instance
|
|
919
|
+
'''
|
|
920
|
+
opt_spec_cls = cls.opt_spec_class
|
|
921
|
+
shortopts = ''
|
|
922
|
+
longopts = []
|
|
923
|
+
getopt_spec_map = {}
|
|
924
|
+
# gather up the option specifications and make getopt arguments
|
|
925
|
+
for opt_k, opt_specs in ChainMap(
|
|
926
|
+
opt_specs_kw,
|
|
927
|
+
cls.COMMON_OPT_SPECS if common_opt_specs is None else common_opt_specs,
|
|
928
|
+
).items():
|
|
929
|
+
with Pfx("opt_spec[%r]=%r", opt_k, opt_specs):
|
|
930
|
+
opt_spec = opt_spec_cls.from_opt_kw(opt_k, opt_specs)
|
|
931
|
+
if opt_spec.getopt_opt in getopt_spec_map:
|
|
932
|
+
raise ValueError(f'repeated spec for {opt_spec.getopt_opt}')
|
|
933
|
+
getopt_spec_map[opt_spec.getopt_opt] = opt_spec
|
|
934
|
+
# update the arguments for getopt()
|
|
935
|
+
shortopts += opt_spec.getopt_short
|
|
936
|
+
getopt_long = opt_spec.getopt_long
|
|
937
|
+
if getopt_long is not None:
|
|
938
|
+
longopts.append(getopt_long)
|
|
939
|
+
return shortopts, longopts, getopt_spec_map
|
|
940
|
+
|
|
941
|
+
def popopts(
|
|
942
|
+
self,
|
|
943
|
+
argv,
|
|
944
|
+
**opt_specs_kw,
|
|
945
|
+
):
|
|
946
|
+
''' Parse option switches from `argv`, a list of command line strings
|
|
947
|
+
with leading option switches and apply them to `self`.
|
|
948
|
+
Modify `argv` in place.
|
|
949
|
+
|
|
950
|
+
Example use in a `BaseCommand` `cmd_foo` method:
|
|
951
|
+
|
|
952
|
+
def cmd_foo(self, argv):
|
|
953
|
+
options = self.options
|
|
954
|
+
options.popopts(
|
|
955
|
+
c_='config',
|
|
956
|
+
l='long',
|
|
957
|
+
x='trace',
|
|
958
|
+
)
|
|
959
|
+
if self.options.dry_run:
|
|
960
|
+
print("dry run!")
|
|
961
|
+
|
|
962
|
+
The expected options are specified by the keyword parameters
|
|
963
|
+
in `opt_specs`.
|
|
964
|
+
Each keyword name has the following semantics:
|
|
965
|
+
* options not starting with a letter may be preceeded by an underscore
|
|
966
|
+
to allow use in the parameter list, for example `_1='once'`
|
|
967
|
+
for a `-1` option setting the `once` option name
|
|
968
|
+
* a single letter name specifies a short option
|
|
969
|
+
and a multiletter name specifies a long option
|
|
970
|
+
* options requiring an argument have a trailing underscore
|
|
971
|
+
* options not requiring an argument normally imply a value
|
|
972
|
+
of `True`; if their synonym commences with a dash they will
|
|
973
|
+
imply a value of `False`, for example `n='dry_run',y='-dry_run'`.
|
|
974
|
+
|
|
975
|
+
The `BaseCommand` class provides a `popopts` method
|
|
976
|
+
which is a shim for this method applied to its `.options`.
|
|
977
|
+
So common use in a command method usually looks like this:
|
|
978
|
+
|
|
979
|
+
class SomeCommand(BaseCommand):
|
|
980
|
+
|
|
981
|
+
def cmd_foo(self, argv):
|
|
982
|
+
# accept a -j or --jobs options
|
|
983
|
+
self.popopts(argv, jobs=1, j='jobs')
|
|
984
|
+
print("jobs =", self.options.jobs)
|
|
985
|
+
|
|
986
|
+
The `self.options` object is preprovided as an instance of
|
|
987
|
+
the `self.Options` class, which is `BaseCommandOptions` by
|
|
988
|
+
default. There is presupplies support for some basic options
|
|
989
|
+
like `-v` for "verbose" and so forth, and a subcommand
|
|
990
|
+
need not describe these in a call to `self.options.popopts()`.
|
|
991
|
+
|
|
992
|
+
Example:
|
|
993
|
+
|
|
994
|
+
>>> import os.path
|
|
995
|
+
>>> from typing import Optional
|
|
996
|
+
>>> @dataclass
|
|
997
|
+
... class DemoOptions(BaseCommandOptions):
|
|
998
|
+
... all: bool = False
|
|
999
|
+
... jobs: int = 1
|
|
1000
|
+
... number: int = 0
|
|
1001
|
+
... once: bool = False
|
|
1002
|
+
... path: Optional[str] = None
|
|
1003
|
+
... trace_exec: bool = False
|
|
1004
|
+
...
|
|
1005
|
+
>>> options = DemoOptions()
|
|
1006
|
+
>>> argv = ['-1', '-v', '-y', '-j4', '--path=/foo', 'bah', '-x']
|
|
1007
|
+
>>> opt_dict = options.popopts(
|
|
1008
|
+
... argv,
|
|
1009
|
+
... _1='once',
|
|
1010
|
+
... a='all',
|
|
1011
|
+
... j_=('jobs',int),
|
|
1012
|
+
... x='-trace_exec',
|
|
1013
|
+
... y='-dry_run',
|
|
1014
|
+
... dry_run=None,
|
|
1015
|
+
... path_=(str, os.path.isabs, 'not an absolute path'),
|
|
1016
|
+
... verbose=None,
|
|
1017
|
+
... )
|
|
1018
|
+
>>> opt_dict
|
|
1019
|
+
{'once': True, 'verbose': True, 'dry_run': False, 'jobs': 4, 'path': '/foo'}
|
|
1020
|
+
>>> options # doctest: +ELLIPSIS
|
|
1021
|
+
DemoOptions(cmd=None, dry_run=False, force=False, quiet=False, runstate_signals=(...), verbose=True, all=False, jobs=4, number=0, once=True, path='/foo', trace_exec=False)
|
|
1022
|
+
'''
|
|
1023
|
+
shortopts, longopts, getopt_spec_map = self.getopt_spec_map(opt_specs_kw)
|
|
1024
|
+
# infill default False/None for new fields
|
|
1025
|
+
for opt_spec in getopt_spec_map.values():
|
|
1026
|
+
field_name = opt_spec.field_name
|
|
1027
|
+
if not hasattr(self, field_name):
|
|
1028
|
+
setattr(self, field_name, opt_spec.field_default)
|
|
1029
|
+
opts, argv[:] = getopt(argv, shortopts, longopts)
|
|
1030
|
+
for opt, val in opts:
|
|
1031
|
+
with Pfx(opt):
|
|
1032
|
+
opt_spec = getopt_spec_map[opt]
|
|
1033
|
+
if opt_spec.needs_arg:
|
|
1034
|
+
with Pfx("%r", val):
|
|
1035
|
+
value = opt_spec.parse_value(val)
|
|
1036
|
+
else:
|
|
1037
|
+
value = not opt_spec.field_default
|
|
1038
|
+
setattr(self, opt_spec.field_name, value)
|
|
1039
|
+
|
|
1040
|
+
@classmethod
|
|
1041
|
+
def usage_options_format(
|
|
1042
|
+
cls,
|
|
1043
|
+
headline="Options:",
|
|
1044
|
+
*,
|
|
1045
|
+
_common_opt_specs=None,
|
|
1046
|
+
**opt_specs_kw,
|
|
1047
|
+
):
|
|
1048
|
+
''' Return an options paragraph describing `opt_specs_kw`.
|
|
1049
|
+
or `''` if `opt_specs_kw` is empty.
|
|
1050
|
+
'''
|
|
1051
|
+
if not opt_specs_kw:
|
|
1052
|
+
return ''
|
|
1053
|
+
_, _, getopt_spec_map = cls.getopt_spec_map(
|
|
1054
|
+
opt_specs_kw, _common_opt_specs
|
|
1055
|
+
)
|
|
1056
|
+
return headline + "\n" + indent(
|
|
1057
|
+
"\n".join(
|
|
1058
|
+
tabulate(
|
|
1059
|
+
*(
|
|
1060
|
+
(
|
|
1061
|
+
opt_spec.option_terse(),
|
|
1062
|
+
stripped_dedent(opt_spec.help_text)
|
|
1063
|
+
) for _, opt_spec in sorted(
|
|
1064
|
+
getopt_spec_map.items(),
|
|
1065
|
+
key=lambda kv: kv[0].lstrip('-').lower()
|
|
1066
|
+
)
|
|
1067
|
+
),
|
|
1068
|
+
)
|
|
1069
|
+
)
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
@decorator
|
|
1073
|
+
def popopts(cmd_method, **opt_specs_kw):
|
|
1074
|
+
''' A decorator to parse command line options from a `cmd_`*method*'s `argv`
|
|
1075
|
+
and update `self.options`. This also updates the method's usage message.
|
|
1076
|
+
|
|
1077
|
+
Example:
|
|
1078
|
+
|
|
1079
|
+
@popopts(x=('trace', 'Trace execution.'))
|
|
1080
|
+
def cmd_do_something(self, argv):
|
|
1081
|
+
""" Usage: {cmd} [-x] blah blah
|
|
1082
|
+
Do some thing to blah.
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
This arranges for cmd_do_something to call `self.options.popopts(argv)`,
|
|
1086
|
+
and updates the usage message with an "Options:" paragraph.
|
|
1087
|
+
|
|
1088
|
+
This avoids needing to manually enumerate the options in the
|
|
1089
|
+
docstring usage and avoids explicitly calling `self.options.popopts`
|
|
1090
|
+
inside the method.
|
|
1091
|
+
'''
|
|
1092
|
+
|
|
1093
|
+
def popopts_cmd_method_wrapper(self, argv, *method_a, **method_kw):
|
|
1094
|
+
self.options.popopts(argv, **opt_specs_kw)
|
|
1095
|
+
return cmd_method(self, argv, *method_a, **method_kw)
|
|
1096
|
+
|
|
1097
|
+
if opt_specs_kw:
|
|
1098
|
+
# patch the cmd_method usage text
|
|
1099
|
+
pre_usage, usage_format, post_usage = split_usage(cmd_method.__doc__ or '')
|
|
1100
|
+
if usage_format:
|
|
1101
|
+
usage_format = (
|
|
1102
|
+
f'Usage: {usage_format}\n' + indent(
|
|
1103
|
+
BaseCommandOptions.usage_options_format(
|
|
1104
|
+
"Options:",
|
|
1105
|
+
_common_opt_specs={},
|
|
1106
|
+
**opt_specs_kw,
|
|
1107
|
+
)
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
cmd_method.__doc__ = "\n\n".join((pre_usage, usage_format, post_usage)
|
|
1111
|
+
).strip()
|
|
1112
|
+
|
|
1113
|
+
return popopts_cmd_method_wrapper
|
|
1114
|
+
|
|
1115
|
+
class BaseCommand:
|
|
1116
|
+
''' A base class for handling nestable command lines.
|
|
1117
|
+
|
|
1118
|
+
This class provides the basic parse and dispatch mechanisms
|
|
1119
|
+
for command lines.
|
|
1120
|
+
To implement a command line one instantiates a subclass of `BaseCommand`:
|
|
1121
|
+
|
|
1122
|
+
class MyCommand(BaseCommand):
|
|
1123
|
+
""" My command to do something. """
|
|
1124
|
+
|
|
1125
|
+
and provides either a `main` method if the command has no subcommands
|
|
1126
|
+
or a suite of `cmd_`*subcommand* methods, one per subcommand.
|
|
1127
|
+
|
|
1128
|
+
Running a command is done by:
|
|
1129
|
+
|
|
1130
|
+
MyCommand(argv).run()
|
|
1131
|
+
|
|
1132
|
+
Modules which implement a command line mode generally look like this:
|
|
1133
|
+
|
|
1134
|
+
... imports etc ...
|
|
1135
|
+
def main(argv=None, **run_kw):
|
|
1136
|
+
""" The command line mode.
|
|
1137
|
+
"""
|
|
1138
|
+
return MyCommand(argv).run(**run_kw)
|
|
1139
|
+
... other code ...
|
|
1140
|
+
class MyCommand(BaseCommand):
|
|
1141
|
+
... other code ...
|
|
1142
|
+
if __name__ == '__main__':
|
|
1143
|
+
sys.exit(main(sys.argv))
|
|
1144
|
+
|
|
1145
|
+
Instances have a `self.options` attribute on which optional
|
|
1146
|
+
modes are set, avoiding conflict with the attributes of `self`.
|
|
1147
|
+
|
|
1148
|
+
The `self.options` object is an instance of the class' `Options` class.
|
|
1149
|
+
The default comes from `BaseCommand.Options` (aka `BaseCommandOptions`)
|
|
1150
|
+
but classes with additional command line options will usually
|
|
1151
|
+
provide their own subclass:
|
|
1152
|
+
|
|
1153
|
+
class MyCommand(BaseCommand):
|
|
1154
|
+
|
|
1155
|
+
@dataclass
|
|
1156
|
+
class Options(BaseCommandOptions):
|
|
1157
|
+
extra_mode : str = None
|
|
1158
|
+
some_flag : bool = False
|
|
1159
|
+
|
|
1160
|
+
# extend the common options for the new fields
|
|
1161
|
+
COMMON_OPT_SPECS = dict(
|
|
1162
|
+
**BaseCommandOptions.COMMON_OPT_SPECS,
|
|
1163
|
+
mode_=('extra_mode', 'The extra mode to do something.'),
|
|
1164
|
+
flag='some_flag',
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
This adds an additional `--mode` *mode* and a `--flag` command line option
|
|
1168
|
+
which affects the fields of `self.options` and updates the
|
|
1169
|
+
automaticly generated usage messages accordingly.
|
|
1170
|
+
See the documentation for `BaseCommandOptions.popopts` for
|
|
1171
|
+
explaination of the `COMMON_OPT_SPECS` values.
|
|
1172
|
+
|
|
1173
|
+
Subclasses with no subcommands
|
|
1174
|
+
generally just implement a `main(argv)` method.
|
|
1175
|
+
|
|
1176
|
+
Subclasses with subcommands
|
|
1177
|
+
should implement a `cmd_`*subcommand*`(argv)` instance method
|
|
1178
|
+
for each subcommand.
|
|
1179
|
+
If a subcommand is itself implemented using `BaseCommand`
|
|
1180
|
+
then it can be a simple attribute:
|
|
1181
|
+
|
|
1182
|
+
cmd_subthing = SubThingCommand
|
|
1183
|
+
|
|
1184
|
+
Returning to methods, if there is a paragraph in the method docstring
|
|
1185
|
+
commencing with `Usage:` then that paragraph is incorporated
|
|
1186
|
+
into the main usage message automatically.
|
|
1187
|
+
|
|
1188
|
+
Example:
|
|
1189
|
+
|
|
1190
|
+
@popopts(l='long_mode')
|
|
1191
|
+
def cmd_ls(self, argv):
|
|
1192
|
+
""" Usage: {cmd} [-l] [paths...]
|
|
1193
|
+
Emit a listing for the named paths.
|
|
1194
|
+
|
|
1195
|
+
Further docstring non-usage information here.
|
|
1196
|
+
"""
|
|
1197
|
+
... do the "ls" subcommand ...
|
|
1198
|
+
... with use of self.options.long_mode as needed ...
|
|
1199
|
+
|
|
1200
|
+
The subclass is customised by overriding the following methods:
|
|
1201
|
+
* `run_context()`:
|
|
1202
|
+
a context manager to provide setup or teardown actions
|
|
1203
|
+
to occur before and after the command implementation respectively,
|
|
1204
|
+
such as to open and close a database.
|
|
1205
|
+
* `cmd_`*subcmd*`(argv)`:
|
|
1206
|
+
if the command line options are followed by an argument
|
|
1207
|
+
whose value is *subcmd*,
|
|
1208
|
+
then the method `cmd_`*subcmd*`(subcmd_argv)`
|
|
1209
|
+
will be called where `subcmd_argv` contains the command line arguments
|
|
1210
|
+
following *subcmd*.
|
|
1211
|
+
* `main(argv)`:
|
|
1212
|
+
if there are no `cmd_`*subcmd* methods then method `main(argv)`
|
|
1213
|
+
will be called where `argv` contains the command line arguments.
|
|
1214
|
+
'''
|
|
1215
|
+
|
|
1216
|
+
SUBCOMMAND_METHOD_PREFIX = 'cmd_'
|
|
1217
|
+
SUBCOMMAND_ARGV_DEFAULT = None
|
|
1218
|
+
SubCommandClass = SubCommand
|
|
1219
|
+
|
|
1220
|
+
GETOPT_SPEC = ''
|
|
1221
|
+
Options = BaseCommandOptions
|
|
1222
|
+
|
|
1223
|
+
# pylint: disable=too-many-branches,too-many-statements,too-many-locals
|
|
1224
|
+
def __init__(
|
|
1225
|
+
self, argv=None, *, cmd=None, options=None, **kw_options
|
|
1226
|
+
) -> str:
|
|
1227
|
+
''' Initialise the command line.
|
|
1228
|
+
Return the subcommand name, or `None` if there is only `main`.
|
|
1229
|
+
Raises `GetoptError` for unrecognised options.
|
|
1230
|
+
|
|
1231
|
+
Parameters:
|
|
1232
|
+
* `argv`:
|
|
1233
|
+
optional command line arguments
|
|
1234
|
+
including the main command name if `cmd` is not specified.
|
|
1235
|
+
The default is `sys.argv`.
|
|
1236
|
+
The contents of `argv` are copied,
|
|
1237
|
+
permitting desctructive parsing of `argv`.
|
|
1238
|
+
* `cmd`:
|
|
1239
|
+
optional keyword specifying the command name for context;
|
|
1240
|
+
if this is not specified it is taken from `argv.pop(0)`.
|
|
1241
|
+
* `options`:
|
|
1242
|
+
an optional keyword providing object for command state and context.
|
|
1243
|
+
If not specified a new `self.Options` instance
|
|
1244
|
+
is allocated for use as `options`.
|
|
1245
|
+
The default `Options` class is `BaseCommandOptions`,
|
|
1246
|
+
a dataclass with some prefilled attributes and properties
|
|
1247
|
+
to aid use later.
|
|
1248
|
+
Other keyword arguments are applied to `self.options`
|
|
1249
|
+
as attributes.
|
|
1250
|
+
|
|
1251
|
+
The `cmd` and `argv` parameters have some fiddly semantics for convenience.
|
|
1252
|
+
There are 3 basic ways to initialise:
|
|
1253
|
+
* `BaseCommand()`: `argv` comes from `sys.argv`
|
|
1254
|
+
and the value for `cmd` is derived from `argv[0]`
|
|
1255
|
+
* `BaseCommand(argv)`: `argv` is the complete command line
|
|
1256
|
+
including the command name and the value for `cmd` is
|
|
1257
|
+
derived from `argv[0]`
|
|
1258
|
+
* `BaseCommand(argv, cmd=foo)`: `argv` is the command
|
|
1259
|
+
arguments _after_ the command name and `cmd` is set to
|
|
1260
|
+
`foo`
|
|
1261
|
+
|
|
1262
|
+
The command line arguments are parsed according to
|
|
1263
|
+
the optional `GETOPT_SPEC` class attribute (default `''`).
|
|
1264
|
+
If `getopt_spec` is not empty
|
|
1265
|
+
then `apply_opts(opts)` is called
|
|
1266
|
+
to apply the supplied options to the state
|
|
1267
|
+
where `opts` is the return from `getopt.getopt(argv,getopt_spec)`.
|
|
1268
|
+
|
|
1269
|
+
After the option parse,
|
|
1270
|
+
if the first command line argument *foo*
|
|
1271
|
+
has a corresponding method `cmd_`*foo*
|
|
1272
|
+
then that argument is removed from the start of `argv`
|
|
1273
|
+
and `self.cmd_`*foo*`(argv,options,cmd=`*foo*`)` is called
|
|
1274
|
+
and its value returned.
|
|
1275
|
+
Otherwise `self.main(argv,options)` is called
|
|
1276
|
+
and its value returned.
|
|
1277
|
+
|
|
1278
|
+
If the command implementation requires some setup or teardown
|
|
1279
|
+
then this may be provided by the `run_context`
|
|
1280
|
+
context manager method,
|
|
1281
|
+
called with `cmd=`*subcmd* for subcommands
|
|
1282
|
+
and with `cmd=None` for `main`.
|
|
1283
|
+
'''
|
|
1284
|
+
if argv is None:
|
|
1285
|
+
# using sys.argv
|
|
1286
|
+
argv = list(sys.argv)
|
|
1287
|
+
elif argv is ...:
|
|
1288
|
+
# dummy mode for BaseCOmmand instances made to access this
|
|
1289
|
+
# but not run a command
|
|
1290
|
+
pass
|
|
1291
|
+
else:
|
|
1292
|
+
# argv provided
|
|
1293
|
+
argv = list(argv)
|
|
1294
|
+
if cmd is None:
|
|
1295
|
+
if argv is ... or not argv:
|
|
1296
|
+
cmd = cutsuffix(self.__class__.__name__, 'Command').lower()
|
|
1297
|
+
else:
|
|
1298
|
+
cmd = basename(argv.pop(0))
|
|
1299
|
+
if cmd.endswith('.py'):
|
|
1300
|
+
# "python -m foo" sets argv[0] to "..../foo.py"
|
|
1301
|
+
# fall back to the class name
|
|
1302
|
+
cmd = (
|
|
1303
|
+
cutsuffix(self.__class__.__name__, 'Command').lower()
|
|
1304
|
+
or self.__class__.__module__
|
|
1305
|
+
)
|
|
1306
|
+
options = self.__class__.Options(cmd=cmd)
|
|
1307
|
+
# override the default options
|
|
1308
|
+
for option, value in kw_options.items():
|
|
1309
|
+
setattr(options, option, value)
|
|
1310
|
+
self.cmd = cmd
|
|
1311
|
+
self._argv = argv
|
|
1312
|
+
self.options = options
|
|
1313
|
+
|
|
1314
|
+
def _prerun_setup(self):
|
|
1315
|
+
argv = self._argv
|
|
1316
|
+
options = self.options
|
|
1317
|
+
subcmds = self.subcommands()
|
|
1318
|
+
has_subcmds = self.has_subcommands()
|
|
1319
|
+
log_level = getattr(options, 'log_level', None)
|
|
1320
|
+
loginfo = setup_logging(cmd=self.cmd, level=log_level)
|
|
1321
|
+
# post: argv is list of arguments after the command name
|
|
1322
|
+
self.loginfo = loginfo
|
|
1323
|
+
self._run = lambda argv: 2
|
|
1324
|
+
# we catch GetoptError from this suite...
|
|
1325
|
+
subcmd = None # default: no subcmd specific usage available
|
|
1326
|
+
try:
|
|
1327
|
+
getopt_spec = getattr(self, 'GETOPT_SPEC', '')
|
|
1328
|
+
# catch bare -h or --help if no 'h' in the getopt_spec
|
|
1329
|
+
if (len(argv) == 1
|
|
1330
|
+
and (argv[0] in ('-help', '--help') or
|
|
1331
|
+
('h' not in getopt_spec and argv[0] in ('-h',)))):
|
|
1332
|
+
argv = self._argv = ['help', '-l']
|
|
1333
|
+
else:
|
|
1334
|
+
if getopt_spec:
|
|
1335
|
+
# legacy GETOPT_SPEC mode
|
|
1336
|
+
# we do this regardless in order to honour '--'
|
|
1337
|
+
opts, argv = getopt(argv, getopt_spec, '')
|
|
1338
|
+
self.apply_opts(opts)
|
|
1339
|
+
else:
|
|
1340
|
+
# modern mode
|
|
1341
|
+
# use the options.COMMON_OPT_SPECS
|
|
1342
|
+
options.popopts(argv)
|
|
1343
|
+
# We do this regardless so that subclasses can do some presubcommand parsing
|
|
1344
|
+
# _after_ any command line options.
|
|
1345
|
+
argv = self._argv = self.apply_preargv(argv)
|
|
1346
|
+
# now prepare self._run, a callable
|
|
1347
|
+
if not has_subcmds:
|
|
1348
|
+
# no subcommands, just use the main() method
|
|
1349
|
+
try:
|
|
1350
|
+
main = self.main
|
|
1351
|
+
except AttributeError:
|
|
1352
|
+
# pylint: disable=raise-missing-from
|
|
1353
|
+
raise GetoptError("no main method and no subcommand methods")
|
|
1354
|
+
self._run = self.SubCommandClass(self, main)
|
|
1355
|
+
else:
|
|
1356
|
+
# expect a subcommand on the command line
|
|
1357
|
+
if not argv:
|
|
1358
|
+
default_argv = self.SUBCOMMAND_ARGV_DEFAULT
|
|
1359
|
+
if not default_argv:
|
|
1360
|
+
warning(
|
|
1361
|
+
"missing subcommand, expected one of: %s",
|
|
1362
|
+
', '.join(sorted(subcmds.keys()))
|
|
1363
|
+
)
|
|
1364
|
+
default_argv = ['help', '-s']
|
|
1365
|
+
argv = (
|
|
1366
|
+
[default_argv]
|
|
1367
|
+
if isinstance(default_argv, str) else list(default_argv)
|
|
1368
|
+
)
|
|
1369
|
+
subcmd = argv.pop(0)
|
|
1370
|
+
try:
|
|
1371
|
+
subcommand = self.subcommand(subcmd)
|
|
1372
|
+
except KeyError:
|
|
1373
|
+
# pylint: disable=raise-missing-from
|
|
1374
|
+
bad_subcmd = subcmd
|
|
1375
|
+
subcmd = None
|
|
1376
|
+
raise GetoptError(
|
|
1377
|
+
f'unrecognised subcommand {bad_subcmd!r}, expected one of:'
|
|
1378
|
+
f' {", ".join(sorted(subcmds.keys()))}'
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
def _run(argv):
|
|
1382
|
+
with Pfx(subcmd):
|
|
1383
|
+
return subcommand(argv)
|
|
1384
|
+
|
|
1385
|
+
self._run = _run
|
|
1386
|
+
except GetoptError as e:
|
|
1387
|
+
if self.getopt_error_handler(
|
|
1388
|
+
options.cmd,
|
|
1389
|
+
self.options,
|
|
1390
|
+
e,
|
|
1391
|
+
self.usage_text(short=True, show_subcmds=subcmd),
|
|
1392
|
+
):
|
|
1393
|
+
return subcmd
|
|
1394
|
+
raise
|
|
1395
|
+
else:
|
|
1396
|
+
return subcmd
|
|
1397
|
+
|
|
1398
|
+
@classmethod
|
|
1399
|
+
def method_cmdname(cls, method_name: str):
|
|
1400
|
+
''' The `cmd` value from a method name.
|
|
1401
|
+
'''
|
|
1402
|
+
return cutprefix(method_name, cls.SUBCOMMAND_METHOD_PREFIX)
|
|
1403
|
+
|
|
1404
|
+
@cache
|
|
1405
|
+
def subcommands(self):
|
|
1406
|
+
''' Return a mapping of subcommand names to subcommand specifications
|
|
1407
|
+
for class attributes which commence with `cls.SUBCOMMAND_METHOD_PREFIX`
|
|
1408
|
+
by default `'cmd_'`.
|
|
1409
|
+
'''
|
|
1410
|
+
cls = type(self)
|
|
1411
|
+
prefix = cls.SUBCOMMAND_METHOD_PREFIX
|
|
1412
|
+
usage_mapping = getattr(cls, 'USAGE_KEYWORDS', {})
|
|
1413
|
+
mapping = {}
|
|
1414
|
+
for method_name in dir(cls):
|
|
1415
|
+
if method_name.startswith(prefix):
|
|
1416
|
+
subcmd = self.method_cmdname(method_name)
|
|
1417
|
+
method = getattr(self, method_name)
|
|
1418
|
+
subusage_mapping = dict(usage_mapping)
|
|
1419
|
+
method_keywords = getattr(method, 'USAGE_KEYWORDS', {})
|
|
1420
|
+
subusage_mapping.update(method_keywords)
|
|
1421
|
+
subusage_mapping.update(cmd=subcmd)
|
|
1422
|
+
mapping[subcmd] = self.SubCommandClass(
|
|
1423
|
+
self,
|
|
1424
|
+
method,
|
|
1425
|
+
cmd=subcmd,
|
|
1426
|
+
usage_mapping=subusage_mapping,
|
|
1427
|
+
)
|
|
1428
|
+
return mapping
|
|
1429
|
+
|
|
1430
|
+
@classmethod
|
|
1431
|
+
def has_subcommands(cls):
|
|
1432
|
+
''' Test whether the class defines additional subcommands.
|
|
1433
|
+
'''
|
|
1434
|
+
prefix = cls.SUBCOMMAND_METHOD_PREFIX
|
|
1435
|
+
for method_name in dir(cls):
|
|
1436
|
+
if not method_name.startswith(prefix):
|
|
1437
|
+
continue
|
|
1438
|
+
if getattr(cls, method_name) is getattr(BaseCommand, method_name, None):
|
|
1439
|
+
continue
|
|
1440
|
+
return True
|
|
1441
|
+
return False
|
|
1442
|
+
|
|
1443
|
+
@cache
|
|
1444
|
+
def subcommand(self, subcmd: str):
|
|
1445
|
+
''' Return the `SubCommand` associated with `subcmd`.
|
|
1446
|
+
'''
|
|
1447
|
+
subcmd_ = subcmd.replace('-', '_').replace('.', '_')
|
|
1448
|
+
subcommands = self.subcommands()
|
|
1449
|
+
return subcommands[subcmd_]
|
|
1450
|
+
|
|
1451
|
+
def usage_text(
|
|
1452
|
+
self,
|
|
1453
|
+
**subcommand_kw,
|
|
1454
|
+
):
|
|
1455
|
+
''' Compute the "Usage:" message for this class.
|
|
1456
|
+
Parameters are as for `SubCommand.usage_text`.
|
|
1457
|
+
'''
|
|
1458
|
+
return self.SubCommandClass(
|
|
1459
|
+
self, method=type(self)
|
|
1460
|
+
).usage_text(**subcommand_kw)
|
|
1461
|
+
|
|
1462
|
+
def subcommand_usage_text(
|
|
1463
|
+
self, subcmd, usage_format_mapping=None, short=False
|
|
1464
|
+
):
|
|
1465
|
+
''' Return the usage text for a subcommand.
|
|
1466
|
+
'''
|
|
1467
|
+
method = self.subcommands()[subcmd].method
|
|
1468
|
+
subusage = None
|
|
1469
|
+
# support (method, get_suboptions)
|
|
1470
|
+
try:
|
|
1471
|
+
classy = issubclass(method, BaseCommand)
|
|
1472
|
+
except TypeError:
|
|
1473
|
+
classy = False
|
|
1474
|
+
if classy:
|
|
1475
|
+
# first paragraph of the class usage text
|
|
1476
|
+
doc = method([]).usage_text(cmd=subcmd)
|
|
1477
|
+
subusage_format, *_ = cutprefix(doc, 'Usage:').lstrip().split("\n\n", 1)
|
|
1478
|
+
else:
|
|
1479
|
+
# extract the usage from the object docstring
|
|
1480
|
+
doc = obj_docstring(method)
|
|
1481
|
+
if doc:
|
|
1482
|
+
if 'Usage:' in doc:
|
|
1483
|
+
# extract the Usage: paragraph
|
|
1484
|
+
pre_usage, post_usage = doc.split('Usage:', 1)
|
|
1485
|
+
pre_usage = pre_usage.strip()
|
|
1486
|
+
post_usage_format, *_ = post_usage.split('\n\n', 1)
|
|
1487
|
+
subusage_format = stripped_dedent(post_usage_format)
|
|
1488
|
+
else:
|
|
1489
|
+
# extract the first paragraph
|
|
1490
|
+
subusage_format, *_ = doc.split('\n\n', 1)
|
|
1491
|
+
else:
|
|
1492
|
+
# default usage text - include the docstring below a header
|
|
1493
|
+
subusage_format = "\n ".join(
|
|
1494
|
+
['{cmd} ...'] + [doc.split('\n\n', 1)[0]]
|
|
1495
|
+
)
|
|
1496
|
+
if subusage_format:
|
|
1497
|
+
if short:
|
|
1498
|
+
subusage_format, *_ = subusage_format.split('\n', 1)
|
|
1499
|
+
mapping = dict(sys.modules[method.__module__].__dict__)
|
|
1500
|
+
if usage_format_mapping:
|
|
1501
|
+
mapping.update(usage_format_mapping)
|
|
1502
|
+
mapping.update(cmd=subcmd)
|
|
1503
|
+
subusage = subusage_format.format_map(mapping)
|
|
1504
|
+
return subusage.replace('\n', '\n ')
|
|
1505
|
+
|
|
1506
|
+
@classmethod
|
|
1507
|
+
def extract_usage(cls, cmd=None):
|
|
1508
|
+
''' Extract the `Usage:` paragraph from `cls__doc__` if present.
|
|
1509
|
+
Return a 2-tuple of `(doc_without_usage,usage_text)`
|
|
1510
|
+
being the remaining docstring and a full usage message.
|
|
1511
|
+
|
|
1512
|
+
*Note*: this actually sets `cls.USAGE_FORMAT` if that does
|
|
1513
|
+
not already exist.
|
|
1514
|
+
'''
|
|
1515
|
+
if cmd is None:
|
|
1516
|
+
# infer a cmd from the class name
|
|
1517
|
+
cmd = cutsuffix(cls.__name__, 'Command').lower()
|
|
1518
|
+
instance = cls([cmd])
|
|
1519
|
+
pre_usage, usage_format, post_usage = split_usage(obj_docstring(cls))
|
|
1520
|
+
if usage_format and not hasattr(cls, 'USAGE_FORMAT'):
|
|
1521
|
+
cls.USAGE_FORMAT = usage_format
|
|
1522
|
+
usage_text = instance.usage_text(recurse=True, short=False)
|
|
1523
|
+
return pre_usage + post_usage, usage_text
|
|
1524
|
+
|
|
1525
|
+
@pfx_method
|
|
1526
|
+
# pylint: disable=no-self-use
|
|
1527
|
+
def apply_opt(self, opt, val):
|
|
1528
|
+
''' Handle an individual global command line option.
|
|
1529
|
+
|
|
1530
|
+
This default implementation raises a `NotImplementedError`.
|
|
1531
|
+
It only fires if `getopt` actually gathered arguments
|
|
1532
|
+
and would imply that a `GETOPT_SPEC` was supplied
|
|
1533
|
+
without an `apply_opt` or `apply_opts` method to implement the options.
|
|
1534
|
+
'''
|
|
1535
|
+
raise NotImplementedError("unhandled option %r" % (opt,))
|
|
1536
|
+
|
|
1537
|
+
def apply_opts(self, opts):
|
|
1538
|
+
''' Apply command line options.
|
|
1539
|
+
|
|
1540
|
+
Subclasses can override this
|
|
1541
|
+
but it is usually easier to override `apply_opt(opt,val)`.
|
|
1542
|
+
'''
|
|
1543
|
+
badopts = False
|
|
1544
|
+
for opt, val in opts:
|
|
1545
|
+
with Pfx(opt if val is None else "%s %r" % (opt, val)):
|
|
1546
|
+
try:
|
|
1547
|
+
self.apply_opt(opt, val)
|
|
1548
|
+
except GetoptError as e:
|
|
1549
|
+
warning("%s", e)
|
|
1550
|
+
badopts = True
|
|
1551
|
+
if badopts:
|
|
1552
|
+
raise GetoptError("bad options")
|
|
1553
|
+
|
|
1554
|
+
# pylint: disable=no-self-use
|
|
1555
|
+
def apply_preargv(self, argv):
|
|
1556
|
+
''' Do any preparsing of `argv` before the subcommand/main-args.
|
|
1557
|
+
Return the remaining arguments.
|
|
1558
|
+
|
|
1559
|
+
This default implementation applies the default options
|
|
1560
|
+
supported by `self.options` (an instance of `self.Options`
|
|
1561
|
+
class).
|
|
1562
|
+
'''
|
|
1563
|
+
return argv
|
|
1564
|
+
|
|
1565
|
+
@classmethod
|
|
1566
|
+
@typechecked
|
|
1567
|
+
def poparg(
|
|
1568
|
+
cls,
|
|
1569
|
+
argv: List[str],
|
|
1570
|
+
*specs,
|
|
1571
|
+
unpop_on_error: bool = False,
|
|
1572
|
+
opt_spec_class=None
|
|
1573
|
+
):
|
|
1574
|
+
''' Pop the leading argument off `argv` and parse it.
|
|
1575
|
+
Return the parsed argument.
|
|
1576
|
+
Raises `getopt.GetoptError` on a missing or invalid argument.
|
|
1577
|
+
|
|
1578
|
+
This is expected to be used inside a `main` or `cmd_*`
|
|
1579
|
+
command handler method or inside `apply_preargv`.
|
|
1580
|
+
|
|
1581
|
+
You can just use:
|
|
1582
|
+
|
|
1583
|
+
value = argv.pop(0)
|
|
1584
|
+
|
|
1585
|
+
but this method provides conversion and valuation
|
|
1586
|
+
and a richer failure mode.
|
|
1587
|
+
|
|
1588
|
+
Parameters:
|
|
1589
|
+
* `argv`: the argument list, which is modified in place with `argv.pop(0)`
|
|
1590
|
+
* the argument list `argv` may be followed by some help text
|
|
1591
|
+
and/or an argument parser function.
|
|
1592
|
+
* `validate`: an optional function to validate the parsed value;
|
|
1593
|
+
this should return a true value if valid,
|
|
1594
|
+
or return a false value or raise a `ValueError` if invalid
|
|
1595
|
+
* `unvalidated_message`: an optional message after `validate`
|
|
1596
|
+
for values failing the validation
|
|
1597
|
+
* `unpop_on_error`: optional keyword parameter, default `False`;
|
|
1598
|
+
if true then push the argument back onto the front of `argv`
|
|
1599
|
+
if it fails to parse; `GetoptError` is still raised
|
|
1600
|
+
|
|
1601
|
+
Typical use inside a `main` or `cmd_*` method might look like:
|
|
1602
|
+
|
|
1603
|
+
self.options.word = self.poparg(argv, int, "a count value")
|
|
1604
|
+
self.options.word = self.poparg(
|
|
1605
|
+
argv, int, "a count value",
|
|
1606
|
+
lambda count: count > 0, "count should be positive")
|
|
1607
|
+
|
|
1608
|
+
Because it raises `GetoptError` on a bad argument
|
|
1609
|
+
the normal usage message failure mode follows automatically.
|
|
1610
|
+
|
|
1611
|
+
Demonstration:
|
|
1612
|
+
|
|
1613
|
+
>>> argv = ['word', '3', 'nine', '4']
|
|
1614
|
+
>>> BaseCommand.poparg(argv, "word to process")
|
|
1615
|
+
'word'
|
|
1616
|
+
>>> BaseCommand.poparg(argv, int, "count value")
|
|
1617
|
+
3
|
|
1618
|
+
>>> BaseCommand.poparg(argv, float, "length")
|
|
1619
|
+
Traceback (most recent call last):
|
|
1620
|
+
...
|
|
1621
|
+
getopt.GetoptError: length 'nine': float('nine'): could not convert string to float: 'nine'
|
|
1622
|
+
>>> BaseCommand.poparg(argv, float, "width", lambda width: width > 5)
|
|
1623
|
+
Traceback (most recent call last):
|
|
1624
|
+
...
|
|
1625
|
+
getopt.GetoptError: width '4': invalid value
|
|
1626
|
+
>>> BaseCommand.poparg(argv, float, "length")
|
|
1627
|
+
Traceback (most recent call last):
|
|
1628
|
+
...
|
|
1629
|
+
getopt.GetoptError: length: missing argument
|
|
1630
|
+
>>> argv = ['-5', 'zz']
|
|
1631
|
+
>>> BaseCommand.poparg(argv, float, "size", lambda size: size > 0, "size should be >0")
|
|
1632
|
+
Traceback (most recent call last):
|
|
1633
|
+
...
|
|
1634
|
+
getopt.GetoptError: size '-5': size should be >0
|
|
1635
|
+
>>> argv # -5 was still consumed
|
|
1636
|
+
['zz']
|
|
1637
|
+
>>> BaseCommand.poparg(argv, float, "size2", unpop_on_error=True)
|
|
1638
|
+
Traceback (most recent call last):
|
|
1639
|
+
...
|
|
1640
|
+
getopt.GetoptError: size2 'zz': float('zz'): could not convert string to float: 'zz'
|
|
1641
|
+
>>> argv # zz was pushed back
|
|
1642
|
+
['zz']
|
|
1643
|
+
'''
|
|
1644
|
+
if opt_spec_class is None:
|
|
1645
|
+
opt_spec_class = OptionSpec
|
|
1646
|
+
opt_spec = opt_spec_class.from_opt_kw('_', specs)
|
|
1647
|
+
with Pfx(opt_spec.help_text):
|
|
1648
|
+
if not argv:
|
|
1649
|
+
raise GetoptError("missing argument")
|
|
1650
|
+
arg0 = argv.pop(0)
|
|
1651
|
+
try:
|
|
1652
|
+
return opt_spec.parse_value(arg0)
|
|
1653
|
+
except GetoptError:
|
|
1654
|
+
if unpop_on_error:
|
|
1655
|
+
argv.insert(0, arg0)
|
|
1656
|
+
raise
|
|
1657
|
+
|
|
1658
|
+
# pylint: disable=too-many-branches,too-many-statements,too-many-locals
|
|
1659
|
+
def run(self, **kw_options):
|
|
1660
|
+
''' Run a command.
|
|
1661
|
+
Returns the exit status of the command.
|
|
1662
|
+
May raise `GetoptError` from subcommands.
|
|
1663
|
+
|
|
1664
|
+
Any keyword arguments are used to override `self.options` attributes
|
|
1665
|
+
for the duration of the run,
|
|
1666
|
+
for example to presupply a shared `Upd` from an outer context.
|
|
1667
|
+
|
|
1668
|
+
If the first command line argument *foo*
|
|
1669
|
+
has a corresponding method `cmd_`*foo*
|
|
1670
|
+
then that argument is removed from the start of `argv`
|
|
1671
|
+
and `self.cmd_`*foo*`(cmd=`*foo*`)` is called
|
|
1672
|
+
and its value returned.
|
|
1673
|
+
Otherwise `self.main(argv)` is called
|
|
1674
|
+
and its value returned.
|
|
1675
|
+
|
|
1676
|
+
If the command implementation requires some setup or teardown
|
|
1677
|
+
then this may be provided by the `run_context()`
|
|
1678
|
+
context manager method.
|
|
1679
|
+
'''
|
|
1680
|
+
subcmd = self._prerun_setup()
|
|
1681
|
+
options = self.options
|
|
1682
|
+
try:
|
|
1683
|
+
try:
|
|
1684
|
+
with self.run_context(**kw_options) as xit:
|
|
1685
|
+
if xit is not None:
|
|
1686
|
+
error("exit status from run_context: %s", xit)
|
|
1687
|
+
return xit
|
|
1688
|
+
return self._run(self._argv)
|
|
1689
|
+
except CancellationError:
|
|
1690
|
+
error("cancelled")
|
|
1691
|
+
return 1
|
|
1692
|
+
except GetoptError as e:
|
|
1693
|
+
if self.getopt_error_handler(
|
|
1694
|
+
self.cmd,
|
|
1695
|
+
options,
|
|
1696
|
+
e,
|
|
1697
|
+
self.usage_text(cmd=self.cmd, show_subcmds=subcmd, short=False),
|
|
1698
|
+
):
|
|
1699
|
+
return 2
|
|
1700
|
+
raise
|
|
1701
|
+
|
|
1702
|
+
def cmdloop(self, intro=None):
|
|
1703
|
+
''' Use `cmd.Cmd` to run a command loop which calls the `cmd_`* methods.
|
|
1704
|
+
'''
|
|
1705
|
+
if not sys.stdin.isatty():
|
|
1706
|
+
raise GetoptError("input is not a tty")
|
|
1707
|
+
# TODO: get intro from usage/help
|
|
1708
|
+
cmdobj = BaseCommandCmd(self)
|
|
1709
|
+
cmdobj.prompt = f'{self.cmd}> '
|
|
1710
|
+
cmdobj.cmdloop(intro)
|
|
1711
|
+
|
|
1712
|
+
# pylint: disable=unused-argument
|
|
1713
|
+
@staticmethod
|
|
1714
|
+
def getopt_error_handler(cmd, options, e, usage, subcmd=None): # pylint: disable=unused-argument
|
|
1715
|
+
''' The `getopt_error_handler` method
|
|
1716
|
+
is used to control the handling of `GetoptError`s raised
|
|
1717
|
+
during the command line parse
|
|
1718
|
+
or during the `main` or `cmd_`*subcmd*` calls.
|
|
1719
|
+
|
|
1720
|
+
This default handler issues a warning containing the exception text,
|
|
1721
|
+
prints the usage message to standard error,
|
|
1722
|
+
and returns `True` to indicate that the error has been handled.
|
|
1723
|
+
|
|
1724
|
+
The handler is called with these parameters:
|
|
1725
|
+
* `cmd`: the command name
|
|
1726
|
+
* `options`: the `options` object
|
|
1727
|
+
* `e`: the `GetoptError` exception
|
|
1728
|
+
* `usage`: the command usage or `None` if this was not provided
|
|
1729
|
+
* `subcmd`: optional subcommand name;
|
|
1730
|
+
if not `None`, is the name of the subcommand which caused the error
|
|
1731
|
+
|
|
1732
|
+
It returns a true value if the exception is considered handled,
|
|
1733
|
+
in which case the main `run` method returns 2.
|
|
1734
|
+
It returns a false value if the exception is considered unhandled,
|
|
1735
|
+
in which case the main `run` method reraises the `GetoptError`.
|
|
1736
|
+
|
|
1737
|
+
To let the exceptions out unhandled
|
|
1738
|
+
this can be overridden with a method which just returns `False`.
|
|
1739
|
+
|
|
1740
|
+
Otherwise,
|
|
1741
|
+
the handler may perform any suitable action
|
|
1742
|
+
and return `True` to contain the exception
|
|
1743
|
+
or `False` to cause the exception to be reraised.
|
|
1744
|
+
'''
|
|
1745
|
+
warning("%s", e)
|
|
1746
|
+
if usage:
|
|
1747
|
+
print("Usage:", usage, file=sys.stderr)
|
|
1748
|
+
return True
|
|
1749
|
+
|
|
1750
|
+
@uses_runstate
|
|
1751
|
+
def handle_signal(self, sig, frame, *, runstate: RunState):
|
|
1752
|
+
''' The default signal handler, which cancels the default `RunState`.
|
|
1753
|
+
'''
|
|
1754
|
+
runstate.cancel()
|
|
1755
|
+
|
|
1756
|
+
@contextmanager
|
|
1757
|
+
@uses_runstate
|
|
1758
|
+
@uses_upd
|
|
1759
|
+
def run_context(self, *, runstate: RunState, upd: Upd, **options_kw):
|
|
1760
|
+
''' The context manager which surrounds `main` or `cmd_`*subcmd*.
|
|
1761
|
+
Normally this will have a bare `yield`, but you can yield
|
|
1762
|
+
a not `None` value to exit before the command, for example
|
|
1763
|
+
if the run setup fails.
|
|
1764
|
+
|
|
1765
|
+
This default does several things, and subclasses should
|
|
1766
|
+
override it like this:
|
|
1767
|
+
|
|
1768
|
+
@contextmanager
|
|
1769
|
+
def run_context(self):
|
|
1770
|
+
with super().run_context():
|
|
1771
|
+
try:
|
|
1772
|
+
... subclass context setup ...
|
|
1773
|
+
yield
|
|
1774
|
+
finally:
|
|
1775
|
+
... any unconditional cleanup ...
|
|
1776
|
+
|
|
1777
|
+
This base implementation does the following things:
|
|
1778
|
+
- provides a `self.options` which is a copy of the original
|
|
1779
|
+
`self.options` so that it may freely be modified during the
|
|
1780
|
+
command
|
|
1781
|
+
- provides a prevailing `RunState` as `self.options.runstate`
|
|
1782
|
+
if one is not already present
|
|
1783
|
+
- provides a `cs.upd.Upd` context for status lines
|
|
1784
|
+
- catches `self.options.runstate_signals` and handles them
|
|
1785
|
+
with `self.handle_signal`
|
|
1786
|
+
'''
|
|
1787
|
+
# prefer the runstate from the options if specified
|
|
1788
|
+
runstate = self.options.runstate or runstate
|
|
1789
|
+
# redundant try/finally to remind subclassers of correct structure
|
|
1790
|
+
try:
|
|
1791
|
+
run_options = self.options.copy(runstate=runstate, **options_kw)
|
|
1792
|
+
with run_options: # make the default ThreadState
|
|
1793
|
+
with stackattrs(
|
|
1794
|
+
self,
|
|
1795
|
+
options=run_options,
|
|
1796
|
+
):
|
|
1797
|
+
with upd:
|
|
1798
|
+
with runstate:
|
|
1799
|
+
with runstate.catch_signal(
|
|
1800
|
+
run_options.runstate_signals,
|
|
1801
|
+
call_previous=False,
|
|
1802
|
+
handle_signal=self.handle_signal,
|
|
1803
|
+
):
|
|
1804
|
+
yield
|
|
1805
|
+
|
|
1806
|
+
finally:
|
|
1807
|
+
pass
|
|
1808
|
+
|
|
1809
|
+
@popopts(
|
|
1810
|
+
l=('long', 'Long listing.'),
|
|
1811
|
+
r=('recurse', 'Recurse into subcommands.'),
|
|
1812
|
+
s=('short', 'Short listing.'),
|
|
1813
|
+
)
|
|
1814
|
+
def cmd_help(self, argv):
|
|
1815
|
+
''' Usage: {cmd} [-l] [-s] [subcommand-names...]
|
|
1816
|
+
Print help for subcommands.
|
|
1817
|
+
This outputs the full help for the named subcommands,
|
|
1818
|
+
or the short help for all subcommands if no names are specified.
|
|
1819
|
+
'''
|
|
1820
|
+
options = self.options
|
|
1821
|
+
if not options.short and not options.long:
|
|
1822
|
+
options.short = not argv
|
|
1823
|
+
recurse = options.recurse
|
|
1824
|
+
short = options.short
|
|
1825
|
+
all_subcmds = self.subcommands()
|
|
1826
|
+
subcmds = argv or sorted(all_subcmds)
|
|
1827
|
+
unknown = False
|
|
1828
|
+
show_subcmds = []
|
|
1829
|
+
for subcmd in subcmds:
|
|
1830
|
+
if subcmd in subcmds:
|
|
1831
|
+
show_subcmds.append(subcmd)
|
|
1832
|
+
else:
|
|
1833
|
+
warning("unknown subcommand %r", subcmd)
|
|
1834
|
+
unknown = True
|
|
1835
|
+
if unknown:
|
|
1836
|
+
warning("I know: %s", ', '.join(sorted(all_subcmds)))
|
|
1837
|
+
if short:
|
|
1838
|
+
print("Longer help with the -l option.")
|
|
1839
|
+
if not recurse:
|
|
1840
|
+
print("Recursive help with the -r option.")
|
|
1841
|
+
print(
|
|
1842
|
+
"Usage:",
|
|
1843
|
+
self.usage_text(
|
|
1844
|
+
short=short,
|
|
1845
|
+
recurse=recurse,
|
|
1846
|
+
show_common=not short,
|
|
1847
|
+
show_subcmds=show_subcmds or None
|
|
1848
|
+
)
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
def cmd_info(self, argv, *, field_names=None, skip_names=None):
|
|
1852
|
+
''' Usage: {cmd} [field-names...]
|
|
1853
|
+
Recite general information.
|
|
1854
|
+
Explicit field names may be provided to override the default listing.
|
|
1855
|
+
|
|
1856
|
+
This default method recites the values from `self.options`,
|
|
1857
|
+
excluding those enumerated by `self.options.INFO_SKIP_NAMES`.
|
|
1858
|
+
|
|
1859
|
+
This base method provides two optional parameters to allow
|
|
1860
|
+
subclasses to tune its behaviour:
|
|
1861
|
+
- `field_namees`: an explicit list of options attributes to print
|
|
1862
|
+
- `skip_names`: a list of option attributes to not print,
|
|
1863
|
+
default from `self.options.INFO_SKIP_NAMES`
|
|
1864
|
+
'''
|
|
1865
|
+
if skip_names is None:
|
|
1866
|
+
skip_names = getattr(
|
|
1867
|
+
self.options, 'INFO_SKIP_NAMES', ('runstate', 'runstate_signals')
|
|
1868
|
+
)
|
|
1869
|
+
self.options.popopts(argv)
|
|
1870
|
+
xit = 0
|
|
1871
|
+
options = self.options
|
|
1872
|
+
if argv:
|
|
1873
|
+
field_names = []
|
|
1874
|
+
for field_name in argv:
|
|
1875
|
+
if not hasattr(options, field_name):
|
|
1876
|
+
warning("no options.%s attribute", field_name)
|
|
1877
|
+
xit = 1
|
|
1878
|
+
else:
|
|
1879
|
+
field_names.append(field_name)
|
|
1880
|
+
if not field_names:
|
|
1881
|
+
field_names = sorted(
|
|
1882
|
+
field_name for field_name in options.as_dict().keys()
|
|
1883
|
+
if field_name not in skip_names
|
|
1884
|
+
)
|
|
1885
|
+
for line in tabulate(
|
|
1886
|
+
*((f'{field_name}:',
|
|
1887
|
+
pformat(getattr(options, field_name), compact=True))
|
|
1888
|
+
for field_name in field_names)):
|
|
1889
|
+
print(line)
|
|
1890
|
+
return xit
|
|
1891
|
+
|
|
1892
|
+
def repl(self, *argv, banner=None, local=None):
|
|
1893
|
+
''' Run an interactive Python prompt with some predefined local names.
|
|
1894
|
+
Aka REPL (Read Evaluate Print Loop).
|
|
1895
|
+
|
|
1896
|
+
Parameters:
|
|
1897
|
+
* `argv`: any notional command line arguments
|
|
1898
|
+
* `banner`: optional banner string
|
|
1899
|
+
* `local`: optional local names mapping
|
|
1900
|
+
|
|
1901
|
+
The default `local` mapping is a `dict` containing:
|
|
1902
|
+
* `argv`: from `argv`
|
|
1903
|
+
* `options`: from `self.options`
|
|
1904
|
+
* `self`: from `self`
|
|
1905
|
+
* the attributes of `options`
|
|
1906
|
+
* the attributes of `self`
|
|
1907
|
+
'''
|
|
1908
|
+
options = self.options
|
|
1909
|
+
if local is None:
|
|
1910
|
+
pub_mapping = lambda d: {
|
|
1911
|
+
k: v
|
|
1912
|
+
for k, v in d.items()
|
|
1913
|
+
if k and not k.startswith('_')
|
|
1914
|
+
}
|
|
1915
|
+
local = pub_mapping(self.__dict__)
|
|
1916
|
+
del local['options']
|
|
1917
|
+
local.update(
|
|
1918
|
+
{
|
|
1919
|
+
f'options.{k}': v
|
|
1920
|
+
for k, v in sorted(pub_mapping(options.__dict__).items())
|
|
1921
|
+
}
|
|
1922
|
+
)
|
|
1923
|
+
local.update(argv=argv, cmd=self.cmd, self=self)
|
|
1924
|
+
if banner is None:
|
|
1925
|
+
vars_banner = indent(
|
|
1926
|
+
"\n".join(
|
|
1927
|
+
tabulate(
|
|
1928
|
+
*(
|
|
1929
|
+
[k, pformat(v, compact=True)]
|
|
1930
|
+
for k, v in sorted(local.items())
|
|
1931
|
+
if k and not k.startswith('_')
|
|
1932
|
+
)
|
|
1933
|
+
)
|
|
1934
|
+
)
|
|
1935
|
+
)
|
|
1936
|
+
banner = f'{self.cmd}\n\n{vars_banner}\n'
|
|
1937
|
+
try:
|
|
1938
|
+
# pylint: disable=import-outside-toplevel
|
|
1939
|
+
from bpython import embed
|
|
1940
|
+
except ImportError:
|
|
1941
|
+
return interact(
|
|
1942
|
+
banner=banner,
|
|
1943
|
+
local=local,
|
|
1944
|
+
)
|
|
1945
|
+
return embed(
|
|
1946
|
+
banner=banner,
|
|
1947
|
+
locals_=local,
|
|
1948
|
+
)
|
|
1949
|
+
|
|
1950
|
+
@popopts(banner_=None)
|
|
1951
|
+
def cmd_repl(self, argv):
|
|
1952
|
+
''' Usage: {cmd}
|
|
1953
|
+
Run a REPL (Read Evaluate Print Loop), an interactive Python prompt.
|
|
1954
|
+
'''
|
|
1955
|
+
if argv:
|
|
1956
|
+
raise GetoptError(f'extra arguments: {argv!r}')
|
|
1957
|
+
options = self.options
|
|
1958
|
+
banner = options.banner
|
|
1959
|
+
del options.banner
|
|
1960
|
+
return self.repl(*argv, banner=banner)
|
|
1961
|
+
|
|
1962
|
+
@uses_upd
|
|
1963
|
+
def cmd_shell(self, argv, *, upd: Upd):
|
|
1964
|
+
''' Usage: {cmd}
|
|
1965
|
+
Run a command prompt via cmd.Cmd using this command's subcommands.
|
|
1966
|
+
'''
|
|
1967
|
+
if argv:
|
|
1968
|
+
raise GetoptError("extra arguments")
|
|
1969
|
+
with upd.without():
|
|
1970
|
+
self.cmdloop()
|
|
1971
|
+
|
|
1972
|
+
@OBSOLETE("self.options.popopts")
|
|
1973
|
+
def popopts(self, argv, options, **opt_specs):
|
|
1974
|
+
''' A convenience shim which returns `self.options.popopts(argv,**opt_specs)`.
|
|
1975
|
+
'''
|
|
1976
|
+
if options is not self.options:
|
|
1977
|
+
warning(
|
|
1978
|
+
"obsolete use of %s.popopts\n with options %s\n is not self.options %s",
|
|
1979
|
+
self.__class__.__name__, r(options), r(self.options)
|
|
1980
|
+
)
|
|
1981
|
+
return options.popopts(argv, **opt_specs)
|
|
1982
|
+
|
|
1983
|
+
BaseCommandSubType = subtype(BaseCommand)
|
|
1984
|
+
|
|
1985
|
+
class BaseCommandCmd(Cmd):
|
|
1986
|
+
''' A `cmd.Cmd` subclass used to provide interactive use of a
|
|
1987
|
+
command's subcommands.
|
|
1988
|
+
|
|
1989
|
+
The `BaseCommand.cmdloop()` class method instantiates an
|
|
1990
|
+
instance of this and calls its `.cmdloop()` method
|
|
1991
|
+
i.e. `cmd.Cmd.cmdloop`.
|
|
1992
|
+
'''
|
|
1993
|
+
|
|
1994
|
+
@typechecked
|
|
1995
|
+
def __init__(self, command: BaseCommandSubType):
|
|
1996
|
+
super().__init__()
|
|
1997
|
+
self.__command = command
|
|
1998
|
+
|
|
1999
|
+
def get_names(self):
|
|
2000
|
+
cmdcls = type(self.__command)
|
|
2001
|
+
names = []
|
|
2002
|
+
for method_name in dir(cmdcls):
|
|
2003
|
+
if method_name.startswith(cmdcls.SUBCOMMAND_METHOD_PREFIX):
|
|
2004
|
+
subcmd = cutprefix(method_name, cmdcls.SUBCOMMAND_METHOD_PREFIX)
|
|
2005
|
+
names.append('do_' + subcmd)
|
|
2006
|
+
##names.append('help_' + subcmd)
|
|
2007
|
+
return names
|
|
2008
|
+
|
|
2009
|
+
def __getattr__(self, attr):
|
|
2010
|
+
command = self.__command
|
|
2011
|
+
cmdcls = type(command)
|
|
2012
|
+
subcmd = cutprefix(attr, 'do_')
|
|
2013
|
+
if subcmd is not attr:
|
|
2014
|
+
method_name = cmdcls.SUBCOMMAND_METHOD_PREFIX + subcmd
|
|
2015
|
+
try:
|
|
2016
|
+
method = getattr(command, method_name)
|
|
2017
|
+
except AttributeError:
|
|
2018
|
+
pass
|
|
2019
|
+
else:
|
|
2020
|
+
|
|
2021
|
+
def do_subcmd(arg: str):
|
|
2022
|
+
argv = shlex.split(arg)
|
|
2023
|
+
method(argv)
|
|
2024
|
+
|
|
2025
|
+
do_subcmd.__name__ = attr
|
|
2026
|
+
do_subcmd.__doc__ = command.subcommand_usage_text(subcmd)
|
|
2027
|
+
return do_subcmd
|
|
2028
|
+
if subcmd in ('EOF', 'exit', 'quit'):
|
|
2029
|
+
return lambda _: True
|
|
2030
|
+
raise AttributeError("%s.%s" % (self.__class__.__name__, attr))
|
|
2031
|
+
|
|
2032
|
+
@uses_cmd_options(quiet=False, verbose=False)
|
|
2033
|
+
def qvprint(*print_a, quiet, verbose, **print_kw):
|
|
2034
|
+
''' Call `print()` if `options.verbose` and not `options.quiet`.
|
|
2035
|
+
'''
|
|
2036
|
+
if verbose and not quiet:
|
|
2037
|
+
print(*print_a, **print_kw)
|
|
2038
|
+
|
|
2039
|
+
def vprint(*print_a, **qvprint_kw):
|
|
2040
|
+
''' Call `print()` if `options.verbose`.
|
|
2041
|
+
This is a compatibility shim for `qvprint()` with `quiet=False`.
|
|
2042
|
+
'''
|
|
2043
|
+
return qvprint(*print_a, quiet=False, **qvprint_kw)
|
|
2044
|
+
|
|
2045
|
+
if __name__ == '__main__':
|
|
2046
|
+
|
|
2047
|
+
class DemoCommand(BaseCommand):
|
|
2048
|
+
|
|
2049
|
+
@popopts
|
|
2050
|
+
def cmd_demo(self, argv):
|
|
2051
|
+
print("This is a demo.")
|
|
2052
|
+
print("argv =", argv)
|
|
2053
|
+
|
|
2054
|
+
sys.exit(DemoCommand(sys.argv).run())
|