cmd2 2.7.0__py3-none-any.whl → 3.0.0b1__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.
cmd2/cmd2.py CHANGED
@@ -10,11 +10,11 @@ Special-character shortcut commands (beyond cmd's "?" and "!")
10
10
  Settable environment parameters
11
11
  Parsing commands with `argparse` argument parsers (flags)
12
12
  Redirection to file or paste buffer (clipboard) with > or >>
13
- Easy transcript-based testing of applications (see examples/example.py)
13
+ Easy transcript-based testing of applications (see examples/transcript_example.py)
14
14
  Bash-style ``select`` available
15
15
 
16
- Note that redirection with > and | will only work if `self.poutput()`
17
- is used in place of `print`.
16
+ Note, if self.stdout is different than sys.stdout, then redirection with > and |
17
+ will only work if `self.poutput()` is used in place of `print`.
18
18
 
19
19
  - Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com
20
20
 
@@ -24,10 +24,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2
24
24
  # This module has many imports, quite a few of which are only
25
25
  # infrequently utilized. To reduce the initial overhead of
26
26
  # import this module, many of these imports are lazy-loaded
27
- # i.e. we only import the module when we use it
28
- # For example, we don't import the 'traceback' module
29
- # until the pexcept() function is called and the debug
30
- # setting is True
27
+ # i.e. we only import the module when we use it.
31
28
  import argparse
32
29
  import cmd
33
30
  import contextlib
@@ -36,7 +33,6 @@ import functools
36
33
  import glob
37
34
  import inspect
38
35
  import os
39
- import pprint
40
36
  import pydoc
41
37
  import re
42
38
  import sys
@@ -61,23 +57,36 @@ from typing import (
61
57
  TYPE_CHECKING,
62
58
  Any,
63
59
  ClassVar,
64
- Optional,
65
60
  TextIO,
66
61
  TypeVar,
67
62
  Union,
68
63
  cast,
69
64
  )
70
65
 
66
+ import rich.box
67
+ from rich.console import Group
68
+ from rich.highlighter import ReprHighlighter
69
+ from rich.rule import Rule
70
+ from rich.style import Style, StyleType
71
+ from rich.table import (
72
+ Column,
73
+ Table,
74
+ )
75
+ from rich.text import Text
76
+ from rich.traceback import Traceback
77
+
71
78
  from . import (
72
- ansi,
73
79
  argparse_completer,
74
80
  argparse_custom,
75
81
  constants,
76
82
  plugin,
77
83
  utils,
78
84
  )
85
+ from . import rich_utils as ru
86
+ from . import string_utils as su
79
87
  from .argparse_custom import (
80
88
  ChoicesProviderFunc,
89
+ Cmd2ArgumentParser,
81
90
  CompleterFunc,
82
91
  CompletionItem,
83
92
  )
@@ -122,10 +131,16 @@ from .parsing import (
122
131
  StatementParser,
123
132
  shlex_split,
124
133
  )
134
+ from .rich_utils import (
135
+ Cmd2ExceptionConsole,
136
+ Cmd2GeneralConsole,
137
+ RichPrintKwargs,
138
+ )
139
+ from .styles import Cmd2Style
125
140
 
126
141
  # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff
127
142
  with contextlib.suppress(ImportError):
128
- from IPython import start_ipython # type: ignore[import]
143
+ from IPython import start_ipython
129
144
 
130
145
  from .rl_utils import (
131
146
  RlType,
@@ -139,10 +154,6 @@ from .rl_utils import (
139
154
  rl_warning,
140
155
  vt100_support,
141
156
  )
142
- from .table_creator import (
143
- Column,
144
- SimpleTable,
145
- )
146
157
  from .utils import (
147
158
  Settable,
148
159
  get_defining_class,
@@ -153,9 +164,9 @@ from .utils import (
153
164
 
154
165
  # Set up readline
155
166
  if rl_type == RlType.NONE: # pragma: no cover
156
- sys.stderr.write(ansi.style_warning(rl_warning))
167
+ Cmd2GeneralConsole(sys.stderr).print(rl_warning, style=Cmd2Style.WARNING)
157
168
  else:
158
- from .rl_utils import ( # type: ignore[attr-defined]
169
+ from .rl_utils import (
159
170
  readline,
160
171
  rl_force_redisplay,
161
172
  )
@@ -185,7 +196,7 @@ class _SavedReadlineSettings:
185
196
  def __init__(self) -> None:
186
197
  self.completer = None
187
198
  self.delims = ''
188
- self.basic_quotes: Optional[bytes] = None
199
+ self.basic_quotes: bytes | None = None
189
200
 
190
201
 
191
202
  class _SavedCmd2Env:
@@ -193,10 +204,8 @@ class _SavedCmd2Env:
193
204
 
194
205
  def __init__(self) -> None:
195
206
  self.readline_settings = _SavedReadlineSettings()
196
- self.readline_module: Optional[ModuleType] = None
207
+ self.readline_module: ModuleType | None = None
197
208
  self.history: list[str] = []
198
- self.sys_stdout: Optional[TextIO] = None
199
- self.sys_stdin: Optional[TextIO] = None
200
209
 
201
210
 
202
211
  # Contains data about a disabled command which is used to restore its original functions when the command is enabled
@@ -205,7 +214,7 @@ DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_funct
205
214
 
206
215
  if TYPE_CHECKING: # pragma: no cover
207
216
  StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
208
- ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
217
+ ClassArgParseBuilder = classmethod['Cmd' | CommandSet, [], argparse.ArgumentParser]
209
218
  else:
210
219
  StaticArgParseBuilder = staticmethod
211
220
  ClassArgParseBuilder = classmethod
@@ -241,7 +250,7 @@ class _CommandParsers:
241
250
  parser = self.get(command_method)
242
251
  return bool(parser)
243
252
 
244
- def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]:
253
+ def get(self, command_method: CommandFunc) -> argparse.ArgumentParser | None:
245
254
  """Return a given method's parser or None if the method is not argparse-based.
246
255
 
247
256
  If the parser does not yet exist, it will be created.
@@ -263,8 +272,8 @@ class _CommandParsers:
263
272
  parser = self._cmd._build_parser(parent, parser_builder, command)
264
273
 
265
274
  # If the description has not been set, then use the method docstring if one exists
266
- if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
267
- parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
275
+ if parser.description is None and command_method.__doc__:
276
+ parser.description = strip_doc_annotations(command_method.__doc__)
268
277
 
269
278
  self._parsers[full_method_name] = parser
270
279
 
@@ -288,12 +297,8 @@ class Cmd(cmd.Cmd):
288
297
 
289
298
  DEFAULT_EDITOR = utils.find_editor()
290
299
 
291
- INTERNAL_COMMAND_EPILOG = (
292
- "Notes:\n This command is for internal use and is not intended to be called from the\n command line."
293
- )
294
-
295
300
  # Sorting keys for strings
296
- ALPHABETICAL_SORT_KEY = utils.norm_fold
301
+ ALPHABETICAL_SORT_KEY = su.norm_fold
297
302
  NATURAL_SORT_KEY = utils.natural_keys
298
303
 
299
304
  # List for storing transcript test file names
@@ -302,8 +307,8 @@ class Cmd(cmd.Cmd):
302
307
  def __init__(
303
308
  self,
304
309
  completekey: str = 'tab',
305
- stdin: Optional[TextIO] = None,
306
- stdout: Optional[TextIO] = None,
310
+ stdin: TextIO | None = None,
311
+ stdout: TextIO | None = None,
307
312
  *,
308
313
  persistent_history_file: str = '',
309
314
  persistent_history_length: int = 1000,
@@ -312,12 +317,12 @@ class Cmd(cmd.Cmd):
312
317
  include_py: bool = False,
313
318
  include_ipy: bool = False,
314
319
  allow_cli_args: bool = True,
315
- transcript_files: Optional[list[str]] = None,
320
+ transcript_files: list[str] | None = None,
316
321
  allow_redirection: bool = True,
317
- multiline_commands: Optional[list[str]] = None,
318
- terminators: Optional[list[str]] = None,
319
- shortcuts: Optional[dict[str, str]] = None,
320
- command_sets: Optional[Iterable[CommandSet]] = None,
322
+ multiline_commands: list[str] | None = None,
323
+ terminators: list[str] | None = None,
324
+ shortcuts: dict[str, str] | None = None,
325
+ command_sets: Iterable[CommandSet] | None = None,
321
326
  auto_load_commands: bool = True,
322
327
  allow_clipboard: bool = True,
323
328
  suggest_similar_command: bool = False,
@@ -417,7 +422,7 @@ class Cmd(cmd.Cmd):
417
422
  # Use as prompt for multiline commands on the 2nd+ line of input
418
423
  self.continuation_prompt: str = '> '
419
424
 
420
- # Allow access to your application in embedded Python shells and scripts py via self
425
+ # Allow access to your application in embedded Python shells and pyscripts via self
421
426
  self.self_in_py = False
422
427
 
423
428
  # Commands to exclude from the help menu and tab completion
@@ -461,7 +466,7 @@ class Cmd(cmd.Cmd):
461
466
 
462
467
  # If the current command created a process to pipe to, then this will be a ProcReader object.
463
468
  # Otherwise it will be None. It's used to know when a pipe process can be killed and/or waited upon.
464
- self._cur_pipe_proc_reader: Optional[utils.ProcReader] = None
469
+ self._cur_pipe_proc_reader: utils.ProcReader | None = None
465
470
 
466
471
  # Used to keep track of whether we are redirecting or piping output
467
472
  self._redirecting = False
@@ -472,8 +477,24 @@ class Cmd(cmd.Cmd):
472
477
  # The multiline command currently being typed which is used to tab complete multiline commands.
473
478
  self._multiline_in_progress = ''
474
479
 
475
- # Set the header used for the help function's listing of documented functions
476
- self.doc_header = "Documented commands (use 'help -v' for verbose/'help <topic>' for details):"
480
+ # Characters used to draw a horizontal rule. Should not be blank.
481
+ self.ruler = ""
482
+
483
+ # Set text which prints right before all of the help tables are listed.
484
+ self.doc_leader = ""
485
+
486
+ # Set header for table listing documented commands.
487
+ self.doc_header = "Documented Commands"
488
+
489
+ # Set header for table listing help topics not related to a command.
490
+ self.misc_header = "Miscellaneous Help Topics"
491
+
492
+ # Set header for table listing commands that have no help info.
493
+ self.undoc_header = "Undocumented Commands"
494
+
495
+ # If any command has been categorized, then all other documented commands that
496
+ # haven't been categorized will display under this section in the help output.
497
+ self.default_category = "Uncategorized Commands"
477
498
 
478
499
  # The error that prints when no help information can be found
479
500
  self.help_error = "No help on {}"
@@ -487,17 +508,30 @@ class Cmd(cmd.Cmd):
487
508
  # Commands that will run at the beginning of the command loop
488
509
  self._startup_commands: list[str] = []
489
510
 
511
+ # Store initial termios settings to restore after each command.
512
+ # This is a faster way of accomplishing what "stty sane" does.
513
+ self._initial_termios_settings = None
514
+ if not sys.platform.startswith('win') and self.stdin.isatty():
515
+ try:
516
+ import io
517
+ import termios
518
+
519
+ self._initial_termios_settings = termios.tcgetattr(self.stdin.fileno())
520
+ except (ImportError, io.UnsupportedOperation, termios.error):
521
+ # This can happen if termios isn't available or stdin is a pseudo-TTY
522
+ self._initial_termios_settings = None
523
+
490
524
  # If a startup script is provided and exists, then execute it in the startup commands
491
525
  if startup_script:
492
526
  startup_script = os.path.abspath(os.path.expanduser(startup_script))
493
527
  if os.path.exists(startup_script):
494
- script_cmd = f"run_script {utils.quote_string(startup_script)}"
528
+ script_cmd = f"run_script {su.quote(startup_script)}"
495
529
  if silence_startup_script:
496
530
  script_cmd += f" {constants.REDIRECTION_OUTPUT} {os.devnull}"
497
531
  self._startup_commands.append(script_cmd)
498
532
 
499
533
  # Transcript files to run instead of interactive command loop
500
- self._transcript_files: Optional[list[str]] = None
534
+ self._transcript_files: list[str] | None = None
501
535
 
502
536
  # Check for command line args
503
537
  if allow_cli_args:
@@ -514,7 +548,7 @@ class Cmd(cmd.Cmd):
514
548
  elif transcript_files:
515
549
  self._transcript_files = transcript_files
516
550
 
517
- # Set the pager(s) for use with the ppaged() method for displaying output using a pager
551
+ # Set the pager(s) for use when displaying output using a pager
518
552
  if sys.platform.startswith('win'):
519
553
  self.pager = self.pager_chop = 'more'
520
554
  else:
@@ -542,10 +576,6 @@ class Cmd(cmd.Cmd):
542
576
  # values are DisabledCommand objects.
543
577
  self.disabled_commands: dict[str, DisabledCommand] = {}
544
578
 
545
- # If any command has been categorized, then all other commands that haven't been categorized
546
- # will display under this section in the help output.
547
- self.default_category = 'Uncategorized'
548
-
549
579
  # The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort.
550
580
  # If natural sorting is preferred, then set this to NATURAL_SORT_KEY.
551
581
  # cmd2 uses this key for sorting:
@@ -626,7 +656,7 @@ class Cmd(cmd.Cmd):
626
656
  self.default_suggestion_message = "Did you mean {}?"
627
657
 
628
658
  # the current command being executed
629
- self.current_command: Optional[Statement] = None
659
+ self.current_command: Statement | None = None
630
660
 
631
661
  def find_commandsets(self, commandset_type: type[CommandSet], *, subclass_match: bool = False) -> list[CommandSet]:
632
662
  """Find all CommandSets that match the provided CommandSet type.
@@ -643,7 +673,7 @@ class Cmd(cmd.Cmd):
643
673
  if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721
644
674
  ]
645
675
 
646
- def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]:
676
+ def find_commandset_for_command(self, command_name: str) -> CommandSet | None:
647
677
  """Find the CommandSet that registered the command name.
648
678
 
649
679
  :param command_name: command name to search
@@ -754,12 +784,10 @@ class Cmd(cmd.Cmd):
754
784
  def _build_parser(
755
785
  self,
756
786
  parent: CommandParent,
757
- parser_builder: Union[
758
- argparse.ArgumentParser,
759
- Callable[[], argparse.ArgumentParser],
760
- StaticArgParseBuilder,
761
- ClassArgParseBuilder,
762
- ],
787
+ parser_builder: argparse.ArgumentParser
788
+ | Callable[[], argparse.ArgumentParser]
789
+ | StaticArgParseBuilder
790
+ | ClassArgParseBuilder,
763
791
  prog: str,
764
792
  ) -> argparse.ArgumentParser:
765
793
  """Build argument parser for a command/subcommand.
@@ -783,9 +811,7 @@ class Cmd(cmd.Cmd):
783
811
  else:
784
812
  raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}")
785
813
 
786
- from .decorators import _set_parser_prog
787
-
788
- _set_parser_prog(parser, prog)
814
+ argparse_custom.set_parser_prog(parser, prog)
789
815
 
790
816
  return parser
791
817
 
@@ -940,7 +966,7 @@ class Cmd(cmd.Cmd):
940
966
 
941
967
  subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
942
968
  if not subcommand_valid:
943
- raise CommandSetRegistrationError(f'Subcommand {subcommand_name!s} is not valid: {errmsg}')
969
+ raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}')
944
970
 
945
971
  command_tokens = full_command_name.split()
946
972
  command_name = command_tokens[0]
@@ -953,11 +979,11 @@ class Cmd(cmd.Cmd):
953
979
  command_func = self.cmd_func(command_name)
954
980
 
955
981
  if command_func is None:
956
- raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}")
982
+ raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
957
983
  command_parser = self._command_parsers.get(command_func)
958
984
  if command_parser is None:
959
985
  raise CommandSetRegistrationError(
960
- f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}"
986
+ f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
961
987
  )
962
988
 
963
989
  def find_subcommand(action: argparse.ArgumentParser, subcmd_names: list[str]) -> argparse.ArgumentParser:
@@ -974,46 +1000,34 @@ class Cmd(cmd.Cmd):
974
1000
 
975
1001
  target_parser = find_subcommand(command_parser, subcommand_names)
976
1002
 
1003
+ # Create the subcommand parser and configure it
977
1004
  subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}')
978
1005
  if subcmd_parser.description is None and method.__doc__:
979
1006
  subcmd_parser.description = strip_doc_annotations(method.__doc__)
980
1007
 
1008
+ # Set the subcommand handler
1009
+ defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
1010
+ subcmd_parser.set_defaults(**defaults)
1011
+
1012
+ # Set what instance the handler is bound to
1013
+ setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET, cmdset)
1014
+
1015
+ # Find the argparse action that handles subcommands
981
1016
  for action in target_parser._actions:
982
1017
  if isinstance(action, argparse._SubParsersAction):
983
1018
  # Get the kwargs for add_parser()
984
1019
  add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
985
1020
 
986
- # Set subcmd_parser as the parent to the parser we're creating to get its arguments
987
- add_parser_kwargs['parents'] = [subcmd_parser]
988
-
989
- # argparse only copies actions from a parent and not the following settings.
990
- # To retain these settings, we will copy them from subcmd_parser and pass them
991
- # as ArgumentParser constructor arguments to add_parser().
992
- add_parser_kwargs['prog'] = subcmd_parser.prog
993
- add_parser_kwargs['usage'] = subcmd_parser.usage
994
- add_parser_kwargs['description'] = subcmd_parser.description
995
- add_parser_kwargs['epilog'] = subcmd_parser.epilog
996
- add_parser_kwargs['formatter_class'] = subcmd_parser.formatter_class
997
- add_parser_kwargs['prefix_chars'] = subcmd_parser.prefix_chars
998
- add_parser_kwargs['fromfile_prefix_chars'] = subcmd_parser.fromfile_prefix_chars
999
- add_parser_kwargs['argument_default'] = subcmd_parser.argument_default
1000
- add_parser_kwargs['conflict_handler'] = subcmd_parser.conflict_handler
1001
- add_parser_kwargs['allow_abbrev'] = subcmd_parser.allow_abbrev
1002
-
1003
- # Set add_help to False and use whatever help option subcmd_parser already has
1004
- add_parser_kwargs['add_help'] = False
1005
-
1006
- attached_parser = action.add_parser(subcommand_name, **add_parser_kwargs)
1007
-
1008
- # Set the subcommand handler
1009
- defaults = {constants.NS_ATTR_SUBCMD_HANDLER: method}
1010
- attached_parser.set_defaults(**defaults)
1011
-
1012
- # Copy value for custom ArgparseCompleter type, which will be None if not present on subcmd_parser
1013
- attached_parser.set_ap_completer_type(subcmd_parser.get_ap_completer_type()) # type: ignore[attr-defined]
1014
-
1015
- # Set what instance the handler is bound to
1016
- setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset)
1021
+ # Use add_parser to register the subcommand name and any aliases
1022
+ action.add_parser(subcommand_name, **add_parser_kwargs)
1023
+
1024
+ # Replace the parser created by add_parser() with our pre-configured one
1025
+ action._name_parser_map[subcommand_name] = subcmd_parser
1026
+
1027
+ # Also remap any aliases to our pre-configured parser
1028
+ for alias in add_parser_kwargs.get("aliases", []):
1029
+ action._name_parser_map[alias] = subcmd_parser
1030
+
1017
1031
  break
1018
1032
 
1019
1033
  def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
@@ -1047,18 +1061,18 @@ class Cmd(cmd.Cmd):
1047
1061
  if command_func is None: # pragma: no cover
1048
1062
  # This really shouldn't be possible since _register_subcommands would prevent this from happening
1049
1063
  # but keeping in case it does for some strange reason
1050
- raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method!s}")
1064
+ raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
1051
1065
  command_parser = self._command_parsers.get(command_func)
1052
1066
  if command_parser is None: # pragma: no cover
1053
1067
  # This really shouldn't be possible since _register_subcommands would prevent this from happening
1054
1068
  # but keeping in case it does for some strange reason
1055
1069
  raise CommandSetRegistrationError(
1056
- f"Could not find argparser for command '{command_name}' needed by subcommand: {method!s}"
1070
+ f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
1057
1071
  )
1058
1072
 
1059
1073
  for action in command_parser._actions:
1060
1074
  if isinstance(action, argparse._SubParsersAction):
1061
- action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined]
1075
+ action.remove_parser(subcommand_name) # type: ignore[attr-defined]
1062
1076
  break
1063
1077
 
1064
1078
  @property
@@ -1126,24 +1140,23 @@ class Cmd(cmd.Cmd):
1126
1140
 
1127
1141
  def get_allow_style_choices(_cli_self: Cmd) -> list[str]:
1128
1142
  """Tab complete allow_style values."""
1129
- return [val.name.lower() for val in ansi.AllowStyle]
1143
+ return [val.name.lower() for val in ru.AllowStyle]
1130
1144
 
1131
- def allow_style_type(value: str) -> ansi.AllowStyle:
1132
- """Convert a string value into an ansi.AllowStyle."""
1145
+ def allow_style_type(value: str) -> ru.AllowStyle:
1146
+ """Convert a string value into an ru.AllowStyle."""
1133
1147
  try:
1134
- return ansi.AllowStyle[value.upper()]
1135
- except KeyError as esc:
1148
+ return ru.AllowStyle[value.upper()]
1149
+ except KeyError as ex:
1136
1150
  raise ValueError(
1137
- f"must be {ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, or "
1138
- f"{ansi.AllowStyle.TERMINAL} (case-insensitive)"
1139
- ) from esc
1151
+ f"must be {ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, or {ru.AllowStyle.TERMINAL} (case-insensitive)"
1152
+ ) from ex
1140
1153
 
1141
1154
  self.add_settable(
1142
1155
  Settable(
1143
1156
  'allow_style',
1144
1157
  allow_style_type,
1145
1158
  'Allow ANSI text style sequences in output (valid values: '
1146
- f'{ansi.AllowStyle.ALWAYS}, {ansi.AllowStyle.NEVER}, {ansi.AllowStyle.TERMINAL})',
1159
+ f'{ru.AllowStyle.ALWAYS}, {ru.AllowStyle.NEVER}, {ru.AllowStyle.TERMINAL})',
1147
1160
  self,
1148
1161
  choices_provider=cast(ChoicesProviderFunc, get_allow_style_choices),
1149
1162
  )
@@ -1155,7 +1168,7 @@ class Cmd(cmd.Cmd):
1155
1168
  self.add_settable(Settable('debug', bool, "Show full traceback on exception", self))
1156
1169
  self.add_settable(Settable('echo', bool, "Echo command issued into output", self))
1157
1170
  self.add_settable(Settable('editor', str, "Program used by 'edit'", self))
1158
- self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|', '>' results", self))
1171
+ self.add_settable(Settable('feedback_to_output', bool, "Include nonessentials in '|' and '>' results", self))
1159
1172
  self.add_settable(
1160
1173
  Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self)
1161
1174
  )
@@ -1166,14 +1179,14 @@ class Cmd(cmd.Cmd):
1166
1179
  # ----- Methods related to presenting output to the user -----
1167
1180
 
1168
1181
  @property
1169
- def allow_style(self) -> ansi.AllowStyle:
1182
+ def allow_style(self) -> ru.AllowStyle:
1170
1183
  """Read-only property needed to support do_set when it reads allow_style."""
1171
- return ansi.allow_style
1184
+ return ru.ALLOW_STYLE
1172
1185
 
1173
1186
  @allow_style.setter
1174
- def allow_style(self, new_val: ansi.AllowStyle) -> None:
1187
+ def allow_style(self, new_val: ru.AllowStyle) -> None:
1175
1188
  """Setter property needed to support do_set when it updates allow_style."""
1176
- ansi.allow_style = new_val
1189
+ ru.ALLOW_STYLE = new_val
1177
1190
 
1178
1191
  def _completion_supported(self) -> bool:
1179
1192
  """Return whether tab completion is supported."""
@@ -1181,181 +1194,392 @@ class Cmd(cmd.Cmd):
1181
1194
 
1182
1195
  @property
1183
1196
  def visible_prompt(self) -> str:
1184
- """Read-only property to get the visible prompt with any ANSI style escape codes stripped.
1197
+ """Read-only property to get the visible prompt with any ANSI style sequences stripped.
1185
1198
 
1186
- Used by transcript testing to make it easier and more reliable when users are doing things like coloring the
1187
- prompt using ANSI color codes.
1199
+ Used by transcript testing to make it easier and more reliable when users are doing things like
1200
+ coloring the prompt.
1188
1201
 
1189
- :return: prompt stripped of any ANSI escape codes
1202
+ :return: the stripped prompt
1190
1203
  """
1191
- return ansi.strip_style(self.prompt)
1204
+ return su.strip_style(self.prompt)
1192
1205
 
1193
1206
  def print_to(
1194
1207
  self,
1195
- dest: IO[str],
1196
- msg: Any,
1197
- *,
1198
- end: str = '\n',
1199
- style: Optional[Callable[[str], str]] = None,
1208
+ file: IO[str],
1209
+ *objects: Any,
1210
+ sep: str = " ",
1211
+ end: str = "\n",
1212
+ style: StyleType | None = None,
1213
+ soft_wrap: bool = True,
1214
+ emoji: bool = False,
1215
+ markup: bool = False,
1216
+ highlight: bool = False,
1217
+ rich_print_kwargs: RichPrintKwargs | None = None,
1218
+ **kwargs: Any, # noqa: ARG002
1200
1219
  ) -> None:
1201
- """Print message to a given file object.
1202
-
1203
- :param dest: the file object being written to
1204
- :param msg: object to print
1205
- :param end: string appended after the end of the message, default a newline
1206
- :param style: optional style function to format msg with (e.g. ansi.style_success)
1220
+ """Print objects to a given file stream.
1221
+
1222
+ This method is configured for general-purpose printing. By default, it enables
1223
+ soft wrap and disables Rich's automatic detection for markup, emoji, and highlighting.
1224
+ These defaults can be overridden by passing explicit keyword arguments.
1225
+
1226
+ :param file: file stream being written to
1227
+ :param objects: objects to print
1228
+ :param sep: string to write between printed text. Defaults to " ".
1229
+ :param end: string to write at end of printed text. Defaults to a newline.
1230
+ :param style: optional style to apply to output
1231
+ :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be
1232
+ word-wrapped or cropped to fit the terminal width. Defaults to True.
1233
+ :param emoji: If True, Rich will replace emoji codes (e.g., :smiley:) with their
1234
+ corresponding Unicode characters. Defaults to False.
1235
+ :param markup: If True, Rich will interpret strings with tags (e.g., [bold]hello[/bold])
1236
+ as styled output. Defaults to False.
1237
+ :param highlight: If True, Rich will automatically apply highlighting to elements within
1238
+ strings, such as common Python data types like numbers, booleans, or None.
1239
+ This is particularly useful when pretty printing objects like lists and
1240
+ dictionaries to display them in color. Defaults to False.
1241
+ :param rich_print_kwargs: optional additional keyword arguments to pass to Rich's Console.print().
1242
+ :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this
1243
+ method and still call `super()` without encountering unexpected keyword argument errors.
1244
+ These arguments are not passed to Rich's Console.print().
1245
+
1246
+ See the Rich documentation for more details on emoji codes, markup tags, and highlighting.
1207
1247
  """
1208
- final_msg = style(msg) if style is not None else msg
1248
+ prepared_objects = ru.prepare_objects_for_rendering(*objects)
1249
+
1209
1250
  try:
1210
- ansi.style_aware_write(dest, f'{final_msg}{end}')
1251
+ Cmd2GeneralConsole(file).print(
1252
+ *prepared_objects,
1253
+ sep=sep,
1254
+ end=end,
1255
+ style=style,
1256
+ soft_wrap=soft_wrap,
1257
+ emoji=emoji,
1258
+ markup=markup,
1259
+ highlight=highlight,
1260
+ **(rich_print_kwargs if rich_print_kwargs is not None else {}),
1261
+ )
1211
1262
  except BrokenPipeError:
1212
1263
  # This occurs if a command's output is being piped to another
1213
- # process and that process closes before the command is
1214
- # finished. If you would like your application to print a
1264
+ # process which closes the pipe before the command is finished
1265
+ # writing. If you would like your application to print a
1215
1266
  # warning message, then set the broken_pipe_warning attribute
1216
1267
  # to the message you want printed.
1217
- if self.broken_pipe_warning:
1218
- sys.stderr.write(self.broken_pipe_warning)
1268
+ if self.broken_pipe_warning and file != sys.stderr:
1269
+ Cmd2GeneralConsole(sys.stderr).print(self.broken_pipe_warning)
1219
1270
 
1220
- def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
1221
- """Print message to self.stdout and appends a newline by default.
1271
+ def poutput(
1272
+ self,
1273
+ *objects: Any,
1274
+ sep: str = " ",
1275
+ end: str = "\n",
1276
+ style: StyleType | None = None,
1277
+ soft_wrap: bool = True,
1278
+ emoji: bool = False,
1279
+ markup: bool = False,
1280
+ highlight: bool = False,
1281
+ rich_print_kwargs: RichPrintKwargs | None = None,
1282
+ **kwargs: Any, # noqa: ARG002
1283
+ ) -> None:
1284
+ """Print objects to self.stdout.
1222
1285
 
1223
- :param msg: object to print
1224
- :param end: string appended after the end of the message, default a newline
1286
+ For details on the parameters, refer to the `print_to` method documentation.
1225
1287
  """
1226
- self.print_to(self.stdout, msg, end=end)
1288
+ self.print_to(
1289
+ self.stdout,
1290
+ *objects,
1291
+ sep=sep,
1292
+ end=end,
1293
+ style=style,
1294
+ soft_wrap=soft_wrap,
1295
+ emoji=emoji,
1296
+ markup=markup,
1297
+ highlight=highlight,
1298
+ rich_print_kwargs=rich_print_kwargs,
1299
+ )
1300
+
1301
+ def perror(
1302
+ self,
1303
+ *objects: Any,
1304
+ sep: str = " ",
1305
+ end: str = "\n",
1306
+ style: StyleType | None = Cmd2Style.ERROR,
1307
+ soft_wrap: bool = True,
1308
+ emoji: bool = False,
1309
+ markup: bool = False,
1310
+ highlight: bool = False,
1311
+ rich_print_kwargs: RichPrintKwargs | None = None,
1312
+ **kwargs: Any, # noqa: ARG002
1313
+ ) -> None:
1314
+ """Print objects to sys.stderr.
1227
1315
 
1228
- def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1229
- """Print message to sys.stderr.
1316
+ :param style: optional style to apply to output. Defaults to Cmd2Style.ERROR.
1230
1317
 
1231
- :param msg: object to print
1232
- :param end: string appended after the end of the message, default a newline
1233
- :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
1234
- where the message text already has the desired style. Defaults to True.
1318
+ For details on the other parameters, refer to the `print_to` method documentation.
1235
1319
  """
1236
- self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None)
1320
+ self.print_to(
1321
+ sys.stderr,
1322
+ *objects,
1323
+ sep=sep,
1324
+ end=end,
1325
+ style=style,
1326
+ soft_wrap=soft_wrap,
1327
+ emoji=emoji,
1328
+ markup=markup,
1329
+ highlight=highlight,
1330
+ rich_print_kwargs=rich_print_kwargs,
1331
+ )
1237
1332
 
1238
- def psuccess(self, msg: Any = '', *, end: str = '\n') -> None:
1239
- """Wrap poutput, but applies ansi.style_success by default.
1333
+ def psuccess(
1334
+ self,
1335
+ *objects: Any,
1336
+ sep: str = " ",
1337
+ end: str = "\n",
1338
+ soft_wrap: bool = True,
1339
+ emoji: bool = False,
1340
+ markup: bool = False,
1341
+ highlight: bool = False,
1342
+ rich_print_kwargs: RichPrintKwargs | None = None,
1343
+ **kwargs: Any, # noqa: ARG002
1344
+ ) -> None:
1345
+ """Wrap poutput, but apply Cmd2Style.SUCCESS.
1240
1346
 
1241
- :param msg: object to print
1242
- :param end: string appended after the end of the message, default a newline
1347
+ For details on the parameters, refer to the `print_to` method documentation.
1243
1348
  """
1244
- msg = ansi.style_success(msg)
1245
- self.poutput(msg, end=end)
1349
+ self.poutput(
1350
+ *objects,
1351
+ sep=sep,
1352
+ end=end,
1353
+ style=Cmd2Style.SUCCESS,
1354
+ soft_wrap=soft_wrap,
1355
+ emoji=emoji,
1356
+ markup=markup,
1357
+ highlight=highlight,
1358
+ rich_print_kwargs=rich_print_kwargs,
1359
+ )
1246
1360
 
1247
- def pwarning(self, msg: Any = '', *, end: str = '\n') -> None:
1248
- """Wrap perror, but applies ansi.style_warning by default.
1361
+ def pwarning(
1362
+ self,
1363
+ *objects: Any,
1364
+ sep: str = " ",
1365
+ end: str = "\n",
1366
+ soft_wrap: bool = True,
1367
+ emoji: bool = False,
1368
+ markup: bool = False,
1369
+ highlight: bool = False,
1370
+ rich_print_kwargs: RichPrintKwargs | None = None,
1371
+ **kwargs: Any, # noqa: ARG002
1372
+ ) -> None:
1373
+ """Wrap perror, but apply Cmd2Style.WARNING.
1249
1374
 
1250
- :param msg: object to print
1251
- :param end: string appended after the end of the message, default a newline
1375
+ For details on the parameters, refer to the `print_to` method documentation.
1252
1376
  """
1253
- msg = ansi.style_warning(msg)
1254
- self.perror(msg, end=end, apply_style=False)
1377
+ self.perror(
1378
+ *objects,
1379
+ sep=sep,
1380
+ end=end,
1381
+ style=Cmd2Style.WARNING,
1382
+ soft_wrap=soft_wrap,
1383
+ emoji=emoji,
1384
+ markup=markup,
1385
+ highlight=highlight,
1386
+ rich_print_kwargs=rich_print_kwargs,
1387
+ )
1255
1388
 
1256
- def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None:
1257
- """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists.
1389
+ def pexcept(
1390
+ self,
1391
+ exception: BaseException,
1392
+ **kwargs: Any, # noqa: ARG002
1393
+ ) -> None:
1394
+ """Print an exception to sys.stderr.
1395
+
1396
+ If `debug` is true, a full traceback is also printed, if one exists.
1258
1397
 
1259
- :param msg: message or Exception to print
1260
- :param end: string appended after the end of the message, default a newline
1261
- :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
1262
- where the message text already has the desired style. Defaults to True.
1398
+ :param exception: the exception to be printed.
1399
+ :param kwargs: Arbitrary keyword arguments. This allows subclasses to extend the signature of this
1400
+ method and still call `super()` without encountering unexpected keyword argument errors.
1263
1401
  """
1402
+ console = Cmd2ExceptionConsole(sys.stderr)
1403
+
1404
+ # Only print a traceback if we're in debug mode and one exists.
1264
1405
  if self.debug and sys.exc_info() != (None, None, None):
1265
- import traceback
1406
+ traceback = Traceback(
1407
+ width=None, # Use all available width
1408
+ code_width=None, # Use all available width
1409
+ show_locals=True,
1410
+ max_frames=0, # 0 means full traceback.
1411
+ word_wrap=True, # Wrap long lines of code instead of truncate
1412
+ )
1413
+ console.print(traceback)
1414
+ console.print()
1415
+ return
1266
1416
 
1267
- traceback.print_exc()
1417
+ # Print the exception in the same style Rich uses after a traceback.
1418
+ exception_str = str(exception)
1268
1419
 
1269
- if isinstance(msg, Exception):
1270
- final_msg = f"EXCEPTION of type '{type(msg).__name__}' occurred with message: {msg}"
1271
- else:
1272
- final_msg = str(msg)
1420
+ if exception_str:
1421
+ highlighter = ReprHighlighter()
1273
1422
 
1274
- if apply_style:
1275
- final_msg = ansi.style_error(final_msg)
1423
+ final_msg = Text.assemble(
1424
+ (f"{type(exception).__name__}: ", "traceback.exc_type"),
1425
+ highlighter(exception_str),
1426
+ )
1427
+ else:
1428
+ final_msg = Text(f"{type(exception).__name__}", style="traceback.exc_type")
1276
1429
 
1430
+ # If not in debug mode and the 'debug' setting is available,
1431
+ # inform the user how to enable full tracebacks.
1277
1432
  if not self.debug and 'debug' in self.settables:
1278
- warning = "\nTo enable full traceback, run the following command: 'set debug true'"
1279
- final_msg += ansi.style_warning(warning)
1433
+ help_msg = Text.assemble(
1434
+ "\n\n",
1435
+ ("To enable full traceback, run the following command: ", Cmd2Style.WARNING),
1436
+ ("set debug true", Cmd2Style.COMMAND_LINE),
1437
+ )
1438
+ final_msg.append(help_msg)
1280
1439
 
1281
- self.perror(final_msg, end=end, apply_style=False)
1440
+ console.print(final_msg)
1441
+ console.print()
1282
1442
 
1283
- def pfeedback(self, msg: Any, *, end: str = '\n') -> None:
1284
- """Print nonessential feedback. Can be silenced with `quiet`.
1443
+ def pfeedback(
1444
+ self,
1445
+ *objects: Any,
1446
+ sep: str = " ",
1447
+ end: str = "\n",
1448
+ style: StyleType | None = None,
1449
+ soft_wrap: bool = True,
1450
+ emoji: bool = False,
1451
+ markup: bool = False,
1452
+ highlight: bool = False,
1453
+ rich_print_kwargs: RichPrintKwargs | None = None,
1454
+ **kwargs: Any, # noqa: ARG002
1455
+ ) -> None:
1456
+ """Print nonessential feedback.
1285
1457
 
1286
- Inclusion in redirected output is controlled by `feedback_to_output`.
1458
+ The output can be silenced with the `quiet` setting and its inclusion in redirected output
1459
+ is controlled by the `feedback_to_output` setting.
1287
1460
 
1288
- :param msg: object to print
1289
- :param end: string appended after the end of the message, default a newline
1461
+ For details on the parameters, refer to the `print_to` method documentation.
1290
1462
  """
1291
1463
  if not self.quiet:
1292
1464
  if self.feedback_to_output:
1293
- self.poutput(msg, end=end)
1465
+ self.poutput(
1466
+ *objects,
1467
+ sep=sep,
1468
+ end=end,
1469
+ style=style,
1470
+ soft_wrap=soft_wrap,
1471
+ emoji=emoji,
1472
+ markup=markup,
1473
+ highlight=highlight,
1474
+ rich_print_kwargs=rich_print_kwargs,
1475
+ )
1294
1476
  else:
1295
- self.perror(msg, end=end, apply_style=False)
1477
+ self.perror(
1478
+ *objects,
1479
+ sep=sep,
1480
+ end=end,
1481
+ style=style,
1482
+ soft_wrap=soft_wrap,
1483
+ emoji=emoji,
1484
+ markup=markup,
1485
+ highlight=highlight,
1486
+ rich_print_kwargs=rich_print_kwargs,
1487
+ )
1296
1488
 
1297
- def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None:
1298
- """Print output using a pager if it would go off screen and stdout isn't currently being redirected.
1489
+ def ppaged(
1490
+ self,
1491
+ *objects: Any,
1492
+ sep: str = " ",
1493
+ end: str = "\n",
1494
+ style: StyleType | None = None,
1495
+ chop: bool = False,
1496
+ soft_wrap: bool = True,
1497
+ emoji: bool = False,
1498
+ markup: bool = False,
1499
+ highlight: bool = False,
1500
+ rich_print_kwargs: RichPrintKwargs | None = None,
1501
+ **kwargs: Any, # noqa: ARG002
1502
+ ) -> None:
1503
+ """Print output using a pager.
1299
1504
 
1300
- Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when
1301
- stdout or stdin are not a fully functional terminal.
1505
+ A pager is used when the terminal is interactive and may exit immediately if the output
1506
+ fits on the screen. A pager is not used inside a script (Python or text) or when output is
1507
+ redirected or piped, and in these cases, output is sent to `poutput`.
1302
1508
 
1303
- :param msg: object to print
1304
- :param end: string appended after the end of the message, default a newline
1305
1509
  :param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped
1306
1510
  - truncated text is still accessible by scrolling with the right & left arrow keys
1307
1511
  - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
1308
1512
  False -> causes lines longer than the screen width to wrap to the next line
1309
1513
  - wrapping is ideal when you want to keep users from having to use horizontal scrolling
1514
+ WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1515
+ :param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to
1516
+ fit the terminal width. Defaults to True.
1517
+
1518
+ Note: If chop is True and a pager is used, soft_wrap is automatically set to True to
1519
+ prevent wrapping and allow for horizontal scrolling.
1310
1520
 
1311
- WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1521
+ For details on the other parameters, refer to the `print_to` method documentation.
1312
1522
  """
1313
- # Attempt to detect if we are not running within a fully functional terminal.
1523
+ # Detect if we are running within an interactive terminal.
1314
1524
  # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect.
1315
- functional_terminal = False
1525
+ functional_terminal = (
1526
+ self.stdin.isatty()
1527
+ and self.stdout.isatty()
1528
+ and (sys.platform.startswith('win') or os.environ.get('TERM') is not None)
1529
+ )
1316
1530
 
1317
- if self.stdin.isatty() and self.stdout.isatty(): # noqa: SIM102
1318
- if sys.platform.startswith('win') or os.environ.get('TERM') is not None:
1319
- functional_terminal = True
1531
+ # A pager application blocks, so only run one if not redirecting or running a script (either text or Python).
1532
+ can_block = not (self._redirecting or self.in_pyscript() or self.in_script())
1320
1533
 
1321
- # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python).
1322
- # Also only attempt to use a pager if actually running in a real fully functional terminal.
1323
- if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script():
1324
- final_msg = f"{msg}{end}"
1325
- if ansi.allow_style == ansi.AllowStyle.NEVER:
1326
- final_msg = ansi.strip_style(final_msg)
1534
+ # Check if we are outputting to a pager.
1535
+ if functional_terminal and can_block:
1536
+ prepared_objects = ru.prepare_objects_for_rendering(*objects)
1327
1537
 
1328
- pager = self.pager
1538
+ # Chopping overrides soft_wrap
1329
1539
  if chop:
1330
- pager = self.pager_chop
1540
+ soft_wrap = True
1541
+
1542
+ # Generate the bytes to send to the pager
1543
+ console = Cmd2GeneralConsole(self.stdout)
1544
+ with console.capture() as capture:
1545
+ console.print(
1546
+ *prepared_objects,
1547
+ sep=sep,
1548
+ end=end,
1549
+ style=style,
1550
+ soft_wrap=soft_wrap,
1551
+ emoji=emoji,
1552
+ markup=markup,
1553
+ highlight=highlight,
1554
+ **(rich_print_kwargs if rich_print_kwargs is not None else {}),
1555
+ )
1556
+ output_bytes = capture.get().encode('utf-8', 'replace')
1331
1557
 
1332
- try:
1333
- # Prevent KeyboardInterrupts while in the pager. The pager application will
1334
- # still receive the SIGINT since it is in the same process group as us.
1335
- with self.sigint_protection:
1336
- import subprocess
1337
-
1338
- pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, stdout=self.stdout) # noqa: S602
1339
- pipe_proc.communicate(final_msg.encode('utf-8', 'replace'))
1340
- except BrokenPipeError:
1341
- # This occurs if a command's output is being piped to another process and that process closes before the
1342
- # command is finished. If you would like your application to print a warning message, then set the
1343
- # broken_pipe_warning attribute to the message you want printed.`
1344
- if self.broken_pipe_warning:
1345
- sys.stderr.write(self.broken_pipe_warning)
1346
- else:
1347
- self.poutput(msg, end=end)
1558
+ # Prevent KeyboardInterrupts while in the pager. The pager application will
1559
+ # still receive the SIGINT since it is in the same process group as us.
1560
+ with self.sigint_protection:
1561
+ import subprocess
1348
1562
 
1349
- def ppretty(self, data: Any, *, indent: int = 2, width: int = 80, depth: Optional[int] = None, end: str = '\n') -> None:
1350
- """Pretty print arbitrary Python data structures to self.stdout and appends a newline by default.
1563
+ pipe_proc = subprocess.Popen( # noqa: S602
1564
+ self.pager_chop if chop else self.pager,
1565
+ shell=True,
1566
+ stdin=subprocess.PIPE,
1567
+ stdout=self.stdout,
1568
+ )
1569
+ pipe_proc.communicate(output_bytes)
1351
1570
 
1352
- :param data: object to print
1353
- :param indent: the amount of indentation added for each nesting level
1354
- :param width: the desired maximum number of characters per line in the output, a best effort will be made for long data
1355
- :param depth: the number of nesting levels which may be printed, if data is too deep, the next level replaced by ...
1356
- :param end: string appended after the end of the message, default a newline
1357
- """
1358
- self.print_to(self.stdout, pprint.pformat(data, indent, width, depth), end=end)
1571
+ else:
1572
+ self.poutput(
1573
+ *objects,
1574
+ sep=sep,
1575
+ end=end,
1576
+ style=style,
1577
+ soft_wrap=soft_wrap,
1578
+ emoji=emoji,
1579
+ markup=markup,
1580
+ highlight=highlight,
1581
+ rich_print_kwargs=rich_print_kwargs,
1582
+ )
1359
1583
 
1360
1584
  # ----- Methods related to tab completion -----
1361
1585
 
@@ -1432,7 +1656,7 @@ class Cmd(cmd.Cmd):
1432
1656
  raw_tokens = self.statement_parser.split_on_punctuation(initial_tokens)
1433
1657
 
1434
1658
  # Save the unquoted tokens
1435
- tokens = [utils.strip_quotes(cur_token) for cur_token in raw_tokens]
1659
+ tokens = [su.strip_quotes(cur_token) for cur_token in raw_tokens]
1436
1660
 
1437
1661
  # If the token being completed had an unclosed quote, we need
1438
1662
  # to remove the closing quote that was added in order for it
@@ -1537,9 +1761,9 @@ class Cmd(cmd.Cmd):
1537
1761
  line: str,
1538
1762
  begidx: int,
1539
1763
  endidx: int,
1540
- flag_dict: dict[str, Union[Iterable[str], CompleterFunc]],
1764
+ flag_dict: dict[str, Iterable[str] | CompleterFunc],
1541
1765
  *,
1542
- all_else: Union[None, Iterable[str], CompleterFunc] = None,
1766
+ all_else: None | Iterable[str] | CompleterFunc = None,
1543
1767
  ) -> list[str]:
1544
1768
  """Tab completes based on a particular flag preceding the token being completed.
1545
1769
 
@@ -1586,9 +1810,9 @@ class Cmd(cmd.Cmd):
1586
1810
  line: str,
1587
1811
  begidx: int,
1588
1812
  endidx: int,
1589
- index_dict: Mapping[int, Union[Iterable[str], CompleterFunc]],
1813
+ index_dict: Mapping[int, Iterable[str] | CompleterFunc],
1590
1814
  *,
1591
- all_else: Optional[Union[Iterable[str], CompleterFunc]] = None,
1815
+ all_else: Iterable[str] | CompleterFunc | None = None,
1592
1816
  ) -> list[str]:
1593
1817
  """Tab completes based on a fixed position in the input string.
1594
1818
 
@@ -1616,7 +1840,7 @@ class Cmd(cmd.Cmd):
1616
1840
  index = len(tokens) - 1
1617
1841
 
1618
1842
  # Check if token is at an index in the dictionary
1619
- match_against: Optional[Union[Iterable[str], CompleterFunc]]
1843
+ match_against: Iterable[str] | CompleterFunc | None
1620
1844
  match_against = index_dict.get(index, all_else)
1621
1845
 
1622
1846
  # Perform tab completion using a Iterable
@@ -1636,7 +1860,7 @@ class Cmd(cmd.Cmd):
1636
1860
  begidx: int, # noqa: ARG002
1637
1861
  endidx: int,
1638
1862
  *,
1639
- path_filter: Optional[Callable[[str], bool]] = None,
1863
+ path_filter: Callable[[str], bool] | None = None,
1640
1864
  ) -> list[str]:
1641
1865
  """Perform completion of local file system paths.
1642
1866
 
@@ -1922,7 +2146,7 @@ class Cmd(cmd.Cmd):
1922
2146
  if self.formatted_completions:
1923
2147
  if not hint_printed:
1924
2148
  sys.stdout.write('\n')
1925
- sys.stdout.write('\n' + self.formatted_completions + '\n\n')
2149
+ sys.stdout.write('\n' + self.formatted_completions + '\n')
1926
2150
 
1927
2151
  # Otherwise use readline's formatter
1928
2152
  else:
@@ -1934,7 +2158,7 @@ class Cmd(cmd.Cmd):
1934
2158
  longest_match_length = 0
1935
2159
 
1936
2160
  for cur_match in matches_to_display:
1937
- cur_length = ansi.style_aware_wcswidth(cur_match)
2161
+ cur_length = su.str_width(cur_match)
1938
2162
  longest_match_length = max(longest_match_length, cur_length)
1939
2163
  else:
1940
2164
  matches_to_display = matches
@@ -1950,7 +2174,7 @@ class Cmd(cmd.Cmd):
1950
2174
 
1951
2175
  # rl_display_match_list() expects matches to be in argv format where
1952
2176
  # substitution is the first element, followed by the matches, and then a NULL.
1953
- strings_array = cast(list[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))())
2177
+ strings_array = cast(list[bytes | None], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))())
1954
2178
 
1955
2179
  # Copy in the encoded strings and add a NULL to the end
1956
2180
  strings_array[0] = encoded_substitution
@@ -1973,13 +2197,13 @@ class Cmd(cmd.Cmd):
1973
2197
  hint_printed = False
1974
2198
  if self.always_show_hint and self.completion_hint:
1975
2199
  hint_printed = True
1976
- readline.rl.mode.console.write('\n' + self.completion_hint)
2200
+ sys.stdout.write('\n' + self.completion_hint)
1977
2201
 
1978
2202
  # Check if we already have formatted results to print
1979
2203
  if self.formatted_completions:
1980
2204
  if not hint_printed:
1981
- readline.rl.mode.console.write('\n')
1982
- readline.rl.mode.console.write('\n' + self.formatted_completions + '\n\n')
2205
+ sys.stdout.write('\n')
2206
+ sys.stdout.write('\n' + self.formatted_completions + '\n')
1983
2207
 
1984
2208
  # Redraw the prompt and input lines
1985
2209
  rl_force_redisplay()
@@ -2000,11 +2224,10 @@ class Cmd(cmd.Cmd):
2000
2224
  """Determine what type of ArgparseCompleter to use on a given parser.
2001
2225
 
2002
2226
  If the parser does not have one set, then use argparse_completer.DEFAULT_AP_COMPLETER.
2003
-
2004
2227
  :param parser: the parser to examine
2005
2228
  :return: type of ArgparseCompleter
2006
2229
  """
2007
- Completer = Optional[type[argparse_completer.ArgparseCompleter]] # noqa: N806
2230
+ Completer = type[argparse_completer.ArgparseCompleter] | None # noqa: N806
2008
2231
  completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined]
2009
2232
 
2010
2233
  if completer_type is None:
@@ -2012,7 +2235,7 @@ class Cmd(cmd.Cmd):
2012
2235
  return completer_type
2013
2236
 
2014
2237
  def _perform_completion(
2015
- self, text: str, line: str, begidx: int, endidx: int, custom_settings: Optional[utils.CustomCompletionSettings] = None
2238
+ self, text: str, line: str, begidx: int, endidx: int, custom_settings: utils.CustomCompletionSettings | None = None
2016
2239
  ) -> None:
2017
2240
  """Perform the actual completion, helper function for complete().
2018
2241
 
@@ -2062,7 +2285,7 @@ class Cmd(cmd.Cmd):
2062
2285
  if custom_settings is None:
2063
2286
  # Check if a macro was entered
2064
2287
  if command in self.macros:
2065
- completer_func = self.path_complete
2288
+ completer_func = self.macro_arg_complete
2066
2289
 
2067
2290
  # Check if a command was entered
2068
2291
  elif command in self.get_all_commands():
@@ -2190,8 +2413,8 @@ class Cmd(cmd.Cmd):
2190
2413
  self.completion_matches[0] += completion_token_quote
2191
2414
 
2192
2415
  def complete( # type: ignore[override]
2193
- self, text: str, state: int, custom_settings: Optional[utils.CustomCompletionSettings] = None
2194
- ) -> Optional[str]:
2416
+ self, text: str, state: int, custom_settings: utils.CustomCompletionSettings | None = None
2417
+ ) -> str | None:
2195
2418
  """Override of cmd's complete method which returns the next possible completion for 'text'.
2196
2419
 
2197
2420
  This completer function is called by readline as complete(text, state), for state in 0, 1, 2, …,
@@ -2281,9 +2504,13 @@ class Cmd(cmd.Cmd):
2281
2504
  # Don't print error and redraw the prompt unless the error has length
2282
2505
  err_str = str(ex)
2283
2506
  if err_str:
2284
- if ex.apply_style:
2285
- err_str = ansi.style_error(err_str)
2286
- ansi.style_aware_write(sys.stdout, '\n' + err_str + '\n')
2507
+ self.print_to(
2508
+ sys.stdout,
2509
+ Text.assemble(
2510
+ "\n",
2511
+ (err_str, Cmd2Style.ERROR if ex.apply_style else ""),
2512
+ ),
2513
+ )
2287
2514
  rl_force_redisplay()
2288
2515
  return None
2289
2516
  except Exception as ex: # noqa: BLE001
@@ -2326,42 +2553,36 @@ class Cmd(cmd.Cmd):
2326
2553
  if command not in self.hidden_commands and command not in self.disabled_commands
2327
2554
  ]
2328
2555
 
2329
- # Table displayed when tab completing aliases
2330
- _alias_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None)
2331
-
2332
2556
  def _get_alias_completion_items(self) -> list[CompletionItem]:
2333
2557
  """Return list of alias names and values as CompletionItems."""
2334
2558
  results: list[CompletionItem] = []
2335
2559
 
2336
- for cur_key in self.aliases:
2337
- row_data = [self.aliases[cur_key]]
2338
- results.append(CompletionItem(cur_key, self._alias_completion_table.generate_data_row(row_data)))
2560
+ for name, value in self.aliases.items():
2561
+ descriptive_data = [value]
2562
+ results.append(CompletionItem(name, descriptive_data))
2339
2563
 
2340
2564
  return results
2341
2565
 
2342
- # Table displayed when tab completing macros
2343
- _macro_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None)
2344
-
2345
2566
  def _get_macro_completion_items(self) -> list[CompletionItem]:
2346
2567
  """Return list of macro names and values as CompletionItems."""
2347
2568
  results: list[CompletionItem] = []
2348
2569
 
2349
- for cur_key in self.macros:
2350
- row_data = [self.macros[cur_key].value]
2351
- results.append(CompletionItem(cur_key, self._macro_completion_table.generate_data_row(row_data)))
2570
+ for name, macro in self.macros.items():
2571
+ descriptive_data = [macro.value]
2572
+ results.append(CompletionItem(name, descriptive_data))
2352
2573
 
2353
2574
  return results
2354
2575
 
2355
- # Table displayed when tab completing Settables
2356
- _settable_completion_table = SimpleTable([Column('Value', width=30), Column('Description', width=60)], divider_char=None)
2357
-
2358
2576
  def _get_settable_completion_items(self) -> list[CompletionItem]:
2359
2577
  """Return list of Settable names, values, and descriptions as CompletionItems."""
2360
2578
  results: list[CompletionItem] = []
2361
2579
 
2362
- for cur_key in self.settables:
2363
- row_data = [self.settables[cur_key].get_value(), self.settables[cur_key].description]
2364
- results.append(CompletionItem(cur_key, self._settable_completion_table.generate_data_row(row_data)))
2580
+ for name, settable in self.settables.items():
2581
+ descriptive_data = [
2582
+ str(settable.value),
2583
+ settable.description,
2584
+ ]
2585
+ results.append(CompletionItem(name, descriptive_data))
2365
2586
 
2366
2587
  return results
2367
2588
 
@@ -2383,13 +2604,17 @@ class Cmd(cmd.Cmd):
2383
2604
  # Filter out hidden and disabled commands
2384
2605
  return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands]
2385
2606
 
2386
- def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None: # noqa: ARG002
2607
+ def sigint_handler(
2608
+ self,
2609
+ signum: int, # noqa: ARG002,
2610
+ frame: FrameType | None, # noqa: ARG002,
2611
+ ) -> None:
2387
2612
  """Signal handler for SIGINTs which typically come from Ctrl-C events.
2388
2613
 
2389
2614
  If you need custom SIGINT behavior, then override this method.
2390
2615
 
2391
2616
  :param signum: signal number
2392
- :param _: the current stack frame or None
2617
+ :param frame: the current stack frame or None
2393
2618
  """
2394
2619
  if self._cur_pipe_proc_reader is not None:
2395
2620
  # Pass the SIGINT to the current pipe process
@@ -2405,7 +2630,7 @@ class Cmd(cmd.Cmd):
2405
2630
  if raise_interrupt:
2406
2631
  self._raise_keyboard_interrupt()
2407
2632
 
2408
- def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None:
2633
+ def termination_signal_handler(self, signum: int, _: FrameType | None) -> None:
2409
2634
  """Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac.
2410
2635
 
2411
2636
  SIGHUP - received when terminal window is closed
@@ -2425,7 +2650,7 @@ class Cmd(cmd.Cmd):
2425
2650
  """Raise a KeyboardInterrupt."""
2426
2651
  raise KeyboardInterrupt("Got a keyboard interrupt")
2427
2652
 
2428
- def precmd(self, statement: Union[Statement, str]) -> Statement:
2653
+ def precmd(self, statement: Statement | str) -> Statement:
2429
2654
  """Ran just before the command is executed by [cmd2.Cmd.onecmd][] and after adding it to history (cmd Hook method).
2430
2655
 
2431
2656
  :param statement: subclass of str which also contains the parsed input
@@ -2436,7 +2661,7 @@ class Cmd(cmd.Cmd):
2436
2661
  """
2437
2662
  return Statement(statement) if not isinstance(statement, Statement) else statement
2438
2663
 
2439
- def postcmd(self, stop: bool, statement: Union[Statement, str]) -> bool: # noqa: ARG002
2664
+ def postcmd(self, stop: bool, statement: Statement | str) -> bool: # noqa: ARG002
2440
2665
  """Ran just after a command is executed by [cmd2.Cmd.onecmd][] (cmd inherited Hook method).
2441
2666
 
2442
2667
  :param stop: return `True` to request the command loop terminate
@@ -2485,7 +2710,7 @@ class Cmd(cmd.Cmd):
2485
2710
  add_to_history: bool = True,
2486
2711
  raise_keyboard_interrupt: bool = False,
2487
2712
  py_bridge_call: bool = False,
2488
- orig_rl_history_length: Optional[int] = None,
2713
+ orig_rl_history_length: int | None = None,
2489
2714
  ) -> bool:
2490
2715
  """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
2491
2716
 
@@ -2526,7 +2751,7 @@ class Cmd(cmd.Cmd):
2526
2751
  # we need to run the finalization hooks
2527
2752
  raise EmptyStatement # noqa: TRY301
2528
2753
 
2529
- redir_saved_state: Optional[utils.RedirectionSavedState] = None
2754
+ redir_saved_state: utils.RedirectionSavedState | None = None
2530
2755
 
2531
2756
  try:
2532
2757
  # Get sigint protection while we set up redirection
@@ -2608,16 +2833,17 @@ class Cmd(cmd.Cmd):
2608
2833
 
2609
2834
  return stop
2610
2835
 
2611
- def _run_cmdfinalization_hooks(self, stop: bool, statement: Optional[Statement]) -> bool:
2836
+ def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool:
2612
2837
  """Run the command finalization hooks."""
2613
- with self.sigint_protection:
2614
- if not sys.platform.startswith('win') and self.stdin.isatty():
2615
- # Before the next command runs, fix any terminal problems like those
2616
- # caused by certain binary characters having been printed to it.
2617
- import subprocess
2838
+ if self._initial_termios_settings is not None and self.stdin.isatty():
2839
+ import io
2840
+ import termios
2618
2841
 
2619
- proc = subprocess.Popen(['stty', 'sane']) # noqa: S607
2620
- proc.communicate()
2842
+ # Before the next command runs, fix any terminal problems like those
2843
+ # caused by certain binary characters having been printed to it.
2844
+ with self.sigint_protection, contextlib.suppress(io.UnsupportedOperation, termios.error):
2845
+ # This can fail if stdin is a pseudo-TTY, in which case we just ignore it
2846
+ termios.tcsetattr(self.stdin.fileno(), termios.TCSANOW, self._initial_termios_settings)
2621
2847
 
2622
2848
  data = plugin.CommandFinalizationData(stop, statement)
2623
2849
  for func in self._cmdfinalization_hooks:
@@ -2628,7 +2854,7 @@ class Cmd(cmd.Cmd):
2628
2854
 
2629
2855
  def runcmds_plus_hooks(
2630
2856
  self,
2631
- cmds: Union[list[HistoryItem], list[str]],
2857
+ cmds: list[HistoryItem] | list[str],
2632
2858
  *,
2633
2859
  add_to_history: bool = True,
2634
2860
  stop_on_keyboard_interrupt: bool = False,
@@ -2663,7 +2889,7 @@ class Cmd(cmd.Cmd):
2663
2889
 
2664
2890
  return False
2665
2891
 
2666
- def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2892
+ def _complete_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement:
2667
2893
  """Keep accepting lines of input until the command is complete.
2668
2894
 
2669
2895
  There is some pretty hacky code here to handle some quirks of
@@ -2753,7 +2979,7 @@ class Cmd(cmd.Cmd):
2753
2979
 
2754
2980
  return statement
2755
2981
 
2756
- def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2982
+ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: int | None = None) -> Statement:
2757
2983
  """Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved.
2758
2984
 
2759
2985
  :param line: the line being parsed
@@ -2806,7 +3032,7 @@ class Cmd(cmd.Cmd):
2806
3032
  )
2807
3033
  return statement
2808
3034
 
2809
- def _resolve_macro(self, statement: Statement) -> Optional[str]:
3035
+ def _resolve_macro(self, statement: Statement) -> str | None:
2810
3036
  """Resolve a macro and return the resulting string.
2811
3037
 
2812
3038
  :param statement: the parsed statement from the command line
@@ -2855,13 +3081,16 @@ class Cmd(cmd.Cmd):
2855
3081
  """
2856
3082
  import subprocess
2857
3083
 
3084
+ # Only redirect sys.stdout if it's the same as self.stdout
3085
+ stdouts_match = self.stdout == sys.stdout
3086
+
2858
3087
  # Initialize the redirection saved state
2859
3088
  redir_saved_state = utils.RedirectionSavedState(
2860
- cast(TextIO, self.stdout), sys.stdout, self._cur_pipe_proc_reader, self._redirecting
3089
+ cast(TextIO, self.stdout), stdouts_match, self._cur_pipe_proc_reader, self._redirecting
2861
3090
  )
2862
3091
 
2863
3092
  # The ProcReader for this command
2864
- cmd_pipe_proc_reader: Optional[utils.ProcReader] = None
3093
+ cmd_pipe_proc_reader: utils.ProcReader | None = None
2865
3094
 
2866
3095
  if not self.allow_redirection:
2867
3096
  # Don't return since we set some state variables at the end of the function
@@ -2890,11 +3119,11 @@ class Cmd(cmd.Cmd):
2890
3119
  kwargs['executable'] = shell
2891
3120
 
2892
3121
  # For any stream that is a StdSim, we will use a pipe so we can capture its output
2893
- proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602
3122
+ proc = subprocess.Popen( # noqa: S602
2894
3123
  statement.pipe_to,
2895
3124
  stdin=subproc_stdin,
2896
3125
  stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
2897
- stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable]
3126
+ stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
2898
3127
  shell=True,
2899
3128
  **kwargs,
2900
3129
  )
@@ -2911,9 +3140,12 @@ class Cmd(cmd.Cmd):
2911
3140
  subproc_stdin.close()
2912
3141
  new_stdout.close()
2913
3142
  raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run')
2914
- redir_saved_state.redirecting = True # type: ignore[unreachable]
3143
+ redir_saved_state.redirecting = True
2915
3144
  cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
2916
- sys.stdout = self.stdout = new_stdout
3145
+
3146
+ self.stdout = new_stdout
3147
+ if stdouts_match:
3148
+ sys.stdout = self.stdout
2917
3149
 
2918
3150
  elif statement.output:
2919
3151
  if statement.output_to:
@@ -2922,12 +3154,15 @@ class Cmd(cmd.Cmd):
2922
3154
  mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w'
2923
3155
  try:
2924
3156
  # Use line buffering
2925
- new_stdout = cast(TextIO, open(utils.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115
3157
+ new_stdout = cast(TextIO, open(su.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115
2926
3158
  except OSError as ex:
2927
3159
  raise RedirectionError('Failed to redirect output') from ex
2928
3160
 
2929
3161
  redir_saved_state.redirecting = True
2930
- sys.stdout = self.stdout = new_stdout
3162
+
3163
+ self.stdout = new_stdout
3164
+ if stdouts_match:
3165
+ sys.stdout = self.stdout
2931
3166
 
2932
3167
  else:
2933
3168
  # Redirecting to a paste buffer
@@ -2945,7 +3180,10 @@ class Cmd(cmd.Cmd):
2945
3180
  # create a temporary file to store output
2946
3181
  new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+")) # noqa: SIM115
2947
3182
  redir_saved_state.redirecting = True
2948
- sys.stdout = self.stdout = new_stdout
3183
+
3184
+ self.stdout = new_stdout
3185
+ if stdouts_match:
3186
+ sys.stdout = self.stdout
2949
3187
 
2950
3188
  if statement.output == constants.REDIRECTION_APPEND:
2951
3189
  self.stdout.write(current_paste_buffer)
@@ -2975,7 +3213,8 @@ class Cmd(cmd.Cmd):
2975
3213
 
2976
3214
  # Restore the stdout values
2977
3215
  self.stdout = cast(TextIO, saved_redir_state.saved_self_stdout)
2978
- sys.stdout = cast(TextIO, saved_redir_state.saved_sys_stdout)
3216
+ if saved_redir_state.stdouts_match:
3217
+ sys.stdout = self.stdout
2979
3218
 
2980
3219
  # Check if we need to wait for the process being piped to
2981
3220
  if self._cur_pipe_proc_reader is not None:
@@ -2985,7 +3224,7 @@ class Cmd(cmd.Cmd):
2985
3224
  self._cur_pipe_proc_reader = saved_redir_state.saved_pipe_proc_reader
2986
3225
  self._redirecting = saved_redir_state.saved_redirecting
2987
3226
 
2988
- def cmd_func(self, command: str) -> Optional[CommandFunc]:
3227
+ def cmd_func(self, command: str) -> CommandFunc | None:
2989
3228
  """Get the function for a command.
2990
3229
 
2991
3230
  :param command: the name of the command
@@ -3002,7 +3241,7 @@ class Cmd(cmd.Cmd):
3002
3241
  func = getattr(self, func_name, None)
3003
3242
  return cast(CommandFunc, func) if callable(func) else None
3004
3243
 
3005
- def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool:
3244
+ def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool:
3006
3245
  """Execute the actual do_* method for a command.
3007
3246
 
3008
3247
  If the command provided doesn't exist, then it executes default() instead.
@@ -3037,7 +3276,7 @@ class Cmd(cmd.Cmd):
3037
3276
 
3038
3277
  return stop if stop is not None else False
3039
3278
 
3040
- def default(self, statement: Statement) -> Optional[bool]: # type: ignore[override]
3279
+ def default(self, statement: Statement) -> bool | None: # type: ignore[override]
3041
3280
  """Execute when the command given isn't a recognized command implemented by a do_* method.
3042
3281
 
3043
3282
  :param statement: Statement object with parsed input
@@ -3045,30 +3284,29 @@ class Cmd(cmd.Cmd):
3045
3284
  if self.default_to_shell:
3046
3285
  if 'shell' not in self.exclude_from_history:
3047
3286
  self.history.append(statement)
3048
-
3049
3287
  return self.do_shell(statement.command_and_args)
3288
+
3050
3289
  err_msg = self.default_error.format(statement.command)
3051
3290
  if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)):
3052
3291
  err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}"
3053
3292
 
3054
- # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden
3055
- self.perror(err_msg, apply_style=False)
3293
+ self.perror(err_msg, style=None)
3056
3294
  return None
3057
3295
 
3058
- def _suggest_similar_command(self, command: str) -> Optional[str]:
3296
+ def _suggest_similar_command(self, command: str) -> str | None:
3059
3297
  return suggest_similar(command, self.get_visible_commands())
3060
3298
 
3061
3299
  def read_input(
3062
3300
  self,
3063
3301
  prompt: str,
3064
3302
  *,
3065
- history: Optional[list[str]] = None,
3303
+ history: list[str] | None = None,
3066
3304
  completion_mode: utils.CompletionMode = utils.CompletionMode.NONE,
3067
3305
  preserve_quotes: bool = False,
3068
- choices: Optional[Iterable[Any]] = None,
3069
- choices_provider: Optional[ChoicesProviderFunc] = None,
3070
- completer: Optional[CompleterFunc] = None,
3071
- parser: Optional[argparse.ArgumentParser] = None,
3306
+ choices: Iterable[Any] | None = None,
3307
+ choices_provider: ChoicesProviderFunc | None = None,
3308
+ completer: CompleterFunc | None = None,
3309
+ parser: argparse.ArgumentParser | None = None,
3072
3310
  ) -> str:
3073
3311
  """Read input from appropriate stdin value.
3074
3312
 
@@ -3102,8 +3340,8 @@ class Cmd(cmd.Cmd):
3102
3340
  :raises Exception: any exceptions raised by input() and stdin.readline()
3103
3341
  """
3104
3342
  readline_configured = False
3105
- saved_completer: Optional[CompleterFunc] = None
3106
- saved_history: Optional[list[str]] = None
3343
+ saved_completer: CompleterFunc | None = None
3344
+ saved_history: list[str] | None = None
3107
3345
 
3108
3346
  def configure_readline() -> None:
3109
3347
  """Configure readline tab completion and history."""
@@ -3122,7 +3360,7 @@ class Cmd(cmd.Cmd):
3122
3360
  # Disable completion
3123
3361
  if completion_mode == utils.CompletionMode.NONE:
3124
3362
 
3125
- def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover # noqa: ARG001
3363
+ def complete_none(text: str, state: int) -> str | None: # pragma: no cover # noqa: ARG001
3126
3364
  return None
3127
3365
 
3128
3366
  complete_func = complete_none
@@ -3138,7 +3376,7 @@ class Cmd(cmd.Cmd):
3138
3376
  parser.add_argument(
3139
3377
  'arg',
3140
3378
  suppress_tab_hint=True,
3141
- choices=choices, # type: ignore[arg-type]
3379
+ choices=choices,
3142
3380
  choices_provider=choices_provider,
3143
3381
  completer=completer,
3144
3382
  )
@@ -3339,13 +3577,24 @@ class Cmd(cmd.Cmd):
3339
3577
  #############################################################
3340
3578
 
3341
3579
  # Top-level parser for alias
3342
- alias_description = "Manage aliases\n\nAn alias is a command that enables replacement of a word by another string."
3343
- alias_epilog = "See also:\n macro"
3344
- alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3345
- alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3580
+ @staticmethod
3581
+ def _build_alias_parser() -> Cmd2ArgumentParser:
3582
+ alias_description = Text.assemble(
3583
+ "Manage aliases.",
3584
+ "\n\n",
3585
+ "An alias is a command that enables replacement of a word by another string.",
3586
+ )
3587
+ alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description)
3588
+ alias_parser.epilog = alias_parser.create_text_group(
3589
+ "See Also",
3590
+ "macro",
3591
+ )
3592
+ alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3593
+
3594
+ return alias_parser
3346
3595
 
3347
3596
  # Preserve quotes since we are passing strings to other commands
3348
- @with_argparser(alias_parser, preserve_quotes=True)
3597
+ @with_argparser(_build_alias_parser, preserve_quotes=True)
3349
3598
  def do_alias(self, args: argparse.Namespace) -> None:
3350
3599
  """Manage aliases."""
3351
3600
  # Call handler for whatever subcommand was selected
@@ -3353,34 +3602,41 @@ class Cmd(cmd.Cmd):
3353
3602
  handler(args)
3354
3603
 
3355
3604
  # alias -> create
3356
- alias_create_description = "Create or overwrite an alias"
3357
-
3358
- alias_create_epilog = (
3359
- "Notes:\n"
3360
- " If you want to use redirection, pipes, or terminators in the value of the\n"
3361
- " alias, then quote them.\n"
3362
- "\n"
3363
- " Since aliases are resolved during parsing, tab completion will function as\n"
3364
- " it would for the actual command the alias resolves to.\n"
3365
- "\n"
3366
- "Examples:\n"
3367
- " alias create ls !ls -lF\n"
3368
- " alias create show_log !cat \"log file.txt\"\n"
3369
- " alias create save_results print_results \">\" out.txt\n"
3370
- )
3605
+ @classmethod
3606
+ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
3607
+ alias_create_description = "Create or overwrite an alias."
3608
+ alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_create_description)
3609
+
3610
+ # Add Notes epilog
3611
+ alias_create_notes = Text.assemble(
3612
+ "If you want to use redirection, pipes, or terminators in the value of the alias, then quote them.",
3613
+ "\n\n",
3614
+ (" alias create save_results print_results \">\" out.txt\n", Cmd2Style.COMMAND_LINE),
3615
+ "\n\n",
3616
+ (
3617
+ "Since aliases are resolved during parsing, tab completion will function as it would "
3618
+ "for the actual command the alias resolves to."
3619
+ ),
3620
+ )
3621
+ alias_create_parser.epilog = alias_create_parser.create_text_group("Notes", alias_create_notes)
3622
+
3623
+ # Add arguments
3624
+ alias_create_parser.add_argument('name', help='name of this alias')
3625
+ alias_create_parser.add_argument(
3626
+ 'command',
3627
+ help='command, alias, or macro to run',
3628
+ choices_provider=cls._get_commands_aliases_and_macros_for_completion,
3629
+ )
3630
+ alias_create_parser.add_argument(
3631
+ 'command_args',
3632
+ nargs=argparse.REMAINDER,
3633
+ help='arguments to pass to command',
3634
+ completer=cls.path_complete,
3635
+ )
3371
3636
 
3372
- alias_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
3373
- description=alias_create_description, epilog=alias_create_epilog
3374
- )
3375
- alias_create_parser.add_argument('name', help='name of this alias')
3376
- alias_create_parser.add_argument(
3377
- 'command', help='what the alias resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion
3378
- )
3379
- alias_create_parser.add_argument(
3380
- 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete
3381
- )
3637
+ return alias_create_parser
3382
3638
 
3383
- @as_subcommand_to('alias', 'create', alias_create_parser, help=alias_create_description.lower())
3639
+ @as_subcommand_to('alias', 'create', _build_alias_create_parser, help="create or overwrite an alias")
3384
3640
  def _alias_create(self, args: argparse.Namespace) -> None:
3385
3641
  """Create or overwrite an alias."""
3386
3642
  self.last_result = False
@@ -3417,20 +3673,23 @@ class Cmd(cmd.Cmd):
3417
3673
  self.last_result = True
3418
3674
 
3419
3675
  # alias -> delete
3420
- alias_delete_help = "delete aliases"
3421
- alias_delete_description = "Delete specified aliases or all aliases if --all is used"
3676
+ @classmethod
3677
+ def _build_alias_delete_parser(cls) -> Cmd2ArgumentParser:
3678
+ alias_delete_description = "Delete specified aliases or all aliases if --all is used."
3679
+
3680
+ alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description)
3681
+ alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases")
3682
+ alias_delete_parser.add_argument(
3683
+ 'names',
3684
+ nargs=argparse.ZERO_OR_MORE,
3685
+ help='alias(es) to delete',
3686
+ choices_provider=cls._get_alias_completion_items,
3687
+ descriptive_headers=["Value"],
3688
+ )
3422
3689
 
3423
- alias_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_delete_description)
3424
- alias_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all aliases")
3425
- alias_delete_parser.add_argument(
3426
- 'names',
3427
- nargs=argparse.ZERO_OR_MORE,
3428
- help='alias(es) to delete',
3429
- choices_provider=_get_alias_completion_items,
3430
- descriptive_header=_alias_completion_table.generate_header(),
3431
- )
3690
+ return alias_delete_parser
3432
3691
 
3433
- @as_subcommand_to('alias', 'delete', alias_delete_parser, help=alias_delete_help)
3692
+ @as_subcommand_to('alias', 'delete', _build_alias_delete_parser, help="delete aliases")
3434
3693
  def _alias_delete(self, args: argparse.Namespace) -> None:
3435
3694
  """Delete aliases."""
3436
3695
  self.last_result = True
@@ -3450,24 +3709,29 @@ class Cmd(cmd.Cmd):
3450
3709
  self.perror(f"Alias '{cur_name}' does not exist")
3451
3710
 
3452
3711
  # alias -> list
3453
- alias_list_help = "list aliases"
3454
- alias_list_description = (
3455
- "List specified aliases in a reusable form that can be saved to a startup\n"
3456
- "script to preserve aliases across sessions\n"
3457
- "\n"
3458
- "Without arguments, all aliases will be listed."
3459
- )
3712
+ @classmethod
3713
+ def _build_alias_list_parser(cls) -> Cmd2ArgumentParser:
3714
+ alias_list_description = Text.assemble(
3715
+ (
3716
+ "List specified aliases in a reusable form that can be saved to a startup "
3717
+ "script to preserve aliases across sessions."
3718
+ ),
3719
+ "\n\n",
3720
+ "Without arguments, all aliases will be listed.",
3721
+ )
3460
3722
 
3461
- alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description)
3462
- alias_list_parser.add_argument(
3463
- 'names',
3464
- nargs=argparse.ZERO_OR_MORE,
3465
- help='alias(es) to list',
3466
- choices_provider=_get_alias_completion_items,
3467
- descriptive_header=_alias_completion_table.generate_header(),
3468
- )
3723
+ alias_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_list_description)
3724
+ alias_list_parser.add_argument(
3725
+ 'names',
3726
+ nargs=argparse.ZERO_OR_MORE,
3727
+ help='alias(es) to list',
3728
+ choices_provider=cls._get_alias_completion_items,
3729
+ descriptive_headers=["Value"],
3730
+ )
3731
+
3732
+ return alias_list_parser
3469
3733
 
3470
- @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_list_help)
3734
+ @as_subcommand_to('alias', 'list', _build_alias_list_parser, help="list aliases")
3471
3735
  def _alias_list(self, args: argparse.Namespace) -> None:
3472
3736
  """List some or all aliases as 'alias create' commands."""
3473
3737
  self.last_result = {} # dict[alias_name, alias_value]
@@ -3503,14 +3767,46 @@ class Cmd(cmd.Cmd):
3503
3767
  # Parsers and functions for macro command and subcommands
3504
3768
  #############################################################
3505
3769
 
3770
+ def macro_arg_complete(
3771
+ self,
3772
+ text: str,
3773
+ line: str,
3774
+ begidx: int,
3775
+ endidx: int,
3776
+ ) -> list[str]:
3777
+ """Tab completes arguments to a macro.
3778
+
3779
+ Its default behavior is to call path_complete, but you can override this as needed.
3780
+
3781
+ The args required by this function are defined in the header of Python's cmd.py.
3782
+
3783
+ :param text: the string prefix we are attempting to match (all matches must begin with it)
3784
+ :param line: the current input line with leading whitespace removed
3785
+ :param begidx: the beginning index of the prefix text
3786
+ :param endidx: the ending index of the prefix text
3787
+ :return: a list of possible tab completions
3788
+ """
3789
+ return self.path_complete(text, line, begidx, endidx)
3790
+
3506
3791
  # Top-level parser for macro
3507
- macro_description = "Manage macros\n\nA macro is similar to an alias, but it can contain argument placeholders."
3508
- macro_epilog = "See also:\n alias"
3509
- macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog)
3510
- macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3792
+ @staticmethod
3793
+ def _build_macro_parser() -> Cmd2ArgumentParser:
3794
+ macro_description = Text.assemble(
3795
+ "Manage macros.",
3796
+ "\n\n",
3797
+ "A macro is similar to an alias, but it can contain argument placeholders.",
3798
+ )
3799
+ macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description)
3800
+ macro_parser.epilog = macro_parser.create_text_group(
3801
+ "See Also",
3802
+ "alias",
3803
+ )
3804
+ macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3805
+
3806
+ return macro_parser
3511
3807
 
3512
3808
  # Preserve quotes since we are passing strings to other commands
3513
- @with_argparser(macro_parser, preserve_quotes=True)
3809
+ @with_argparser(_build_macro_parser, preserve_quotes=True)
3514
3810
  def do_macro(self, args: argparse.Namespace) -> None:
3515
3811
  """Manage macros."""
3516
3812
  # Call handler for whatever subcommand was selected
@@ -3518,58 +3814,72 @@ class Cmd(cmd.Cmd):
3518
3814
  handler(args)
3519
3815
 
3520
3816
  # macro -> create
3521
- macro_create_help = "create or overwrite a macro"
3522
- macro_create_description = "Create or overwrite a macro"
3523
-
3524
- macro_create_epilog = (
3525
- "A macro is similar to an alias, but it can contain argument placeholders.\n"
3526
- "Arguments are expressed when creating a macro using {#} notation where {1}\n"
3527
- "means the first argument.\n"
3528
- "\n"
3529
- "The following creates a macro called my_macro that expects two arguments:\n"
3530
- "\n"
3531
- " macro create my_macro make_dinner --meat {1} --veggie {2}\n"
3532
- "\n"
3533
- "When the macro is called, the provided arguments are resolved and the\n"
3534
- "assembled command is run. For example:\n"
3535
- "\n"
3536
- " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli\n"
3537
- "\n"
3538
- "Notes:\n"
3539
- " To use the literal string {1} in your command, escape it this way: {{1}}.\n"
3540
- "\n"
3541
- " Extra arguments passed to a macro are appended to resolved command.\n"
3542
- "\n"
3543
- " An argument number can be repeated in a macro. In the following example the\n"
3544
- " first argument will populate both {1} instances.\n"
3545
- "\n"
3546
- " macro create ft file_taxes -p {1} -q {2} -r {1}\n"
3547
- "\n"
3548
- " To quote an argument in the resolved command, quote it during creation.\n"
3549
- "\n"
3550
- " macro create backup !cp \"{1}\" \"{1}.orig\"\n"
3551
- "\n"
3552
- " If you want to use redirection, pipes, or terminators in the value of the\n"
3553
- " macro, then quote them.\n"
3554
- "\n"
3555
- " macro create show_results print_results -type {1} \"|\" less\n"
3556
- "\n"
3557
- " Because macros do not resolve until after hitting Enter, tab completion\n"
3558
- " will only complete paths while typing a macro."
3559
- )
3817
+ @classmethod
3818
+ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
3819
+ macro_create_description = Text.assemble(
3820
+ "Create or overwrite a macro.",
3821
+ "\n\n",
3822
+ "A macro is similar to an alias, but it can contain argument placeholders.",
3823
+ "\n\n",
3824
+ "Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.",
3825
+ "\n\n",
3826
+ "The following creates a macro called my_macro that expects two arguments:",
3827
+ "\n\n",
3828
+ (" macro create my_macro make_dinner --meat {1} --veggie {2}", Cmd2Style.COMMAND_LINE),
3829
+ "\n\n",
3830
+ "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:",
3831
+ "\n\n",
3832
+ (" my_macro beef broccoli", Cmd2Style.COMMAND_LINE),
3833
+ (" ───> ", Style(bold=True)),
3834
+ ("make_dinner --meat beef --veggie broccoli", Cmd2Style.COMMAND_LINE),
3835
+ )
3836
+ macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description)
3837
+
3838
+ # Add Notes epilog
3839
+ macro_create_notes = Text.assemble(
3840
+ "To use the literal string {1} in your command, escape it this way: {{1}}.",
3841
+ "\n\n",
3842
+ "Extra arguments passed to a macro are appended to resolved command.",
3843
+ "\n\n",
3844
+ (
3845
+ "An argument number can be repeated in a macro. In the following example the "
3846
+ "first argument will populate both {1} instances."
3847
+ ),
3848
+ "\n\n",
3849
+ (" macro create ft file_taxes -p {1} -q {2} -r {1}", Cmd2Style.COMMAND_LINE),
3850
+ "\n\n",
3851
+ "To quote an argument in the resolved command, quote it during creation.",
3852
+ "\n\n",
3853
+ (" macro create backup !cp \"{1}\" \"{1}.orig\"", Cmd2Style.COMMAND_LINE),
3854
+ "\n\n",
3855
+ "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.",
3856
+ "\n\n",
3857
+ (" macro create show_results print_results -type {1} \"|\" less", Cmd2Style.COMMAND_LINE),
3858
+ "\n\n",
3859
+ (
3860
+ "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. "
3861
+ "This default behavior changes if custom tab completion for macro arguments has been implemented."
3862
+ ),
3863
+ )
3864
+ macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes)
3865
+
3866
+ # Add arguments
3867
+ macro_create_parser.add_argument('name', help='name of this macro')
3868
+ macro_create_parser.add_argument(
3869
+ 'command',
3870
+ help='command, alias, or macro to run',
3871
+ choices_provider=cls._get_commands_aliases_and_macros_for_completion,
3872
+ )
3873
+ macro_create_parser.add_argument(
3874
+ 'command_args',
3875
+ nargs=argparse.REMAINDER,
3876
+ help='arguments to pass to command',
3877
+ completer=cls.path_complete,
3878
+ )
3560
3879
 
3561
- macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
3562
- description=macro_create_description, epilog=macro_create_epilog
3563
- )
3564
- macro_create_parser.add_argument('name', help='name of this macro')
3565
- macro_create_parser.add_argument(
3566
- 'command', help='what the macro resolves to', choices_provider=_get_commands_aliases_and_macros_for_completion
3567
- )
3568
- macro_create_parser.add_argument(
3569
- 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete
3570
- )
3880
+ return macro_create_parser
3571
3881
 
3572
- @as_subcommand_to('macro', 'create', macro_create_parser, help=macro_create_help)
3882
+ @as_subcommand_to('macro', 'create', _build_macro_create_parser, help="create or overwrite a macro")
3573
3883
  def _macro_create(self, args: argparse.Namespace) -> None:
3574
3884
  """Create or overwrite a macro."""
3575
3885
  self.last_result = False
@@ -3649,19 +3959,23 @@ class Cmd(cmd.Cmd):
3649
3959
  self.last_result = True
3650
3960
 
3651
3961
  # macro -> delete
3652
- macro_delete_help = "delete macros"
3653
- macro_delete_description = "Delete specified macros or all macros if --all is used"
3654
- macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description)
3655
- macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros")
3656
- macro_delete_parser.add_argument(
3657
- 'names',
3658
- nargs=argparse.ZERO_OR_MORE,
3659
- help='macro(s) to delete',
3660
- choices_provider=_get_macro_completion_items,
3661
- descriptive_header=_macro_completion_table.generate_header(),
3662
- )
3962
+ @classmethod
3963
+ def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser:
3964
+ macro_delete_description = "Delete specified macros or all macros if --all is used."
3965
+
3966
+ macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description)
3967
+ macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros")
3968
+ macro_delete_parser.add_argument(
3969
+ 'names',
3970
+ nargs=argparse.ZERO_OR_MORE,
3971
+ help='macro(s) to delete',
3972
+ choices_provider=cls._get_macro_completion_items,
3973
+ descriptive_headers=["Value"],
3974
+ )
3663
3975
 
3664
- @as_subcommand_to('macro', 'delete', macro_delete_parser, help=macro_delete_help)
3976
+ return macro_delete_parser
3977
+
3978
+ @as_subcommand_to('macro', 'delete', _build_macro_delete_parser, help="delete macros")
3665
3979
  def _macro_delete(self, args: argparse.Namespace) -> None:
3666
3980
  """Delete macros."""
3667
3981
  self.last_result = True
@@ -3682,11 +3996,10 @@ class Cmd(cmd.Cmd):
3682
3996
 
3683
3997
  # macro -> list
3684
3998
  macro_list_help = "list macros"
3685
- macro_list_description = (
3686
- "List specified macros in a reusable form that can be saved to a startup script\n"
3687
- "to preserve macros across sessions\n"
3688
- "\n"
3689
- "Without arguments, all macros will be listed."
3999
+ macro_list_description = Text.assemble(
4000
+ "List specified macros in a reusable form that can be saved to a startup script to preserve macros across sessions.",
4001
+ "\n\n",
4002
+ "Without arguments, all macros will be listed.",
3690
4003
  )
3691
4004
 
3692
4005
  macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
@@ -3695,7 +4008,7 @@ class Cmd(cmd.Cmd):
3695
4008
  nargs=argparse.ZERO_OR_MORE,
3696
4009
  help='macro(s) to list',
3697
4010
  choices_provider=_get_macro_completion_items,
3698
- descriptive_header=_macro_completion_table.generate_header(),
4011
+ descriptive_headers=["Value"],
3699
4012
  )
3700
4013
 
3701
4014
  @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help)
@@ -3754,30 +4067,110 @@ class Cmd(cmd.Cmd):
3754
4067
  completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
3755
4068
  return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
3756
4069
 
3757
- help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
3758
- description="List available commands or provide detailed help for a specific command"
3759
- )
3760
- help_parser.add_argument(
3761
- '-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each"
3762
- )
3763
- help_parser.add_argument(
3764
- 'command', nargs=argparse.OPTIONAL, help="command to retrieve help for", completer=complete_help_command
3765
- )
3766
- help_parser.add_argument(
3767
- 'subcommands', nargs=argparse.REMAINDER, help="subcommand(s) to retrieve help for", completer=complete_help_subcommands
3768
- )
4070
+ def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
4071
+ """Categorizes and sorts visible commands and help topics for display.
4072
+
4073
+ :return: tuple containing:
4074
+ - dictionary mapping category names to lists of command names
4075
+ - list of documented command names
4076
+ - list of undocumented command names
4077
+ - list of help topic names that are not also commands
4078
+ """
4079
+ # Get a sorted list of help topics
4080
+ help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
4081
+
4082
+ # Get a sorted list of visible command names
4083
+ visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
4084
+ cmds_doc: list[str] = []
4085
+ cmds_undoc: list[str] = []
4086
+ cmds_cats: dict[str, list[str]] = {}
4087
+ for command in visible_commands:
4088
+ func = cast(CommandFunc, self.cmd_func(command))
4089
+ has_help_func = False
4090
+ has_parser = func in self._command_parsers
4091
+
4092
+ if command in help_topics:
4093
+ # Prevent the command from showing as both a command and help topic in the output
4094
+ help_topics.remove(command)
4095
+
4096
+ # Non-argparse commands can have help_functions for their documentation
4097
+ has_help_func = not has_parser
4098
+
4099
+ if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
4100
+ category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
4101
+ cmds_cats.setdefault(category, [])
4102
+ cmds_cats[category].append(command)
4103
+ elif func.__doc__ or has_help_func or has_parser:
4104
+ cmds_doc.append(command)
4105
+ else:
4106
+ cmds_undoc.append(command)
4107
+ return cmds_cats, cmds_doc, cmds_undoc, help_topics
4108
+
4109
+ @classmethod
4110
+ def _build_help_parser(cls) -> Cmd2ArgumentParser:
4111
+ help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
4112
+ description="List available commands or provide detailed help for a specific command."
4113
+ )
4114
+ help_parser.add_argument(
4115
+ '-v',
4116
+ '--verbose',
4117
+ action='store_true',
4118
+ help="print a list of all commands with descriptions of each",
4119
+ )
4120
+ help_parser.add_argument(
4121
+ 'command',
4122
+ nargs=argparse.OPTIONAL,
4123
+ help="command to retrieve help for",
4124
+ completer=cls.complete_help_command,
4125
+ )
4126
+ help_parser.add_argument(
4127
+ 'subcommands',
4128
+ nargs=argparse.REMAINDER,
4129
+ help="subcommand(s) to retrieve help for",
4130
+ completer=cls.complete_help_subcommands,
4131
+ )
4132
+ return help_parser
3769
4133
 
3770
4134
  # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command
3771
4135
  if getattr(cmd.Cmd, 'complete_help', None) is not None:
3772
4136
  delattr(cmd.Cmd, 'complete_help')
3773
4137
 
3774
- @with_argparser(help_parser)
4138
+ @with_argparser(_build_help_parser)
3775
4139
  def do_help(self, args: argparse.Namespace) -> None:
3776
4140
  """List available commands or provide detailed help for a specific command."""
3777
4141
  self.last_result = True
3778
4142
 
3779
4143
  if not args.command or args.verbose:
3780
- self._help_menu(args.verbose)
4144
+ cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4145
+
4146
+ if self.doc_leader:
4147
+ self.poutput()
4148
+ self.poutput(Text(self.doc_leader, style=Cmd2Style.HELP_LEADER))
4149
+ self.poutput()
4150
+
4151
+ # Print any categories first and then the remaining documented commands.
4152
+ sorted_categories = sorted(cmds_cats.keys(), key=self.default_sort_key)
4153
+ all_cmds = {category: cmds_cats[category] for category in sorted_categories}
4154
+ if all_cmds:
4155
+ all_cmds[self.default_category] = cmds_doc
4156
+ else:
4157
+ all_cmds[self.doc_header] = cmds_doc
4158
+
4159
+ # Used to provide verbose table separation for better readability.
4160
+ previous_table_printed = False
4161
+
4162
+ for category, commands in all_cmds.items():
4163
+ if previous_table_printed:
4164
+ self.poutput()
4165
+
4166
+ self._print_documented_command_topics(category, commands, args.verbose)
4167
+ previous_table_printed = bool(commands) and args.verbose
4168
+
4169
+ if previous_table_printed and (help_topics or cmds_undoc):
4170
+ self.poutput()
4171
+
4172
+ self.print_topics(self.misc_header, help_topics, 15, 80)
4173
+ self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
3781
4174
 
3782
4175
  else:
3783
4176
  # Getting help for a specific command
@@ -3788,63 +4181,131 @@ class Cmd(cmd.Cmd):
3788
4181
  # If the command function uses argparse, then use argparse's help
3789
4182
  if func is not None and argparser is not None:
3790
4183
  completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
4184
+ completer.print_help(args.subcommands, self.stdout)
3791
4185
 
3792
- # Set end to blank so the help output matches how it looks when "command -h" is used
3793
- self.poutput(completer.format_help(args.subcommands), end='')
3794
-
3795
- # If there is a help func delegate to do_help
4186
+ # If the command has a custom help function, then call it
3796
4187
  elif help_func is not None:
3797
- super().do_help(args.command)
4188
+ help_func()
3798
4189
 
3799
- # If there's no help_func __doc__ then format and output it
4190
+ # If the command function has a docstring, then print it
3800
4191
  elif func is not None and func.__doc__ is not None:
3801
4192
  self.poutput(pydoc.getdoc(func))
3802
4193
 
3803
4194
  # If there is no help information then print an error
3804
4195
  else:
3805
4196
  err_msg = self.help_error.format(args.command)
3806
-
3807
- # Set apply_style to False so help_error's style is not overridden
3808
- self.perror(err_msg, apply_style=False)
4197
+ self.perror(err_msg, style=None)
3809
4198
  self.last_result = False
3810
4199
 
3811
- def print_topics(self, header: str, cmds: Optional[list[str]], cmdlen: int, maxcol: int) -> None: # noqa: ARG002
4200
+ def print_topics(self, header: str, cmds: list[str] | None, cmdlen: int, maxcol: int) -> None: # noqa: ARG002
3812
4201
  """Print groups of commands and topics in columns and an optional header.
3813
4202
 
3814
- Override of cmd's print_topics() to handle headers with newlines, ANSI style sequences, and wide characters.
4203
+ Override of cmd's print_topics() to use Rich.
3815
4204
 
3816
4205
  :param header: string to print above commands being printed
3817
4206
  :param cmds: list of topics to print
3818
4207
  :param cmdlen: unused, even by cmd's version
3819
4208
  :param maxcol: max number of display columns to fit into
3820
4209
  """
3821
- if cmds:
3822
- self.poutput(header)
3823
- if self.ruler:
3824
- divider = utils.align_left('', fill_char=self.ruler, width=ansi.widest_line(header))
3825
- self.poutput(divider)
3826
- self.columnize(cmds, maxcol - 1)
3827
- self.poutput()
4210
+ if not cmds:
4211
+ return
3828
4212
 
3829
- def columnize(self, str_list: Optional[list[str]], display_width: int = 80) -> None:
3830
- """Display a list of single-line strings as a compact set of columns.
4213
+ # Print a row that looks like a table header.
4214
+ if header:
4215
+ header_grid = Table.grid()
4216
+ header_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER))
4217
+ header_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER))
4218
+ self.poutput(header_grid)
3831
4219
 
3832
- Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters.
4220
+ # Subtract 1 from maxcol to account for a one-space right margin.
4221
+ maxcol = min(maxcol, ru.console_width()) - 1
4222
+ self.columnize(cmds, maxcol)
4223
+ self.poutput()
4224
+
4225
+ def _print_documented_command_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
4226
+ """Print topics which are documented commands, switching between verbose or traditional output."""
4227
+ import io
3833
4228
 
3834
- Each column is only as wide as necessary.
3835
- Columns are separated by two spaces (one was not legible enough).
4229
+ if not cmds:
4230
+ return
4231
+
4232
+ if not verbose:
4233
+ self.print_topics(header, cmds, 15, 80)
4234
+ return
4235
+
4236
+ # Create a grid to hold the header and the topics table
4237
+ category_grid = Table.grid()
4238
+ category_grid.add_row(Text(header, style=Cmd2Style.HELP_HEADER))
4239
+ category_grid.add_row(Rule(characters=self.ruler, style=Cmd2Style.TABLE_BORDER))
4240
+
4241
+ topics_table = Table(
4242
+ Column("Name", no_wrap=True),
4243
+ Column("Description", overflow="fold"),
4244
+ box=rich.box.SIMPLE_HEAD,
4245
+ show_edge=False,
4246
+ border_style=Cmd2Style.TABLE_BORDER,
4247
+ )
4248
+
4249
+ # Try to get the documentation string for each command
4250
+ topics = self.get_help_topics()
4251
+ for command in cmds:
4252
+ if (cmd_func := self.cmd_func(command)) is None:
4253
+ continue
4254
+
4255
+ doc: str | None
4256
+
4257
+ # Non-argparse commands can have help_functions for their documentation
4258
+ if command in topics:
4259
+ help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
4260
+ result = io.StringIO()
4261
+
4262
+ # try to redirect system stdout
4263
+ with contextlib.redirect_stdout(result):
4264
+ # save our internal stdout
4265
+ stdout_orig = self.stdout
4266
+ try:
4267
+ # redirect our internal stdout
4268
+ self.stdout = cast(TextIO, result)
4269
+ help_func()
4270
+ finally:
4271
+ with self.sigint_protection:
4272
+ # restore internal stdout
4273
+ self.stdout = stdout_orig
4274
+ doc = result.getvalue()
4275
+
4276
+ else:
4277
+ doc = cmd_func.__doc__
4278
+
4279
+ # Attempt to locate the first documentation block
4280
+ cmd_desc = strip_doc_annotations(doc) if doc else ''
4281
+
4282
+ # Add this command to the table
4283
+ topics_table.add_row(command, cmd_desc)
4284
+
4285
+ category_grid.add_row(topics_table)
4286
+ self.poutput(category_grid)
4287
+ self.poutput()
4288
+
4289
+ def render_columns(self, str_list: list[str] | None, display_width: int = 80) -> str:
4290
+ """Render a list of single-line strings as a compact set of columns.
4291
+
4292
+ This method correctly handles strings containing ANSI style sequences and
4293
+ full-width characters (like those used in CJK languages). Each column is
4294
+ only as wide as necessary and columns are separated by two spaces.
4295
+
4296
+ :param str_list: list of single-line strings to display
4297
+ :param display_width: max number of display columns to fit into
4298
+ :return: a string containing the columnized output
3836
4299
  """
3837
4300
  if not str_list:
3838
- self.poutput("<empty>")
3839
- return
4301
+ return ""
3840
4302
 
3841
- nonstrings = [i for i in range(len(str_list)) if not isinstance(str_list[i], str)]
3842
- if nonstrings:
3843
- raise TypeError(f"str_list[i] not a string for i in {nonstrings}")
3844
4303
  size = len(str_list)
3845
4304
  if size == 1:
3846
- self.poutput(str_list[0])
3847
- return
4305
+ return str_list[0]
4306
+
4307
+ rows: list[str] = []
4308
+
3848
4309
  # Try every row count from 1 upwards
3849
4310
  for nrows in range(1, len(str_list)):
3850
4311
  ncols = (size + nrows - 1) // nrows
@@ -3857,7 +4318,7 @@ class Cmd(cmd.Cmd):
3857
4318
  if i >= size:
3858
4319
  break
3859
4320
  x = str_list[i]
3860
- colwidth = max(colwidth, ansi.style_aware_wcswidth(x))
4321
+ colwidth = max(colwidth, su.str_width(x))
3861
4322
  colwidths.append(colwidth)
3862
4323
  totwidth += colwidth + 2
3863
4324
  if totwidth > display_width:
@@ -3868,7 +4329,8 @@ class Cmd(cmd.Cmd):
3868
4329
  # The output is wider than display_width. Print 1 column with each string on its own row.
3869
4330
  nrows = len(str_list)
3870
4331
  ncols = 1
3871
- colwidths = [1]
4332
+ max_width = max(su.str_width(s) for s in str_list)
4333
+ colwidths = [max_width]
3872
4334
  for row in range(nrows):
3873
4335
  texts = []
3874
4336
  for col in range(ncols):
@@ -3878,130 +4340,29 @@ class Cmd(cmd.Cmd):
3878
4340
  while texts and not texts[-1]:
3879
4341
  del texts[-1]
3880
4342
  for col in range(len(texts)):
3881
- texts[col] = utils.align_left(texts[col], width=colwidths[col])
3882
- self.poutput(" ".join(texts))
3883
-
3884
- def _help_menu(self, verbose: bool = False) -> None:
3885
- """Show a list of commands which help can be displayed for."""
3886
- cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info()
4343
+ texts[col] = su.align_left(texts[col], width=colwidths[col])
4344
+ rows.append(" ".join(texts))
3887
4345
 
3888
- if not cmds_cats:
3889
- # No categories found, fall back to standard behavior
3890
- self.poutput(self.doc_leader)
3891
- self._print_topics(self.doc_header, cmds_doc, verbose)
3892
- else:
3893
- # Categories found, Organize all commands by category
3894
- self.poutput(self.doc_leader)
3895
- self.poutput(self.doc_header, end="\n\n")
3896
- for category in sorted(cmds_cats.keys(), key=self.default_sort_key):
3897
- self._print_topics(category, cmds_cats[category], verbose)
3898
- self._print_topics(self.default_category, cmds_doc, verbose)
3899
-
3900
- self.print_topics(self.misc_header, help_topics, 15, 80)
3901
- self.print_topics(self.undoc_header, cmds_undoc, 15, 80)
4346
+ return "\n".join(rows)
3902
4347
 
3903
- def _build_command_info(self) -> tuple[dict[str, list[str]], list[str], list[str], list[str]]:
3904
- # Get a sorted list of help topics
3905
- help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
3906
-
3907
- # Get a sorted list of visible command names
3908
- visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
3909
- cmds_doc: list[str] = []
3910
- cmds_undoc: list[str] = []
3911
- cmds_cats: dict[str, list[str]] = {}
3912
- for command in visible_commands:
3913
- func = cast(CommandFunc, self.cmd_func(command))
3914
- has_help_func = False
3915
- has_parser = func in self._command_parsers
3916
-
3917
- if command in help_topics:
3918
- # Prevent the command from showing as both a command and help topic in the output
3919
- help_topics.remove(command)
3920
-
3921
- # Non-argparse commands can have help_functions for their documentation
3922
- has_help_func = not has_parser
3923
-
3924
- if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
3925
- category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
3926
- cmds_cats.setdefault(category, [])
3927
- cmds_cats[category].append(command)
3928
- elif func.__doc__ or has_help_func or has_parser:
3929
- cmds_doc.append(command)
3930
- else:
3931
- cmds_undoc.append(command)
3932
- return cmds_cats, cmds_doc, cmds_undoc, help_topics
3933
-
3934
- def _print_topics(self, header: str, cmds: list[str], verbose: bool) -> None:
3935
- """Print topics, switching between verbose or traditional output."""
3936
- import io
3937
-
3938
- if cmds:
3939
- if not verbose:
3940
- self.print_topics(header, cmds, 15, 80)
3941
- else:
3942
- # Find the widest command
3943
- widest = max([ansi.style_aware_wcswidth(command) for command in cmds])
3944
-
3945
- # Define the table structure
3946
- name_column = Column('', width=max(widest, 20))
3947
- desc_column = Column('', width=80)
3948
-
3949
- topic_table = SimpleTable([name_column, desc_column], divider_char=self.ruler)
3950
-
3951
- # Build the topic table
3952
- table_str_buf = io.StringIO()
3953
- if header:
3954
- table_str_buf.write(header + "\n")
3955
-
3956
- divider = topic_table.generate_divider()
3957
- if divider:
3958
- table_str_buf.write(divider + "\n")
3959
-
3960
- # Try to get the documentation string for each command
3961
- topics = self.get_help_topics()
3962
- for command in cmds:
3963
- if (cmd_func := self.cmd_func(command)) is None:
3964
- continue
3965
-
3966
- doc: Optional[str]
3967
-
3968
- # If this is an argparse command, use its description.
3969
- if (cmd_parser := self._command_parsers.get(cmd_func)) is not None:
3970
- doc = cmd_parser.description
3971
-
3972
- # Non-argparse commands can have help_functions for their documentation
3973
- elif command in topics:
3974
- help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
3975
- result = io.StringIO()
3976
-
3977
- # try to redirect system stdout
3978
- with contextlib.redirect_stdout(result):
3979
- # save our internal stdout
3980
- stdout_orig = self.stdout
3981
- try:
3982
- # redirect our internal stdout
3983
- self.stdout = cast(TextIO, result)
3984
- help_func()
3985
- finally:
3986
- # restore internal stdout
3987
- self.stdout = stdout_orig
3988
- doc = result.getvalue()
3989
-
3990
- else:
3991
- doc = cmd_func.__doc__
3992
-
3993
- # Attempt to locate the first documentation block
3994
- cmd_desc = strip_doc_annotations(doc) if doc else ''
4348
+ def columnize(self, str_list: list[str] | None, display_width: int = 80) -> None:
4349
+ """Display a list of single-line strings as a compact set of columns.
3995
4350
 
3996
- # Add this command to the table
3997
- table_row = topic_table.generate_data_row([command, cmd_desc])
3998
- table_str_buf.write(table_row + '\n')
4351
+ Override of cmd's columnize() that uses the render_columns() method.
4352
+ The method correctly handles strings with ANSI style sequences and
4353
+ full-width characters (like those used in CJK languages).
3999
4354
 
4000
- self.poutput(table_str_buf.getvalue())
4355
+ :param str_list: list of single-line strings to display
4356
+ :param display_width: max number of display columns to fit into
4357
+ """
4358
+ columnized_strs = self.render_columns(str_list, display_width)
4359
+ self.poutput(columnized_strs)
4001
4360
 
4002
- shortcuts_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts")
4361
+ @staticmethod
4362
+ def _build_shortcuts_parser() -> Cmd2ArgumentParser:
4363
+ return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="List available shortcuts.")
4003
4364
 
4004
- @with_argparser(shortcuts_parser)
4365
+ @with_argparser(_build_shortcuts_parser)
4005
4366
  def do_shortcuts(self, _: argparse.Namespace) -> None:
4006
4367
  """List available shortcuts."""
4007
4368
  # Sort the shortcut tuples by name
@@ -4010,12 +4371,18 @@ class Cmd(cmd.Cmd):
4010
4371
  self.poutput(f"Shortcuts for other commands:\n{result}")
4011
4372
  self.last_result = True
4012
4373
 
4013
- eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
4014
- description="Called when Ctrl-D is pressed", epilog=INTERNAL_COMMAND_EPILOG
4015
- )
4374
+ @staticmethod
4375
+ def _build_eof_parser() -> Cmd2ArgumentParser:
4376
+ eof_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.")
4377
+ eof_parser.epilog = eof_parser.create_text_group(
4378
+ "Note",
4379
+ "This command is for internal use and is not intended to be called from the command line.",
4380
+ )
4016
4381
 
4017
- @with_argparser(eof_parser)
4018
- def do_eof(self, _: argparse.Namespace) -> Optional[bool]:
4382
+ return eof_parser
4383
+
4384
+ @with_argparser(_build_eof_parser)
4385
+ def do_eof(self, _: argparse.Namespace) -> bool | None:
4019
4386
  """Quit with no arguments, called when Ctrl-D is pressed.
4020
4387
 
4021
4388
  This can be overridden if quit should be called differently.
@@ -4025,16 +4392,18 @@ class Cmd(cmd.Cmd):
4025
4392
  # self.last_result will be set by do_quit()
4026
4393
  return self.do_quit('')
4027
4394
 
4028
- quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application")
4395
+ @staticmethod
4396
+ def _build_quit_parser() -> Cmd2ArgumentParser:
4397
+ return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application.")
4029
4398
 
4030
- @with_argparser(quit_parser)
4031
- def do_quit(self, _: argparse.Namespace) -> Optional[bool]:
4399
+ @with_argparser(_build_quit_parser)
4400
+ def do_quit(self, _: argparse.Namespace) -> bool | None:
4032
4401
  """Exit this application."""
4033
4402
  # Return True to stop the command loop
4034
4403
  self.last_result = True
4035
4404
  return True
4036
4405
 
4037
- def select(self, opts: Union[str, list[str], list[tuple[Any, Optional[str]]]], prompt: str = 'Your choice? ') -> Any:
4406
+ def select(self, opts: str | list[str] | list[tuple[Any, str | None]], prompt: str = 'Your choice? ') -> Any:
4038
4407
  """Present a numbered menu to the user.
4039
4408
 
4040
4409
  Modeled after the bash shell's SELECT. Returns the item chosen.
@@ -4047,12 +4416,12 @@ class Cmd(cmd.Cmd):
4047
4416
  that the return value can differ from
4048
4417
  the text advertised to the user
4049
4418
  """
4050
- local_opts: Union[list[str], list[tuple[Any, Optional[str]]]]
4419
+ local_opts: list[str] | list[tuple[Any, str | None]]
4051
4420
  if isinstance(opts, str):
4052
- local_opts = cast(list[tuple[Any, Optional[str]]], list(zip(opts.split(), opts.split())))
4421
+ local_opts = cast(list[tuple[Any, str | None]], list(zip(opts.split(), opts.split(), strict=False)))
4053
4422
  else:
4054
4423
  local_opts = opts
4055
- fulloptions: list[tuple[Any, Optional[str]]] = []
4424
+ fulloptions: list[tuple[Any, str | None]] = []
4056
4425
  for opt in local_opts:
4057
4426
  if isinstance(opt, str):
4058
4427
  fulloptions.append((opt, opt))
@@ -4085,6 +4454,29 @@ class Cmd(cmd.Cmd):
4085
4454
  except (ValueError, IndexError):
4086
4455
  self.poutput(f"'{response}' isn't a valid choice. Pick a number between 1 and {len(fulloptions)}:")
4087
4456
 
4457
+ @classmethod
4458
+ def _build_base_set_parser(cls) -> Cmd2ArgumentParser:
4459
+ # When tab completing value, we recreate the set command parser with a value argument specific to
4460
+ # the settable being edited. To make this easier, define a base parser with all the common elements.
4461
+ set_description = Text.assemble(
4462
+ "Set a settable parameter or show current settings of parameters.",
4463
+ "\n\n",
4464
+ (
4465
+ "Call without arguments for a list of all settable parameters with their values. "
4466
+ "Call with just param to view that parameter's value."
4467
+ ),
4468
+ )
4469
+ base_set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description)
4470
+ base_set_parser.add_argument(
4471
+ 'param',
4472
+ nargs=argparse.OPTIONAL,
4473
+ help='parameter to set or view',
4474
+ choices_provider=cls._get_settable_completion_items,
4475
+ descriptive_headers=["Value", "Description"],
4476
+ )
4477
+
4478
+ return base_set_parser
4479
+
4088
4480
  def complete_set_value(
4089
4481
  self, text: str, line: str, begidx: int, endidx: int, arg_tokens: dict[str, list[str]]
4090
4482
  ) -> list[str]:
@@ -4096,7 +4488,7 @@ class Cmd(cmd.Cmd):
4096
4488
  raise CompletionError(param + " is not a settable parameter") from exc
4097
4489
 
4098
4490
  # Create a parser with a value field based on this settable
4099
- settable_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[Cmd.set_parser_parent])
4491
+ settable_parser = self._build_base_set_parser()
4100
4492
 
4101
4493
  # Settables with choices list the values of those choices instead of the arg name
4102
4494
  # in help text and this shows in tab completion hints. Set metavar to avoid this.
@@ -4105,7 +4497,7 @@ class Cmd(cmd.Cmd):
4105
4497
  arg_name,
4106
4498
  metavar=arg_name,
4107
4499
  help=settable.description,
4108
- choices=settable.choices, # type: ignore[arg-type]
4500
+ choices=settable.choices,
4109
4501
  choices_provider=settable.choices_provider,
4110
4502
  completer=settable.completer,
4111
4503
  )
@@ -4116,30 +4508,22 @@ class Cmd(cmd.Cmd):
4116
4508
  _, raw_tokens = self.tokens_for_completion(line, begidx, endidx)
4117
4509
  return completer.complete(text, line, begidx, endidx, raw_tokens[1:])
4118
4510
 
4119
- # When tab completing value, we recreate the set command parser with a value argument specific to
4120
- # the settable being edited. To make this easier, define a parent parser with all the common elements.
4121
- set_description = (
4122
- "Set a settable parameter or show current settings of parameters\n"
4123
- "Call without arguments for a list of all settable parameters with their values.\n"
4124
- "Call with just param to view that parameter's value."
4125
- )
4126
- set_parser_parent = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=set_description, add_help=False)
4127
- set_parser_parent.add_argument(
4128
- 'param',
4129
- nargs=argparse.OPTIONAL,
4130
- help='parameter to set or view',
4131
- choices_provider=_get_settable_completion_items,
4132
- descriptive_header=_settable_completion_table.generate_header(),
4133
- )
4511
+ @classmethod
4512
+ def _build_set_parser(cls) -> Cmd2ArgumentParser:
4513
+ # Create the parser for the set command
4514
+ set_parser = cls._build_base_set_parser()
4515
+ set_parser.add_argument(
4516
+ 'value',
4517
+ nargs=argparse.OPTIONAL,
4518
+ help='new value for settable',
4519
+ completer=cls.complete_set_value,
4520
+ suppress_tab_hint=True,
4521
+ )
4134
4522
 
4135
- # Create the parser for the set command
4136
- set_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(parents=[set_parser_parent])
4137
- set_parser.add_argument(
4138
- 'value', nargs=argparse.OPTIONAL, help='new value for settable', completer=complete_set_value, suppress_tab_hint=True
4139
- )
4523
+ return set_parser
4140
4524
 
4141
4525
  # Preserve quotes so users can pass in quoted empty strings and flags (e.g. -h) as the value
4142
- @with_argparser(set_parser, preserve_quotes=True)
4526
+ @with_argparser(_build_set_parser, preserve_quotes=True)
4143
4527
  def do_set(self, args: argparse.Namespace) -> None:
4144
4528
  """Set a settable parameter or show current settings of parameters."""
4145
4529
  self.last_result = False
@@ -4158,52 +4542,59 @@ class Cmd(cmd.Cmd):
4158
4542
  if args.value:
4159
4543
  # Try to update the settable's value
4160
4544
  try:
4161
- orig_value = settable.get_value()
4162
- settable.set_value(utils.strip_quotes(args.value))
4545
+ orig_value = settable.value
4546
+ settable.value = su.strip_quotes(args.value)
4163
4547
  except ValueError as ex:
4164
4548
  self.perror(f"Error setting {args.param}: {ex}")
4165
4549
  else:
4166
- self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.get_value()!r}")
4550
+ self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.value!r}")
4167
4551
  self.last_result = True
4168
4552
  return
4169
4553
 
4170
4554
  # Show one settable
4171
- to_show = [args.param]
4555
+ to_show: list[str] = [args.param]
4172
4556
  else:
4173
4557
  # Show all settables
4174
4558
  to_show = list(self.settables.keys())
4175
4559
 
4176
4560
  # Define the table structure
4177
- name_label = 'Name'
4178
- max_name_width = max([ansi.style_aware_wcswidth(param) for param in to_show])
4179
- max_name_width = max(max_name_width, ansi.style_aware_wcswidth(name_label))
4180
-
4181
- cols: list[Column] = [
4182
- Column(name_label, width=max_name_width),
4183
- Column('Value', width=30),
4184
- Column('Description', width=60),
4185
- ]
4186
-
4187
- table = SimpleTable(cols, divider_char=self.ruler)
4188
- self.poutput(table.generate_header())
4561
+ settable_table = Table(
4562
+ Column("Name", no_wrap=True),
4563
+ Column("Value", overflow="fold"),
4564
+ Column("Description", overflow="fold"),
4565
+ box=rich.box.SIMPLE_HEAD,
4566
+ show_edge=False,
4567
+ border_style=Cmd2Style.TABLE_BORDER,
4568
+ )
4189
4569
 
4190
4570
  # Build the table and populate self.last_result
4191
4571
  self.last_result = {} # dict[settable_name, settable_value]
4192
4572
 
4193
4573
  for param in sorted(to_show, key=self.default_sort_key):
4194
4574
  settable = self.settables[param]
4195
- row_data = [param, settable.get_value(), settable.description]
4196
- self.poutput(table.generate_data_row(row_data))
4197
- self.last_result[param] = settable.get_value()
4198
-
4199
- shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt")
4200
- shell_parser.add_argument('command', help='the command to run', completer=shell_cmd_complete)
4201
- shell_parser.add_argument(
4202
- 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=path_complete
4203
- )
4575
+ settable_table.add_row(
4576
+ param,
4577
+ str(settable.value),
4578
+ settable.description,
4579
+ )
4580
+ self.last_result[param] = settable.value
4581
+
4582
+ self.poutput()
4583
+ self.poutput(settable_table)
4584
+ self.poutput()
4585
+
4586
+ @classmethod
4587
+ def _build_shell_parser(cls) -> Cmd2ArgumentParser:
4588
+ shell_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Execute a command as if at the OS prompt.")
4589
+ shell_parser.add_argument('command', help='the command to run', completer=cls.shell_cmd_complete)
4590
+ shell_parser.add_argument(
4591
+ 'command_args', nargs=argparse.REMAINDER, help='arguments to pass to command', completer=cls.path_complete
4592
+ )
4593
+
4594
+ return shell_parser
4204
4595
 
4205
4596
  # Preserve quotes since we are passing these strings to the shell
4206
- @with_argparser(shell_parser, preserve_quotes=True)
4597
+ @with_argparser(_build_shell_parser, preserve_quotes=True)
4207
4598
  def do_shell(self, args: argparse.Namespace) -> None:
4208
4599
  """Execute a command as if at the OS prompt."""
4209
4600
  import signal
@@ -4241,15 +4632,15 @@ class Cmd(cmd.Cmd):
4241
4632
  # still receive the SIGINT since it is in the same process group as us.
4242
4633
  with self.sigint_protection:
4243
4634
  # For any stream that is a StdSim, we will use a pipe so we can capture its output
4244
- proc = subprocess.Popen( # type: ignore[call-overload] # noqa: S602
4635
+ proc = subprocess.Popen( # noqa: S602
4245
4636
  expanded_command,
4246
4637
  stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable]
4247
- stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, # type: ignore[unreachable]
4638
+ stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr,
4248
4639
  shell=True,
4249
4640
  **kwargs,
4250
4641
  )
4251
4642
 
4252
- proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) # type: ignore[arg-type]
4643
+ proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
4253
4644
  proc_reader.wait()
4254
4645
 
4255
4646
  # Save the return code of the application for use in a pyscript
@@ -4331,19 +4722,13 @@ class Cmd(cmd.Cmd):
4331
4722
  # Save off the current completer and set a new one in the Python console
4332
4723
  # Make sure it tab completes from its locals() dictionary
4333
4724
  cmd2_env.readline_settings.completer = readline.get_completer()
4334
- interp.runcode("from rlcompleter import Completer") # type: ignore[arg-type]
4335
- interp.runcode("import readline") # type: ignore[arg-type]
4336
- interp.runcode("readline.set_completer(Completer(locals()).complete)") # type: ignore[arg-type]
4725
+ interp.runcode(compile("from rlcompleter import Completer", "<stdin>", "exec"))
4726
+ interp.runcode(compile("import readline", "<stdin>", "exec"))
4727
+ interp.runcode(compile("readline.set_completer(Completer(locals()).complete)", "<stdin>", "exec"))
4337
4728
 
4338
4729
  # Set up sys module for the Python console
4339
4730
  self._reset_py_display()
4340
4731
 
4341
- cmd2_env.sys_stdout = sys.stdout
4342
- sys.stdout = self.stdout # type: ignore[assignment]
4343
-
4344
- cmd2_env.sys_stdin = sys.stdin
4345
- sys.stdin = self.stdin # type: ignore[assignment]
4346
-
4347
4732
  return cmd2_env
4348
4733
 
4349
4734
  def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None:
@@ -4351,9 +4736,6 @@ class Cmd(cmd.Cmd):
4351
4736
 
4352
4737
  :param cmd2_env: the environment settings to restore
4353
4738
  """
4354
- sys.stdout = cmd2_env.sys_stdout # type: ignore[assignment]
4355
- sys.stdin = cmd2_env.sys_stdin # type: ignore[assignment]
4356
-
4357
4739
  # Set up readline for cmd2
4358
4740
  if rl_type != RlType.NONE:
4359
4741
  # Save py's history
@@ -4382,7 +4764,7 @@ class Cmd(cmd.Cmd):
4382
4764
  else:
4383
4765
  sys.modules['readline'] = cmd2_env.readline_module
4384
4766
 
4385
- def _run_python(self, *, pyscript: Optional[str] = None) -> Optional[bool]:
4767
+ def _run_python(self, *, pyscript: str | None = None) -> bool | None:
4386
4768
  """Run an interactive Python shell or execute a pyscript file.
4387
4769
 
4388
4770
  Called by do_py() and do_run_pyscript().
@@ -4500,10 +4882,12 @@ class Cmd(cmd.Cmd):
4500
4882
 
4501
4883
  return py_bridge.stop
4502
4884
 
4503
- py_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell")
4885
+ @staticmethod
4886
+ def _build_py_parser() -> Cmd2ArgumentParser:
4887
+ return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive Python shell.")
4504
4888
 
4505
- @with_argparser(py_parser)
4506
- def do_py(self, _: argparse.Namespace) -> Optional[bool]:
4889
+ @with_argparser(_build_py_parser)
4890
+ def do_py(self, _: argparse.Namespace) -> bool | None:
4507
4891
  """Run an interactive Python shell.
4508
4892
 
4509
4893
  :return: True if running of commands should stop.
@@ -4511,15 +4895,21 @@ class Cmd(cmd.Cmd):
4511
4895
  # self.last_result will be set by _run_python()
4512
4896
  return self._run_python()
4513
4897
 
4514
- run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run a Python script file inside the console")
4515
- run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=path_complete)
4516
- run_pyscript_parser.add_argument(
4517
- 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=path_complete
4518
- )
4898
+ @classmethod
4899
+ def _build_run_pyscript_parser(cls) -> Cmd2ArgumentParser:
4900
+ run_pyscript_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
4901
+ description="Run Python script within this application's environment."
4902
+ )
4903
+ run_pyscript_parser.add_argument('script_path', help='path to the script file', completer=cls.path_complete)
4904
+ run_pyscript_parser.add_argument(
4905
+ 'script_arguments', nargs=argparse.REMAINDER, help='arguments to pass to script', completer=cls.path_complete
4906
+ )
4907
+
4908
+ return run_pyscript_parser
4519
4909
 
4520
- @with_argparser(run_pyscript_parser)
4521
- def do_run_pyscript(self, args: argparse.Namespace) -> Optional[bool]:
4522
- """Run a Python script file inside the console.
4910
+ @with_argparser(_build_run_pyscript_parser)
4911
+ def do_run_pyscript(self, args: argparse.Namespace) -> bool | None:
4912
+ """Run Python script within this application's environment.
4523
4913
 
4524
4914
  :return: True if running of commands should stop
4525
4915
  """
@@ -4551,11 +4941,13 @@ class Cmd(cmd.Cmd):
4551
4941
 
4552
4942
  return py_return
4553
4943
 
4554
- ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
4944
+ @staticmethod
4945
+ def _build_ipython_parser() -> Cmd2ArgumentParser:
4946
+ return argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell.")
4555
4947
 
4556
- @with_argparser(ipython_parser)
4557
- def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover
4558
- """Enter an interactive IPython shell.
4948
+ @with_argparser(_build_ipython_parser)
4949
+ def do_ipy(self, _: argparse.Namespace) -> bool | None: # pragma: no cover
4950
+ """Run an interactive IPython shell.
4559
4951
 
4560
4952
  :return: True if running of commands should stop
4561
4953
  """
@@ -4563,18 +4955,18 @@ class Cmd(cmd.Cmd):
4563
4955
 
4564
4956
  # Detect whether IPython is installed
4565
4957
  try:
4566
- import traitlets.config.loader as traitlets_loader # type: ignore[import]
4958
+ import traitlets.config.loader as traitlets_loader
4567
4959
 
4568
4960
  # Allow users to install ipython from a cmd2 prompt when needed and still have ipy command work
4569
4961
  try:
4570
4962
  _dummy = start_ipython # noqa: F823
4571
4963
  except NameError:
4572
- from IPython import start_ipython # type: ignore[import]
4964
+ from IPython import start_ipython
4573
4965
 
4574
- from IPython.terminal.interactiveshell import ( # type: ignore[import]
4966
+ from IPython.terminal.interactiveshell import (
4575
4967
  TerminalInteractiveShell,
4576
4968
  )
4577
- from IPython.terminal.ipapp import ( # type: ignore[import]
4969
+ from IPython.terminal.ipapp import (
4578
4970
  TerminalIPythonApp,
4579
4971
  )
4580
4972
  except ImportError:
@@ -4625,55 +5017,71 @@ class Cmd(cmd.Cmd):
4625
5017
  finally:
4626
5018
  self._in_py = False
4627
5019
 
4628
- history_description = "View, run, edit, save, or clear previously entered commands"
5020
+ @classmethod
5021
+ def _build_history_parser(cls) -> Cmd2ArgumentParser:
5022
+ history_description = "View, run, edit, save, or clear previously entered commands."
4629
5023
 
4630
- history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=history_description)
4631
- history_action_group = history_parser.add_mutually_exclusive_group()
4632
- history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
4633
- history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items')
4634
- history_action_group.add_argument(
4635
- '-o', '--output_file', metavar='FILE', help='output commands to a script file, implies -s', completer=path_complete
4636
- )
4637
- history_action_group.add_argument(
4638
- '-t',
4639
- '--transcript',
4640
- metavar='TRANSCRIPT_FILE',
4641
- help='create a transcript file by re-running the commands,\nimplies both -r and -s',
4642
- completer=path_complete,
4643
- )
4644
- history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history')
5024
+ history_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
5025
+ description=history_description, formatter_class=argparse_custom.RawTextCmd2HelpFormatter
5026
+ )
5027
+ history_action_group = history_parser.add_mutually_exclusive_group()
5028
+ history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
5029
+ history_action_group.add_argument('-e', '--edit', action='store_true', help='edit and then run selected history items')
5030
+ history_action_group.add_argument(
5031
+ '-o',
5032
+ '--output_file',
5033
+ metavar='FILE',
5034
+ help='output commands to a script file, implies -s',
5035
+ completer=cls.path_complete,
5036
+ )
5037
+ history_action_group.add_argument(
5038
+ '-t',
5039
+ '--transcript',
5040
+ metavar='TRANSCRIPT_FILE',
5041
+ help='create a transcript file by re-running the commands, implies both -r and -s',
5042
+ completer=cls.path_complete,
5043
+ )
5044
+ history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history')
5045
+
5046
+ history_format_group = history_parser.add_argument_group(title='formatting')
5047
+ history_format_group.add_argument(
5048
+ '-s',
5049
+ '--script',
5050
+ action='store_true',
5051
+ help='output commands in script format, i.e. without command numbers',
5052
+ )
5053
+ history_format_group.add_argument(
5054
+ '-x',
5055
+ '--expanded',
5056
+ action='store_true',
5057
+ help='output fully parsed commands with shortcuts, aliases, and macros expanded',
5058
+ )
5059
+ history_format_group.add_argument(
5060
+ '-v',
5061
+ '--verbose',
5062
+ action='store_true',
5063
+ help='display history and include expanded commands if they differ from the typed command',
5064
+ )
5065
+ history_format_group.add_argument(
5066
+ '-a',
5067
+ '--all',
5068
+ action='store_true',
5069
+ help='display all commands, including ones persisted from previous sessions',
5070
+ )
4645
5071
 
4646
- history_format_group = history_parser.add_argument_group(title='formatting')
4647
- history_format_group.add_argument(
4648
- '-s', '--script', action='store_true', help='output commands in script format, i.e. without command\nnumbers'
4649
- )
4650
- history_format_group.add_argument(
4651
- '-x',
4652
- '--expanded',
4653
- action='store_true',
4654
- help='output fully parsed commands with any aliases and\nmacros expanded, instead of typed commands',
4655
- )
4656
- history_format_group.add_argument(
4657
- '-v',
4658
- '--verbose',
4659
- action='store_true',
4660
- help='display history and include expanded commands if they\ndiffer from the typed command',
4661
- )
4662
- history_format_group.add_argument(
4663
- '-a', '--all', action='store_true', help='display all commands, including ones persisted from\nprevious sessions'
4664
- )
5072
+ history_arg_help = (
5073
+ "empty all history items\n"
5074
+ "a one history item by number\n"
5075
+ "a..b, a:b, a:, ..b items by indices (inclusive)\n"
5076
+ "string items containing string\n"
5077
+ "/regex/ items matching regular expression"
5078
+ )
5079
+ history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help)
4665
5080
 
4666
- history_arg_help = (
4667
- "empty all history items\n"
4668
- "a one history item by number\n"
4669
- "a..b, a:b, a:, ..b items by indices (inclusive)\n"
4670
- "string items containing string\n"
4671
- "/regex/ items matching regular expression"
4672
- )
4673
- history_parser.add_argument('arg', nargs=argparse.OPTIONAL, help=history_arg_help)
5081
+ return history_parser
4674
5082
 
4675
- @with_argparser(history_parser)
4676
- def do_history(self, args: argparse.Namespace) -> Optional[bool]:
5083
+ @with_argparser(_build_history_parser)
5084
+ def do_history(self, args: argparse.Namespace) -> bool | None:
4677
5085
  """View, run, edit, save, or clear previously entered commands.
4678
5086
 
4679
5087
  :return: True if running of commands should stop
@@ -4684,13 +5092,11 @@ class Cmd(cmd.Cmd):
4684
5092
  if args.verbose: # noqa: SIM102
4685
5093
  if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script:
4686
5094
  self.poutput("-v cannot be used with any other options")
4687
- self.poutput(self.history_parser.format_usage())
4688
5095
  return None
4689
5096
 
4690
5097
  # -s and -x can only be used if none of these options are present: [-c -r -e -o -t]
4691
5098
  if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript):
4692
5099
  self.poutput("-s and -x cannot be used with -c, -r, -e, -o, or -t")
4693
- self.poutput(self.history_parser.format_usage())
4694
5100
  return None
4695
5101
 
4696
5102
  if args.clear:
@@ -4737,7 +5143,7 @@ class Cmd(cmd.Cmd):
4737
5143
  self.run_editor(fname)
4738
5144
 
4739
5145
  # self.last_result will be set by do_run_script()
4740
- return self.do_run_script(utils.quote_string(fname))
5146
+ return self.do_run_script(su.quote(fname))
4741
5147
  finally:
4742
5148
  os.remove(fname)
4743
5149
  elif args.output_file:
@@ -4904,7 +5310,7 @@ class Cmd(cmd.Cmd):
4904
5310
 
4905
5311
  def _generate_transcript(
4906
5312
  self,
4907
- history: Union[list[HistoryItem], list[str]],
5313
+ history: list[HistoryItem] | list[str],
4908
5314
  transcript_file: str,
4909
5315
  *,
4910
5316
  add_to_history: bool = True,
@@ -4997,70 +5403,87 @@ class Cmd(cmd.Cmd):
4997
5403
  self.pfeedback(f"{commands_run} {plural} saved to transcript file '{transcript_path}'")
4998
5404
  self.last_result = True
4999
5405
 
5000
- edit_description = (
5001
- "Run a text editor and optionally open a file with it\n"
5002
- "\n"
5003
- "The editor used is determined by a settable parameter. To set it:\n"
5004
- "\n"
5005
- " set editor (program-name)"
5006
- )
5406
+ @classmethod
5407
+ def _build_edit_parser(cls) -> Cmd2ArgumentParser:
5408
+ edit_description = "Run a text editor and optionally open a file with it."
5409
+ edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description)
5410
+ edit_parser.epilog = edit_parser.create_text_group(
5411
+ "Note",
5412
+ Text.assemble(
5413
+ "To set a new editor, run: ",
5414
+ ("set editor <program>", Cmd2Style.COMMAND_LINE),
5415
+ ),
5416
+ )
5007
5417
 
5008
- edit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=edit_description)
5009
- edit_parser.add_argument(
5010
- 'file_path', nargs=argparse.OPTIONAL, help="optional path to a file to open in editor", completer=path_complete
5011
- )
5418
+ edit_parser.add_argument(
5419
+ 'file_path',
5420
+ nargs=argparse.OPTIONAL,
5421
+ help="optional path to a file to open in editor",
5422
+ completer=cls.path_complete,
5423
+ )
5424
+ return edit_parser
5012
5425
 
5013
- @with_argparser(edit_parser)
5426
+ @with_argparser(_build_edit_parser)
5014
5427
  def do_edit(self, args: argparse.Namespace) -> None:
5015
5428
  """Run a text editor and optionally open a file with it."""
5016
5429
  # self.last_result will be set by do_shell() which is called by run_editor()
5017
5430
  self.run_editor(args.file_path)
5018
5431
 
5019
- def run_editor(self, file_path: Optional[str] = None) -> None:
5432
+ def run_editor(self, file_path: str | None = None) -> None:
5020
5433
  """Run a text editor and optionally open a file with it.
5021
5434
 
5022
5435
  :param file_path: optional path of the file to edit. Defaults to None.
5023
- :raises EnvironmentError: if self.editor is not set
5436
+ :raises ValueError: if self.editor is not set
5024
5437
  """
5025
5438
  if not self.editor:
5026
- raise OSError("Please use 'set editor' to specify your text editing program of choice.")
5439
+ raise ValueError("Please use 'set editor' to specify your text editing program of choice.")
5027
5440
 
5028
- command = utils.quote_string(os.path.expanduser(self.editor))
5441
+ command = su.quote(os.path.expanduser(self.editor))
5029
5442
  if file_path:
5030
- command += " " + utils.quote_string(os.path.expanduser(file_path))
5443
+ command += " " + su.quote(os.path.expanduser(file_path))
5031
5444
 
5032
5445
  self.do_shell(command)
5033
5446
 
5034
5447
  @property
5035
- def _current_script_dir(self) -> Optional[str]:
5448
+ def _current_script_dir(self) -> str | None:
5036
5449
  """Accessor to get the current script directory from the _script_dir LIFO queue."""
5037
5450
  if self._script_dir:
5038
5451
  return self._script_dir[-1]
5039
5452
  return None
5040
5453
 
5041
- run_script_description = (
5042
- "Run commands in script file that is encoded as either ASCII or UTF-8 text\n"
5043
- "\n"
5044
- "Script should contain one command per line, just like the command would be\n"
5045
- "typed in the console.\n"
5046
- "\n"
5047
- "If the -t/--transcript flag is used, this command instead records\n"
5048
- "the output of the script commands to a transcript for testing purposes.\n"
5049
- )
5454
+ @classmethod
5455
+ def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser:
5456
+ run_script_description = Text.assemble(
5457
+ "Run text script.",
5458
+ "\n\n",
5459
+ "Scripts should contain one command per line, entered as you would in the console.",
5460
+ )
5050
5461
 
5051
- run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description)
5052
- run_script_parser.add_argument(
5053
- '-t',
5054
- '--transcript',
5055
- metavar='TRANSCRIPT_FILE',
5056
- help='record the output of the script as a transcript file',
5057
- completer=path_complete,
5058
- )
5059
- run_script_parser.add_argument('script_path', help="path to the script file", completer=path_complete)
5462
+ run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=run_script_description)
5463
+ run_script_parser.add_argument(
5464
+ 'script_path',
5465
+ help="path to the script file",
5466
+ completer=cls.path_complete,
5467
+ )
5060
5468
 
5061
- @with_argparser(run_script_parser)
5062
- def do_run_script(self, args: argparse.Namespace) -> Optional[bool]:
5063
- """Run commands in script file that is encoded as either ASCII or UTF-8 text.
5469
+ return run_script_parser
5470
+
5471
+ @classmethod
5472
+ def _build_run_script_parser(cls) -> Cmd2ArgumentParser:
5473
+ run_script_parser = cls._build_base_run_script_parser()
5474
+ run_script_parser.add_argument(
5475
+ '-t',
5476
+ '--transcript',
5477
+ metavar='TRANSCRIPT_FILE',
5478
+ help='record the output of the script as a transcript file',
5479
+ completer=cls.path_complete,
5480
+ )
5481
+
5482
+ return run_script_parser
5483
+
5484
+ @with_argparser(_build_run_script_parser)
5485
+ def do_run_script(self, args: argparse.Namespace) -> bool | None:
5486
+ """Run text script.
5064
5487
 
5065
5488
  :return: True if running of commands should stop
5066
5489
  """
@@ -5121,32 +5544,41 @@ class Cmd(cmd.Cmd):
5121
5544
  self._script_dir.pop()
5122
5545
  return None
5123
5546
 
5124
- relative_run_script_description = run_script_description
5125
- relative_run_script_description += (
5126
- "\n\n"
5127
- "If this is called from within an already-running script, the filename will be\n"
5128
- "interpreted relative to the already-running script's directory."
5129
- )
5547
+ @classmethod
5548
+ def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser:
5549
+ relative_run_script_parser = cls._build_base_run_script_parser()
5550
+
5551
+ # Append to existing description
5552
+ relative_run_script_parser.description = Group(
5553
+ cast(Group, relative_run_script_parser.description),
5554
+ "\n",
5555
+ (
5556
+ "If this is called from within an already-running script, the filename will be "
5557
+ "interpreted relative to the already-running script's directory."
5558
+ ),
5559
+ )
5130
5560
 
5131
- relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts."
5561
+ relative_run_script_parser.epilog = relative_run_script_parser.create_text_group(
5562
+ "Note",
5563
+ "This command is intended to be used from within a text script.",
5564
+ )
5132
5565
 
5133
- relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
5134
- description=relative_run_script_description, epilog=relative_run_script_epilog
5135
- )
5136
- relative_run_script_parser.add_argument('file_path', help='a file path pointing to a script')
5566
+ return relative_run_script_parser
5137
5567
 
5138
- @with_argparser(relative_run_script_parser)
5139
- def do__relative_run_script(self, args: argparse.Namespace) -> Optional[bool]:
5140
- """Run commands in script file that is encoded as either ASCII or UTF-8 text.
5568
+ @with_argparser(_build_relative_run_script_parser)
5569
+ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None:
5570
+ """Run text script.
5571
+
5572
+ This command is intended to be used from within a text script.
5141
5573
 
5142
5574
  :return: True if running of commands should stop
5143
5575
  """
5144
- file_path = args.file_path
5576
+ script_path = args.script_path
5145
5577
  # NOTE: Relative path is an absolute path, it is just relative to the current script directory
5146
- relative_path = os.path.join(self._current_script_dir or '', file_path)
5578
+ relative_path = os.path.join(self._current_script_dir or '', script_path)
5147
5579
 
5148
5580
  # self.last_result will be set by do_run_script()
5149
- return self.do_run_script(utils.quote_string(relative_path))
5581
+ return self.do_run_script(su.quote(relative_path))
5150
5582
 
5151
5583
  def _run_transcript_tests(self, transcript_paths: list[str]) -> None:
5152
5584
  """Run transcript tests for provided file(s).
@@ -5178,11 +5610,14 @@ class Cmd(cmd.Cmd):
5178
5610
  verinfo = ".".join(map(str, sys.version_info[:3]))
5179
5611
  num_transcripts = len(transcripts_expanded)
5180
5612
  plural = '' if len(transcripts_expanded) == 1 else 's'
5181
- self.poutput(ansi.style(utils.align_center(' cmd2 transcript test ', fill_char='='), bold=True))
5613
+ self.poutput(
5614
+ Rule("cmd2 transcript test", characters=self.ruler, style=Style.null()),
5615
+ style=Style(bold=True),
5616
+ )
5182
5617
  self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}, readline-{rl_type}')
5183
5618
  self.poutput(f'cwd: {os.getcwd()}')
5184
5619
  self.poutput(f'cmd2 app: {sys.argv[0]}')
5185
- self.poutput(ansi.style(f'collected {num_transcripts} transcript{plural}', bold=True))
5620
+ self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True))
5186
5621
 
5187
5622
  self.__class__.testfiles = transcripts_expanded
5188
5623
  sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main()
@@ -5193,10 +5628,9 @@ class Cmd(cmd.Cmd):
5193
5628
  test_results = runner.run(testcase)
5194
5629
  execution_time = time.time() - start_time
5195
5630
  if test_results.wasSuccessful():
5196
- ansi.style_aware_write(sys.stderr, stream.read())
5197
- finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds '
5198
- finish_msg = utils.align_center(finish_msg, fill_char='=')
5199
- self.psuccess(finish_msg)
5631
+ self.perror(stream.read(), end="", style=None)
5632
+ finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds'
5633
+ self.psuccess(Rule(finish_msg, characters=self.ruler, style=Style.null()))
5200
5634
  else:
5201
5635
  # Strip off the initial traceback which isn't particularly useful for end users
5202
5636
  error_str = stream.read()
@@ -5210,7 +5644,7 @@ class Cmd(cmd.Cmd):
5210
5644
  # Return a failure error code to support automated transcript-based testing
5211
5645
  self.exit_code = 1
5212
5646
 
5213
- def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
5647
+ def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: # pragma: no cover
5214
5648
  """Display an important message to the user while they are at a command line prompt.
5215
5649
 
5216
5650
  To the user it appears as if an alert message is printed above the prompt and their
@@ -5255,24 +5689,19 @@ class Cmd(cmd.Cmd):
5255
5689
  rl_set_prompt(self.prompt)
5256
5690
 
5257
5691
  if update_terminal:
5258
- import shutil
5259
-
5260
- # Prior to Python 3.11 this can return 0, so use a fallback if needed.
5261
- terminal_columns = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH
5692
+ from .terminal_utils import async_alert_str
5262
5693
 
5263
5694
  # Print a string which replaces the onscreen prompt and input lines with the alert.
5264
- terminal_str = ansi.async_alert_str(
5265
- terminal_columns=terminal_columns,
5695
+ terminal_str = async_alert_str(
5696
+ terminal_columns=ru.console_width(),
5266
5697
  prompt=rl_get_display_prompt(),
5267
5698
  line=readline.get_line_buffer(),
5268
5699
  cursor_offset=rl_get_point(),
5269
5700
  alert_msg=alert_msg,
5270
5701
  )
5271
- if rl_type == RlType.GNU:
5272
- sys.stderr.write(terminal_str)
5273
- sys.stderr.flush()
5274
- elif rl_type == RlType.PYREADLINE:
5275
- readline.rl.mode.console.write(terminal_str)
5702
+
5703
+ sys.stdout.write(terminal_str)
5704
+ sys.stdout.flush()
5276
5705
 
5277
5706
  # Redraw the prompt and input lines below the alert
5278
5707
  rl_force_redisplay()
@@ -5330,17 +5759,16 @@ class Cmd(cmd.Cmd):
5330
5759
  def set_window_title(title: str) -> None: # pragma: no cover
5331
5760
  """Set the terminal window title.
5332
5761
 
5333
- NOTE: This function writes to stderr. Therefore, if you call this during a command run by a pyscript,
5334
- the string which updates the title will appear in that command's CommandResult.stderr data.
5335
-
5336
5762
  :param title: the new window title
5337
5763
  """
5338
5764
  if not vt100_support:
5339
5765
  return
5340
5766
 
5767
+ from .terminal_utils import set_title_str
5768
+
5341
5769
  try:
5342
- sys.stderr.write(ansi.set_title(title))
5343
- sys.stderr.flush()
5770
+ sys.stdout.write(set_title_str(title))
5771
+ sys.stdout.flush()
5344
5772
  except AttributeError:
5345
5773
  # Debugging in Pycharm has issues with setting terminal title
5346
5774
  pass
@@ -5450,10 +5878,9 @@ class Cmd(cmd.Cmd):
5450
5878
  :param message_to_print: the message reporting that the command is disabled
5451
5879
  :param _kwargs: not used
5452
5880
  """
5453
- # Set apply_style to False so message_to_print's style is not overridden
5454
- self.perror(message_to_print, apply_style=False)
5881
+ self.perror(message_to_print, style=None)
5455
5882
 
5456
- def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override]
5883
+ def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override]
5457
5884
  """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop().
5458
5885
 
5459
5886
  _cmdloop() provides the main loop equivalent to cmd.cmdloop(). This is a wrapper around that which deals with
@@ -5597,7 +6024,7 @@ class Cmd(cmd.Cmd):
5597
6024
  type_hints, ret_ann = get_types(func)
5598
6025
  if not type_hints:
5599
6026
  raise TypeError(f"{func.__name__} parameter is missing a type hint, expected: {data_type}")
5600
- param_name, par_ann = next(iter(type_hints.items()))
6027
+ _param_name, par_ann = next(iter(type_hints.items()))
5601
6028
  # validate the parameter has the right annotation
5602
6029
  if par_ann != data_type:
5603
6030
  raise TypeError(f'argument 1 of {func.__name__} has incompatible type {par_ann}, expected {data_type}')
@@ -5645,7 +6072,7 @@ class Cmd(cmd.Cmd):
5645
6072
  self,
5646
6073
  cmd_support_func: Callable[..., Any],
5647
6074
  cmd_self: Union[CommandSet, 'Cmd', None],
5648
- ) -> Optional[object]:
6075
+ ) -> object | None:
5649
6076
  """Attempt to resolve a candidate instance to pass as 'self'.
5650
6077
 
5651
6078
  Used for an unbound class method that was used when defining command's argparse object.
@@ -5657,7 +6084,7 @@ class Cmd(cmd.Cmd):
5657
6084
  :param cmd_self: The `self` associated with the command or subcommand
5658
6085
  """
5659
6086
  # figure out what class the command support function was defined in
5660
- func_class: Optional[type[Any]] = get_defining_class(cmd_support_func)
6087
+ func_class: type[Any] | None = get_defining_class(cmd_support_func)
5661
6088
 
5662
6089
  # Was there a defining class identified? If so, is it a sub-class of CommandSet?
5663
6090
  if func_class is not None and issubclass(func_class, CommandSet):
@@ -5668,7 +6095,7 @@ class Cmd(cmd.Cmd):
5668
6095
  # 2. Do any of the registered CommandSets in the Cmd2 application exactly match the type?
5669
6096
  # 3. Is there a registered CommandSet that is is the only matching subclass?
5670
6097
 
5671
- func_self: Optional[Union[CommandSet, Cmd]]
6098
+ func_self: CommandSet | Cmd | None
5672
6099
 
5673
6100
  # check if the command's CommandSet is a sub-class of the support function's defining class
5674
6101
  if isinstance(cmd_self, func_class):