cmd2 2.4.2__py3-none-any.whl → 2.5.0__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
@@ -21,6 +21,7 @@ is used in place of `print`.
21
21
 
22
22
  Git repository on GitHub at https://github.com/python-cmd2/cmd2
23
23
  """
24
+
24
25
  # This module has many imports, quite a few of which are only
25
26
  # infrequently utilized. To reduce the initial overhead of
26
27
  # import this module, many of these imports are lazy-loaded
@@ -30,6 +31,7 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2
30
31
  # setting is True
31
32
  import argparse
32
33
  import cmd
34
+ import copy
33
35
  import functools
34
36
  import glob
35
37
  import inspect
@@ -37,6 +39,7 @@ import os
37
39
  import pydoc
38
40
  import re
39
41
  import sys
42
+ import tempfile
40
43
  import threading
41
44
  from code import (
42
45
  InteractiveConsole,
@@ -53,6 +56,8 @@ from types import (
53
56
  ModuleType,
54
57
  )
55
58
  from typing import (
59
+ IO,
60
+ TYPE_CHECKING,
56
61
  Any,
57
62
  Callable,
58
63
  Dict,
@@ -83,7 +88,6 @@ from .argparse_custom import (
83
88
  CompletionItem,
84
89
  )
85
90
  from .clipboard import (
86
- can_clip,
87
91
  get_paste_buffer,
88
92
  write_to_paste_buffer,
89
93
  )
@@ -98,6 +102,7 @@ from .constants import (
98
102
  HELP_FUNC_PREFIX,
99
103
  )
100
104
  from .decorators import (
105
+ CommandParent,
101
106
  as_subcommand_to,
102
107
  with_argparser,
103
108
  )
@@ -114,6 +119,7 @@ from .exceptions import (
114
119
  from .history import (
115
120
  History,
116
121
  HistoryItem,
122
+ single_line_format,
117
123
  )
118
124
  from .parsing import (
119
125
  Macro,
@@ -125,8 +131,10 @@ from .parsing import (
125
131
  from .rl_utils import (
126
132
  RlType,
127
133
  rl_escape_prompt,
134
+ rl_get_display_prompt,
128
135
  rl_get_point,
129
136
  rl_get_prompt,
137
+ rl_in_search_mode,
130
138
  rl_set_prompt,
131
139
  rl_type,
132
140
  rl_warning,
@@ -140,6 +148,7 @@ from .utils import (
140
148
  Settable,
141
149
  get_defining_class,
142
150
  strip_doc_annotations,
151
+ suggest_similar,
143
152
  )
144
153
 
145
154
  # Set up readline
@@ -155,13 +164,10 @@ else:
155
164
  orig_rl_delims = readline.get_completer_delims()
156
165
 
157
166
  if rl_type == RlType.PYREADLINE:
158
-
159
167
  # Save the original pyreadline3 display completion function since we need to override it and restore it
160
- # noinspection PyProtectedMember,PyUnresolvedReferences
161
168
  orig_pyreadline_display = readline.rl.mode._display_completions
162
169
 
163
170
  elif rl_type == RlType.GNU:
164
-
165
171
  # Get the readline lib so we can make changes to it
166
172
  import ctypes
167
173
 
@@ -197,6 +203,14 @@ class _SavedCmd2Env:
197
203
  DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function'])
198
204
 
199
205
 
206
+ if TYPE_CHECKING: # pragma: no cover
207
+ StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
208
+ ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
209
+ else:
210
+ StaticArgParseBuilder = staticmethod
211
+ ClassArgParseBuilder = classmethod
212
+
213
+
200
214
  class Cmd(cmd.Cmd):
201
215
  """An easy but powerful framework for writing line-oriented command interpreters.
202
216
 
@@ -236,6 +250,8 @@ class Cmd(cmd.Cmd):
236
250
  shortcuts: Optional[Dict[str, str]] = None,
237
251
  command_sets: Optional[Iterable[CommandSet]] = None,
238
252
  auto_load_commands: bool = True,
253
+ allow_clipboard: bool = True,
254
+ suggest_similar_command: bool = False,
239
255
  ) -> None:
240
256
  """An easy but powerful framework for writing line-oriented command
241
257
  interpreters. Extends Python's cmd package.
@@ -283,6 +299,10 @@ class Cmd(cmd.Cmd):
283
299
  that are currently loaded by Python and automatically
284
300
  instantiate and register all commands. If False, CommandSets
285
301
  must be manually installed with `register_command_set`.
302
+ :param allow_clipboard: If False, cmd2 will disable clipboard interactions
303
+ :param suggest_similar_command: If ``True``, ``cmd2`` will attempt to suggest the most
304
+ similar command when the user types a command that does
305
+ not exist. Default: ``False``.
286
306
  """
287
307
  # Check if py or ipy need to be disabled in this instance
288
308
  if not include_py:
@@ -308,6 +328,7 @@ class Cmd(cmd.Cmd):
308
328
  self.editor = Cmd.DEFAULT_EDITOR
309
329
  self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing)
310
330
  self.quiet = False # Do not suppress nonessential output
331
+ self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
311
332
  self.timing = False # Prints elapsed time for each command
312
333
 
313
334
  # The maximum number of CompletionItems to display during tab completion. If the number of completion
@@ -335,6 +356,7 @@ class Cmd(cmd.Cmd):
335
356
  self.hidden_commands = ['eof', '_relative_run_script']
336
357
 
337
358
  # Initialize history
359
+ self.persistent_history_file = ''
338
360
  self._persistent_history_length = persistent_history_length
339
361
  self._initialize_history(persistent_history_file)
340
362
 
@@ -389,7 +411,7 @@ class Cmd(cmd.Cmd):
389
411
  self.help_error = "No help on {}"
390
412
 
391
413
  # The error that prints when a non-existent command is run
392
- self.default_error = "{} is not a recognized command, alias, or macro"
414
+ self.default_error = "{} is not a recognized command, alias, or macro."
393
415
 
394
416
  # If non-empty, this string will be displayed if a broken pipe error occurs
395
417
  self.broken_pipe_warning = ''
@@ -436,8 +458,8 @@ class Cmd(cmd.Cmd):
436
458
  self.pager = 'less -RXF'
437
459
  self.pager_chop = 'less -SRXF'
438
460
 
439
- # This boolean flag determines whether or not the cmd2 application can interact with the clipboard
440
- self._can_clip = can_clip
461
+ # This boolean flag stores whether cmd2 will allow clipboard related features
462
+ self.allow_clipboard = allow_clipboard
441
463
 
442
464
  # This determines the value returned by cmdloop() when exiting the application
443
465
  self.exit_code = 0
@@ -507,6 +529,16 @@ class Cmd(cmd.Cmd):
507
529
  # This does not affect self.formatted_completions.
508
530
  self.matches_sorted = False
509
531
 
532
+ # Command parsers for this Cmd instance.
533
+ self._command_parsers: Dict[str, argparse.ArgumentParser] = {}
534
+
535
+ # Locates the command parser template or factory and creates an instance-specific parser
536
+ for command in self.get_all_commands():
537
+ self._register_command_parser(command, self.cmd_func(command)) # type: ignore[arg-type]
538
+
539
+ # Add functions decorated to be subcommands
540
+ self._register_subcommands(self)
541
+
510
542
  ############################################################################################################
511
543
  # The following code block loads CommandSets, verifies command names, and registers subcommands.
512
544
  # This block should appear after all attributes have been created since the registration code
@@ -526,8 +558,11 @@ class Cmd(cmd.Cmd):
526
558
  if not valid:
527
559
  raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")
528
560
 
529
- # Add functions decorated to be subcommands
530
- self._register_subcommands(self)
561
+ self.suggest_similar_command = suggest_similar_command
562
+ self.default_suggestion_message = "Did you mean {}?"
563
+
564
+ # the current command being executed
565
+ self.current_command: Optional[Statement] = None
531
566
 
532
567
  def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
533
568
  """
@@ -541,7 +576,7 @@ class Cmd(cmd.Cmd):
541
576
  return [
542
577
  cmdset
543
578
  for cmdset in self._installed_command_sets
544
- if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type))
579
+ if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721
545
580
  ]
546
581
 
547
582
  def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]:
@@ -601,11 +636,14 @@ class Cmd(cmd.Cmd):
601
636
  raise CommandSetRegistrationError(f'Duplicate settable {key} is already registered')
602
637
 
603
638
  cmdset.on_register(self)
604
- methods = inspect.getmembers(
605
- cmdset,
606
- predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
607
- and hasattr(meth, '__name__')
608
- and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
639
+ methods = cast(
640
+ List[Tuple[str, Callable[..., Any]]],
641
+ inspect.getmembers(
642
+ cmdset,
643
+ predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
644
+ and hasattr(meth, '__name__')
645
+ and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
646
+ ),
609
647
  )
610
648
 
611
649
  default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
@@ -650,6 +688,50 @@ class Cmd(cmd.Cmd):
650
688
  cmdset.on_unregistered()
651
689
  raise
652
690
 
691
+ def _build_parser(
692
+ self,
693
+ parent: CommandParent,
694
+ parser_builder: Optional[
695
+ Union[
696
+ argparse.ArgumentParser,
697
+ Callable[[], argparse.ArgumentParser],
698
+ StaticArgParseBuilder,
699
+ ClassArgParseBuilder,
700
+ ]
701
+ ],
702
+ ) -> Optional[argparse.ArgumentParser]:
703
+ parser: Optional[argparse.ArgumentParser] = None
704
+ if isinstance(parser_builder, staticmethod):
705
+ parser = parser_builder.__func__()
706
+ elif isinstance(parser_builder, classmethod):
707
+ parser = parser_builder.__func__(parent if not None else self) # type: ignore[arg-type]
708
+ elif callable(parser_builder):
709
+ parser = parser_builder()
710
+ elif isinstance(parser_builder, argparse.ArgumentParser):
711
+ parser = copy.deepcopy(parser_builder)
712
+ return parser
713
+
714
+ def _register_command_parser(self, command: str, command_method: Callable[..., Any]) -> None:
715
+ if command not in self._command_parsers:
716
+ parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
717
+ parent = self.find_commandset_for_command(command) or self
718
+ parser = self._build_parser(parent, parser_builder)
719
+ if parser is None:
720
+ return
721
+
722
+ # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
723
+ from .decorators import (
724
+ _set_parser_prog,
725
+ )
726
+
727
+ _set_parser_prog(parser, command)
728
+
729
+ # If the description has not been set, then use the method docstring if one exists
730
+ if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
731
+ parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
732
+
733
+ self._command_parsers[command] = parser
734
+
653
735
  def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
654
736
  cmd_func_name = COMMAND_FUNC_PREFIX + command
655
737
 
@@ -672,6 +754,8 @@ class Cmd(cmd.Cmd):
672
754
  self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
673
755
  del self.macros[command]
674
756
 
757
+ self._register_command_parser(command, command_wrapper)
758
+
675
759
  setattr(self, cmd_func_name, command_wrapper)
676
760
 
677
761
  def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -699,7 +783,7 @@ class Cmd(cmd.Cmd):
699
783
  cmdset.on_unregister()
700
784
  self._unregister_subcommands(cmdset)
701
785
 
702
- methods = inspect.getmembers(
786
+ methods: List[Tuple[str, Callable[[Any], Any]]] = inspect.getmembers(
703
787
  cmdset,
704
788
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
705
789
  and hasattr(meth, '__name__')
@@ -718,6 +802,8 @@ class Cmd(cmd.Cmd):
718
802
  del self._cmd_to_command_sets[cmd_name]
719
803
 
720
804
  delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
805
+ if cmd_name in self._command_parsers:
806
+ del self._command_parsers[cmd_name]
721
807
 
722
808
  if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
723
809
  delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -728,7 +814,7 @@ class Cmd(cmd.Cmd):
728
814
  self._installed_command_sets.remove(cmdset)
729
815
 
730
816
  def _check_uninstallable(self, cmdset: CommandSet) -> None:
731
- methods = inspect.getmembers(
817
+ methods: List[Tuple[str, Callable[[Any], Any]]] = inspect.getmembers(
732
818
  cmdset,
733
819
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
734
820
  and hasattr(meth, '__name__')
@@ -737,14 +823,7 @@ class Cmd(cmd.Cmd):
737
823
 
738
824
  for method in methods:
739
825
  command_name = method[0][len(COMMAND_FUNC_PREFIX) :]
740
-
741
- # Search for the base command function and verify it has an argparser defined
742
- if command_name in self.disabled_commands:
743
- command_func = self.disabled_commands[command_name].command_function
744
- else:
745
- command_func = self.cmd_func(command_name)
746
-
747
- command_parser = cast(argparse.ArgumentParser, getattr(command_func, constants.CMD_ATTR_ARGPARSER, None))
826
+ command_parser = self._command_parsers.get(command_name, None)
748
827
 
749
828
  def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
750
829
  for action in parser._actions:
@@ -783,7 +862,7 @@ class Cmd(cmd.Cmd):
783
862
  for method_name, method in methods:
784
863
  subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
785
864
  full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
786
- subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
865
+ subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
787
866
 
788
867
  subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
789
868
  if not subcommand_valid:
@@ -803,7 +882,7 @@ class Cmd(cmd.Cmd):
803
882
  raise CommandSetRegistrationError(
804
883
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
805
884
  )
806
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
885
+ command_parser = self._command_parsers.get(command_name, None)
807
886
  if command_parser is None:
808
887
  raise CommandSetRegistrationError(
809
888
  f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -823,16 +902,17 @@ class Cmd(cmd.Cmd):
823
902
 
824
903
  target_parser = find_subcommand(command_parser, subcommand_names)
825
904
 
905
+ subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
906
+ from .decorators import (
907
+ _set_parser_prog,
908
+ )
909
+
910
+ _set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
911
+ if subcmd_parser.description is None and method.__doc__:
912
+ subcmd_parser.description = strip_doc_annotations(method.__doc__)
913
+
826
914
  for action in target_parser._actions:
827
915
  if isinstance(action, argparse._SubParsersAction):
828
- # Temporary workaround for avoiding subcommand help text repeatedly getting added to
829
- # action._choices_actions. Until we have instance-specific parser objects, we will remove
830
- # any existing subcommand which has the same name before replacing it. This problem is
831
- # exercised when more than one cmd2.Cmd-based object is created and the same subcommands
832
- # get added each time. Argparse overwrites the previous subcommand but keeps growing the help
833
- # text which is shown by running something like 'alias -h'.
834
- action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined]
835
-
836
916
  # Get the kwargs for add_parser()
837
917
  add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
838
918
 
@@ -904,7 +984,7 @@ class Cmd(cmd.Cmd):
904
984
  raise CommandSetRegistrationError(
905
985
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
906
986
  )
907
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
987
+ command_parser = self._command_parsers.get(command_name, None)
908
988
  if command_parser is None: # pragma: no cover
909
989
  # This really shouldn't be possible since _register_subcommands would prevent this from happening
910
990
  # but keeping in case it does for some strange reason
@@ -1012,12 +1092,7 @@ class Cmd(cmd.Cmd):
1012
1092
  )
1013
1093
 
1014
1094
  self.add_settable(
1015
- Settable(
1016
- 'always_show_hint',
1017
- bool,
1018
- 'Display tab completion hint even when completion suggestions print',
1019
- self,
1020
- )
1095
+ Settable('always_show_hint', bool, 'Display tab completion hint even when completion suggestions print', self)
1021
1096
  )
1022
1097
  self.add_settable(Settable('debug', bool, "Show full traceback on exception", self))
1023
1098
  self.add_settable(Settable('echo', bool, "Echo command issued into output", self))
@@ -1027,6 +1102,7 @@ class Cmd(cmd.Cmd):
1027
1102
  Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self)
1028
1103
  )
1029
1104
  self.add_settable(Settable('quiet', bool, "Don't print nonessential feedback", self))
1105
+ self.add_settable(Settable('scripts_add_to_history', bool, 'Scripts and pyscripts add commands to history', self))
1030
1106
  self.add_settable(Settable('timing', bool, "Report execution times", self))
1031
1107
 
1032
1108
  # ----- Methods related to presenting output to the user -----
@@ -1056,7 +1132,40 @@ class Cmd(cmd.Cmd):
1056
1132
  """
1057
1133
  return ansi.strip_style(self.prompt)
1058
1134
 
1059
- def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
1135
+ def print_to(
1136
+ self,
1137
+ dest: Union[TextIO, IO[str]],
1138
+ msg: Any,
1139
+ *,
1140
+ end: str = '\n',
1141
+ style: Optional[Callable[[str], str]] = None,
1142
+ paged: bool = False,
1143
+ chop: bool = False,
1144
+ ) -> None:
1145
+ final_msg = style(msg) if style is not None else msg
1146
+ if paged:
1147
+ self.ppaged(final_msg, end=end, chop=chop, dest=dest)
1148
+ else:
1149
+ try:
1150
+ ansi.style_aware_write(dest, f'{final_msg}{end}')
1151
+ except BrokenPipeError:
1152
+ # This occurs if a command's output is being piped to another
1153
+ # process and that process closes before the command is
1154
+ # finished. If you would like your application to print a
1155
+ # warning message, then set the broken_pipe_warning attribute
1156
+ # to the message you want printed.
1157
+ if self.broken_pipe_warning:
1158
+ sys.stderr.write(self.broken_pipe_warning)
1159
+
1160
+ def poutput(
1161
+ self,
1162
+ msg: Any = '',
1163
+ *,
1164
+ end: str = '\n',
1165
+ apply_style: bool = True,
1166
+ paged: bool = False,
1167
+ chop: bool = False,
1168
+ ) -> None:
1060
1169
  """Print message to self.stdout and appends a newline by default
1061
1170
 
1062
1171
  Also handles BrokenPipeError exceptions for when a command's output has
@@ -1065,44 +1174,83 @@ class Cmd(cmd.Cmd):
1065
1174
 
1066
1175
  :param msg: object to print
1067
1176
  :param end: string appended after the end of the message, default a newline
1177
+ :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
1178
+ where the message text already has the desired style. Defaults to True.
1179
+ :param paged: If True, pass the output through the configured pager.
1180
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1068
1181
  """
1069
- try:
1070
- ansi.style_aware_write(self.stdout, f"{msg}{end}")
1071
- except BrokenPipeError:
1072
- # This occurs if a command's output is being piped to another
1073
- # process and that process closes before the command is
1074
- # finished. If you would like your application to print a
1075
- # warning message, then set the broken_pipe_warning attribute
1076
- # to the message you want printed.
1077
- if self.broken_pipe_warning:
1078
- sys.stderr.write(self.broken_pipe_warning)
1182
+ self.print_to(self.stdout, msg, end=end, style=ansi.style_output if apply_style else None, paged=paged, chop=chop)
1079
1183
 
1080
- # noinspection PyMethodMayBeStatic
1081
- def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1184
+ def perror(
1185
+ self,
1186
+ msg: Any = '',
1187
+ *,
1188
+ end: str = '\n',
1189
+ apply_style: bool = True,
1190
+ paged: bool = False,
1191
+ chop: bool = False,
1192
+ ) -> None:
1082
1193
  """Print message to sys.stderr
1083
1194
 
1084
1195
  :param msg: object to print
1085
1196
  :param end: string appended after the end of the message, default a newline
1086
1197
  :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
1087
1198
  where the message text already has the desired style. Defaults to True.
1199
+ :param paged: If True, pass the output through the configured pager.
1200
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1088
1201
  """
1089
- if apply_style:
1090
- final_msg = ansi.style_error(msg)
1091
- else:
1092
- final_msg = str(msg)
1093
- ansi.style_aware_write(sys.stderr, final_msg + end)
1202
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None, paged=paged, chop=chop)
1094
1203
 
1095
- def pwarning(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1204
+ def psuccess(
1205
+ self,
1206
+ msg: Any = '',
1207
+ *,
1208
+ end: str = '\n',
1209
+ paged: bool = False,
1210
+ chop: bool = False,
1211
+ ) -> None:
1212
+ """Writes to stdout applying ansi.style_success by default
1213
+
1214
+ :param msg: object to print
1215
+ :param end: string appended after the end of the message, default a newline
1216
+ :param paged: If True, pass the output through the configured pager.
1217
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1218
+ """
1219
+ self.print_to(self.stdout, msg, end=end, style=ansi.style_success, paged=paged, chop=chop)
1220
+
1221
+ def pwarning(
1222
+ self,
1223
+ msg: Any = '',
1224
+ *,
1225
+ end: str = '\n',
1226
+ paged: bool = False,
1227
+ chop: bool = False,
1228
+ ) -> None:
1096
1229
  """Wraps perror, but applies ansi.style_warning by default
1097
1230
 
1098
1231
  :param msg: object to print
1099
1232
  :param end: string appended after the end of the message, default a newline
1100
- :param apply_style: If True, then ansi.style_warning will be applied to the message text. Set to False in cases
1101
- where the message text already has the desired style. Defaults to True.
1233
+ :param paged: If True, pass the output through the configured pager.
1234
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1102
1235
  """
1103
- if apply_style:
1104
- msg = ansi.style_warning(msg)
1105
- self.perror(msg, end=end, apply_style=False)
1236
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_warning, paged=paged, chop=chop)
1237
+
1238
+ def pfailure(
1239
+ self,
1240
+ msg: Any = '',
1241
+ *,
1242
+ end: str = '\n',
1243
+ paged: bool = False,
1244
+ chop: bool = False,
1245
+ ) -> None:
1246
+ """Writes to stderr applying ansi.style_error by default
1247
+
1248
+ :param msg: object to print
1249
+ :param end: string appended after the end of the message, default a newline
1250
+ :param paged: If True, pass the output through the configured pager.
1251
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1252
+ """
1253
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_error, paged=paged, chop=chop)
1106
1254
 
1107
1255
  def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None:
1108
1256
  """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists.
@@ -1131,23 +1279,39 @@ class Cmd(cmd.Cmd):
1131
1279
 
1132
1280
  self.perror(final_msg, end=end, apply_style=False)
1133
1281
 
1134
- def pfeedback(self, msg: Any, *, end: str = '\n') -> None:
1282
+ def pfeedback(
1283
+ self,
1284
+ msg: Any,
1285
+ *,
1286
+ end: str = '\n',
1287
+ apply_style: bool = True,
1288
+ paged: bool = False,
1289
+ chop: bool = False,
1290
+ ) -> None:
1135
1291
  """For printing nonessential feedback. Can be silenced with `quiet`.
1136
1292
  Inclusion in redirected output is controlled by `feedback_to_output`.
1137
1293
 
1138
1294
  :param msg: object to print
1139
1295
  :param end: string appended after the end of the message, default a newline
1296
+ :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
1297
+ where the message text already has the desired style. Defaults to True.
1298
+ :param paged: If True, pass the output through the configured pager.
1299
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1140
1300
  """
1141
1301
  if not self.quiet:
1142
- if self.feedback_to_output:
1143
- self.poutput(msg, end=end)
1144
- else:
1145
- self.perror(msg, end=end, apply_style=False)
1302
+ self.print_to(
1303
+ self.stdout if self.feedback_to_output else sys.stderr,
1304
+ msg,
1305
+ end=end,
1306
+ style=ansi.style_output if apply_style else None,
1307
+ paged=paged,
1308
+ chop=chop,
1309
+ )
1146
1310
 
1147
- def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None:
1311
+ def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False, dest: Optional[Union[TextIO, IO[str]]] = None) -> None:
1148
1312
  """Print output using a pager if it would go off screen and stdout isn't currently being redirected.
1149
1313
 
1150
- Never uses a pager inside of a script (Python or text) or when output is being redirected or piped or when
1314
+ Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when
1151
1315
  stdout or stdin are not a fully functional terminal.
1152
1316
 
1153
1317
  :param msg: object to print
@@ -1157,11 +1321,13 @@ class Cmd(cmd.Cmd):
1157
1321
  - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
1158
1322
  False -> causes lines longer than the screen width to wrap to the next line
1159
1323
  - wrapping is ideal when you want to keep users from having to use horizontal scrolling
1324
+ :param dest: Optionally specify the destination stream to write to. If unspecified, defaults to self.stdout
1160
1325
 
1161
1326
  WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1162
1327
  """
1163
1328
  # msg can be any type, so convert to string before checking if it's blank
1164
1329
  msg_str = str(msg)
1330
+ dest = self.stdout if dest is None else dest
1165
1331
 
1166
1332
  # Consider None to be no data to print
1167
1333
  if msg is None or msg_str == '':
@@ -1195,7 +1361,7 @@ class Cmd(cmd.Cmd):
1195
1361
  pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE)
1196
1362
  pipe_proc.communicate(msg_str.encode('utf-8', 'replace'))
1197
1363
  else:
1198
- self.poutput(msg_str, end=end)
1364
+ ansi.style_aware_write(dest, f'{msg_str}{end}')
1199
1365
  except BrokenPipeError:
1200
1366
  # This occurs if a command's output is being piped to another process and that process closes before the
1201
1367
  # command is finished. If you would like your application to print a warning message, then set the
@@ -1222,7 +1388,6 @@ class Cmd(cmd.Cmd):
1222
1388
  if rl_type == RlType.GNU:
1223
1389
  readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
1224
1390
  elif rl_type == RlType.PYREADLINE:
1225
- # noinspection PyUnresolvedReferences
1226
1391
  readline.rl.mode._display_completions = self._display_matches_pyreadline
1227
1392
 
1228
1393
  def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]:
@@ -1289,7 +1454,6 @@ class Cmd(cmd.Cmd):
1289
1454
 
1290
1455
  return tokens, raw_tokens
1291
1456
 
1292
- # noinspection PyMethodMayBeStatic, PyUnusedLocal
1293
1457
  def basic_complete(
1294
1458
  self,
1295
1459
  text: str,
@@ -1480,7 +1644,6 @@ class Cmd(cmd.Cmd):
1480
1644
 
1481
1645
  return matches
1482
1646
 
1483
- # noinspection PyUnusedLocal
1484
1647
  def path_complete(
1485
1648
  self, text: str, line: str, begidx: int, endidx: int, *, path_filter: Optional[Callable[[str], bool]] = None
1486
1649
  ) -> List[str]:
@@ -1498,7 +1661,6 @@ class Cmd(cmd.Cmd):
1498
1661
 
1499
1662
  # Used to complete ~ and ~user strings
1500
1663
  def complete_users() -> List[str]:
1501
-
1502
1664
  users = []
1503
1665
 
1504
1666
  # Windows lacks the pwd module so we can't get a list of users.
@@ -1516,10 +1678,8 @@ class Cmd(cmd.Cmd):
1516
1678
 
1517
1679
  # Iterate through a list of users from the password database
1518
1680
  for cur_pw in pwd.getpwall():
1519
-
1520
1681
  # Check if the user has an existing home dir
1521
1682
  if os.path.isdir(cur_pw.pw_dir):
1522
-
1523
1683
  # Add a ~ to the user to match against text
1524
1684
  cur_user = '~' + cur_pw.pw_name
1525
1685
  if cur_user.startswith(text):
@@ -1605,7 +1765,6 @@ class Cmd(cmd.Cmd):
1605
1765
 
1606
1766
  # Build display_matches and add a slash to directories
1607
1767
  for index, cur_match in enumerate(matches):
1608
-
1609
1768
  # Display only the basename of this path in the tab completion suggestions
1610
1769
  self.display_matches.append(os.path.basename(cur_match))
1611
1770
 
@@ -1674,7 +1833,6 @@ class Cmd(cmd.Cmd):
1674
1833
 
1675
1834
  # Must at least have the command
1676
1835
  if len(raw_tokens) > 1:
1677
-
1678
1836
  # True when command line contains any redirection tokens
1679
1837
  has_redirection = False
1680
1838
 
@@ -1766,7 +1924,6 @@ class Cmd(cmd.Cmd):
1766
1924
  :param longest_match_length: longest printed length of the matches
1767
1925
  """
1768
1926
  if rl_type == RlType.GNU:
1769
-
1770
1927
  # Print hint if one exists and we are supposed to display it
1771
1928
  hint_printed = False
1772
1929
  if self.always_show_hint and self.completion_hint:
@@ -1806,7 +1963,6 @@ class Cmd(cmd.Cmd):
1806
1963
 
1807
1964
  # rl_display_match_list() expects matches to be in argv format where
1808
1965
  # substitution is the first element, followed by the matches, and then a NULL.
1809
- # noinspection PyCallingNonCallable,PyTypeChecker
1810
1966
  strings_array = cast(List[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))())
1811
1967
 
1812
1968
  # Copy in the encoded strings and add a NULL to the end
@@ -1826,7 +1982,6 @@ class Cmd(cmd.Cmd):
1826
1982
  :param matches: the tab completion matches to display
1827
1983
  """
1828
1984
  if rl_type == RlType.PYREADLINE:
1829
-
1830
1985
  # Print hint if one exists and we are supposed to display it
1831
1986
  hint_printed = False
1832
1987
  if self.always_show_hint and self.completion_hint:
@@ -1865,9 +2020,8 @@ class Cmd(cmd.Cmd):
1865
2020
  :param parser: the parser to examine
1866
2021
  :return: type of ArgparseCompleter
1867
2022
  """
1868
- completer_type: Optional[
1869
- Type[argparse_completer.ArgparseCompleter]
1870
- ] = parser.get_ap_completer_type() # type: ignore[attr-defined]
2023
+ Completer = Optional[Type[argparse_completer.ArgparseCompleter]]
2024
+ completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined]
1871
2025
 
1872
2026
  if completer_type is None:
1873
2027
  completer_type = argparse_completer.DEFAULT_AP_COMPLETER
@@ -1898,11 +2052,14 @@ class Cmd(cmd.Cmd):
1898
2052
 
1899
2053
  expanded_line = statement.command_and_args
1900
2054
 
1901
- # We overwrote line with a properly formatted but fully stripped version
1902
- # Restore the end spaces since line is only supposed to be lstripped when
1903
- # passed to completer functions according to Python docs
1904
- rstripped_len = len(line) - len(line.rstrip())
1905
- expanded_line += ' ' * rstripped_len
2055
+ if not expanded_line[-1:].isspace():
2056
+ # Unquoted trailing whitespace gets stripped by parse_command_only().
2057
+ # Restore it since line is only supposed to be lstripped when passed
2058
+ # to completer functions according to the Python cmd docs. Regardless
2059
+ # of what type of whitespace (' ', \n) was stripped, just append spaces
2060
+ # since shlex treats whitespace characters the same when splitting.
2061
+ rstripped_len = len(line) - len(line.rstrip())
2062
+ expanded_line += ' ' * rstripped_len
1906
2063
 
1907
2064
  # Fix the index values if expanded_line has a different size than line
1908
2065
  if len(expanded_line) != len(line):
@@ -1934,7 +2091,7 @@ class Cmd(cmd.Cmd):
1934
2091
  else:
1935
2092
  # There's no completer function, next see if the command uses argparse
1936
2093
  func = self.cmd_func(command)
1937
- argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2094
+ argparser = self._command_parsers.get(command, None)
1938
2095
 
1939
2096
  if func is not None and argparser is not None:
1940
2097
  # Get arguments for complete()
@@ -1980,7 +2137,6 @@ class Cmd(cmd.Cmd):
1980
2137
 
1981
2138
  # Check if the token being completed has an opening quote
1982
2139
  if raw_completion_token and raw_completion_token[0] in constants.QUOTES:
1983
-
1984
2140
  # Since the token is still being completed, we know the opening quote is unclosed.
1985
2141
  # Save the quote so we can add a matching closing quote later.
1986
2142
  completion_token_quote = raw_completion_token[0]
@@ -2005,7 +2161,6 @@ class Cmd(cmd.Cmd):
2005
2161
  self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func)
2006
2162
 
2007
2163
  if self.completion_matches:
2008
-
2009
2164
  # Eliminate duplicates
2010
2165
  self.completion_matches = utils.remove_duplicates(self.completion_matches)
2011
2166
  self.display_matches = utils.remove_duplicates(self.display_matches)
@@ -2020,7 +2175,6 @@ class Cmd(cmd.Cmd):
2020
2175
 
2021
2176
  # Check if we need to add an opening quote
2022
2177
  if not completion_token_quote:
2023
-
2024
2178
  add_quote = False
2025
2179
 
2026
2180
  # This is the tab completion text that will appear on the command line.
@@ -2073,7 +2227,6 @@ class Cmd(cmd.Cmd):
2073
2227
  :param custom_settings: used when not tab completing the main command line
2074
2228
  :return: the next possible completion for text or None
2075
2229
  """
2076
- # noinspection PyBroadException
2077
2230
  try:
2078
2231
  if state == 0:
2079
2232
  self._reset_completion_defaults()
@@ -2081,7 +2234,7 @@ class Cmd(cmd.Cmd):
2081
2234
  # Check if we are completing a multiline command
2082
2235
  if self._at_continuation_prompt:
2083
2236
  # lstrip and prepend the previously typed portion of this multiline command
2084
- lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ')
2237
+ lstripped_previous = self._multiline_in_progress.lstrip()
2085
2238
  line = lstripped_previous + readline.get_line_buffer()
2086
2239
 
2087
2240
  # Increment the indexes to account for the prepended text
@@ -2103,7 +2256,7 @@ class Cmd(cmd.Cmd):
2103
2256
  # from text and update the indexes. This only applies if we are at the beginning of the command line.
2104
2257
  shortcut_to_restore = ''
2105
2258
  if begidx == 0 and custom_settings is None:
2106
- for (shortcut, _) in self.statement_parser.shortcuts:
2259
+ for shortcut, _ in self.statement_parser.shortcuts:
2107
2260
  if text.startswith(shortcut):
2108
2261
  # Save the shortcut to restore later
2109
2262
  shortcut_to_restore = shortcut
@@ -2251,14 +2404,13 @@ class Cmd(cmd.Cmd):
2251
2404
  # Filter out hidden and disabled commands
2252
2405
  return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands]
2253
2406
 
2254
- # noinspection PyUnusedLocal
2255
- def sigint_handler(self, signum: int, _: FrameType) -> None:
2407
+ def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None:
2256
2408
  """Signal handler for SIGINTs which typically come from Ctrl-C events.
2257
2409
 
2258
- If you need custom SIGINT behavior, then override this function.
2410
+ If you need custom SIGINT behavior, then override this method.
2259
2411
 
2260
2412
  :param signum: signal number
2261
- :param _: required param for signal handlers
2413
+ :param _: the current stack frame or None
2262
2414
  """
2263
2415
  if self._cur_pipe_proc_reader is not None:
2264
2416
  # Pass the SIGINT to the current pipe process
@@ -2266,7 +2418,30 @@ class Cmd(cmd.Cmd):
2266
2418
 
2267
2419
  # Check if we are allowed to re-raise the KeyboardInterrupt
2268
2420
  if not self.sigint_protection:
2269
- self._raise_keyboard_interrupt()
2421
+ raise_interrupt = True
2422
+ if self.current_command is not None:
2423
+ command_set = self.find_commandset_for_command(self.current_command.command)
2424
+ if command_set is not None:
2425
+ raise_interrupt = not command_set.sigint_handler()
2426
+ if raise_interrupt:
2427
+ self._raise_keyboard_interrupt()
2428
+
2429
+ def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None:
2430
+ """
2431
+ Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac.
2432
+
2433
+ SIGHUP - received when terminal window is closed
2434
+ SIGTERM - received when this app has been requested to terminate
2435
+
2436
+ The basic purpose of this method is to call sys.exit() so our exit handler will run
2437
+ and save the persistent history file. If you need more complex behavior like killing
2438
+ threads and performing cleanup, then override this method.
2439
+
2440
+ :param signum: signal number
2441
+ :param _: the current stack frame or None
2442
+ """
2443
+ # POSIX systems add 128 to signal numbers for the exit code
2444
+ sys.exit(128 + signum)
2270
2445
 
2271
2446
  def _raise_keyboard_interrupt(self) -> None:
2272
2447
  """Helper function to raise a KeyboardInterrupt"""
@@ -2335,7 +2510,13 @@ class Cmd(cmd.Cmd):
2335
2510
  return statement.command, statement.args, statement.command_and_args
2336
2511
 
2337
2512
  def onecmd_plus_hooks(
2338
- self, line: str, *, add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False
2513
+ self,
2514
+ line: str,
2515
+ *,
2516
+ add_to_history: bool = True,
2517
+ raise_keyboard_interrupt: bool = False,
2518
+ py_bridge_call: bool = False,
2519
+ orig_rl_history_length: Optional[int] = None,
2339
2520
  ) -> bool:
2340
2521
  """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
2341
2522
 
@@ -2347,6 +2528,9 @@ class Cmd(cmd.Cmd):
2347
2528
  :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
2348
2529
  of an app() call from Python. It is used to enable/disable the storage of the
2349
2530
  command's stdout.
2531
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2532
+ This is used to assist in combining multiline readline history entries and is only
2533
+ populated by cmd2. Defaults to None.
2350
2534
  :return: True if running of commands should stop
2351
2535
  """
2352
2536
  import datetime
@@ -2356,7 +2540,7 @@ class Cmd(cmd.Cmd):
2356
2540
 
2357
2541
  try:
2358
2542
  # Convert the line into a Statement
2359
- statement = self._input_line_to_statement(line)
2543
+ statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
2360
2544
 
2361
2545
  # call the postparsing hooks
2362
2546
  postparsing_data = plugin.PostparsingData(False, statement)
@@ -2431,7 +2615,8 @@ class Cmd(cmd.Cmd):
2431
2615
  if raise_keyboard_interrupt and not stop:
2432
2616
  raise ex
2433
2617
  except SystemExit as ex:
2434
- self.exit_code = ex.code
2618
+ if isinstance(ex.code, int):
2619
+ self.exit_code = ex.code
2435
2620
  stop = True
2436
2621
  except PassThroughException as ex:
2437
2622
  raise ex.wrapped_ex
@@ -2444,7 +2629,8 @@ class Cmd(cmd.Cmd):
2444
2629
  if raise_keyboard_interrupt and not stop:
2445
2630
  raise ex
2446
2631
  except SystemExit as ex:
2447
- self.exit_code = ex.code
2632
+ if isinstance(ex.code, int):
2633
+ self.exit_code = ex.code
2448
2634
  stop = True
2449
2635
  except PassThroughException as ex:
2450
2636
  raise ex.wrapped_ex
@@ -2508,7 +2694,7 @@ class Cmd(cmd.Cmd):
2508
2694
 
2509
2695
  return False
2510
2696
 
2511
- def _complete_statement(self, line: str) -> Statement:
2697
+ def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2512
2698
  """Keep accepting lines of input until the command is complete.
2513
2699
 
2514
2700
  There is some pretty hacky code here to handle some quirks of
@@ -2517,10 +2703,29 @@ class Cmd(cmd.Cmd):
2517
2703
  backwards compatibility with the standard library version of cmd.
2518
2704
 
2519
2705
  :param line: the line being parsed
2706
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2707
+ This is used to assist in combining multiline readline history entries and is only
2708
+ populated by cmd2. Defaults to None.
2520
2709
  :return: the completed Statement
2521
2710
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2522
2711
  :raises: EmptyStatement when the resulting Statement is blank
2523
2712
  """
2713
+
2714
+ def combine_rl_history(statement: Statement) -> None:
2715
+ """Combine all lines of a multiline command into a single readline history entry"""
2716
+ if orig_rl_history_length is None or not statement.multiline_command:
2717
+ return
2718
+
2719
+ # Remove all previous lines added to history for this command
2720
+ while readline.get_current_history_length() > orig_rl_history_length:
2721
+ readline.remove_history_item(readline.get_current_history_length() - 1)
2722
+
2723
+ formatted_command = single_line_format(statement)
2724
+
2725
+ # If formatted command is different than the previous history item, add it
2726
+ if orig_rl_history_length == 0 or formatted_command != readline.get_history_item(orig_rl_history_length):
2727
+ readline.add_history(formatted_command)
2728
+
2524
2729
  while True:
2525
2730
  try:
2526
2731
  statement = self.statement_parser.parse(line)
@@ -2532,7 +2737,7 @@ class Cmd(cmd.Cmd):
2532
2737
  # so we are done
2533
2738
  break
2534
2739
  except Cmd2ShlexError:
2535
- # we have unclosed quotation marks, lets parse only the command
2740
+ # we have an unclosed quotation mark, let's parse only the command
2536
2741
  # and see if it's a multiline
2537
2742
  statement = self.statement_parser.parse_command_only(line)
2538
2743
  if not statement.multiline_command:
@@ -2548,6 +2753,7 @@ class Cmd(cmd.Cmd):
2548
2753
  # Save the command line up to this point for tab completion
2549
2754
  self._multiline_in_progress = line + '\n'
2550
2755
 
2756
+ # Get next line of this command
2551
2757
  nextline = self._read_command_line(self.continuation_prompt)
2552
2758
  if nextline == 'eof':
2553
2759
  # they entered either a blank line, or we hit an EOF
@@ -2556,7 +2762,14 @@ class Cmd(cmd.Cmd):
2556
2762
  # terminator
2557
2763
  nextline = '\n'
2558
2764
  self.poutput(nextline)
2559
- line = f'{self._multiline_in_progress}{nextline}'
2765
+
2766
+ line += f'\n{nextline}'
2767
+
2768
+ # Combine all history lines of this multiline command as we go.
2769
+ if nextline:
2770
+ statement = self.statement_parser.parse_command_only(line)
2771
+ combine_rl_history(statement)
2772
+
2560
2773
  except KeyboardInterrupt:
2561
2774
  self.poutput('^C')
2562
2775
  statement = self.statement_parser.parse('')
@@ -2566,13 +2779,20 @@ class Cmd(cmd.Cmd):
2566
2779
 
2567
2780
  if not statement.command:
2568
2781
  raise EmptyStatement
2782
+ else:
2783
+ # If necessary, update history with completed multiline command.
2784
+ combine_rl_history(statement)
2785
+
2569
2786
  return statement
2570
2787
 
2571
- def _input_line_to_statement(self, line: str) -> Statement:
2788
+ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2572
2789
  """
2573
2790
  Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
2574
2791
 
2575
2792
  :param line: the line being parsed
2793
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2794
+ This is used to assist in combining multiline readline history entries and is only
2795
+ populated by cmd2. Defaults to None.
2576
2796
  :return: parsed command line as a Statement
2577
2797
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2578
2798
  :raises: EmptyStatement when the resulting Statement is blank
@@ -2583,11 +2803,13 @@ class Cmd(cmd.Cmd):
2583
2803
  # Continue until all macros are resolved
2584
2804
  while True:
2585
2805
  # Make sure all input has been read and convert it to a Statement
2586
- statement = self._complete_statement(line)
2806
+ statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
2587
2807
 
2588
- # Save the fully entered line if this is the first loop iteration
2808
+ # If this is the first loop iteration, save the original line and stop
2809
+ # combining multiline history entries in the remaining iterations.
2589
2810
  if orig_line is None:
2590
2811
  orig_line = statement.raw
2812
+ orig_rl_history_length = None
2591
2813
 
2592
2814
  # Check if this command matches a macro and wasn't already processed to avoid an infinite loop
2593
2815
  if statement.command in self.macros.keys() and statement.command not in used_macros:
@@ -2732,13 +2954,8 @@ class Cmd(cmd.Cmd):
2732
2954
  sys.stdout = self.stdout = new_stdout
2733
2955
 
2734
2956
  elif statement.output:
2735
- import tempfile
2736
-
2737
- if (not statement.output_to) and (not self._can_clip):
2738
- raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies")
2739
-
2740
- # Redirecting to a file
2741
- elif statement.output_to:
2957
+ if statement.output_to:
2958
+ # redirecting to a file
2742
2959
  # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT
2743
2960
  mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w'
2744
2961
  try:
@@ -2750,14 +2967,26 @@ class Cmd(cmd.Cmd):
2750
2967
  redir_saved_state.redirecting = True
2751
2968
  sys.stdout = self.stdout = new_stdout
2752
2969
 
2753
- # Redirecting to a paste buffer
2754
2970
  else:
2971
+ # Redirecting to a paste buffer
2972
+ # we are going to direct output to a temporary file, then read it back in and
2973
+ # put it in the paste buffer later
2974
+ if not self.allow_clipboard:
2975
+ raise RedirectionError("Clipboard access not allowed")
2976
+
2977
+ # attempt to get the paste buffer, this forces pyperclip to go figure
2978
+ # out if it can actually interact with the paste buffer, and will throw exceptions
2979
+ # if it's not gonna work. That way we throw the exception before we go
2980
+ # run the command and queue up all the output. if this is going to fail,
2981
+ # no point opening up the temporary file
2982
+ current_paste_buffer = get_paste_buffer()
2983
+ # create a temporary file to store output
2755
2984
  new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+"))
2756
2985
  redir_saved_state.redirecting = True
2757
2986
  sys.stdout = self.stdout = new_stdout
2758
2987
 
2759
2988
  if statement.output == constants.REDIRECTION_APPEND:
2760
- self.stdout.write(get_paste_buffer())
2989
+ self.stdout.write(current_paste_buffer)
2761
2990
  self.stdout.flush()
2762
2991
 
2763
2992
  # These are updated regardless of whether the command redirected
@@ -2822,7 +3051,6 @@ class Cmd(cmd.Cmd):
2822
3051
  target = constants.COMMAND_FUNC_PREFIX + command
2823
3052
  return target if callable(getattr(self, target, None)) else ''
2824
3053
 
2825
- # noinspection PyMethodOverriding
2826
3054
  def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool:
2827
3055
  """This executes the actual do_* method for a command.
2828
3056
 
@@ -2847,7 +3075,11 @@ class Cmd(cmd.Cmd):
2847
3075
  ):
2848
3076
  self.history.append(statement)
2849
3077
 
2850
- stop = func(statement)
3078
+ try:
3079
+ self.current_command = statement
3080
+ stop = func(statement)
3081
+ finally:
3082
+ self.current_command = None
2851
3083
 
2852
3084
  else:
2853
3085
  stop = self.default(statement)
@@ -2863,15 +3095,19 @@ class Cmd(cmd.Cmd):
2863
3095
  if 'shell' not in self.exclude_from_history:
2864
3096
  self.history.append(statement)
2865
3097
 
2866
- # noinspection PyTypeChecker
2867
3098
  return self.do_shell(statement.command_and_args)
2868
3099
  else:
2869
3100
  err_msg = self.default_error.format(statement.command)
3101
+ if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)):
3102
+ err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}"
2870
3103
 
2871
- # Set apply_style to False so default_error's style is not overridden
3104
+ # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden
2872
3105
  self.perror(err_msg, apply_style=False)
2873
3106
  return None
2874
3107
 
3108
+ def _suggest_similar_command(self, command: str) -> Optional[str]:
3109
+ return suggest_similar(command, self.get_visible_commands())
3110
+
2875
3111
  def read_input(
2876
3112
  self,
2877
3113
  prompt: str,
@@ -2926,7 +3162,7 @@ class Cmd(cmd.Cmd):
2926
3162
  nonlocal saved_history
2927
3163
  nonlocal parser
2928
3164
 
2929
- if readline_configured: # pragma: no cover
3165
+ if readline_configured or rl_type == RlType.NONE: # pragma: no cover
2930
3166
  return
2931
3167
 
2932
3168
  # Configure tab completion
@@ -2935,7 +3171,7 @@ class Cmd(cmd.Cmd):
2935
3171
 
2936
3172
  # Disable completion
2937
3173
  if completion_mode == utils.CompletionMode.NONE:
2938
- # noinspection PyUnusedLocal
3174
+
2939
3175
  def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover
2940
3176
  return None
2941
3177
 
@@ -2966,7 +3202,6 @@ class Cmd(cmd.Cmd):
2966
3202
  if completion_mode != utils.CompletionMode.COMMANDS or history is not None:
2967
3203
  saved_history = []
2968
3204
  for i in range(1, readline.get_current_history_length() + 1):
2969
- # noinspection PyArgumentList
2970
3205
  saved_history.append(readline.get_history_item(i))
2971
3206
 
2972
3207
  readline.clear_history()
@@ -2979,7 +3214,7 @@ class Cmd(cmd.Cmd):
2979
3214
  def restore_readline() -> None:
2980
3215
  """Restore readline tab completion and history"""
2981
3216
  nonlocal readline_configured
2982
- if not readline_configured: # pragma: no cover
3217
+ if not readline_configured or rl_type == RlType.NONE: # pragma: no cover
2983
3218
  return
2984
3219
 
2985
3220
  if self._completion_supported():
@@ -3063,8 +3298,13 @@ class Cmd(cmd.Cmd):
3063
3298
  """
3064
3299
  readline_settings = _SavedReadlineSettings()
3065
3300
 
3066
- if self._completion_supported():
3301
+ if rl_type == RlType.GNU:
3302
+ # To calculate line count when printing async_alerts, we rely on commands wider than
3303
+ # the terminal to wrap across multiple lines. The default for horizontal-scroll-mode
3304
+ # is "off" but a user may have overridden it in their readline initialization file.
3305
+ readline.parse_and_bind("set horizontal-scroll-mode off")
3067
3306
 
3307
+ if self._completion_supported():
3068
3308
  # Set up readline for our tab completion needs
3069
3309
  if rl_type == RlType.GNU:
3070
3310
  # GNU readline automatically adds a closing quote if the text being completed has an opening quote.
@@ -3098,7 +3338,6 @@ class Cmd(cmd.Cmd):
3098
3338
  :param readline_settings: the readline settings to restore
3099
3339
  """
3100
3340
  if self._completion_supported():
3101
-
3102
3341
  # Restore what we changed in readline
3103
3342
  readline.set_completer(readline_settings.completer)
3104
3343
  readline.set_completer_delims(readline_settings.delims)
@@ -3107,7 +3346,6 @@ class Cmd(cmd.Cmd):
3107
3346
  readline.set_completion_display_matches_hook(None)
3108
3347
  rl_basic_quote_characters.value = readline_settings.basic_quotes
3109
3348
  elif rl_type == RlType.PYREADLINE:
3110
- # noinspection PyUnresolvedReferences
3111
3349
  readline.rl.mode._display_completions = orig_pyreadline_display
3112
3350
 
3113
3351
  def _cmdloop(self) -> None:
@@ -3129,6 +3367,13 @@ class Cmd(cmd.Cmd):
3129
3367
  self._startup_commands.clear()
3130
3368
 
3131
3369
  while not stop:
3370
+ # Used in building multiline readline history entries. Only applies
3371
+ # when command line is read by input() in a terminal.
3372
+ if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty():
3373
+ orig_rl_history_length = readline.get_current_history_length()
3374
+ else:
3375
+ orig_rl_history_length = None
3376
+
3132
3377
  # Get commands from user
3133
3378
  try:
3134
3379
  line = self._read_command_line(self.prompt)
@@ -3137,7 +3382,7 @@ class Cmd(cmd.Cmd):
3137
3382
  line = ''
3138
3383
 
3139
3384
  # Run the command along with all associated pre and post hooks
3140
- stop = self.onecmd_plus_hooks(line)
3385
+ stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
3141
3386
  finally:
3142
3387
  # Get sigint protection while we restore readline settings
3143
3388
  with self.sigint_protection:
@@ -3571,7 +3816,7 @@ class Cmd(cmd.Cmd):
3571
3816
 
3572
3817
  # Check if this command uses argparse
3573
3818
  func = self.cmd_func(command)
3574
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3819
+ argparser = self._command_parsers.get(command, None)
3575
3820
  if func is None or argparser is None:
3576
3821
  return []
3577
3822
 
@@ -3607,7 +3852,7 @@ class Cmd(cmd.Cmd):
3607
3852
  # Getting help for a specific command
3608
3853
  func = self.cmd_func(args.command)
3609
3854
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3610
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3855
+ argparser = self._command_parsers.get(args.command, None)
3611
3856
 
3612
3857
  # If the command function uses argparse, then use argparse's help
3613
3858
  if func is not None and argparser is not None:
@@ -3688,9 +3933,10 @@ class Cmd(cmd.Cmd):
3688
3933
  if totwidth <= display_width:
3689
3934
  break
3690
3935
  else:
3936
+ # The output is wider than display_width. Print 1 column with each string on its own row.
3691
3937
  nrows = len(str_list)
3692
3938
  ncols = 1
3693
- colwidths = [0]
3939
+ colwidths = [1]
3694
3940
  for row in range(nrows):
3695
3941
  texts = []
3696
3942
  for col in range(ncols):
@@ -3742,7 +3988,7 @@ class Cmd(cmd.Cmd):
3742
3988
  help_topics.remove(command)
3743
3989
 
3744
3990
  # Non-argparse commands can have help_functions for their documentation
3745
- if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3991
+ if command not in self._command_parsers:
3746
3992
  has_help_func = True
3747
3993
 
3748
3994
  if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3788,7 +4034,7 @@ class Cmd(cmd.Cmd):
3788
4034
  doc: Optional[str]
3789
4035
 
3790
4036
  # Non-argparse commands can have help_functions for their documentation
3791
- if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
4037
+ if command not in self._command_parsers and command in topics:
3792
4038
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
3793
4039
  result = io.StringIO()
3794
4040
 
@@ -3841,7 +4087,6 @@ class Cmd(cmd.Cmd):
3841
4087
  self.poutput()
3842
4088
 
3843
4089
  # self.last_result will be set by do_quit()
3844
- # noinspection PyTypeChecker
3845
4090
  return self.do_quit('')
3846
4091
 
3847
4092
  quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application")
@@ -3878,7 +4123,7 @@ class Cmd(cmd.Cmd):
3878
4123
  fulloptions.append((opt[0], opt[1]))
3879
4124
  except IndexError:
3880
4125
  fulloptions.append((opt[0], opt[0]))
3881
- for (idx, (_, text)) in enumerate(fulloptions):
4126
+ for idx, (_, text) in enumerate(fulloptions):
3882
4127
  self.poutput(' %2d. %s' % (idx + 1, text))
3883
4128
 
3884
4129
  while True:
@@ -3977,7 +4222,6 @@ class Cmd(cmd.Cmd):
3977
4222
  try:
3978
4223
  orig_value = settable.get_value()
3979
4224
  new_value = settable.set_value(utils.strip_quotes(args.value))
3980
- # noinspection PyBroadException
3981
4225
  except Exception as ex:
3982
4226
  self.perror(f"Error setting {args.param}: {ex}")
3983
4227
  else:
@@ -4113,7 +4357,6 @@ class Cmd(cmd.Cmd):
4113
4357
  if rl_type != RlType.NONE:
4114
4358
  # Save cmd2 history
4115
4359
  for i in range(1, readline.get_current_history_length() + 1):
4116
- # noinspection PyArgumentList
4117
4360
  cmd2_env.history.append(readline.get_history_item(i))
4118
4361
 
4119
4362
  readline.clear_history()
@@ -4147,7 +4390,6 @@ class Cmd(cmd.Cmd):
4147
4390
  if rl_type == RlType.GNU:
4148
4391
  readline.set_completion_display_matches_hook(None)
4149
4392
  elif rl_type == RlType.PYREADLINE:
4150
- # noinspection PyUnresolvedReferences
4151
4393
  readline.rl.mode._display_completions = orig_pyreadline_display
4152
4394
 
4153
4395
  # Save off the current completer and set a new one in the Python console
@@ -4182,7 +4424,6 @@ class Cmd(cmd.Cmd):
4182
4424
  # Save py's history
4183
4425
  self._py_history.clear()
4184
4426
  for i in range(1, readline.get_current_history_length() + 1):
4185
- # noinspection PyArgumentList
4186
4427
  self._py_history.append(readline.get_history_item(i))
4187
4428
 
4188
4429
  readline.clear_history()
@@ -4226,7 +4467,8 @@ class Cmd(cmd.Cmd):
4226
4467
  PyBridge,
4227
4468
  )
4228
4469
 
4229
- py_bridge = PyBridge(self)
4470
+ add_to_history = self.scripts_add_to_history if pyscript else True
4471
+ py_bridge = PyBridge(self, add_to_history=add_to_history)
4230
4472
  saved_sys_path = None
4231
4473
 
4232
4474
  if self.in_pyscript():
@@ -4278,7 +4520,6 @@ class Cmd(cmd.Cmd):
4278
4520
 
4279
4521
  # Check if we are running Python code
4280
4522
  if py_code_to_run:
4281
- # noinspection PyBroadException
4282
4523
  try:
4283
4524
  interp.runcode(py_code_to_run) # type: ignore[arg-type]
4284
4525
  except BaseException:
@@ -4296,7 +4537,6 @@ class Cmd(cmd.Cmd):
4296
4537
 
4297
4538
  saved_cmd2_env = None
4298
4539
 
4299
- # noinspection PyBroadException
4300
4540
  try:
4301
4541
  # Get sigint protection while we set up the Python shell environment
4302
4542
  with self.sigint_protection:
@@ -4377,7 +4617,6 @@ class Cmd(cmd.Cmd):
4377
4617
 
4378
4618
  ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
4379
4619
 
4380
- # noinspection PyPackageRequirements
4381
4620
  @with_argparser(ipython_parser)
4382
4621
  def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover
4383
4622
  """
@@ -4426,14 +4665,14 @@ class Cmd(cmd.Cmd):
4426
4665
  local_vars['self'] = self
4427
4666
 
4428
4667
  # Configure IPython
4429
- config = TraitletsLoader.Config()
4668
+ config = TraitletsLoader.Config() # type: ignore
4430
4669
  config.InteractiveShell.banner2 = (
4431
4670
  'Entering an IPython shell. Type exit, quit, or Ctrl-D to exit.\n'
4432
4671
  f'Run CLI commands with: {self.py_bridge_name}("command ...")\n'
4433
4672
  )
4434
4673
 
4435
4674
  # Start IPython
4436
- start_ipython(config=config, argv=[], user_ns=local_vars)
4675
+ start_ipython(config=config, argv=[], user_ns=local_vars) # type: ignore[no-untyped-call]
4437
4676
  self.poutput("Now exiting IPython shell...")
4438
4677
 
4439
4678
  # The IPython application is a singleton and won't be recreated next time
@@ -4548,8 +4787,6 @@ class Cmd(cmd.Cmd):
4548
4787
  self.last_result = True
4549
4788
  return stop
4550
4789
  elif args.edit:
4551
- import tempfile
4552
-
4553
4790
  fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
4554
4791
  fobj: TextIO
4555
4792
  with os.fdopen(fd, 'w') as fobj:
@@ -4562,7 +4799,6 @@ class Cmd(cmd.Cmd):
4562
4799
  self.run_editor(fname)
4563
4800
 
4564
4801
  # self.last_resort will be set by do_run_script()
4565
- # noinspection PyTypeChecker
4566
4802
  return self.do_run_script(utils.quote_string(fname))
4567
4803
  finally:
4568
4804
  os.remove(fname)
@@ -4622,11 +4858,9 @@ class Cmd(cmd.Cmd):
4622
4858
  previous sessions will be included. Additionally, all history will be written
4623
4859
  to this file when the application exits.
4624
4860
  """
4625
- import json
4626
- import lzma
4627
-
4628
4861
  self.history = History()
4629
- # with no persistent history, nothing else in this method is relevant
4862
+
4863
+ # With no persistent history, nothing else in this method is relevant
4630
4864
  if not hist_file:
4631
4865
  self.persistent_history_file = hist_file
4632
4866
  return
@@ -4647,64 +4881,96 @@ class Cmd(cmd.Cmd):
4647
4881
  self.perror(f"Error creating persistent history file directory '{hist_file_dir}': {ex}")
4648
4882
  return
4649
4883
 
4650
- # Read and process history file
4884
+ # Read history file
4651
4885
  try:
4652
4886
  with open(hist_file, 'rb') as fobj:
4653
4887
  compressed_bytes = fobj.read()
4654
- history_json = lzma.decompress(compressed_bytes).decode(encoding='utf-8')
4655
- self.history = History.from_json(history_json)
4656
4888
  except FileNotFoundError:
4657
- # Just use an empty history
4658
- pass
4889
+ compressed_bytes = bytes()
4659
4890
  except OSError as ex:
4660
4891
  self.perror(f"Cannot read persistent history file '{hist_file}': {ex}")
4661
4892
  return
4662
- except (json.JSONDecodeError, lzma.LZMAError, KeyError, UnicodeDecodeError, ValueError) as ex:
4893
+
4894
+ # Register a function to write history at save
4895
+ import atexit
4896
+
4897
+ self.persistent_history_file = hist_file
4898
+ atexit.register(self._persist_history)
4899
+
4900
+ # Empty or nonexistent history file. Nothing more to do.
4901
+ if not compressed_bytes:
4902
+ return
4903
+
4904
+ # Decompress history data
4905
+ try:
4906
+ import lzma as decompress_lib
4907
+
4908
+ decompress_exceptions: Tuple[type[Exception]] = (decompress_lib.LZMAError,)
4909
+ except ModuleNotFoundError: # pragma: no cover
4910
+ import bz2 as decompress_lib # type: ignore[no-redef]
4911
+
4912
+ decompress_exceptions: Tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef]
4913
+
4914
+ try:
4915
+ history_json = decompress_lib.decompress(compressed_bytes).decode(encoding='utf-8')
4916
+ except decompress_exceptions as ex:
4917
+ self.perror(
4918
+ f"Error decompressing persistent history data '{hist_file}': {ex}\n"
4919
+ f"The history file will be recreated when this application exits."
4920
+ )
4921
+ return
4922
+
4923
+ # Decode history json
4924
+ import json
4925
+
4926
+ try:
4927
+ self.history = History.from_json(history_json)
4928
+ except (json.JSONDecodeError, KeyError, ValueError) as ex:
4663
4929
  self.perror(
4664
- f"Error processing persistent history file '{hist_file}': {ex}\n"
4930
+ f"Error processing persistent history data '{hist_file}': {ex}\n"
4665
4931
  f"The history file will be recreated when this application exits."
4666
4932
  )
4933
+ return
4667
4934
 
4668
4935
  self.history.start_session()
4669
- self.persistent_history_file = hist_file
4670
4936
 
4671
- # populate readline history
4937
+ # Populate readline history
4672
4938
  if rl_type != RlType.NONE:
4673
- last = None
4674
4939
  for item in self.history:
4675
- # Break the command into its individual lines
4676
- for line in item.raw.splitlines():
4677
- # readline only adds a single entry for multiple sequential identical lines
4678
- # so we emulate that behavior here
4679
- if line != last:
4680
- readline.add_history(line)
4681
- last = line
4682
-
4683
- # register a function to write history at save
4684
- # if the history file is in plain text format from 0.9.12 or lower
4685
- # this will fail, and the history in the plain text file will be lost
4686
- import atexit
4940
+ formatted_command = single_line_format(item.statement)
4687
4941
 
4688
- atexit.register(self._persist_history)
4942
+ # If formatted command is different than the previous history item, add it
4943
+ cur_history_length = readline.get_current_history_length()
4944
+ if cur_history_length == 0 or formatted_command != readline.get_history_item(cur_history_length):
4945
+ readline.add_history(formatted_command)
4689
4946
 
4690
4947
  def _persist_history(self) -> None:
4691
4948
  """Write history out to the persistent history file as compressed JSON"""
4692
- import lzma
4693
-
4694
4949
  if not self.persistent_history_file:
4695
4950
  return
4696
4951
 
4697
- self.history.truncate(self._persistent_history_length)
4698
4952
  try:
4699
- history_json = self.history.to_json()
4700
- compressed_bytes = lzma.compress(history_json.encode(encoding='utf-8'))
4953
+ import lzma as compress_lib
4954
+ except ModuleNotFoundError: # pragma: no cover
4955
+ import bz2 as compress_lib # type: ignore[no-redef]
4701
4956
 
4957
+ self.history.truncate(self._persistent_history_length)
4958
+ history_json = self.history.to_json()
4959
+ compressed_bytes = compress_lib.compress(history_json.encode(encoding='utf-8'))
4960
+
4961
+ try:
4702
4962
  with open(self.persistent_history_file, 'wb') as fobj:
4703
4963
  fobj.write(compressed_bytes)
4704
4964
  except OSError as ex:
4705
4965
  self.perror(f"Cannot write persistent history file '{self.persistent_history_file}': {ex}")
4706
4966
 
4707
- def _generate_transcript(self, history: Union[List[HistoryItem], List[str]], transcript_file: str) -> None:
4967
+ def _generate_transcript(
4968
+ self,
4969
+ history: Union[List[HistoryItem], List[str]],
4970
+ transcript_file: str,
4971
+ *,
4972
+ add_to_history: bool = True,
4973
+ ) -> None:
4708
4974
  """Generate a transcript file from a given history of commands"""
4709
4975
  self.last_result = False
4710
4976
 
@@ -4754,7 +5020,11 @@ class Cmd(cmd.Cmd):
4754
5020
 
4755
5021
  # then run the command and let the output go into our buffer
4756
5022
  try:
4757
- stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
5023
+ stop = self.onecmd_plus_hooks(
5024
+ history_item,
5025
+ add_to_history=add_to_history,
5026
+ raise_keyboard_interrupt=True,
5027
+ )
4758
5028
  except KeyboardInterrupt as ex:
4759
5029
  self.perror(ex)
4760
5030
  stop = True
@@ -4826,7 +5096,6 @@ class Cmd(cmd.Cmd):
4826
5096
  if file_path:
4827
5097
  command += " " + utils.quote_string(os.path.expanduser(file_path))
4828
5098
 
4829
- # noinspection PyTypeChecker
4830
5099
  self.do_shell(command)
4831
5100
 
4832
5101
  @property
@@ -4899,9 +5168,17 @@ class Cmd(cmd.Cmd):
4899
5168
 
4900
5169
  if args.transcript:
4901
5170
  # self.last_resort will be set by _generate_transcript()
4902
- self._generate_transcript(script_commands, os.path.expanduser(args.transcript))
5171
+ self._generate_transcript(
5172
+ script_commands,
5173
+ os.path.expanduser(args.transcript),
5174
+ add_to_history=self.scripts_add_to_history,
5175
+ )
4903
5176
  else:
4904
- stop = self.runcmds_plus_hooks(script_commands, stop_on_keyboard_interrupt=True)
5177
+ stop = self.runcmds_plus_hooks(
5178
+ script_commands,
5179
+ add_to_history=self.scripts_add_to_history,
5180
+ stop_on_keyboard_interrupt=True,
5181
+ )
4905
5182
  self.last_result = True
4906
5183
  return stop
4907
5184
 
@@ -4938,7 +5215,6 @@ class Cmd(cmd.Cmd):
4938
5215
  relative_path = os.path.join(self._current_script_dir or '', file_path)
4939
5216
 
4940
5217
  # self.last_result will be set by do_run_script()
4941
- # noinspection PyTypeChecker
4942
5218
  return self.do_run_script(utils.quote_string(relative_path))
4943
5219
 
4944
5220
  def _run_transcript_tests(self, transcript_paths: List[str]) -> None:
@@ -4981,7 +5257,6 @@ class Cmd(cmd.Cmd):
4981
5257
  sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main()
4982
5258
  testcase = TestMyAppCase()
4983
5259
  stream = cast(TextIO, utils.StdSim(sys.stderr))
4984
- # noinspection PyTypeChecker
4985
5260
  runner = unittest.TextTestRunner(stream=stream)
4986
5261
  start_time = time.time()
4987
5262
  test_results = runner.run(testcase)
@@ -4989,8 +5264,8 @@ class Cmd(cmd.Cmd):
4989
5264
  if test_results.wasSuccessful():
4990
5265
  ansi.style_aware_write(sys.stderr, stream.read())
4991
5266
  finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds '
4992
- finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
4993
- self.poutput(finish_msg)
5267
+ finish_msg = utils.align_center(finish_msg, fill_char='=')
5268
+ self.psuccess(finish_msg)
4994
5269
  else:
4995
5270
  # Strip off the initial traceback which isn't particularly useful for end users
4996
5271
  error_str = stream.read()
@@ -5007,16 +5282,16 @@ class Cmd(cmd.Cmd):
5007
5282
  def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
5008
5283
  """
5009
5284
  Display an important message to the user while they are at a command line prompt.
5010
- To the user it appears as if an alert message is printed above the prompt and their current input
5011
- text and cursor location is left alone.
5285
+ To the user it appears as if an alert message is printed above the prompt and their
5286
+ current input text and cursor location is left alone.
5012
5287
 
5013
- IMPORTANT: This function will not print an alert unless it can acquire self.terminal_lock to ensure
5014
- a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5015
- to guarantee the alert prints and to avoid raising a RuntimeError.
5288
+ This function needs to acquire self.terminal_lock to ensure a prompt is on screen.
5289
+ Therefore, it is best to acquire the lock before calling this function to avoid
5290
+ raising a RuntimeError.
5016
5291
 
5017
- This function is only needed when you need to print an alert while the main thread is blocking
5018
- at the prompt. Therefore, this should never be called from the main thread. Doing so will
5019
- raise a RuntimeError.
5292
+ This function is only needed when you need to print an alert or update the prompt while the
5293
+ main thread is blocking at the prompt. Therefore, this should never be called from the main
5294
+ thread. Doing so will raise a RuntimeError.
5020
5295
 
5021
5296
  :param alert_msg: the message to display to the user
5022
5297
  :param new_prompt: If you also want to change the prompt that is displayed, then include it here.
@@ -5032,7 +5307,6 @@ class Cmd(cmd.Cmd):
5032
5307
 
5033
5308
  # Sanity check that can't fail if self.terminal_lock was acquired before calling this function
5034
5309
  if self.terminal_lock.acquire(blocking=False):
5035
-
5036
5310
  # Windows terminals tend to flicker when we redraw the prompt and input lines.
5037
5311
  # To reduce how often this occurs, only update terminal if there are changes.
5038
5312
  update_terminal = False
@@ -5044,20 +5318,18 @@ class Cmd(cmd.Cmd):
5044
5318
  if new_prompt is not None:
5045
5319
  self.prompt = new_prompt
5046
5320
 
5047
- # Check if the prompt to display has changed from what's currently displayed
5048
- cur_onscreen_prompt = rl_get_prompt()
5049
- new_onscreen_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5050
-
5051
- if new_onscreen_prompt != cur_onscreen_prompt:
5321
+ # Check if the onscreen prompt needs to be refreshed to match self.prompt.
5322
+ if self.need_prompt_refresh():
5052
5323
  update_terminal = True
5324
+ rl_set_prompt(self.prompt)
5053
5325
 
5054
5326
  if update_terminal:
5055
5327
  import shutil
5056
5328
 
5057
- # Generate the string which will replace the current prompt and input lines with the alert
5329
+ # Print a string which replaces the onscreen prompt and input lines with the alert.
5058
5330
  terminal_str = ansi.async_alert_str(
5059
5331
  terminal_columns=shutil.get_terminal_size().columns,
5060
- prompt=cur_onscreen_prompt,
5332
+ prompt=rl_get_display_prompt(),
5061
5333
  line=readline.get_line_buffer(),
5062
5334
  cursor_offset=rl_get_point(),
5063
5335
  alert_msg=alert_msg,
@@ -5066,12 +5338,8 @@ class Cmd(cmd.Cmd):
5066
5338
  sys.stderr.write(terminal_str)
5067
5339
  sys.stderr.flush()
5068
5340
  elif rl_type == RlType.PYREADLINE:
5069
- # noinspection PyUnresolvedReferences
5070
5341
  readline.rl.mode.console.write(terminal_str)
5071
5342
 
5072
- # Update Readline's prompt before we redraw it
5073
- rl_set_prompt(new_onscreen_prompt)
5074
-
5075
5343
  # Redraw the prompt and input lines below the alert
5076
5344
  rl_force_redisplay()
5077
5345
 
@@ -5082,23 +5350,17 @@ class Cmd(cmd.Cmd):
5082
5350
 
5083
5351
  def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
5084
5352
  """
5085
- Update the command line prompt while the user is still typing at it. This is good for alerting the user to
5086
- system changes dynamically in between commands. For instance you could alter the color of the prompt to
5087
- indicate a system status or increase a counter to report an event. If you do alter the actual text of the
5088
- prompt, it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will
5089
- be shifted and the update will not be seamless.
5353
+ Update the command line prompt while the user is still typing at it.
5090
5354
 
5091
- IMPORTANT: This function will not update the prompt unless it can acquire self.terminal_lock to ensure
5092
- a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5093
- to guarantee the prompt changes and to avoid raising a RuntimeError.
5355
+ This is good for alerting the user to system changes dynamically in between commands.
5356
+ For instance you could alter the color of the prompt to indicate a system status or increase a
5357
+ counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5358
+ the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5359
+ and the update will not be seamless.
5094
5360
 
5095
- This function is only needed when you need to update the prompt while the main thread is blocking
5096
- at the prompt. Therefore, this should never be called from the main thread. Doing so will
5097
- raise a RuntimeError.
5098
-
5099
- If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5100
- not change. However, self.prompt will still be updated and display immediately after the multiline
5101
- line command completes.
5361
+ If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5362
+ not change. However, self.prompt will still be updated and display immediately after the multiline
5363
+ line command completes.
5102
5364
 
5103
5365
  :param new_prompt: what to change the prompt to
5104
5366
  :raises RuntimeError: if called from the main thread.
@@ -5106,6 +5368,32 @@ class Cmd(cmd.Cmd):
5106
5368
  """
5107
5369
  self.async_alert('', new_prompt)
5108
5370
 
5371
+ def async_refresh_prompt(self) -> None: # pragma: no cover
5372
+ """
5373
+ Refresh the oncreen prompt to match self.prompt.
5374
+
5375
+ One case where the onscreen prompt and self.prompt can get out of sync is
5376
+ when async_alert() is called while a user is in search mode (e.g. Ctrl-r).
5377
+ To prevent overwriting readline's onscreen search prompt, self.prompt is updated
5378
+ but readline's saved prompt isn't.
5379
+
5380
+ Therefore when a user aborts a search, the old prompt is still on screen until they
5381
+ press Enter or this method is called. Call need_prompt_refresh() in an async print
5382
+ thread to know when a refresh is needed.
5383
+
5384
+ :raises RuntimeError: if called from the main thread.
5385
+ :raises RuntimeError: if called while another thread holds `terminal_lock`
5386
+ """
5387
+ self.async_alert('')
5388
+
5389
+ def need_prompt_refresh(self) -> bool: # pragma: no cover
5390
+ """Check whether the onscreen prompt needs to be asynchronously refreshed to match self.prompt."""
5391
+ if not (vt100_support and self.use_rawinput):
5392
+ return False
5393
+
5394
+ # Don't overwrite a readline search prompt or a continuation prompt.
5395
+ return not rl_in_search_mode() and not self._at_continuation_prompt and self.prompt != rl_get_prompt()
5396
+
5109
5397
  @staticmethod
5110
5398
  def set_window_title(title: str) -> None: # pragma: no cover
5111
5399
  """
@@ -5249,15 +5537,22 @@ class Cmd(cmd.Cmd):
5249
5537
  """
5250
5538
  # cmdloop() expects to be run in the main thread to support extensive use of KeyboardInterrupts throughout the
5251
5539
  # other built-in functions. You are free to override cmdloop, but much of cmd2's features will be limited.
5252
- if not threading.current_thread() is threading.main_thread():
5540
+ if threading.current_thread() is not threading.main_thread():
5253
5541
  raise RuntimeError("cmdloop must be run in the main thread")
5254
5542
 
5255
- # Register a SIGINT signal handler for Ctrl+C
5543
+ # Register signal handlers
5256
5544
  import signal
5257
5545
 
5258
5546
  original_sigint_handler = signal.getsignal(signal.SIGINT)
5259
5547
  signal.signal(signal.SIGINT, self.sigint_handler)
5260
5548
 
5549
+ if not sys.platform.startswith('win'):
5550
+ original_sighup_handler = signal.getsignal(signal.SIGHUP)
5551
+ signal.signal(signal.SIGHUP, self.termination_signal_handler)
5552
+
5553
+ original_sigterm_handler = signal.getsignal(signal.SIGTERM)
5554
+ signal.signal(signal.SIGTERM, self.termination_signal_handler)
5555
+
5261
5556
  # Grab terminal lock before the command line prompt has been drawn by readline
5262
5557
  self.terminal_lock.acquire()
5263
5558
 
@@ -5290,9 +5585,13 @@ class Cmd(cmd.Cmd):
5290
5585
  # This will also zero the lock count in case cmdloop() is called again
5291
5586
  self.terminal_lock.release()
5292
5587
 
5293
- # Restore the original signal handler
5588
+ # Restore original signal handlers
5294
5589
  signal.signal(signal.SIGINT, original_sigint_handler)
5295
5590
 
5591
+ if not sys.platform.startswith('win'):
5592
+ signal.signal(signal.SIGHUP, original_sighup_handler)
5593
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
5594
+
5296
5595
  return self.exit_code
5297
5596
 
5298
5597
  ###
@@ -5443,7 +5742,7 @@ class Cmd(cmd.Cmd):
5443
5742
  func_self = None
5444
5743
  candidate_sets: List[CommandSet] = []
5445
5744
  for installed_cmd_set in self._installed_command_sets:
5446
- if type(installed_cmd_set) == func_class:
5745
+ if type(installed_cmd_set) == func_class: # noqa: E721
5447
5746
  # Case 2: CommandSet is an exact type match for the function's CommandSet
5448
5747
  func_self = installed_cmd_set
5449
5748
  break