-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcmdutils.py
More file actions
2190 lines (1984 loc) · 74 KB
/
Copy pathcmdutils.py
File metadata and controls
2190 lines (1984 loc) · 74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
#
# Command line stuff. - Cameron Simpson <cs@cskk.id.au> 03sep2015
#
# pylint: disable=too-many-lines
''' Convenience functions for working with the cmd stdlib module,
the BaseCommand class for constructing command line programmes,
and other command line related stuff.
This module provides the following main items:
- `BaseCommand`: a base class for creating command line programmes
with easier setup and usage than libraries like `optparse` or `argparse`
- `@popopts`: a decorator which works with `BaseCommand` subcommand
methods to parse their command line options
- `@docmd`: a decorator for command methods of a `cmd.Cmd` class
providing better quality of service
Editorial: why not arparse?
I find the whole argparse `add_argument` thing very cumbersome
and hard to use and remember.
Also, when incorrectly invoked an argparse command line prints
the help/usage messgae and aborts the whole programme with
`SystemExit`.
'''
from cmd import Cmd
from code import interact
from collections import ChainMap
from contextlib import contextmanager
from dataclasses import dataclass, field, fields
from datetime import datetime
try:
from functools import cache # 3.9 onward
except ImportError:
from functools import lru_cache
cache = lru_cache(maxsize=None)
try:
from functools import cached_property # 3.8 onward
except ImportError:
cached_property = lambda func: property(cache(func))
from getopt import getopt, GetoptError
from inspect import isclass
from itertools import chain
import os
from os.path import basename
import re
# this enables readline support in the docmd stuff
try:
import readline # pylint: disable=unused-import
except ImportError:
pass
import shlex
from signal import SIGHUP, SIGINT, SIGQUIT, SIGTERM
import sys
from textwrap import dedent
from typing import (
Any, Callable, Iterable, List, Mapping, Optional, Tuple, Union
)
from typeguard import typechecked
from cs.buffer import CornuCopyBuffer
from cs.context import stackattrs
from cs.deco import decorator, OBSOLETE, uses_cmd_options, uses_quiet
from cs.lex import (
cutprefix,
cutsuffix,
indent,
is_identifier,
printt,
r,
stripped_dedent,
tabulate,
)
from cs.logutils import setup_logging, warning, error, exception
from cs.pfx import Pfx, pfx_call, pfx_method
from cs.py.doc import obj_docstring
from cs.resources import RunState, uses_runstate
from cs.result import CancellationError
from cs.threads import HasThreadState, ThreadState
from cs.typingutils import subtype
from cs.upd import Upd, uses_upd, print # pylint: disable=redefined-builtin
__version__ = '20260531-post'
DISTINFO = {
'keywords': ["python2", "python3"],
'classifiers': [
"Programming Language :: Python",
"Programming Language :: Python :: 3",
],
'install_requires': [
'cs.buffer',
'cs.context',
'cs.deco>=decorator__attrs',
'cs.lex',
'cs.logutils',
'cs.pfx',
'cs.py.doc',
'cs.resources',
'cs.result',
'cs.threads',
'cs.typingutils',
'cs.upd',
'typeguard',
],
}
def docmd(dofunc):
''' Decorator for `cmd.Cmd` subclass methods
to supply some basic quality of service.
This decorator:
- wraps the function call in a `cs.pfx.Pfx` for context
- intercepts `getopt.GetoptError`s, issues a `warning`
and runs `self.do_help` with the method name,
then returns `None`
- intercepts other `Exception`s,
issues an `exception` log message
and returns `None`
The intended use is to decorate `cmd.Cmd` `do_`* methods:
from cmd import Cmd
from cs.cmdutils import docmd
...
class MyCmd(Cmd):
@docmd
def do_something(...):
... do something ...
'''
funcname = dofunc.__name__
def docmd_wrapper(self, *a, **kw):
''' Run a `Cmd` "do" method with some context and handling.
'''
if not funcname.startswith('do_'):
raise ValueError(f"function does not start with 'do_': {funcname}")
argv0 = funcname[3:]
with Pfx(argv0):
try:
return dofunc(self, *a, **kw)
except GetoptError as e:
warning("%s", e)
self.do_help(argv0)
return None
except Exception as e: # pylint: disable=broad-except
exception("%s", e)
return None
docmd_wrapper.__name__ = f'@docmd({funcname})'
docmd_wrapper.__doc__ = dofunc.__doc__
return docmd_wrapper
@dataclass
class OptionSpec:
''' A class to support parsing an option value.
'''
UNVALIDATED_MESSAGE_DEFAULT = "invalid value"
# the option name, eg 'n' for -n or 'dry-run' for --dry-run
opt_name: str
# whether an argument is expected
# the argument usage name or None
# eg "username"
arg_name: Optional[str] = None
# whether multiple options accrue into a list instead of overwriting
accrues: bool = False
# the name of the options field/attribute
field_name: Optional[str] = None
# the initial value of the option
field_default: Optional[Any] = None
# the help text
help_text: Optional[str] = None
# optional callable to convert the argument to the value
parse: Optional[Callable[[str], Any]] = None
# optional callable to validate the value
validate: Optional[Callable[[Any], bool]] = None
# optional message for invalid values
unvalidated_message: str = UNVALIDATED_MESSAGE_DEFAULT
def __post_init__(self):
''' Infer `field_name` and `help_text` if unspecified.
'''
if self.field_name is None:
self.field_name = self.opt_name.replace('-', '_')
if self.help_text is None:
self.help_text = self.help_text_from_field_name(self.field_name)
def parse_value(self, value):
''' Parse `value` according to the spec.
Raises `GetoptError` for invalid values.
'''
if self.parse is None:
return value
with Pfx("%s %r", self.help_text, value):
try:
value = pfx_call(self.parse, value)
if self.validate is not None:
try:
if not pfx_call(self.validate, value):
raise GetoptError(self.unvalidated_message)
except ValueError as e:
raise GetoptError(
f'{self.unvalidated_message}: {e.__class__.__name__}:{e}'
) from e
except ValueError as e:
raise GetoptError(str(e)) from e # pylint: disable=raise-missing-from
return value
@classmethod
@pfx_method
def from_opt_kw(cls, opt_k: str, specs: Union[str, List, Tuple, None]):
''' Factory to produce an `OptionSpec` from a `(key,specs)` 2-tuple
as from the `items()` from a `popopts()` call.
The `specs` is normally a list or tuple, but a bare string
will be promoted to a 1-element list containing the string.
The elements of `specs` are considered in order for:
- an identifier specifying the `arg_name`,
optionally prepended with a dash to indicate an inverted option
- a help text about the option
- a callable to parse the option string value to obtain the actual value
- a callable to validate the option value
- a message for use when validation fails
'''
# produce needs_arg and cleaned up opt_name from the opt_k
needs_arg = False
accrues_arg = False
# leading underscore for numeric options like -1
if opt_k.startswith('_'):
opt_k = opt_k[1:]
if is_identifier(opt_k):
warning("unnecessary leading underscore on valid identifier option")
# trailing underscore indicates that the option expected an argument
# two underscore indicates that the argument accrues as a list
if opt_k.endswith('_'):
needs_arg = True
opt_k = opt_k[:-1]
if opt_k.endswith('_'):
accrues_arg = True
opt_k = opt_k[:-1]
opt_name = opt_k.replace('_', '-')
field_name = opt_k.replace('-', '_')
field_default = None
help_text = None
parse = None
validate = None
unvalidated_message = None
# apply the provided specifications
if specs is None:
specs = [field_name]
elif isinstance(specs, str):
# bare field name or help text
specs = [specs]
elif isinstance(specs, (list, tuple)):
specs = list(specs)
elif callable(specs):
# bare conversion function (or a type eg int)
specs = [specs]
else:
raise TypeError(
f'expected str or list or tuple for specs, got {r(specs)}'
)
spec0 = specs.pop(0) if specs else None
# first: optional field_name
# field_name, an identifier
if isinstance(spec0, str) and is_identifier(spec0):
field_name = spec0
spec0 = specs.pop(0) if specs else None
# -field_name, a dash followed by an identifier
elif (isinstance(spec0, str) and spec0.startswith('-')
and is_identifier(spec0[1:])):
field_name = spec0[1:]
if needs_arg:
raise ValueError(
f'field name {field_name!r} expects an argument'
': inverted options only make sense for Boolean options'
)
field_default = True
spec0 = specs.pop(0) if specs else None
# optional help text
if isinstance(spec0, str):
help_text = spec0
spec0 = specs.pop(0) if specs else None
# optional parse callable
if callable(spec0):
parse = spec0
spec0 = specs.pop(0) if specs else None
# optional validate callable
if callable(spec0):
validate = spec0
spec0 = specs.pop(0) if specs else None
# optional unvalidated_message
if isinstance(spec0, str):
if not parse and not validate:
raise ValueError(
f'unexpected unvalidated_message {spec0!r} when there is no parse or validate callable'
)
unvalidated_message = spec0
spec0 = specs.pop(0) if specs else None
if spec0 is not None:
raise ValueError(f'unhandled specifications: {[spec0]+specs!r}')
if needs_arg:
if accrues_arg:
if field_default is None:
field_default = list
else:
# sanity check Boolean option
if field_default is None:
field_default = False
elif not isinstance(field_default, bool):
raise ValueError(
f'non-Booolean specified for the field default: {r(field_default)}'
)
if field_default is None and not needs_arg:
field_default = False
self = cls(
opt_name=opt_name,
arg_name=(field_name.replace('_', '-') if needs_arg else None),
accrues=accrues_arg,
field_name=field_name,
field_default=field_default,
help_text=help_text,
parse=parse,
validate=validate,
unvalidated_message=unvalidated_message,
)
return self
@property
def needs_arg(self):
''' Whether we expect an argument: we have a `self.arg_name`.
'''
return bool(self.arg_name)
@property
def getopt_opt(self):
''' The `opt` we expect from `opt,val=getopt(argv,...)`.
'''
return (
f'-{self.opt_name}'
if len(self.opt_name) == 1 else f'--{self.opt_name}'
)
@property
def getopt_short(self):
''' The option specification for a getopt short option.
Return `''` if `self.opt_name` is longer than 1 character.
'''
if len(self.opt_name) > 1:
return ''
opt_char = self.opt_name[0]
return f'{opt_char}:' if self.needs_arg else opt_char
@property
def getopt_long(self):
''' The option specification for a getopt long option.
Return `None` if `self.opt_name` is only 1 character.
'''
if len(self.opt_name) < 2:
return None
return f'{self.opt_name}=' if self.needs_arg else f'{self.opt_name}'
def option_terse(self):
''' Return the `"-x"` or `"--name"` option string (with the arg name if expected).
'''
return f'{self.getopt_opt} {self.arg_name}' if self.needs_arg else self.getopt_opt
@staticmethod
def help_text_from_field_name(field_name):
''' Compute an inferred `help_text` from an option `field_name`.
'''
help_text = field_name.replace('_', ' ')
return help_text[0].upper() + help_text[1:] + '.'
def option_usage(self):
''' A 2 line usage entry for this option.
Example:
-j jobs
Job limit.
'''
line1 = self.option_terse()
# TODO: allow multiline help_text, indent it here
return f'{line1}\n {self.help_text}'
def add_argument(self, parser, options=None):
''' Add this option to an `argparser`-style option parser.
The optional `options` parameter may be used to supply an
`Options` instance to provide a default value.
'''
parser.add_argument(
self.getopt_opt,
action=(
('append' if self.accrues else 'store')
if self.arg_name else 'store_true'
),
dest=self.field_name,
help=self.help_text,
default=(
(None if self.arg_name else False)
if options is None else getattr(options, self.field_name, None)
),
)
def split_usage(doc: Union[str, None],
usage_marker="Usage:") -> Tuple[str, str, str]:
''' Extract a `"Usage:"`paragraph from a docstring
and return a 3-tuple of `(preusage,usage,postusage)`.
If the usage paragraph is not present `''` is returned as
the middle comonpent, otherwise it is the unindented usage
without the leading `"Usage:"`.
'''
if not doc:
# no doc, return unchanged
return '', '', ''
try:
pre_usage, usage_onward = doc.split(usage_marker, 1)
except ValueError:
# no Usage: paragraph
# use the first paragraph
pre_usage = ''
usage_format, *post_usage_paras = doc.split("\n\n")
usage_format = f'Usage: {{cmd}} subcommand [options...]\n{usage_format}'
post_usage = "\n\n".join(post_usage_paras)
else:
try:
usage_format, post_usage = usage_onward.split("\n\n", 1)
except ValueError:
usage_format, post_usage = usage_onward.rstrip(), ''
usage_format = stripped_dedent(usage_format)
# indent the second and following lines
try:
top_line, post_lines = usage_format.split("\n", 1)
except ValueError:
# single line usage only
pass
else:
usage_format = f'{top_line}\n{indent(post_lines)}'
return pre_usage, usage_format, post_usage
@dataclass
class SubCommand:
''' An implementation for a subcommand.
'''
# the BaseCommand instance with which we're associated
command: "BaseCommand"
# a method or a subclass of BaseCommand
method: Callable
# the notional name of the command/subcommand
cmd: str = None
# optional additional usage keyword mapping
usage_mapping: Mapping[str, Any] = field(default_factory=dict)
@property
def instance(self):
''' An instance of the class for `self.method`.
'''
return self.method(...) if isclass(self.method) else self.method.__self__
def get_cmd(self) -> str:
''' Return the `cmd` string for this `SubCommand`,
derived from the subcommand's method name or class name
if `self.cmd` is not set.
'''
if self.cmd is None:
method = self.method
if isclass(method):
return cutsuffix(method.__name__, 'Command').lower()
return cutprefix(method.__name__, self.command.SUBCOMMAND_METHOD_PREFIX)
return self.cmd
@typechecked
def __call__(self, argv: List[str]):
''' Run the subcommand.
Parameters:
* `argv`: the command line arguments after the subcommand name
'''
method = self.method
if isclass(method):
# plumb self.command.options through to the subcommand
updates = self.command.options.as_dict()
updates.update(cmd=self.get_cmd())
return pfx_call(method, argv, **updates).run()
return method(argv)
@cached_property
def usage_default(self):
''' The fallback usage line if nothing specified:
`'{cmd} [options...]'` or `'{cmd} subcommand [options...]'`.
'''
if isclass(self.method):
has_subcommands_test = getattr(
self.instance, 'has_subcommands', lambda: False
)
else:
has_subcommands_test = getattr(
self.method, 'has_subcommands', lambda: False
)
return (
'{cmd} subcommand [options...]'
if has_subcommands_test() else '{cmd} [options...]'
)
@cached_property
def usage_commonopts_format(self):
''' The `Common options:` format string paragraph
or `None` if there are no common options.
'''
return self.command.Options.usage_options_format(
"Common options:", **self.command.options.COMMON_OPT_SPECS
)
@cached_property
def usage_format(self) -> str:
''' The usage format string for this subcommand.
*Note*: no leading "Usage:" prefix.
This first tries the legacy `self.method.USAGE_FORMAT`,
falling back to deriving it from `obj_docstring(self.method)`.
When deriving from the docstring we look for a paragraph
commencing with the string `Usage:` and otherwise fall back
to its first parapgraph.
'''
method = self.method
try:
# the old way
usage_format = method.USAGE_FORMAT
except AttributeError:
# the preferred way
# derive from the docstring or from self.usage_default
doc = obj_docstring(method)
pre_usage, usage_format, post_usage = split_usage(doc)
if not usage_format:
# No "Usage:" paragraph - use default usage line and first paragraph.
usage_format = self.usage_default
paragraph1 = stripped_dedent(pre_usage.split('\n\n', 1)[0])
if paragraph1:
usage_format += "\n" + indent(paragraph1)
else:
# The existing USAGE_FORMAT based usages have the word "Usage:"
# at the front but this is supplied at print time now.
usage_format = indent(
stripped_dedent(cutprefix(usage_format.lstrip(), 'Usage:'))
).lstrip()
return usage_format
@cached_property
def usage_format_parts(
self
) -> Tuple[str, Union[str, None], Union[str, None]]:
''' The usage description format string broken into:
- the usage line (or lines if slosh extended)
- the first line of the description, or `None`
- the trailing lines of the description, or `None`
'''
lines = self.usage_format.split('\n')
usage_lines = [lines.pop(0)]
while usage_lines[-1].endswith('\\'):
usage_lines.append(lines.pop(0))
return (
"\n".join(usage_lines),
lines.pop(0).lstrip() if lines and lines[0].endswith('.') else None,
dedent("\n".join(lines)) if lines else None,
)
@cached_property
def usage_format_usage(self) -> str:
''' The usage line(s) part of the format string.
'''
return self.usage_format_parts[0]
@cached_property
def usage_format_desc1(self) -> str:
''' The leading usage line part of the format string.
'''
return self.usage_format_parts[1]
def get_usage_format(self, show_common=False) -> str:
''' Return the usage format string for this subcommand.
*Note*: no leading "Usage:" prefix.
If `show_common` is true, include the `Common options:` paragraph.
'''
usage_format = self.usage_format
if show_common:
copts_format = self.usage_commonopts_format
if copts_format:
usage_format += "\n" + indent(copts_format)
return usage_format
def get_usage_keywords(
self,
*,
cmd: Optional[str] = None,
usage_mapping: Optional[Mapping] = None,
) -> str:
''' Return a mapping to be used when formatting the usage format string.
This is an elaborate `ChainMap` of:
- the optional `cmd` or `self.get_cmd()`
- the optional `usage_mapping` parameter
- `self.usage_mapping`
- `self.method.USAGE_KEYWORDS` if present
- the attributes of `self.command`
- the attributes of `type(self.command)`
- the attributes of the module for `type(self.command)`
'''
# TODO maybe this should return the ChainMap used in usage_text
# elaborate search path for symbols in the usage format string
# TODO: should this _be_ get_usage_keywords? pretty verbose
format_cmd = cmd or self.get_cmd().replace('_', '-')
if self.command.options.COMMON_OPT_SPECS:
format_cmd += ' [common-options...]'
return ChainMap(
# normalised cmd name
{'cmd': format_cmd},
# supplied usage_mapping, if any
usage_mapping or {},
# direct .usage_mapping attribute
self.usage_mapping or {},
# usage mapping via the method
dict(getattr(self.method, 'USAGE_KEYWORDS', {})),
# the names in the command
self.command.__dict__,
# ... and its class
self.command.__class__.__dict__,
# ... and its module's top level
sys.modules[self.command.__module__].__dict__,
)
def get_subcommands(self):
''' Return `self.method`'s mapping of subcommand name to `SubCommand`.
'''
method = self.method
if isclass(method):
# special constructor using Ellipsis to get a placeholder instance
method = method(...)
try:
get_subcommands = method.subcommands
except AttributeError:
return {}
return get_subcommands()
def has_subcommands(self):
''' Whether this `SubCommand`'s `.method` has subcommands.
'''
try:
has_subcommands = self.method.has_subcommands
except AttributeError:
# just inspect whatever subcommands the method has
return bool(self.get_subcommands())
# probaby a class - use its has_subcommands() test
return has_subcommands()
def get_subcmds(self):
''' Return the names of `self.method`'s subcommands in lexical order.
'''
return sorted(self.get_subcommands().keys())
def subusage_table(self, subcmds: List[str], *, recurse=False, short=False):
''' Return rows for use with `cs.lex.tabulate`
for the short subusage listing.
'''
rows = []
subcommands = self.get_subcommands()
for subcmd in subcmds:
subcommand = subcommands[subcmd]
rows.append(
[
subcmd.replace('_', '-'),
(
# TODO: full desc if not short
subcommand.usage_format_desc1
or f'{subcmd.title()} subcommand.'
)
]
)
if recurse and subcommand.has_subcommands():
rows.extend(
[indent(subc), indent(subd)]
for subc, subd in subcommand.subusage_table(
sorted(subcommand.get_subcommands().keys()),
recurse=recurse,
short=short,
)
)
return rows
def short_subusages(self, subcmds: List[str], *, recurse=False, short=False):
''' Return a list of tabulated one line subcommand summaries.
'''
table = self.subusage_table(subcmds, recurse=recurse, short=short)
return list(tabulate(*table))
@typechecked
def usage_text(
self,
*,
cmd=None,
short: bool,
recurse: bool = False,
show_common: bool = False,
show_subcmds: Optional[Union[bool, str, List[str]]] = None,
usage_mapping: Optional[Mapping] = None,
seen_subcommands: Optional[Mapping] = None,
) -> str:
''' Return the filled out usage text for this subcommand.
'''
if show_subcmds is None:
show_subcmds = True
if seen_subcommands is None:
seen_subcommands = {}
subcommands = self.get_subcommands()
if show_subcmds:
# compute those already seen and those new
common_subcmds = subcommands.keys() & seen_subcommands.keys()
additional_subcommands = subcommands.keys() - common_subcmds
# turn show_subcmds into the list of subcommand names to show in the usage
if isinstance(show_subcmds, bool):
# all the subcommands winnowed by the sub_seen_subcommands
assert show_subcmds is True
show_subcmds = sorted(additional_subcommands)
elif isinstance(show_subcmds, str):
# show a single subcommand
show_subcmds = [show_subcmds]
# the seen_subcommands for our subcommands
sub_seen_subcommands = dict(seen_subcommands)
sub_seen_subcommands.update(subcommands)
# normalise the subcommand names to match the subcommands mapping
show_subcmds = [subcmd.replace('-', '_') for subcmd in show_subcmds]
if short:
usage_line, desc1, _ = self.usage_format_parts
usage_format = usage_line
if desc1:
usage_format += '\n' + indent(desc1)
else:
usage_format = self.usage_format
if show_common:
copts_format = self.usage_commonopts_format
if copts_format:
usage_format += "\n" + indent(copts_format)
# the elaborate search path for symbols in the usage format string
mapping = self.get_usage_keywords(cmd=cmd, usage_mapping=usage_mapping)
with Pfx("format %r using %r", usage_format, mapping):
usage = usage_format.format_map(mapping)
subusage_listing = []
if self.has_subcommands():
if short:
# the terse one subcommand-per-line listing
subusages = self.short_subusages(
show_subcmds, recurse=recurse, short=short
)
else:
# the longer descriptions
subusages = []
for subcmd in show_subcmds:
try:
subcommand = subcommands[subcmd]
except KeyError:
warning("unknown subcommand %r", subcmd)
else:
# recursive long listing
subusages.append(
subcommand.usage_text(
short=short,
recurse=recurse,
seen_subcommands=sub_seen_subcommands,
)
)
if common_subcmds:
common_subcmds_line = f'Common subcommands: {", ".join(sorted(common_subcmds))}.'
if subusages:
subcmds_header = (
'Subcommands'
if show_subcmds is None or len(show_subcmds) > 1 else 'Subcommand'
)
subusage_listing.append(f'{subcmds_header}:')
if common_subcmds:
subusage_listing.append(indent(common_subcmds_line))
subusage_listing.extend(map(indent, subusages))
elif common_subcmds:
subusage_listing.append(common_subcmds_line)
if subusage_listing:
subusage = "\n".join(subusage_listing)
usage = f'{usage}\n{indent(subusage)}'
return usage
# exposed outside the class for @fmtdoc
SSH_EXE_DEFAULT = 'ssh'
SSH_EXE_ENVVAR = 'SSH_EXE'
# gimmicked name to support @fmtdoc on BaseCommandOptions.popopts
_COMMON_OPT_SPECS = dict(
dry_run=('dry_run', 'Dry run, aka no action.'),
e_=(
'ssh_exe',
''' An ssh-like command to use for remote command execution.
The string is a shell-like command string parsable by shlex.split.''',
),
n=('dry_run', 'No action, aka dry run.'),
q='quiet',
v='verbose',
)
@dataclass
class BaseCommandOptions(HasThreadState):
''' A base class for the `BaseCommand` `options` object.
This is the default class for the `self.options` object
available during `BaseCommand.run()`,
and available as the `BaseCommand.Options` attribute.
Any keyword arguments are applied as field updates to the instance.
It comes prefilled with:
* `.dry_run=False`
* `.force=False`
* `.quiet=False`
* `.ssh_exe='ssh'`
* `.verbose=False`
and a `.doit` property which is the inverse of `.dry_run`.
It is recommended that if `BaseCommand` subclasses use a
different type for their `Options` that it should be a
subclass of `BaseCommandOptions`.
Since `BaseCommandOptions` is a data class, this typically looks like:
@dataclass
class Options(BaseCommand.Options):
... optional extra fields etc ...
'''
INFO_SKIP_NAMES = ('runstate', 'runstate_signals')
DEFAULT_SIGNALS = SIGHUP, SIGINT, SIGQUIT, SIGTERM
COMMON_OPT_SPECS = _COMMON_OPT_SPECS
# the cmd prefix while a command runs
cmd: Optional[str] = None
# dry run, no action
dry_run: bool = False
force: bool = False
quiet: bool = False
runstate: Optional[RunState] = None
runstate_signals: Tuple[int] = DEFAULT_SIGNALS
ssh_exe: str = field(
default_factory=lambda: (
os.environ.get(SSH_EXE_ENVVAR, os.environ.get('RSYNC_RSH', '')) or
SSH_EXE_DEFAULT
)
)
verbose: bool = False
opt_spec_class = OptionSpec
perthread_state = ThreadState()
def __post_init__(self):
''' A empty base post `__init__` method so that all subclasses'
`__post_init__`s can call their `super().__post_init__`.
'''
def as_dict(self):
''' Return the options as a `dict`.
This contains all the public attributes of `self`.
'''
return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
def fields_as_dict(self):
''' Return the options' fields as a `dict`.
This contains all the field values of `self`.
'''
return {f.name: getattr(self, f.name) for f in fields(self)}
def copy(self, **updates):
''' Return a new instance of `BaseCommandOptions` (well, `type(self)`)
which is a shallow copy of the public attributes from `self.__dict__`.
Any keyword arguments are applied as attribute updates to the copy.
'''
# instantiate copied with the fields
copied = pfx_call(type(self), **self.fields_as_dict())
# infill any attributes which were not fields
for k, v in self.as_dict().items():
setattr(copied, k, v)
# apply the supplied updates
for k, v in updates.items():
setattr(copied, k, v)
return copied
def update(self, **updates):
''' Modify the options in place with the mapping `updates`.
It would be more normal to call the options in a `with` statement
as shown for `__call__`.
'''
for k, v in updates.items():
setattr(self, k, v)
# TODO: remove this - the overt make-a-copy-and-with-the-copy is clearer
@contextmanager
def __call__(self, **updates):
''' Calling the options object returns a context manager whose
value is a shallow copy of the options with any `updates` applied.
Example showing the semantics:
>>> from cs.cmdutils import BaseCommandOptions
>>> @dataclass
... class DemoOptions(BaseCommandOptions):
... x: int = 0
...
>>> options = DemoOptions(x=1)
>>> assert options.x == 1
>>> assert not options.verbose
>>> with options(verbose=True) as subopts:
... assert options is not subopts
... assert options.x == 1
... assert not options.verbose
... assert subopts.x == 1
... assert subopts.verbose
...
>>> assert options.x == 1
>>> assert not options.verbose
'''
suboptions = self.copy(**updates)
yield suboptions
@property
def doit(self):
''' I usually use a `doit` flag, the inverse of `dry_run`.
'''
return not self.dry_run
@doit.setter
def doit(self, new_doit):
''' Set `dry_run` to the inverse of `new_doit`.
'''
self.dry_run = not new_doit
@classmethod
def getopt_spec_map(cls, opt_specs_kw: Mapping, common_opt_specs=None):
''' Return a 3-tuple of (shortopts,longopts,getopt_spec_map)` being:
- `shortopts`: the `getopt()` short options specification string
- `longopts`: the `getopts()` long option specification list
- `getopt_spec_map`: a mapping of `opt`->`OptionSpec`
where `opt` is as from `opt,val` from `getopt()`
and `opt_spec` is the associated `OptionSpec` instance
'''
if common_opt_specs is None:
common_opt_specs = cls.COMMON_OPT_SPECS
opt_spec_cls = cls.opt_spec_class
shortopts = ''
longopts = []
getopt_spec_map = {}
# gather up the option specifications and make getopt arguments
for opt_k, opt_specs in chain(opt_specs_kw.items(),
common_opt_specs.items()):
with Pfx("opt_spec[%r]=%r", opt_k, opt_specs):
opt_spec = opt_spec_cls.from_opt_kw(opt_k, opt_specs)
if opt_spec.getopt_opt in getopt_spec_map:
# keep the earlier definition
continue
getopt_spec_map[opt_spec.getopt_opt] = opt_spec
# update the arguments for getopt()
shortopts += opt_spec.getopt_short
getopt_long = opt_spec.getopt_long
if getopt_long is not None:
longopts.append(getopt_long)
return shortopts, longopts, getopt_spec_map
def popopts(
self,
argv,
**opt_specs_kw,
):
''' Parse option switches from `argv`, a list of command line strings
with leading option switches and apply them to `self`.
Modify `argv` in place.
Example use in a `BaseCommand` `cmd_foo` method:
def cmd_foo(self, argv):
options = self.options
options.popopts(
c_='config',
l='long',
x='trace',
)
if self.options.dry_run:
print("dry run!")