cmd2 2.4.3__py3-none-any.whl → 2.5.1__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,
@@ -122,11 +128,20 @@ from .parsing import (
122
128
  StatementParser,
123
129
  shlex_split,
124
130
  )
131
+
132
+ # NOTE: When using gnureadline with Python 3.13, start_ipython needs to be imported before any readline-related stuff
133
+ try:
134
+ from IPython import start_ipython # type: ignore[import]
135
+ except ImportError:
136
+ pass
137
+
125
138
  from .rl_utils import (
126
139
  RlType,
127
140
  rl_escape_prompt,
141
+ rl_get_display_prompt,
128
142
  rl_get_point,
129
143
  rl_get_prompt,
144
+ rl_in_search_mode,
130
145
  rl_set_prompt,
131
146
  rl_type,
132
147
  rl_warning,
@@ -140,6 +155,7 @@ from .utils import (
140
155
  Settable,
141
156
  get_defining_class,
142
157
  strip_doc_annotations,
158
+ suggest_similar,
143
159
  )
144
160
 
145
161
  # Set up readline
@@ -155,13 +171,10 @@ else:
155
171
  orig_rl_delims = readline.get_completer_delims()
156
172
 
157
173
  if rl_type == RlType.PYREADLINE:
158
-
159
174
  # Save the original pyreadline3 display completion function since we need to override it and restore it
160
- # noinspection PyProtectedMember,PyUnresolvedReferences
161
175
  orig_pyreadline_display = readline.rl.mode._display_completions
162
176
 
163
177
  elif rl_type == RlType.GNU:
164
-
165
178
  # Get the readline lib so we can make changes to it
166
179
  import ctypes
167
180
 
@@ -197,6 +210,14 @@ class _SavedCmd2Env:
197
210
  DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function'])
198
211
 
199
212
 
213
+ if TYPE_CHECKING: # pragma: no cover
214
+ StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
215
+ ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
216
+ else:
217
+ StaticArgParseBuilder = staticmethod
218
+ ClassArgParseBuilder = classmethod
219
+
220
+
200
221
  class Cmd(cmd.Cmd):
201
222
  """An easy but powerful framework for writing line-oriented command interpreters.
202
223
 
@@ -236,6 +257,8 @@ class Cmd(cmd.Cmd):
236
257
  shortcuts: Optional[Dict[str, str]] = None,
237
258
  command_sets: Optional[Iterable[CommandSet]] = None,
238
259
  auto_load_commands: bool = True,
260
+ allow_clipboard: bool = True,
261
+ suggest_similar_command: bool = False,
239
262
  ) -> None:
240
263
  """An easy but powerful framework for writing line-oriented command
241
264
  interpreters. Extends Python's cmd package.
@@ -283,6 +306,10 @@ class Cmd(cmd.Cmd):
283
306
  that are currently loaded by Python and automatically
284
307
  instantiate and register all commands. If False, CommandSets
285
308
  must be manually installed with `register_command_set`.
309
+ :param allow_clipboard: If False, cmd2 will disable clipboard interactions
310
+ :param suggest_similar_command: If ``True``, ``cmd2`` will attempt to suggest the most
311
+ similar command when the user types a command that does
312
+ not exist. Default: ``False``.
286
313
  """
287
314
  # Check if py or ipy need to be disabled in this instance
288
315
  if not include_py:
@@ -308,6 +335,7 @@ class Cmd(cmd.Cmd):
308
335
  self.editor = Cmd.DEFAULT_EDITOR
309
336
  self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing)
310
337
  self.quiet = False # Do not suppress nonessential output
338
+ self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
311
339
  self.timing = False # Prints elapsed time for each command
312
340
 
313
341
  # The maximum number of CompletionItems to display during tab completion. If the number of completion
@@ -335,6 +363,7 @@ class Cmd(cmd.Cmd):
335
363
  self.hidden_commands = ['eof', '_relative_run_script']
336
364
 
337
365
  # Initialize history
366
+ self.persistent_history_file = ''
338
367
  self._persistent_history_length = persistent_history_length
339
368
  self._initialize_history(persistent_history_file)
340
369
 
@@ -389,7 +418,7 @@ class Cmd(cmd.Cmd):
389
418
  self.help_error = "No help on {}"
390
419
 
391
420
  # The error that prints when a non-existent command is run
392
- self.default_error = "{} is not a recognized command, alias, or macro"
421
+ self.default_error = "{} is not a recognized command, alias, or macro."
393
422
 
394
423
  # If non-empty, this string will be displayed if a broken pipe error occurs
395
424
  self.broken_pipe_warning = ''
@@ -436,8 +465,8 @@ class Cmd(cmd.Cmd):
436
465
  self.pager = 'less -RXF'
437
466
  self.pager_chop = 'less -SRXF'
438
467
 
439
- # This boolean flag determines whether or not the cmd2 application can interact with the clipboard
440
- self._can_clip = can_clip
468
+ # This boolean flag stores whether cmd2 will allow clipboard related features
469
+ self.allow_clipboard = allow_clipboard
441
470
 
442
471
  # This determines the value returned by cmdloop() when exiting the application
443
472
  self.exit_code = 0
@@ -507,6 +536,16 @@ class Cmd(cmd.Cmd):
507
536
  # This does not affect self.formatted_completions.
508
537
  self.matches_sorted = False
509
538
 
539
+ # Command parsers for this Cmd instance.
540
+ self._command_parsers: Dict[str, argparse.ArgumentParser] = {}
541
+
542
+ # Locates the command parser template or factory and creates an instance-specific parser
543
+ for command in self.get_all_commands():
544
+ self._register_command_parser(command, self.cmd_func(command)) # type: ignore[arg-type]
545
+
546
+ # Add functions decorated to be subcommands
547
+ self._register_subcommands(self)
548
+
510
549
  ############################################################################################################
511
550
  # The following code block loads CommandSets, verifies command names, and registers subcommands.
512
551
  # This block should appear after all attributes have been created since the registration code
@@ -526,8 +565,11 @@ class Cmd(cmd.Cmd):
526
565
  if not valid:
527
566
  raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")
528
567
 
529
- # Add functions decorated to be subcommands
530
- self._register_subcommands(self)
568
+ self.suggest_similar_command = suggest_similar_command
569
+ self.default_suggestion_message = "Did you mean {}?"
570
+
571
+ # the current command being executed
572
+ self.current_command: Optional[Statement] = None
531
573
 
532
574
  def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
533
575
  """
@@ -541,7 +583,7 @@ class Cmd(cmd.Cmd):
541
583
  return [
542
584
  cmdset
543
585
  for cmdset in self._installed_command_sets
544
- if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type))
586
+ if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721
545
587
  ]
546
588
 
547
589
  def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]:
@@ -601,11 +643,14 @@ class Cmd(cmd.Cmd):
601
643
  raise CommandSetRegistrationError(f'Duplicate settable {key} is already registered')
602
644
 
603
645
  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),
646
+ methods = cast(
647
+ List[Tuple[str, Callable[..., Any]]],
648
+ inspect.getmembers(
649
+ cmdset,
650
+ predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
651
+ and hasattr(meth, '__name__')
652
+ and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
653
+ ),
609
654
  )
610
655
 
611
656
  default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
@@ -650,6 +695,50 @@ class Cmd(cmd.Cmd):
650
695
  cmdset.on_unregistered()
651
696
  raise
652
697
 
698
+ def _build_parser(
699
+ self,
700
+ parent: CommandParent,
701
+ parser_builder: Optional[
702
+ Union[
703
+ argparse.ArgumentParser,
704
+ Callable[[], argparse.ArgumentParser],
705
+ StaticArgParseBuilder,
706
+ ClassArgParseBuilder,
707
+ ]
708
+ ],
709
+ ) -> Optional[argparse.ArgumentParser]:
710
+ parser: Optional[argparse.ArgumentParser] = None
711
+ if isinstance(parser_builder, staticmethod):
712
+ parser = parser_builder.__func__()
713
+ elif isinstance(parser_builder, classmethod):
714
+ parser = parser_builder.__func__(parent if not None else self) # type: ignore[arg-type]
715
+ elif callable(parser_builder):
716
+ parser = parser_builder()
717
+ elif isinstance(parser_builder, argparse.ArgumentParser):
718
+ parser = copy.deepcopy(parser_builder)
719
+ return parser
720
+
721
+ def _register_command_parser(self, command: str, command_method: Callable[..., Any]) -> None:
722
+ if command not in self._command_parsers:
723
+ parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
724
+ parent = self.find_commandset_for_command(command) or self
725
+ parser = self._build_parser(parent, parser_builder)
726
+ if parser is None:
727
+ return
728
+
729
+ # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
730
+ from .decorators import (
731
+ _set_parser_prog,
732
+ )
733
+
734
+ _set_parser_prog(parser, command)
735
+
736
+ # If the description has not been set, then use the method docstring if one exists
737
+ if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
738
+ parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
739
+
740
+ self._command_parsers[command] = parser
741
+
653
742
  def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
654
743
  cmd_func_name = COMMAND_FUNC_PREFIX + command
655
744
 
@@ -672,6 +761,8 @@ class Cmd(cmd.Cmd):
672
761
  self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
673
762
  del self.macros[command]
674
763
 
764
+ self._register_command_parser(command, command_wrapper)
765
+
675
766
  setattr(self, cmd_func_name, command_wrapper)
676
767
 
677
768
  def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -699,7 +790,7 @@ class Cmd(cmd.Cmd):
699
790
  cmdset.on_unregister()
700
791
  self._unregister_subcommands(cmdset)
701
792
 
702
- methods = inspect.getmembers(
793
+ methods: List[Tuple[str, Callable[[Any], Any]]] = inspect.getmembers(
703
794
  cmdset,
704
795
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
705
796
  and hasattr(meth, '__name__')
@@ -718,6 +809,8 @@ class Cmd(cmd.Cmd):
718
809
  del self._cmd_to_command_sets[cmd_name]
719
810
 
720
811
  delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
812
+ if cmd_name in self._command_parsers:
813
+ del self._command_parsers[cmd_name]
721
814
 
722
815
  if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
723
816
  delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -728,7 +821,7 @@ class Cmd(cmd.Cmd):
728
821
  self._installed_command_sets.remove(cmdset)
729
822
 
730
823
  def _check_uninstallable(self, cmdset: CommandSet) -> None:
731
- methods = inspect.getmembers(
824
+ methods: List[Tuple[str, Callable[[Any], Any]]] = inspect.getmembers(
732
825
  cmdset,
733
826
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
734
827
  and hasattr(meth, '__name__')
@@ -737,14 +830,7 @@ class Cmd(cmd.Cmd):
737
830
 
738
831
  for method in methods:
739
832
  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))
833
+ command_parser = self._command_parsers.get(command_name, None)
748
834
 
749
835
  def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
750
836
  for action in parser._actions:
@@ -783,7 +869,7 @@ class Cmd(cmd.Cmd):
783
869
  for method_name, method in methods:
784
870
  subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
785
871
  full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
786
- subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
872
+ subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
787
873
 
788
874
  subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
789
875
  if not subcommand_valid:
@@ -803,7 +889,7 @@ class Cmd(cmd.Cmd):
803
889
  raise CommandSetRegistrationError(
804
890
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
805
891
  )
806
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
892
+ command_parser = self._command_parsers.get(command_name, None)
807
893
  if command_parser is None:
808
894
  raise CommandSetRegistrationError(
809
895
  f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -823,16 +909,17 @@ class Cmd(cmd.Cmd):
823
909
 
824
910
  target_parser = find_subcommand(command_parser, subcommand_names)
825
911
 
912
+ subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
913
+ from .decorators import (
914
+ _set_parser_prog,
915
+ )
916
+
917
+ _set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
918
+ if subcmd_parser.description is None and method.__doc__:
919
+ subcmd_parser.description = strip_doc_annotations(method.__doc__)
920
+
826
921
  for action in target_parser._actions:
827
922
  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
923
  # Get the kwargs for add_parser()
837
924
  add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
838
925
 
@@ -904,7 +991,7 @@ class Cmd(cmd.Cmd):
904
991
  raise CommandSetRegistrationError(
905
992
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
906
993
  )
907
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
994
+ command_parser = self._command_parsers.get(command_name, None)
908
995
  if command_parser is None: # pragma: no cover
909
996
  # This really shouldn't be possible since _register_subcommands would prevent this from happening
910
997
  # but keeping in case it does for some strange reason
@@ -1012,12 +1099,7 @@ class Cmd(cmd.Cmd):
1012
1099
  )
1013
1100
 
1014
1101
  self.add_settable(
1015
- Settable(
1016
- 'always_show_hint',
1017
- bool,
1018
- 'Display tab completion hint even when completion suggestions print',
1019
- self,
1020
- )
1102
+ Settable('always_show_hint', bool, 'Display tab completion hint even when completion suggestions print', self)
1021
1103
  )
1022
1104
  self.add_settable(Settable('debug', bool, "Show full traceback on exception", self))
1023
1105
  self.add_settable(Settable('echo', bool, "Echo command issued into output", self))
@@ -1027,6 +1109,7 @@ class Cmd(cmd.Cmd):
1027
1109
  Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self)
1028
1110
  )
1029
1111
  self.add_settable(Settable('quiet', bool, "Don't print nonessential feedback", self))
1112
+ self.add_settable(Settable('scripts_add_to_history', bool, 'Scripts and pyscripts add commands to history', self))
1030
1113
  self.add_settable(Settable('timing', bool, "Report execution times", self))
1031
1114
 
1032
1115
  # ----- Methods related to presenting output to the user -----
@@ -1056,7 +1139,40 @@ class Cmd(cmd.Cmd):
1056
1139
  """
1057
1140
  return ansi.strip_style(self.prompt)
1058
1141
 
1059
- def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
1142
+ def print_to(
1143
+ self,
1144
+ dest: Union[TextIO, IO[str]],
1145
+ msg: Any,
1146
+ *,
1147
+ end: str = '\n',
1148
+ style: Optional[Callable[[str], str]] = None,
1149
+ paged: bool = False,
1150
+ chop: bool = False,
1151
+ ) -> None:
1152
+ final_msg = style(msg) if style is not None else msg
1153
+ if paged:
1154
+ self.ppaged(final_msg, end=end, chop=chop, dest=dest)
1155
+ else:
1156
+ try:
1157
+ ansi.style_aware_write(dest, f'{final_msg}{end}')
1158
+ except BrokenPipeError:
1159
+ # This occurs if a command's output is being piped to another
1160
+ # process and that process closes before the command is
1161
+ # finished. If you would like your application to print a
1162
+ # warning message, then set the broken_pipe_warning attribute
1163
+ # to the message you want printed.
1164
+ if self.broken_pipe_warning:
1165
+ sys.stderr.write(self.broken_pipe_warning)
1166
+
1167
+ def poutput(
1168
+ self,
1169
+ msg: Any = '',
1170
+ *,
1171
+ end: str = '\n',
1172
+ apply_style: bool = True,
1173
+ paged: bool = False,
1174
+ chop: bool = False,
1175
+ ) -> None:
1060
1176
  """Print message to self.stdout and appends a newline by default
1061
1177
 
1062
1178
  Also handles BrokenPipeError exceptions for when a command's output has
@@ -1065,44 +1181,83 @@ class Cmd(cmd.Cmd):
1065
1181
 
1066
1182
  :param msg: object to print
1067
1183
  :param end: string appended after the end of the message, default a newline
1184
+ :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
1185
+ where the message text already has the desired style. Defaults to True.
1186
+ :param paged: If True, pass the output through the configured pager.
1187
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1068
1188
  """
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)
1189
+ self.print_to(self.stdout, msg, end=end, style=ansi.style_output if apply_style else None, paged=paged, chop=chop)
1079
1190
 
1080
- # noinspection PyMethodMayBeStatic
1081
- def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1191
+ def perror(
1192
+ self,
1193
+ msg: Any = '',
1194
+ *,
1195
+ end: str = '\n',
1196
+ apply_style: bool = True,
1197
+ paged: bool = False,
1198
+ chop: bool = False,
1199
+ ) -> None:
1082
1200
  """Print message to sys.stderr
1083
1201
 
1084
1202
  :param msg: object to print
1085
1203
  :param end: string appended after the end of the message, default a newline
1086
1204
  :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
1087
1205
  where the message text already has the desired style. Defaults to True.
1206
+ :param paged: If True, pass the output through the configured pager.
1207
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1088
1208
  """
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)
1209
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None, paged=paged, chop=chop)
1210
+
1211
+ def psuccess(
1212
+ self,
1213
+ msg: Any = '',
1214
+ *,
1215
+ end: str = '\n',
1216
+ paged: bool = False,
1217
+ chop: bool = False,
1218
+ ) -> None:
1219
+ """Writes to stdout applying ansi.style_success by default
1220
+
1221
+ :param msg: object to print
1222
+ :param end: string appended after the end of the message, default a newline
1223
+ :param paged: If True, pass the output through the configured pager.
1224
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1225
+ """
1226
+ self.print_to(self.stdout, msg, end=end, style=ansi.style_success, paged=paged, chop=chop)
1094
1227
 
1095
- def pwarning(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1228
+ def pwarning(
1229
+ self,
1230
+ msg: Any = '',
1231
+ *,
1232
+ end: str = '\n',
1233
+ paged: bool = False,
1234
+ chop: bool = False,
1235
+ ) -> None:
1096
1236
  """Wraps perror, but applies ansi.style_warning by default
1097
1237
 
1098
1238
  :param msg: object to print
1099
1239
  :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.
1240
+ :param paged: If True, pass the output through the configured pager.
1241
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1102
1242
  """
1103
- if apply_style:
1104
- msg = ansi.style_warning(msg)
1105
- self.perror(msg, end=end, apply_style=False)
1243
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_warning, paged=paged, chop=chop)
1244
+
1245
+ def pfailure(
1246
+ self,
1247
+ msg: Any = '',
1248
+ *,
1249
+ end: str = '\n',
1250
+ paged: bool = False,
1251
+ chop: bool = False,
1252
+ ) -> None:
1253
+ """Writes to stderr applying ansi.style_error by default
1254
+
1255
+ :param msg: object to print
1256
+ :param end: string appended after the end of the message, default a newline
1257
+ :param paged: If True, pass the output through the configured pager.
1258
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1259
+ """
1260
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_error, paged=paged, chop=chop)
1106
1261
 
1107
1262
  def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None:
1108
1263
  """Print Exception message to sys.stderr. If debug is true, print exception traceback if one exists.
@@ -1131,23 +1286,39 @@ class Cmd(cmd.Cmd):
1131
1286
 
1132
1287
  self.perror(final_msg, end=end, apply_style=False)
1133
1288
 
1134
- def pfeedback(self, msg: Any, *, end: str = '\n') -> None:
1289
+ def pfeedback(
1290
+ self,
1291
+ msg: Any,
1292
+ *,
1293
+ end: str = '\n',
1294
+ apply_style: bool = True,
1295
+ paged: bool = False,
1296
+ chop: bool = False,
1297
+ ) -> None:
1135
1298
  """For printing nonessential feedback. Can be silenced with `quiet`.
1136
1299
  Inclusion in redirected output is controlled by `feedback_to_output`.
1137
1300
 
1138
1301
  :param msg: object to print
1139
1302
  :param end: string appended after the end of the message, default a newline
1303
+ :param apply_style: If True, then ansi.style_output will be applied to the message text. Set to False in cases
1304
+ where the message text already has the desired style. Defaults to True.
1305
+ :param paged: If True, pass the output through the configured pager.
1306
+ :param chop: If paged is True, True to truncate long lines or False to wrap long lines.
1140
1307
  """
1141
1308
  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)
1309
+ self.print_to(
1310
+ self.stdout if self.feedback_to_output else sys.stderr,
1311
+ msg,
1312
+ end=end,
1313
+ style=ansi.style_output if apply_style else None,
1314
+ paged=paged,
1315
+ chop=chop,
1316
+ )
1146
1317
 
1147
- def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None:
1318
+ def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False, dest: Optional[Union[TextIO, IO[str]]] = None) -> None:
1148
1319
  """Print output using a pager if it would go off screen and stdout isn't currently being redirected.
1149
1320
 
1150
- Never uses a pager inside of a script (Python or text) or when output is being redirected or piped or when
1321
+ Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when
1151
1322
  stdout or stdin are not a fully functional terminal.
1152
1323
 
1153
1324
  :param msg: object to print
@@ -1157,11 +1328,13 @@ class Cmd(cmd.Cmd):
1157
1328
  - chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
1158
1329
  False -> causes lines longer than the screen width to wrap to the next line
1159
1330
  - wrapping is ideal when you want to keep users from having to use horizontal scrolling
1331
+ :param dest: Optionally specify the destination stream to write to. If unspecified, defaults to self.stdout
1160
1332
 
1161
1333
  WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1162
1334
  """
1163
1335
  # msg can be any type, so convert to string before checking if it's blank
1164
1336
  msg_str = str(msg)
1337
+ dest = self.stdout if dest is None else dest
1165
1338
 
1166
1339
  # Consider None to be no data to print
1167
1340
  if msg is None or msg_str == '':
@@ -1195,7 +1368,7 @@ class Cmd(cmd.Cmd):
1195
1368
  pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE)
1196
1369
  pipe_proc.communicate(msg_str.encode('utf-8', 'replace'))
1197
1370
  else:
1198
- self.poutput(msg_str, end=end)
1371
+ ansi.style_aware_write(dest, f'{msg_str}{end}')
1199
1372
  except BrokenPipeError:
1200
1373
  # This occurs if a command's output is being piped to another process and that process closes before the
1201
1374
  # command is finished. If you would like your application to print a warning message, then set the
@@ -1222,7 +1395,6 @@ class Cmd(cmd.Cmd):
1222
1395
  if rl_type == RlType.GNU:
1223
1396
  readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
1224
1397
  elif rl_type == RlType.PYREADLINE:
1225
- # noinspection PyUnresolvedReferences
1226
1398
  readline.rl.mode._display_completions = self._display_matches_pyreadline
1227
1399
 
1228
1400
  def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]:
@@ -1289,7 +1461,6 @@ class Cmd(cmd.Cmd):
1289
1461
 
1290
1462
  return tokens, raw_tokens
1291
1463
 
1292
- # noinspection PyMethodMayBeStatic, PyUnusedLocal
1293
1464
  def basic_complete(
1294
1465
  self,
1295
1466
  text: str,
@@ -1480,7 +1651,6 @@ class Cmd(cmd.Cmd):
1480
1651
 
1481
1652
  return matches
1482
1653
 
1483
- # noinspection PyUnusedLocal
1484
1654
  def path_complete(
1485
1655
  self, text: str, line: str, begidx: int, endidx: int, *, path_filter: Optional[Callable[[str], bool]] = None
1486
1656
  ) -> List[str]:
@@ -1498,7 +1668,6 @@ class Cmd(cmd.Cmd):
1498
1668
 
1499
1669
  # Used to complete ~ and ~user strings
1500
1670
  def complete_users() -> List[str]:
1501
-
1502
1671
  users = []
1503
1672
 
1504
1673
  # Windows lacks the pwd module so we can't get a list of users.
@@ -1516,10 +1685,8 @@ class Cmd(cmd.Cmd):
1516
1685
 
1517
1686
  # Iterate through a list of users from the password database
1518
1687
  for cur_pw in pwd.getpwall():
1519
-
1520
1688
  # Check if the user has an existing home dir
1521
1689
  if os.path.isdir(cur_pw.pw_dir):
1522
-
1523
1690
  # Add a ~ to the user to match against text
1524
1691
  cur_user = '~' + cur_pw.pw_name
1525
1692
  if cur_user.startswith(text):
@@ -1605,7 +1772,6 @@ class Cmd(cmd.Cmd):
1605
1772
 
1606
1773
  # Build display_matches and add a slash to directories
1607
1774
  for index, cur_match in enumerate(matches):
1608
-
1609
1775
  # Display only the basename of this path in the tab completion suggestions
1610
1776
  self.display_matches.append(os.path.basename(cur_match))
1611
1777
 
@@ -1674,7 +1840,6 @@ class Cmd(cmd.Cmd):
1674
1840
 
1675
1841
  # Must at least have the command
1676
1842
  if len(raw_tokens) > 1:
1677
-
1678
1843
  # True when command line contains any redirection tokens
1679
1844
  has_redirection = False
1680
1845
 
@@ -1766,7 +1931,6 @@ class Cmd(cmd.Cmd):
1766
1931
  :param longest_match_length: longest printed length of the matches
1767
1932
  """
1768
1933
  if rl_type == RlType.GNU:
1769
-
1770
1934
  # Print hint if one exists and we are supposed to display it
1771
1935
  hint_printed = False
1772
1936
  if self.always_show_hint and self.completion_hint:
@@ -1806,7 +1970,6 @@ class Cmd(cmd.Cmd):
1806
1970
 
1807
1971
  # rl_display_match_list() expects matches to be in argv format where
1808
1972
  # substitution is the first element, followed by the matches, and then a NULL.
1809
- # noinspection PyCallingNonCallable,PyTypeChecker
1810
1973
  strings_array = cast(List[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))())
1811
1974
 
1812
1975
  # Copy in the encoded strings and add a NULL to the end
@@ -1826,7 +1989,6 @@ class Cmd(cmd.Cmd):
1826
1989
  :param matches: the tab completion matches to display
1827
1990
  """
1828
1991
  if rl_type == RlType.PYREADLINE:
1829
-
1830
1992
  # Print hint if one exists and we are supposed to display it
1831
1993
  hint_printed = False
1832
1994
  if self.always_show_hint and self.completion_hint:
@@ -1865,9 +2027,8 @@ class Cmd(cmd.Cmd):
1865
2027
  :param parser: the parser to examine
1866
2028
  :return: type of ArgparseCompleter
1867
2029
  """
1868
- completer_type: Optional[
1869
- Type[argparse_completer.ArgparseCompleter]
1870
- ] = parser.get_ap_completer_type() # type: ignore[attr-defined]
2030
+ Completer = Optional[Type[argparse_completer.ArgparseCompleter]]
2031
+ completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined]
1871
2032
 
1872
2033
  if completer_type is None:
1873
2034
  completer_type = argparse_completer.DEFAULT_AP_COMPLETER
@@ -1898,11 +2059,14 @@ class Cmd(cmd.Cmd):
1898
2059
 
1899
2060
  expanded_line = statement.command_and_args
1900
2061
 
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
2062
+ if not expanded_line[-1:].isspace():
2063
+ # Unquoted trailing whitespace gets stripped by parse_command_only().
2064
+ # Restore it since line is only supposed to be lstripped when passed
2065
+ # to completer functions according to the Python cmd docs. Regardless
2066
+ # of what type of whitespace (' ', \n) was stripped, just append spaces
2067
+ # since shlex treats whitespace characters the same when splitting.
2068
+ rstripped_len = len(line) - len(line.rstrip())
2069
+ expanded_line += ' ' * rstripped_len
1906
2070
 
1907
2071
  # Fix the index values if expanded_line has a different size than line
1908
2072
  if len(expanded_line) != len(line):
@@ -1934,7 +2098,7 @@ class Cmd(cmd.Cmd):
1934
2098
  else:
1935
2099
  # There's no completer function, next see if the command uses argparse
1936
2100
  func = self.cmd_func(command)
1937
- argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2101
+ argparser = self._command_parsers.get(command, None)
1938
2102
 
1939
2103
  if func is not None and argparser is not None:
1940
2104
  # Get arguments for complete()
@@ -1980,7 +2144,6 @@ class Cmd(cmd.Cmd):
1980
2144
 
1981
2145
  # Check if the token being completed has an opening quote
1982
2146
  if raw_completion_token and raw_completion_token[0] in constants.QUOTES:
1983
-
1984
2147
  # Since the token is still being completed, we know the opening quote is unclosed.
1985
2148
  # Save the quote so we can add a matching closing quote later.
1986
2149
  completion_token_quote = raw_completion_token[0]
@@ -2005,7 +2168,6 @@ class Cmd(cmd.Cmd):
2005
2168
  self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func)
2006
2169
 
2007
2170
  if self.completion_matches:
2008
-
2009
2171
  # Eliminate duplicates
2010
2172
  self.completion_matches = utils.remove_duplicates(self.completion_matches)
2011
2173
  self.display_matches = utils.remove_duplicates(self.display_matches)
@@ -2020,7 +2182,6 @@ class Cmd(cmd.Cmd):
2020
2182
 
2021
2183
  # Check if we need to add an opening quote
2022
2184
  if not completion_token_quote:
2023
-
2024
2185
  add_quote = False
2025
2186
 
2026
2187
  # This is the tab completion text that will appear on the command line.
@@ -2073,7 +2234,6 @@ class Cmd(cmd.Cmd):
2073
2234
  :param custom_settings: used when not tab completing the main command line
2074
2235
  :return: the next possible completion for text or None
2075
2236
  """
2076
- # noinspection PyBroadException
2077
2237
  try:
2078
2238
  if state == 0:
2079
2239
  self._reset_completion_defaults()
@@ -2081,7 +2241,7 @@ class Cmd(cmd.Cmd):
2081
2241
  # Check if we are completing a multiline command
2082
2242
  if self._at_continuation_prompt:
2083
2243
  # lstrip and prepend the previously typed portion of this multiline command
2084
- lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ')
2244
+ lstripped_previous = self._multiline_in_progress.lstrip()
2085
2245
  line = lstripped_previous + readline.get_line_buffer()
2086
2246
 
2087
2247
  # Increment the indexes to account for the prepended text
@@ -2103,7 +2263,7 @@ class Cmd(cmd.Cmd):
2103
2263
  # from text and update the indexes. This only applies if we are at the beginning of the command line.
2104
2264
  shortcut_to_restore = ''
2105
2265
  if begidx == 0 and custom_settings is None:
2106
- for (shortcut, _) in self.statement_parser.shortcuts:
2266
+ for shortcut, _ in self.statement_parser.shortcuts:
2107
2267
  if text.startswith(shortcut):
2108
2268
  # Save the shortcut to restore later
2109
2269
  shortcut_to_restore = shortcut
@@ -2251,14 +2411,13 @@ class Cmd(cmd.Cmd):
2251
2411
  # Filter out hidden and disabled commands
2252
2412
  return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands]
2253
2413
 
2254
- # noinspection PyUnusedLocal
2255
- def sigint_handler(self, signum: int, _: FrameType) -> None:
2414
+ def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None:
2256
2415
  """Signal handler for SIGINTs which typically come from Ctrl-C events.
2257
2416
 
2258
- If you need custom SIGINT behavior, then override this function.
2417
+ If you need custom SIGINT behavior, then override this method.
2259
2418
 
2260
2419
  :param signum: signal number
2261
- :param _: required param for signal handlers
2420
+ :param _: the current stack frame or None
2262
2421
  """
2263
2422
  if self._cur_pipe_proc_reader is not None:
2264
2423
  # Pass the SIGINT to the current pipe process
@@ -2266,7 +2425,30 @@ class Cmd(cmd.Cmd):
2266
2425
 
2267
2426
  # Check if we are allowed to re-raise the KeyboardInterrupt
2268
2427
  if not self.sigint_protection:
2269
- self._raise_keyboard_interrupt()
2428
+ raise_interrupt = True
2429
+ if self.current_command is not None:
2430
+ command_set = self.find_commandset_for_command(self.current_command.command)
2431
+ if command_set is not None:
2432
+ raise_interrupt = not command_set.sigint_handler()
2433
+ if raise_interrupt:
2434
+ self._raise_keyboard_interrupt()
2435
+
2436
+ def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None:
2437
+ """
2438
+ Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac.
2439
+
2440
+ SIGHUP - received when terminal window is closed
2441
+ SIGTERM - received when this app has been requested to terminate
2442
+
2443
+ The basic purpose of this method is to call sys.exit() so our exit handler will run
2444
+ and save the persistent history file. If you need more complex behavior like killing
2445
+ threads and performing cleanup, then override this method.
2446
+
2447
+ :param signum: signal number
2448
+ :param _: the current stack frame or None
2449
+ """
2450
+ # POSIX systems add 128 to signal numbers for the exit code
2451
+ sys.exit(128 + signum)
2270
2452
 
2271
2453
  def _raise_keyboard_interrupt(self) -> None:
2272
2454
  """Helper function to raise a KeyboardInterrupt"""
@@ -2335,7 +2517,13 @@ class Cmd(cmd.Cmd):
2335
2517
  return statement.command, statement.args, statement.command_and_args
2336
2518
 
2337
2519
  def onecmd_plus_hooks(
2338
- self, line: str, *, add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False
2520
+ self,
2521
+ line: str,
2522
+ *,
2523
+ add_to_history: bool = True,
2524
+ raise_keyboard_interrupt: bool = False,
2525
+ py_bridge_call: bool = False,
2526
+ orig_rl_history_length: Optional[int] = None,
2339
2527
  ) -> bool:
2340
2528
  """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
2341
2529
 
@@ -2347,6 +2535,9 @@ class Cmd(cmd.Cmd):
2347
2535
  :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
2348
2536
  of an app() call from Python. It is used to enable/disable the storage of the
2349
2537
  command's stdout.
2538
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2539
+ This is used to assist in combining multiline readline history entries and is only
2540
+ populated by cmd2. Defaults to None.
2350
2541
  :return: True if running of commands should stop
2351
2542
  """
2352
2543
  import datetime
@@ -2356,7 +2547,7 @@ class Cmd(cmd.Cmd):
2356
2547
 
2357
2548
  try:
2358
2549
  # Convert the line into a Statement
2359
- statement = self._input_line_to_statement(line)
2550
+ statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
2360
2551
 
2361
2552
  # call the postparsing hooks
2362
2553
  postparsing_data = plugin.PostparsingData(False, statement)
@@ -2510,7 +2701,7 @@ class Cmd(cmd.Cmd):
2510
2701
 
2511
2702
  return False
2512
2703
 
2513
- def _complete_statement(self, line: str) -> Statement:
2704
+ def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2514
2705
  """Keep accepting lines of input until the command is complete.
2515
2706
 
2516
2707
  There is some pretty hacky code here to handle some quirks of
@@ -2519,10 +2710,29 @@ class Cmd(cmd.Cmd):
2519
2710
  backwards compatibility with the standard library version of cmd.
2520
2711
 
2521
2712
  :param line: the line being parsed
2713
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2714
+ This is used to assist in combining multiline readline history entries and is only
2715
+ populated by cmd2. Defaults to None.
2522
2716
  :return: the completed Statement
2523
2717
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2524
2718
  :raises: EmptyStatement when the resulting Statement is blank
2525
2719
  """
2720
+
2721
+ def combine_rl_history(statement: Statement) -> None:
2722
+ """Combine all lines of a multiline command into a single readline history entry"""
2723
+ if orig_rl_history_length is None or not statement.multiline_command:
2724
+ return
2725
+
2726
+ # Remove all previous lines added to history for this command
2727
+ while readline.get_current_history_length() > orig_rl_history_length:
2728
+ readline.remove_history_item(readline.get_current_history_length() - 1)
2729
+
2730
+ formatted_command = single_line_format(statement)
2731
+
2732
+ # If formatted command is different than the previous history item, add it
2733
+ if orig_rl_history_length == 0 or formatted_command != readline.get_history_item(orig_rl_history_length):
2734
+ readline.add_history(formatted_command)
2735
+
2526
2736
  while True:
2527
2737
  try:
2528
2738
  statement = self.statement_parser.parse(line)
@@ -2534,7 +2744,7 @@ class Cmd(cmd.Cmd):
2534
2744
  # so we are done
2535
2745
  break
2536
2746
  except Cmd2ShlexError:
2537
- # we have unclosed quotation marks, lets parse only the command
2747
+ # we have an unclosed quotation mark, let's parse only the command
2538
2748
  # and see if it's a multiline
2539
2749
  statement = self.statement_parser.parse_command_only(line)
2540
2750
  if not statement.multiline_command:
@@ -2550,6 +2760,7 @@ class Cmd(cmd.Cmd):
2550
2760
  # Save the command line up to this point for tab completion
2551
2761
  self._multiline_in_progress = line + '\n'
2552
2762
 
2763
+ # Get next line of this command
2553
2764
  nextline = self._read_command_line(self.continuation_prompt)
2554
2765
  if nextline == 'eof':
2555
2766
  # they entered either a blank line, or we hit an EOF
@@ -2558,7 +2769,14 @@ class Cmd(cmd.Cmd):
2558
2769
  # terminator
2559
2770
  nextline = '\n'
2560
2771
  self.poutput(nextline)
2561
- line = f'{self._multiline_in_progress}{nextline}'
2772
+
2773
+ line += f'\n{nextline}'
2774
+
2775
+ # Combine all history lines of this multiline command as we go.
2776
+ if nextline:
2777
+ statement = self.statement_parser.parse_command_only(line)
2778
+ combine_rl_history(statement)
2779
+
2562
2780
  except KeyboardInterrupt:
2563
2781
  self.poutput('^C')
2564
2782
  statement = self.statement_parser.parse('')
@@ -2568,13 +2786,20 @@ class Cmd(cmd.Cmd):
2568
2786
 
2569
2787
  if not statement.command:
2570
2788
  raise EmptyStatement
2789
+ else:
2790
+ # If necessary, update history with completed multiline command.
2791
+ combine_rl_history(statement)
2792
+
2571
2793
  return statement
2572
2794
 
2573
- def _input_line_to_statement(self, line: str) -> Statement:
2795
+ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2574
2796
  """
2575
2797
  Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
2576
2798
 
2577
2799
  :param line: the line being parsed
2800
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2801
+ This is used to assist in combining multiline readline history entries and is only
2802
+ populated by cmd2. Defaults to None.
2578
2803
  :return: parsed command line as a Statement
2579
2804
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2580
2805
  :raises: EmptyStatement when the resulting Statement is blank
@@ -2585,11 +2810,13 @@ class Cmd(cmd.Cmd):
2585
2810
  # Continue until all macros are resolved
2586
2811
  while True:
2587
2812
  # Make sure all input has been read and convert it to a Statement
2588
- statement = self._complete_statement(line)
2813
+ statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
2589
2814
 
2590
- # Save the fully entered line if this is the first loop iteration
2815
+ # If this is the first loop iteration, save the original line and stop
2816
+ # combining multiline history entries in the remaining iterations.
2591
2817
  if orig_line is None:
2592
2818
  orig_line = statement.raw
2819
+ orig_rl_history_length = None
2593
2820
 
2594
2821
  # Check if this command matches a macro and wasn't already processed to avoid an infinite loop
2595
2822
  if statement.command in self.macros.keys() and statement.command not in used_macros:
@@ -2734,13 +2961,8 @@ class Cmd(cmd.Cmd):
2734
2961
  sys.stdout = self.stdout = new_stdout
2735
2962
 
2736
2963
  elif statement.output:
2737
- import tempfile
2738
-
2739
- if (not statement.output_to) and (not self._can_clip):
2740
- raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies")
2741
-
2742
- # Redirecting to a file
2743
- elif statement.output_to:
2964
+ if statement.output_to:
2965
+ # redirecting to a file
2744
2966
  # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT
2745
2967
  mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w'
2746
2968
  try:
@@ -2752,14 +2974,26 @@ class Cmd(cmd.Cmd):
2752
2974
  redir_saved_state.redirecting = True
2753
2975
  sys.stdout = self.stdout = new_stdout
2754
2976
 
2755
- # Redirecting to a paste buffer
2756
2977
  else:
2978
+ # Redirecting to a paste buffer
2979
+ # we are going to direct output to a temporary file, then read it back in and
2980
+ # put it in the paste buffer later
2981
+ if not self.allow_clipboard:
2982
+ raise RedirectionError("Clipboard access not allowed")
2983
+
2984
+ # attempt to get the paste buffer, this forces pyperclip to go figure
2985
+ # out if it can actually interact with the paste buffer, and will throw exceptions
2986
+ # if it's not gonna work. That way we throw the exception before we go
2987
+ # run the command and queue up all the output. if this is going to fail,
2988
+ # no point opening up the temporary file
2989
+ current_paste_buffer = get_paste_buffer()
2990
+ # create a temporary file to store output
2757
2991
  new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+"))
2758
2992
  redir_saved_state.redirecting = True
2759
2993
  sys.stdout = self.stdout = new_stdout
2760
2994
 
2761
2995
  if statement.output == constants.REDIRECTION_APPEND:
2762
- self.stdout.write(get_paste_buffer())
2996
+ self.stdout.write(current_paste_buffer)
2763
2997
  self.stdout.flush()
2764
2998
 
2765
2999
  # These are updated regardless of whether the command redirected
@@ -2824,7 +3058,6 @@ class Cmd(cmd.Cmd):
2824
3058
  target = constants.COMMAND_FUNC_PREFIX + command
2825
3059
  return target if callable(getattr(self, target, None)) else ''
2826
3060
 
2827
- # noinspection PyMethodOverriding
2828
3061
  def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool:
2829
3062
  """This executes the actual do_* method for a command.
2830
3063
 
@@ -2849,7 +3082,11 @@ class Cmd(cmd.Cmd):
2849
3082
  ):
2850
3083
  self.history.append(statement)
2851
3084
 
2852
- stop = func(statement)
3085
+ try:
3086
+ self.current_command = statement
3087
+ stop = func(statement)
3088
+ finally:
3089
+ self.current_command = None
2853
3090
 
2854
3091
  else:
2855
3092
  stop = self.default(statement)
@@ -2865,15 +3102,19 @@ class Cmd(cmd.Cmd):
2865
3102
  if 'shell' not in self.exclude_from_history:
2866
3103
  self.history.append(statement)
2867
3104
 
2868
- # noinspection PyTypeChecker
2869
3105
  return self.do_shell(statement.command_and_args)
2870
3106
  else:
2871
3107
  err_msg = self.default_error.format(statement.command)
3108
+ if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)):
3109
+ err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}"
2872
3110
 
2873
- # Set apply_style to False so default_error's style is not overridden
3111
+ # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden
2874
3112
  self.perror(err_msg, apply_style=False)
2875
3113
  return None
2876
3114
 
3115
+ def _suggest_similar_command(self, command: str) -> Optional[str]:
3116
+ return suggest_similar(command, self.get_visible_commands())
3117
+
2877
3118
  def read_input(
2878
3119
  self,
2879
3120
  prompt: str,
@@ -2928,7 +3169,7 @@ class Cmd(cmd.Cmd):
2928
3169
  nonlocal saved_history
2929
3170
  nonlocal parser
2930
3171
 
2931
- if readline_configured: # pragma: no cover
3172
+ if readline_configured or rl_type == RlType.NONE: # pragma: no cover
2932
3173
  return
2933
3174
 
2934
3175
  # Configure tab completion
@@ -2937,7 +3178,7 @@ class Cmd(cmd.Cmd):
2937
3178
 
2938
3179
  # Disable completion
2939
3180
  if completion_mode == utils.CompletionMode.NONE:
2940
- # noinspection PyUnusedLocal
3181
+
2941
3182
  def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover
2942
3183
  return None
2943
3184
 
@@ -2968,7 +3209,6 @@ class Cmd(cmd.Cmd):
2968
3209
  if completion_mode != utils.CompletionMode.COMMANDS or history is not None:
2969
3210
  saved_history = []
2970
3211
  for i in range(1, readline.get_current_history_length() + 1):
2971
- # noinspection PyArgumentList
2972
3212
  saved_history.append(readline.get_history_item(i))
2973
3213
 
2974
3214
  readline.clear_history()
@@ -2981,7 +3221,7 @@ class Cmd(cmd.Cmd):
2981
3221
  def restore_readline() -> None:
2982
3222
  """Restore readline tab completion and history"""
2983
3223
  nonlocal readline_configured
2984
- if not readline_configured: # pragma: no cover
3224
+ if not readline_configured or rl_type == RlType.NONE: # pragma: no cover
2985
3225
  return
2986
3226
 
2987
3227
  if self._completion_supported():
@@ -3065,8 +3305,13 @@ class Cmd(cmd.Cmd):
3065
3305
  """
3066
3306
  readline_settings = _SavedReadlineSettings()
3067
3307
 
3068
- if self._completion_supported():
3308
+ if rl_type == RlType.GNU:
3309
+ # To calculate line count when printing async_alerts, we rely on commands wider than
3310
+ # the terminal to wrap across multiple lines. The default for horizontal-scroll-mode
3311
+ # is "off" but a user may have overridden it in their readline initialization file.
3312
+ readline.parse_and_bind("set horizontal-scroll-mode off")
3069
3313
 
3314
+ if self._completion_supported():
3070
3315
  # Set up readline for our tab completion needs
3071
3316
  if rl_type == RlType.GNU:
3072
3317
  # GNU readline automatically adds a closing quote if the text being completed has an opening quote.
@@ -3100,7 +3345,6 @@ class Cmd(cmd.Cmd):
3100
3345
  :param readline_settings: the readline settings to restore
3101
3346
  """
3102
3347
  if self._completion_supported():
3103
-
3104
3348
  # Restore what we changed in readline
3105
3349
  readline.set_completer(readline_settings.completer)
3106
3350
  readline.set_completer_delims(readline_settings.delims)
@@ -3109,7 +3353,6 @@ class Cmd(cmd.Cmd):
3109
3353
  readline.set_completion_display_matches_hook(None)
3110
3354
  rl_basic_quote_characters.value = readline_settings.basic_quotes
3111
3355
  elif rl_type == RlType.PYREADLINE:
3112
- # noinspection PyUnresolvedReferences
3113
3356
  readline.rl.mode._display_completions = orig_pyreadline_display
3114
3357
 
3115
3358
  def _cmdloop(self) -> None:
@@ -3131,6 +3374,13 @@ class Cmd(cmd.Cmd):
3131
3374
  self._startup_commands.clear()
3132
3375
 
3133
3376
  while not stop:
3377
+ # Used in building multiline readline history entries. Only applies
3378
+ # when command line is read by input() in a terminal.
3379
+ if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty():
3380
+ orig_rl_history_length = readline.get_current_history_length()
3381
+ else:
3382
+ orig_rl_history_length = None
3383
+
3134
3384
  # Get commands from user
3135
3385
  try:
3136
3386
  line = self._read_command_line(self.prompt)
@@ -3139,7 +3389,7 @@ class Cmd(cmd.Cmd):
3139
3389
  line = ''
3140
3390
 
3141
3391
  # Run the command along with all associated pre and post hooks
3142
- stop = self.onecmd_plus_hooks(line)
3392
+ stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
3143
3393
  finally:
3144
3394
  # Get sigint protection while we restore readline settings
3145
3395
  with self.sigint_protection:
@@ -3573,7 +3823,7 @@ class Cmd(cmd.Cmd):
3573
3823
 
3574
3824
  # Check if this command uses argparse
3575
3825
  func = self.cmd_func(command)
3576
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3826
+ argparser = self._command_parsers.get(command, None)
3577
3827
  if func is None or argparser is None:
3578
3828
  return []
3579
3829
 
@@ -3609,7 +3859,7 @@ class Cmd(cmd.Cmd):
3609
3859
  # Getting help for a specific command
3610
3860
  func = self.cmd_func(args.command)
3611
3861
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3612
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3862
+ argparser = self._command_parsers.get(args.command, None)
3613
3863
 
3614
3864
  # If the command function uses argparse, then use argparse's help
3615
3865
  if func is not None and argparser is not None:
@@ -3745,7 +3995,7 @@ class Cmd(cmd.Cmd):
3745
3995
  help_topics.remove(command)
3746
3996
 
3747
3997
  # Non-argparse commands can have help_functions for their documentation
3748
- if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3998
+ if command not in self._command_parsers:
3749
3999
  has_help_func = True
3750
4000
 
3751
4001
  if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3791,7 +4041,7 @@ class Cmd(cmd.Cmd):
3791
4041
  doc: Optional[str]
3792
4042
 
3793
4043
  # Non-argparse commands can have help_functions for their documentation
3794
- if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
4044
+ if command not in self._command_parsers and command in topics:
3795
4045
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
3796
4046
  result = io.StringIO()
3797
4047
 
@@ -3844,7 +4094,6 @@ class Cmd(cmd.Cmd):
3844
4094
  self.poutput()
3845
4095
 
3846
4096
  # self.last_result will be set by do_quit()
3847
- # noinspection PyTypeChecker
3848
4097
  return self.do_quit('')
3849
4098
 
3850
4099
  quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application")
@@ -3881,7 +4130,7 @@ class Cmd(cmd.Cmd):
3881
4130
  fulloptions.append((opt[0], opt[1]))
3882
4131
  except IndexError:
3883
4132
  fulloptions.append((opt[0], opt[0]))
3884
- for (idx, (_, text)) in enumerate(fulloptions):
4133
+ for idx, (_, text) in enumerate(fulloptions):
3885
4134
  self.poutput(' %2d. %s' % (idx + 1, text))
3886
4135
 
3887
4136
  while True:
@@ -3980,7 +4229,6 @@ class Cmd(cmd.Cmd):
3980
4229
  try:
3981
4230
  orig_value = settable.get_value()
3982
4231
  new_value = settable.set_value(utils.strip_quotes(args.value))
3983
- # noinspection PyBroadException
3984
4232
  except Exception as ex:
3985
4233
  self.perror(f"Error setting {args.param}: {ex}")
3986
4234
  else:
@@ -4116,7 +4364,6 @@ class Cmd(cmd.Cmd):
4116
4364
  if rl_type != RlType.NONE:
4117
4365
  # Save cmd2 history
4118
4366
  for i in range(1, readline.get_current_history_length() + 1):
4119
- # noinspection PyArgumentList
4120
4367
  cmd2_env.history.append(readline.get_history_item(i))
4121
4368
 
4122
4369
  readline.clear_history()
@@ -4150,7 +4397,6 @@ class Cmd(cmd.Cmd):
4150
4397
  if rl_type == RlType.GNU:
4151
4398
  readline.set_completion_display_matches_hook(None)
4152
4399
  elif rl_type == RlType.PYREADLINE:
4153
- # noinspection PyUnresolvedReferences
4154
4400
  readline.rl.mode._display_completions = orig_pyreadline_display
4155
4401
 
4156
4402
  # Save off the current completer and set a new one in the Python console
@@ -4185,7 +4431,6 @@ class Cmd(cmd.Cmd):
4185
4431
  # Save py's history
4186
4432
  self._py_history.clear()
4187
4433
  for i in range(1, readline.get_current_history_length() + 1):
4188
- # noinspection PyArgumentList
4189
4434
  self._py_history.append(readline.get_history_item(i))
4190
4435
 
4191
4436
  readline.clear_history()
@@ -4229,7 +4474,8 @@ class Cmd(cmd.Cmd):
4229
4474
  PyBridge,
4230
4475
  )
4231
4476
 
4232
- py_bridge = PyBridge(self)
4477
+ add_to_history = self.scripts_add_to_history if pyscript else True
4478
+ py_bridge = PyBridge(self, add_to_history=add_to_history)
4233
4479
  saved_sys_path = None
4234
4480
 
4235
4481
  if self.in_pyscript():
@@ -4281,7 +4527,6 @@ class Cmd(cmd.Cmd):
4281
4527
 
4282
4528
  # Check if we are running Python code
4283
4529
  if py_code_to_run:
4284
- # noinspection PyBroadException
4285
4530
  try:
4286
4531
  interp.runcode(py_code_to_run) # type: ignore[arg-type]
4287
4532
  except BaseException:
@@ -4299,7 +4544,6 @@ class Cmd(cmd.Cmd):
4299
4544
 
4300
4545
  saved_cmd2_env = None
4301
4546
 
4302
- # noinspection PyBroadException
4303
4547
  try:
4304
4548
  # Get sigint protection while we set up the Python shell environment
4305
4549
  with self.sigint_protection:
@@ -4380,7 +4624,6 @@ class Cmd(cmd.Cmd):
4380
4624
 
4381
4625
  ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
4382
4626
 
4383
- # noinspection PyPackageRequirements
4384
4627
  @with_argparser(ipython_parser)
4385
4628
  def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover
4386
4629
  """
@@ -4393,9 +4636,13 @@ class Cmd(cmd.Cmd):
4393
4636
  # Detect whether IPython is installed
4394
4637
  try:
4395
4638
  import traitlets.config.loader as TraitletsLoader # type: ignore[import]
4396
- from IPython import ( # type: ignore[import]
4397
- start_ipython,
4398
- )
4639
+
4640
+ # Allow users to install ipython from a cmd2 prompt when needed and still have ipy command work
4641
+ try:
4642
+ start_ipython # noqa F823
4643
+ except NameError:
4644
+ from IPython import start_ipython # type: ignore[import]
4645
+
4399
4646
  from IPython.terminal.interactiveshell import ( # type: ignore[import]
4400
4647
  TerminalInteractiveShell,
4401
4648
  )
@@ -4436,7 +4683,7 @@ class Cmd(cmd.Cmd):
4436
4683
  )
4437
4684
 
4438
4685
  # Start IPython
4439
- start_ipython(config=config, argv=[], user_ns=local_vars)
4686
+ start_ipython(config=config, argv=[], user_ns=local_vars) # type: ignore[no-untyped-call]
4440
4687
  self.poutput("Now exiting IPython shell...")
4441
4688
 
4442
4689
  # The IPython application is a singleton and won't be recreated next time
@@ -4551,8 +4798,6 @@ class Cmd(cmd.Cmd):
4551
4798
  self.last_result = True
4552
4799
  return stop
4553
4800
  elif args.edit:
4554
- import tempfile
4555
-
4556
4801
  fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
4557
4802
  fobj: TextIO
4558
4803
  with os.fdopen(fd, 'w') as fobj:
@@ -4565,7 +4810,6 @@ class Cmd(cmd.Cmd):
4565
4810
  self.run_editor(fname)
4566
4811
 
4567
4812
  # self.last_resort will be set by do_run_script()
4568
- # noinspection PyTypeChecker
4569
4813
  return self.do_run_script(utils.quote_string(fname))
4570
4814
  finally:
4571
4815
  os.remove(fname)
@@ -4625,11 +4869,9 @@ class Cmd(cmd.Cmd):
4625
4869
  previous sessions will be included. Additionally, all history will be written
4626
4870
  to this file when the application exits.
4627
4871
  """
4628
- import json
4629
- import lzma
4630
-
4631
4872
  self.history = History()
4632
- # with no persistent history, nothing else in this method is relevant
4873
+
4874
+ # With no persistent history, nothing else in this method is relevant
4633
4875
  if not hist_file:
4634
4876
  self.persistent_history_file = hist_file
4635
4877
  return
@@ -4650,64 +4892,96 @@ class Cmd(cmd.Cmd):
4650
4892
  self.perror(f"Error creating persistent history file directory '{hist_file_dir}': {ex}")
4651
4893
  return
4652
4894
 
4653
- # Read and process history file
4895
+ # Read history file
4654
4896
  try:
4655
4897
  with open(hist_file, 'rb') as fobj:
4656
4898
  compressed_bytes = fobj.read()
4657
- history_json = lzma.decompress(compressed_bytes).decode(encoding='utf-8')
4658
- self.history = History.from_json(history_json)
4659
4899
  except FileNotFoundError:
4660
- # Just use an empty history
4661
- pass
4900
+ compressed_bytes = bytes()
4662
4901
  except OSError as ex:
4663
4902
  self.perror(f"Cannot read persistent history file '{hist_file}': {ex}")
4664
4903
  return
4665
- except (json.JSONDecodeError, lzma.LZMAError, KeyError, UnicodeDecodeError, ValueError) as ex:
4904
+
4905
+ # Register a function to write history at save
4906
+ import atexit
4907
+
4908
+ self.persistent_history_file = hist_file
4909
+ atexit.register(self._persist_history)
4910
+
4911
+ # Empty or nonexistent history file. Nothing more to do.
4912
+ if not compressed_bytes:
4913
+ return
4914
+
4915
+ # Decompress history data
4916
+ try:
4917
+ import lzma as decompress_lib
4918
+
4919
+ decompress_exceptions: Tuple[type[Exception]] = (decompress_lib.LZMAError,)
4920
+ except ModuleNotFoundError: # pragma: no cover
4921
+ import bz2 as decompress_lib # type: ignore[no-redef]
4922
+
4923
+ decompress_exceptions: Tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef]
4924
+
4925
+ try:
4926
+ history_json = decompress_lib.decompress(compressed_bytes).decode(encoding='utf-8')
4927
+ except decompress_exceptions as ex:
4928
+ self.perror(
4929
+ f"Error decompressing persistent history data '{hist_file}': {ex}\n"
4930
+ f"The history file will be recreated when this application exits."
4931
+ )
4932
+ return
4933
+
4934
+ # Decode history json
4935
+ import json
4936
+
4937
+ try:
4938
+ self.history = History.from_json(history_json)
4939
+ except (json.JSONDecodeError, KeyError, ValueError) as ex:
4666
4940
  self.perror(
4667
- f"Error processing persistent history file '{hist_file}': {ex}\n"
4941
+ f"Error processing persistent history data '{hist_file}': {ex}\n"
4668
4942
  f"The history file will be recreated when this application exits."
4669
4943
  )
4944
+ return
4670
4945
 
4671
4946
  self.history.start_session()
4672
- self.persistent_history_file = hist_file
4673
4947
 
4674
- # populate readline history
4948
+ # Populate readline history
4675
4949
  if rl_type != RlType.NONE:
4676
- last = None
4677
4950
  for item in self.history:
4678
- # Break the command into its individual lines
4679
- for line in item.raw.splitlines():
4680
- # readline only adds a single entry for multiple sequential identical lines
4681
- # so we emulate that behavior here
4682
- if line != last:
4683
- readline.add_history(line)
4684
- last = line
4685
-
4686
- # register a function to write history at save
4687
- # if the history file is in plain text format from 0.9.12 or lower
4688
- # this will fail, and the history in the plain text file will be lost
4689
- import atexit
4951
+ formatted_command = single_line_format(item.statement)
4690
4952
 
4691
- atexit.register(self._persist_history)
4953
+ # If formatted command is different than the previous history item, add it
4954
+ cur_history_length = readline.get_current_history_length()
4955
+ if cur_history_length == 0 or formatted_command != readline.get_history_item(cur_history_length):
4956
+ readline.add_history(formatted_command)
4692
4957
 
4693
4958
  def _persist_history(self) -> None:
4694
4959
  """Write history out to the persistent history file as compressed JSON"""
4695
- import lzma
4696
-
4697
4960
  if not self.persistent_history_file:
4698
4961
  return
4699
4962
 
4700
- self.history.truncate(self._persistent_history_length)
4701
4963
  try:
4702
- history_json = self.history.to_json()
4703
- compressed_bytes = lzma.compress(history_json.encode(encoding='utf-8'))
4964
+ import lzma as compress_lib
4965
+ except ModuleNotFoundError: # pragma: no cover
4966
+ import bz2 as compress_lib # type: ignore[no-redef]
4967
+
4968
+ self.history.truncate(self._persistent_history_length)
4969
+ history_json = self.history.to_json()
4970
+ compressed_bytes = compress_lib.compress(history_json.encode(encoding='utf-8'))
4704
4971
 
4972
+ try:
4705
4973
  with open(self.persistent_history_file, 'wb') as fobj:
4706
4974
  fobj.write(compressed_bytes)
4707
4975
  except OSError as ex:
4708
4976
  self.perror(f"Cannot write persistent history file '{self.persistent_history_file}': {ex}")
4709
4977
 
4710
- def _generate_transcript(self, history: Union[List[HistoryItem], List[str]], transcript_file: str) -> None:
4978
+ def _generate_transcript(
4979
+ self,
4980
+ history: Union[List[HistoryItem], List[str]],
4981
+ transcript_file: str,
4982
+ *,
4983
+ add_to_history: bool = True,
4984
+ ) -> None:
4711
4985
  """Generate a transcript file from a given history of commands"""
4712
4986
  self.last_result = False
4713
4987
 
@@ -4757,7 +5031,11 @@ class Cmd(cmd.Cmd):
4757
5031
 
4758
5032
  # then run the command and let the output go into our buffer
4759
5033
  try:
4760
- stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
5034
+ stop = self.onecmd_plus_hooks(
5035
+ history_item,
5036
+ add_to_history=add_to_history,
5037
+ raise_keyboard_interrupt=True,
5038
+ )
4761
5039
  except KeyboardInterrupt as ex:
4762
5040
  self.perror(ex)
4763
5041
  stop = True
@@ -4829,7 +5107,6 @@ class Cmd(cmd.Cmd):
4829
5107
  if file_path:
4830
5108
  command += " " + utils.quote_string(os.path.expanduser(file_path))
4831
5109
 
4832
- # noinspection PyTypeChecker
4833
5110
  self.do_shell(command)
4834
5111
 
4835
5112
  @property
@@ -4902,9 +5179,17 @@ class Cmd(cmd.Cmd):
4902
5179
 
4903
5180
  if args.transcript:
4904
5181
  # self.last_resort will be set by _generate_transcript()
4905
- self._generate_transcript(script_commands, os.path.expanduser(args.transcript))
5182
+ self._generate_transcript(
5183
+ script_commands,
5184
+ os.path.expanduser(args.transcript),
5185
+ add_to_history=self.scripts_add_to_history,
5186
+ )
4906
5187
  else:
4907
- stop = self.runcmds_plus_hooks(script_commands, stop_on_keyboard_interrupt=True)
5188
+ stop = self.runcmds_plus_hooks(
5189
+ script_commands,
5190
+ add_to_history=self.scripts_add_to_history,
5191
+ stop_on_keyboard_interrupt=True,
5192
+ )
4908
5193
  self.last_result = True
4909
5194
  return stop
4910
5195
 
@@ -4941,7 +5226,6 @@ class Cmd(cmd.Cmd):
4941
5226
  relative_path = os.path.join(self._current_script_dir or '', file_path)
4942
5227
 
4943
5228
  # self.last_result will be set by do_run_script()
4944
- # noinspection PyTypeChecker
4945
5229
  return self.do_run_script(utils.quote_string(relative_path))
4946
5230
 
4947
5231
  def _run_transcript_tests(self, transcript_paths: List[str]) -> None:
@@ -4984,7 +5268,6 @@ class Cmd(cmd.Cmd):
4984
5268
  sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main()
4985
5269
  testcase = TestMyAppCase()
4986
5270
  stream = cast(TextIO, utils.StdSim(sys.stderr))
4987
- # noinspection PyTypeChecker
4988
5271
  runner = unittest.TextTestRunner(stream=stream)
4989
5272
  start_time = time.time()
4990
5273
  test_results = runner.run(testcase)
@@ -4992,8 +5275,8 @@ class Cmd(cmd.Cmd):
4992
5275
  if test_results.wasSuccessful():
4993
5276
  ansi.style_aware_write(sys.stderr, stream.read())
4994
5277
  finish_msg = f' {num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds '
4995
- finish_msg = ansi.style_success(utils.align_center(finish_msg, fill_char='='))
4996
- self.poutput(finish_msg)
5278
+ finish_msg = utils.align_center(finish_msg, fill_char='=')
5279
+ self.psuccess(finish_msg)
4997
5280
  else:
4998
5281
  # Strip off the initial traceback which isn't particularly useful for end users
4999
5282
  error_str = stream.read()
@@ -5010,16 +5293,16 @@ class Cmd(cmd.Cmd):
5010
5293
  def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
5011
5294
  """
5012
5295
  Display an important message to the user while they are at a command line prompt.
5013
- To the user it appears as if an alert message is printed above the prompt and their current input
5014
- text and cursor location is left alone.
5296
+ To the user it appears as if an alert message is printed above the prompt and their
5297
+ current input text and cursor location is left alone.
5015
5298
 
5016
- IMPORTANT: This function will not print an alert unless it can acquire self.terminal_lock to ensure
5017
- a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5018
- to guarantee the alert prints and to avoid raising a RuntimeError.
5299
+ This function needs to acquire self.terminal_lock to ensure a prompt is on screen.
5300
+ Therefore, it is best to acquire the lock before calling this function to avoid
5301
+ raising a RuntimeError.
5019
5302
 
5020
- This function is only needed when you need to print an alert while the main thread is blocking
5021
- at the prompt. Therefore, this should never be called from the main thread. Doing so will
5022
- raise a RuntimeError.
5303
+ This function is only needed when you need to print an alert or update the prompt while the
5304
+ main thread is blocking at the prompt. Therefore, this should never be called from the main
5305
+ thread. Doing so will raise a RuntimeError.
5023
5306
 
5024
5307
  :param alert_msg: the message to display to the user
5025
5308
  :param new_prompt: If you also want to change the prompt that is displayed, then include it here.
@@ -5035,7 +5318,6 @@ class Cmd(cmd.Cmd):
5035
5318
 
5036
5319
  # Sanity check that can't fail if self.terminal_lock was acquired before calling this function
5037
5320
  if self.terminal_lock.acquire(blocking=False):
5038
-
5039
5321
  # Windows terminals tend to flicker when we redraw the prompt and input lines.
5040
5322
  # To reduce how often this occurs, only update terminal if there are changes.
5041
5323
  update_terminal = False
@@ -5047,20 +5329,18 @@ class Cmd(cmd.Cmd):
5047
5329
  if new_prompt is not None:
5048
5330
  self.prompt = new_prompt
5049
5331
 
5050
- # Check if the prompt to display has changed from what's currently displayed
5051
- cur_onscreen_prompt = rl_get_prompt()
5052
- new_onscreen_prompt = self.continuation_prompt if self._at_continuation_prompt else self.prompt
5053
-
5054
- if new_onscreen_prompt != cur_onscreen_prompt:
5332
+ # Check if the onscreen prompt needs to be refreshed to match self.prompt.
5333
+ if self.need_prompt_refresh():
5055
5334
  update_terminal = True
5335
+ rl_set_prompt(self.prompt)
5056
5336
 
5057
5337
  if update_terminal:
5058
5338
  import shutil
5059
5339
 
5060
- # Generate the string which will replace the current prompt and input lines with the alert
5340
+ # Print a string which replaces the onscreen prompt and input lines with the alert.
5061
5341
  terminal_str = ansi.async_alert_str(
5062
5342
  terminal_columns=shutil.get_terminal_size().columns,
5063
- prompt=cur_onscreen_prompt,
5343
+ prompt=rl_get_display_prompt(),
5064
5344
  line=readline.get_line_buffer(),
5065
5345
  cursor_offset=rl_get_point(),
5066
5346
  alert_msg=alert_msg,
@@ -5069,12 +5349,8 @@ class Cmd(cmd.Cmd):
5069
5349
  sys.stderr.write(terminal_str)
5070
5350
  sys.stderr.flush()
5071
5351
  elif rl_type == RlType.PYREADLINE:
5072
- # noinspection PyUnresolvedReferences
5073
5352
  readline.rl.mode.console.write(terminal_str)
5074
5353
 
5075
- # Update Readline's prompt before we redraw it
5076
- rl_set_prompt(new_onscreen_prompt)
5077
-
5078
5354
  # Redraw the prompt and input lines below the alert
5079
5355
  rl_force_redisplay()
5080
5356
 
@@ -5085,23 +5361,17 @@ class Cmd(cmd.Cmd):
5085
5361
 
5086
5362
  def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
5087
5363
  """
5088
- Update the command line prompt while the user is still typing at it. This is good for alerting the user to
5089
- system changes dynamically in between commands. For instance you could alter the color of the prompt to
5090
- indicate a system status or increase a counter to report an event. If you do alter the actual text of the
5091
- prompt, it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will
5092
- be shifted and the update will not be seamless.
5093
-
5094
- IMPORTANT: This function will not update the prompt unless it can acquire self.terminal_lock to ensure
5095
- a prompt is onscreen. Therefore, it is best to acquire the lock before calling this function
5096
- to guarantee the prompt changes and to avoid raising a RuntimeError.
5364
+ Update the command line prompt while the user is still typing at it.
5097
5365
 
5098
- This function is only needed when you need to update the prompt while the main thread is blocking
5099
- at the prompt. Therefore, this should never be called from the main thread. Doing so will
5100
- raise a RuntimeError.
5366
+ This is good for alerting the user to system changes dynamically in between commands.
5367
+ For instance you could alter the color of the prompt to indicate a system status or increase a
5368
+ counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5369
+ the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5370
+ and the update will not be seamless.
5101
5371
 
5102
- If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5103
- not change. However, self.prompt will still be updated and display immediately after the multiline
5104
- line command completes.
5372
+ If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5373
+ not change. However, self.prompt will still be updated and display immediately after the multiline
5374
+ line command completes.
5105
5375
 
5106
5376
  :param new_prompt: what to change the prompt to
5107
5377
  :raises RuntimeError: if called from the main thread.
@@ -5109,6 +5379,32 @@ class Cmd(cmd.Cmd):
5109
5379
  """
5110
5380
  self.async_alert('', new_prompt)
5111
5381
 
5382
+ def async_refresh_prompt(self) -> None: # pragma: no cover
5383
+ """
5384
+ Refresh the oncreen prompt to match self.prompt.
5385
+
5386
+ One case where the onscreen prompt and self.prompt can get out of sync is
5387
+ when async_alert() is called while a user is in search mode (e.g. Ctrl-r).
5388
+ To prevent overwriting readline's onscreen search prompt, self.prompt is updated
5389
+ but readline's saved prompt isn't.
5390
+
5391
+ Therefore when a user aborts a search, the old prompt is still on screen until they
5392
+ press Enter or this method is called. Call need_prompt_refresh() in an async print
5393
+ thread to know when a refresh is needed.
5394
+
5395
+ :raises RuntimeError: if called from the main thread.
5396
+ :raises RuntimeError: if called while another thread holds `terminal_lock`
5397
+ """
5398
+ self.async_alert('')
5399
+
5400
+ def need_prompt_refresh(self) -> bool: # pragma: no cover
5401
+ """Check whether the onscreen prompt needs to be asynchronously refreshed to match self.prompt."""
5402
+ if not (vt100_support and self.use_rawinput):
5403
+ return False
5404
+
5405
+ # Don't overwrite a readline search prompt or a continuation prompt.
5406
+ return not rl_in_search_mode() and not self._at_continuation_prompt and self.prompt != rl_get_prompt()
5407
+
5112
5408
  @staticmethod
5113
5409
  def set_window_title(title: str) -> None: # pragma: no cover
5114
5410
  """
@@ -5252,14 +5548,21 @@ class Cmd(cmd.Cmd):
5252
5548
  """
5253
5549
  # cmdloop() expects to be run in the main thread to support extensive use of KeyboardInterrupts throughout the
5254
5550
  # other built-in functions. You are free to override cmdloop, but much of cmd2's features will be limited.
5255
- if not threading.current_thread() is threading.main_thread():
5551
+ if threading.current_thread() is not threading.main_thread():
5256
5552
  raise RuntimeError("cmdloop must be run in the main thread")
5257
5553
 
5258
- # Register a SIGINT signal handler for Ctrl+C
5554
+ # Register signal handlers
5259
5555
  import signal
5260
5556
 
5261
5557
  original_sigint_handler = signal.getsignal(signal.SIGINT)
5262
- signal.signal(signal.SIGINT, self.sigint_handler) # type: ignore
5558
+ signal.signal(signal.SIGINT, self.sigint_handler)
5559
+
5560
+ if not sys.platform.startswith('win'):
5561
+ original_sighup_handler = signal.getsignal(signal.SIGHUP)
5562
+ signal.signal(signal.SIGHUP, self.termination_signal_handler)
5563
+
5564
+ original_sigterm_handler = signal.getsignal(signal.SIGTERM)
5565
+ signal.signal(signal.SIGTERM, self.termination_signal_handler)
5263
5566
 
5264
5567
  # Grab terminal lock before the command line prompt has been drawn by readline
5265
5568
  self.terminal_lock.acquire()
@@ -5293,9 +5596,13 @@ class Cmd(cmd.Cmd):
5293
5596
  # This will also zero the lock count in case cmdloop() is called again
5294
5597
  self.terminal_lock.release()
5295
5598
 
5296
- # Restore the original signal handler
5599
+ # Restore original signal handlers
5297
5600
  signal.signal(signal.SIGINT, original_sigint_handler)
5298
5601
 
5602
+ if not sys.platform.startswith('win'):
5603
+ signal.signal(signal.SIGHUP, original_sighup_handler)
5604
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
5605
+
5299
5606
  return self.exit_code
5300
5607
 
5301
5608
  ###
@@ -5446,7 +5753,7 @@ class Cmd(cmd.Cmd):
5446
5753
  func_self = None
5447
5754
  candidate_sets: List[CommandSet] = []
5448
5755
  for installed_cmd_set in self._installed_command_sets:
5449
- if type(installed_cmd_set) == func_class:
5756
+ if type(installed_cmd_set) == func_class: # noqa: E721
5450
5757
  # Case 2: CommandSet is an exact type match for the function's CommandSet
5451
5758
  func_self = installed_cmd_set
5452
5759
  break