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 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())