cmd2 2.4.3__py3-none-any.whl → 2.5.9__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,89 @@ 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
+
221
+ class _CommandParsers:
222
+ """
223
+ Create and store all command method argument parsers for a given Cmd instance.
224
+
225
+ Parser creation and retrieval are accomplished through the get() method.
226
+ """
227
+
228
+ def __init__(self, cmd: 'Cmd') -> None:
229
+ self._cmd = cmd
230
+
231
+ # Keyed by the fully qualified method names. This is more reliable than
232
+ # the methods themselves, since wrapping a method will change its address.
233
+ self._parsers: Dict[str, argparse.ArgumentParser] = {}
234
+
235
+ @staticmethod
236
+ def _fully_qualified_name(command_method: CommandFunc) -> str:
237
+ """Return the fully qualified name of a method or None if a method wasn't passed in."""
238
+ try:
239
+ return f"{command_method.__module__}.{command_method.__qualname__}"
240
+ except AttributeError:
241
+ return ""
242
+
243
+ def __contains__(self, command_method: CommandFunc) -> bool:
244
+ """
245
+ Return whether a given method's parser is in self.
246
+
247
+ If the parser does not yet exist, it will be created if applicable.
248
+ This is basically for checking if a method is argarse-based.
249
+ """
250
+ parser = self.get(command_method)
251
+ return bool(parser)
252
+
253
+ def get(self, command_method: CommandFunc) -> Optional[argparse.ArgumentParser]:
254
+ """
255
+ Return a given method's parser or None if the method is not argparse-based.
256
+
257
+ If the parser does not yet exist, it will be created.
258
+ """
259
+ full_method_name = self._fully_qualified_name(command_method)
260
+ if not full_method_name:
261
+ return None
262
+
263
+ if full_method_name not in self._parsers:
264
+ if not command_method.__name__.startswith(COMMAND_FUNC_PREFIX):
265
+ return None
266
+ command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :]
267
+
268
+ parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
269
+ parent = self._cmd.find_commandset_for_command(command) or self._cmd
270
+ parser = self._cmd._build_parser(parent, parser_builder)
271
+ if parser is None:
272
+ return None
273
+
274
+ # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
275
+ from .decorators import (
276
+ _set_parser_prog,
277
+ )
278
+
279
+ _set_parser_prog(parser, command)
280
+
281
+ # If the description has not been set, then use the method docstring if one exists
282
+ if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
283
+ parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
284
+
285
+ self._parsers[full_method_name] = parser
286
+
287
+ return self._parsers.get(full_method_name)
288
+
289
+ def remove(self, command_method: CommandFunc) -> None:
290
+ """Remove a given method's parser if it exists."""
291
+ full_method_name = self._fully_qualified_name(command_method)
292
+ if full_method_name in self._parsers:
293
+ del self._parsers[full_method_name]
294
+
295
+
200
296
  class Cmd(cmd.Cmd):
201
297
  """An easy but powerful framework for writing line-oriented command interpreters.
202
298
 
@@ -209,7 +305,7 @@ class Cmd(cmd.Cmd):
209
305
  DEFAULT_EDITOR = utils.find_editor()
210
306
 
211
307
  INTERNAL_COMMAND_EPILOG = (
212
- "Notes:\n" " This command is for internal use and is not intended to be called from the\n" " command line."
308
+ "Notes:\n This command is for internal use and is not intended to be called from the\n command line."
213
309
  )
214
310
 
215
311
  # Sorting keys for strings
@@ -236,6 +332,8 @@ class Cmd(cmd.Cmd):
236
332
  shortcuts: Optional[Dict[str, str]] = None,
237
333
  command_sets: Optional[Iterable[CommandSet]] = None,
238
334
  auto_load_commands: bool = True,
335
+ allow_clipboard: bool = True,
336
+ suggest_similar_command: bool = False,
239
337
  ) -> None:
240
338
  """An easy but powerful framework for writing line-oriented command
241
339
  interpreters. Extends Python's cmd package.
@@ -251,7 +349,7 @@ class Cmd(cmd.Cmd):
251
349
  suppressed. Anything written to stderr will still display.
252
350
  :param include_py: should the "py" command be included for an embedded Python shell
253
351
  :param include_ipy: should the "ipy" command be included for an embedded IPython shell
254
- :param allow_cli_args: if ``True``, then :meth:`cmd2.Cmd.__init__` will process command
352
+ :param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command
255
353
  line arguments as either commands to be run or, if ``-t`` or
256
354
  ``--test`` are given, transcript files to run. This should be
257
355
  set to ``False`` if your application parses its own command line
@@ -283,6 +381,10 @@ class Cmd(cmd.Cmd):
283
381
  that are currently loaded by Python and automatically
284
382
  instantiate and register all commands. If False, CommandSets
285
383
  must be manually installed with `register_command_set`.
384
+ :param allow_clipboard: If False, cmd2 will disable clipboard interactions
385
+ :param suggest_similar_command: If ``True``, ``cmd2`` will attempt to suggest the most
386
+ similar command when the user types a command that does
387
+ not exist. Default: ``False``.
286
388
  """
287
389
  # Check if py or ipy need to be disabled in this instance
288
390
  if not include_py:
@@ -308,6 +410,7 @@ class Cmd(cmd.Cmd):
308
410
  self.editor = Cmd.DEFAULT_EDITOR
309
411
  self.feedback_to_output = False # Do not include nonessentials in >, | output by default (things like timing)
310
412
  self.quiet = False # Do not suppress nonessential output
413
+ self.scripts_add_to_history = True # Scripts and pyscripts add commands to history
311
414
  self.timing = False # Prints elapsed time for each command
312
415
 
313
416
  # The maximum number of CompletionItems to display during tab completion. If the number of completion
@@ -335,6 +438,7 @@ class Cmd(cmd.Cmd):
335
438
  self.hidden_commands = ['eof', '_relative_run_script']
336
439
 
337
440
  # Initialize history
441
+ self.persistent_history_file = ''
338
442
  self._persistent_history_length = persistent_history_length
339
443
  self._initialize_history(persistent_history_file)
340
444
 
@@ -389,7 +493,7 @@ class Cmd(cmd.Cmd):
389
493
  self.help_error = "No help on {}"
390
494
 
391
495
  # The error that prints when a non-existent command is run
392
- self.default_error = "{} is not a recognized command, alias, or macro"
496
+ self.default_error = "{} is not a recognized command, alias, or macro."
393
497
 
394
498
  # If non-empty, this string will be displayed if a broken pipe error occurs
395
499
  self.broken_pipe_warning = ''
@@ -436,8 +540,8 @@ class Cmd(cmd.Cmd):
436
540
  self.pager = 'less -RXF'
437
541
  self.pager_chop = 'less -SRXF'
438
542
 
439
- # This boolean flag determines whether or not the cmd2 application can interact with the clipboard
440
- self._can_clip = can_clip
543
+ # This boolean flag stores whether cmd2 will allow clipboard related features
544
+ self.allow_clipboard = allow_clipboard
441
545
 
442
546
  # This determines the value returned by cmdloop() when exiting the application
443
547
  self.exit_code = 0
@@ -507,6 +611,12 @@ class Cmd(cmd.Cmd):
507
611
  # This does not affect self.formatted_completions.
508
612
  self.matches_sorted = False
509
613
 
614
+ # Command parsers for this Cmd instance.
615
+ self._command_parsers = _CommandParsers(self)
616
+
617
+ # Add functions decorated to be subcommands
618
+ self._register_subcommands(self)
619
+
510
620
  ############################################################################################################
511
621
  # The following code block loads CommandSets, verifies command names, and registers subcommands.
512
622
  # This block should appear after all attributes have been created since the registration code
@@ -526,8 +636,11 @@ class Cmd(cmd.Cmd):
526
636
  if not valid:
527
637
  raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")
528
638
 
529
- # Add functions decorated to be subcommands
530
- self._register_subcommands(self)
639
+ self.suggest_similar_command = suggest_similar_command
640
+ self.default_suggestion_message = "Did you mean {}?"
641
+
642
+ # the current command being executed
643
+ self.current_command: Optional[Statement] = None
531
644
 
532
645
  def find_commandsets(self, commandset_type: Type[CommandSet], *, subclass_match: bool = False) -> List[CommandSet]:
533
646
  """
@@ -541,7 +654,7 @@ class Cmd(cmd.Cmd):
541
654
  return [
542
655
  cmdset
543
656
  for cmdset in self._installed_command_sets
544
- if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type))
657
+ if type(cmdset) == commandset_type or (subclass_match and isinstance(cmdset, commandset_type)) # noqa: E721
545
658
  ]
546
659
 
547
660
  def find_commandset_for_command(self, command_name: str) -> Optional[CommandSet]:
@@ -601,22 +714,25 @@ class Cmd(cmd.Cmd):
601
714
  raise CommandSetRegistrationError(f'Duplicate settable {key} is already registered')
602
715
 
603
716
  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),
717
+ methods = cast(
718
+ List[Tuple[str, Callable[..., Any]]],
719
+ inspect.getmembers(
720
+ cmdset,
721
+ predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
722
+ and hasattr(meth, '__name__')
723
+ and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
724
+ ),
609
725
  )
610
726
 
611
727
  default_category = getattr(cmdset, CLASS_ATTR_DEFAULT_HELP_CATEGORY, None)
612
728
 
613
729
  installed_attributes = []
614
730
  try:
615
- for method_name, method in methods:
616
- command = method_name[len(COMMAND_FUNC_PREFIX) :]
731
+ for cmd_func_name, command_method in methods:
732
+ command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :]
617
733
 
618
- self._install_command_function(command, method, type(cmdset).__name__)
619
- installed_attributes.append(method_name)
734
+ self._install_command_function(cmd_func_name, command_method, type(cmdset).__name__)
735
+ installed_attributes.append(cmd_func_name)
620
736
 
621
737
  completer_func_name = COMPLETER_FUNC_PREFIX + command
622
738
  cmd_completer = getattr(cmdset, completer_func_name, None)
@@ -632,8 +748,8 @@ class Cmd(cmd.Cmd):
632
748
 
633
749
  self._cmd_to_command_sets[command] = cmdset
634
750
 
635
- if default_category and not hasattr(method, constants.CMD_ATTR_HELP_CATEGORY):
636
- utils.categorize(method, default_category)
751
+ if default_category and not hasattr(command_method, constants.CMD_ATTR_HELP_CATEGORY):
752
+ utils.categorize(command_method, default_category)
637
753
 
638
754
  self._installed_command_sets.add(cmdset)
639
755
 
@@ -650,12 +766,54 @@ class Cmd(cmd.Cmd):
650
766
  cmdset.on_unregistered()
651
767
  raise
652
768
 
653
- def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
654
- cmd_func_name = COMMAND_FUNC_PREFIX + command
769
+ def _build_parser(
770
+ self,
771
+ parent: CommandParent,
772
+ parser_builder: Optional[
773
+ Union[
774
+ argparse.ArgumentParser,
775
+ Callable[[], argparse.ArgumentParser],
776
+ StaticArgParseBuilder,
777
+ ClassArgParseBuilder,
778
+ ]
779
+ ],
780
+ ) -> Optional[argparse.ArgumentParser]:
781
+ parser: Optional[argparse.ArgumentParser] = None
782
+ if isinstance(parser_builder, staticmethod):
783
+ parser = parser_builder.__func__()
784
+ elif isinstance(parser_builder, classmethod):
785
+ parser = parser_builder.__func__(parent if not None else self) # type: ignore[arg-type]
786
+ elif callable(parser_builder):
787
+ parser = parser_builder()
788
+ elif isinstance(parser_builder, argparse.ArgumentParser):
789
+ parser = copy.deepcopy(parser_builder)
790
+ return parser
791
+
792
+ def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None:
793
+ """
794
+ Install a new command function into the CLI.
795
+
796
+ :param command_func_name: name of command function to add
797
+ This points to the command method and may differ from the method's
798
+ name if it's being used as a synonym. (e.g. do_exit = do_quit)
799
+ :param command_method: the actual command method which runs when the command function is called
800
+ :param context: optional info to provide in error message. (e.g. class this function belongs to)
801
+ :raises CommandSetRegistrationError: if the command function fails to install
802
+ """
803
+
804
+ # command_func_name must begin with COMMAND_FUNC_PREFIX to be identified as a command by cmd2.
805
+ if not command_func_name.startswith(COMMAND_FUNC_PREFIX):
806
+ raise CommandSetRegistrationError(f"{command_func_name} does not begin with '{COMMAND_FUNC_PREFIX}'")
807
+
808
+ # command_method must start with COMMAND_FUNC_PREFIX for use in self._command_parsers.
809
+ if not command_method.__name__.startswith(COMMAND_FUNC_PREFIX):
810
+ raise CommandSetRegistrationError(f"{command_method.__name__} does not begin with '{COMMAND_FUNC_PREFIX}'")
811
+
812
+ command = command_func_name[len(COMMAND_FUNC_PREFIX) :]
655
813
 
656
814
  # Make sure command function doesn't share name with existing attribute
657
- if hasattr(self, cmd_func_name):
658
- raise CommandSetRegistrationError(f'Attribute already exists: {cmd_func_name} ({context})')
815
+ if hasattr(self, command_func_name):
816
+ raise CommandSetRegistrationError(f'Attribute already exists: {command_func_name} ({context})')
659
817
 
660
818
  # Check if command has an invalid name
661
819
  valid, errmsg = self.statement_parser.is_valid_command(command)
@@ -672,7 +830,7 @@ class Cmd(cmd.Cmd):
672
830
  self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
673
831
  del self.macros[command]
674
832
 
675
- setattr(self, cmd_func_name, command_wrapper)
833
+ setattr(self, command_func_name, command_method)
676
834
 
677
835
  def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
678
836
  completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name
@@ -699,67 +857,66 @@ class Cmd(cmd.Cmd):
699
857
  cmdset.on_unregister()
700
858
  self._unregister_subcommands(cmdset)
701
859
 
702
- methods = inspect.getmembers(
860
+ methods: List[Tuple[str, Callable[..., Any]]] = inspect.getmembers(
703
861
  cmdset,
704
862
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
705
863
  and hasattr(meth, '__name__')
706
864
  and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
707
865
  )
708
866
 
709
- for method in methods:
710
- cmd_name = method[0][len(COMMAND_FUNC_PREFIX) :]
867
+ for cmd_func_name, command_method in methods:
868
+ command = cmd_func_name[len(COMMAND_FUNC_PREFIX) :]
711
869
 
712
870
  # Enable the command before uninstalling it to make sure we remove both
713
871
  # the real functions and the ones used by the DisabledCommand object.
714
- if cmd_name in self.disabled_commands:
715
- self.enable_command(cmd_name)
872
+ if command in self.disabled_commands:
873
+ self.enable_command(command)
874
+
875
+ if command in self._cmd_to_command_sets:
876
+ del self._cmd_to_command_sets[command]
716
877
 
717
- if cmd_name in self._cmd_to_command_sets:
718
- del self._cmd_to_command_sets[cmd_name]
878
+ # Only remove the parser if this is the actual
879
+ # command since command synonyms don't own it.
880
+ if cmd_func_name == command_method.__name__:
881
+ self._command_parsers.remove(command_method)
719
882
 
720
- delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
883
+ if hasattr(self, COMPLETER_FUNC_PREFIX + command):
884
+ delattr(self, COMPLETER_FUNC_PREFIX + command)
885
+ if hasattr(self, HELP_FUNC_PREFIX + command):
886
+ delattr(self, HELP_FUNC_PREFIX + command)
721
887
 
722
- if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
723
- delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
724
- if hasattr(self, HELP_FUNC_PREFIX + cmd_name):
725
- delattr(self, HELP_FUNC_PREFIX + cmd_name)
888
+ delattr(self, cmd_func_name)
726
889
 
727
890
  cmdset.on_unregistered()
728
891
  self._installed_command_sets.remove(cmdset)
729
892
 
730
893
  def _check_uninstallable(self, cmdset: CommandSet) -> None:
731
- methods = inspect.getmembers(
894
+ def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
895
+ for action in parser._actions:
896
+ if isinstance(action, argparse._SubParsersAction):
897
+ for subparser in action.choices.values():
898
+ attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None)
899
+ if attached_cmdset is not None and attached_cmdset is not cmdset:
900
+ raise CommandSetRegistrationError(
901
+ 'Cannot uninstall CommandSet when another CommandSet depends on it'
902
+ )
903
+ check_parser_uninstallable(subparser)
904
+ break
905
+
906
+ methods: List[Tuple[str, Callable[..., Any]]] = inspect.getmembers(
732
907
  cmdset,
733
908
  predicate=lambda meth: isinstance(meth, Callable) # type: ignore[arg-type]
734
909
  and hasattr(meth, '__name__')
735
910
  and meth.__name__.startswith(COMMAND_FUNC_PREFIX),
736
911
  )
737
912
 
738
- for method in methods:
739
- 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))
748
-
749
- def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
750
- for action in parser._actions:
751
- if isinstance(action, argparse._SubParsersAction):
752
- for subparser in action.choices.values():
753
- attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None)
754
- if attached_cmdset is not None and attached_cmdset is not cmdset:
755
- raise CommandSetRegistrationError(
756
- 'Cannot uninstall CommandSet when another CommandSet depends on it'
757
- )
758
- check_parser_uninstallable(subparser)
759
- break
760
-
761
- if command_parser is not None:
762
- check_parser_uninstallable(command_parser)
913
+ for cmd_func_name, command_method in methods:
914
+ # We only need to check if it's safe to remove the parser if this
915
+ # is the actual command since command synonyms don't own it.
916
+ if cmd_func_name == command_method.__name__:
917
+ command_parser = self._command_parsers.get(command_method)
918
+ if command_parser is not None:
919
+ check_parser_uninstallable(command_parser)
763
920
 
764
921
  def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
765
922
  """
@@ -783,7 +940,7 @@ class Cmd(cmd.Cmd):
783
940
  for method_name, method in methods:
784
941
  subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
785
942
  full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
786
- subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
943
+ subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
787
944
 
788
945
  subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
789
946
  if not subcommand_valid:
@@ -803,7 +960,7 @@ class Cmd(cmd.Cmd):
803
960
  raise CommandSetRegistrationError(
804
961
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
805
962
  )
806
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
963
+ command_parser = self._command_parsers.get(command_func)
807
964
  if command_parser is None:
808
965
  raise CommandSetRegistrationError(
809
966
  f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -823,16 +980,17 @@ class Cmd(cmd.Cmd):
823
980
 
824
981
  target_parser = find_subcommand(command_parser, subcommand_names)
825
982
 
983
+ subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
984
+ from .decorators import (
985
+ _set_parser_prog,
986
+ )
987
+
988
+ _set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
989
+ if subcmd_parser.description is None and method.__doc__:
990
+ subcmd_parser.description = strip_doc_annotations(method.__doc__)
991
+
826
992
  for action in target_parser._actions:
827
993
  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
994
  # Get the kwargs for add_parser()
837
995
  add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
838
996
 
@@ -904,7 +1062,7 @@ class Cmd(cmd.Cmd):
904
1062
  raise CommandSetRegistrationError(
905
1063
  f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
906
1064
  )
907
- command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
1065
+ command_parser = self._command_parsers.get(command_func)
908
1066
  if command_parser is None: # pragma: no cover
909
1067
  # This really shouldn't be possible since _register_subcommands would prevent this from happening
910
1068
  # but keeping in case it does for some strange reason
@@ -1012,12 +1170,7 @@ class Cmd(cmd.Cmd):
1012
1170
  )
1013
1171
 
1014
1172
  self.add_settable(
1015
- Settable(
1016
- 'always_show_hint',
1017
- bool,
1018
- 'Display tab completion hint even when completion suggestions print',
1019
- self,
1020
- )
1173
+ Settable('always_show_hint', bool, 'Display tab completion hint even when completion suggestions print', self)
1021
1174
  )
1022
1175
  self.add_settable(Settable('debug', bool, "Show full traceback on exception", self))
1023
1176
  self.add_settable(Settable('echo', bool, "Echo command issued into output", self))
@@ -1027,6 +1180,7 @@ class Cmd(cmd.Cmd):
1027
1180
  Settable('max_completion_items', int, "Maximum number of CompletionItems to display during tab completion", self)
1028
1181
  )
1029
1182
  self.add_settable(Settable('quiet', bool, "Don't print nonessential feedback", self))
1183
+ self.add_settable(Settable('scripts_add_to_history', bool, 'Scripts and pyscripts add commands to history', self))
1030
1184
  self.add_settable(Settable('timing', bool, "Report execution times", self))
1031
1185
 
1032
1186
  # ----- Methods related to presenting output to the user -----
@@ -1056,18 +1210,25 @@ class Cmd(cmd.Cmd):
1056
1210
  """
1057
1211
  return ansi.strip_style(self.prompt)
1058
1212
 
1059
- def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
1060
- """Print message to self.stdout and appends a newline by default
1061
-
1062
- Also handles BrokenPipeError exceptions for when a command's output has
1063
- been piped to another process and that process terminates before the
1064
- cmd2 command is finished executing.
1213
+ def print_to(
1214
+ self,
1215
+ dest: IO[str],
1216
+ msg: Any,
1217
+ *,
1218
+ end: str = '\n',
1219
+ style: Optional[Callable[[str], str]] = None,
1220
+ ) -> None:
1221
+ """
1222
+ Print message to a given file object.
1065
1223
 
1224
+ :param dest: the file object being written to
1066
1225
  :param msg: object to print
1067
1226
  :param end: string appended after the end of the message, default a newline
1227
+ :param style: optional style function to format msg with (e.g. ansi.style_success)
1068
1228
  """
1229
+ final_msg = style(msg) if style is not None else msg
1069
1230
  try:
1070
- ansi.style_aware_write(self.stdout, f"{msg}{end}")
1231
+ ansi.style_aware_write(dest, f'{final_msg}{end}')
1071
1232
  except BrokenPipeError:
1072
1233
  # This occurs if a command's output is being piped to another
1073
1234
  # process and that process closes before the command is
@@ -1077,7 +1238,14 @@ class Cmd(cmd.Cmd):
1077
1238
  if self.broken_pipe_warning:
1078
1239
  sys.stderr.write(self.broken_pipe_warning)
1079
1240
 
1080
- # noinspection PyMethodMayBeStatic
1241
+ def poutput(self, msg: Any = '', *, end: str = '\n') -> None:
1242
+ """Print message to self.stdout and appends a newline by default
1243
+
1244
+ :param msg: object to print
1245
+ :param end: string appended after the end of the message, default a newline
1246
+ """
1247
+ self.print_to(self.stdout, msg, end=end)
1248
+
1081
1249
  def perror(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1082
1250
  """Print message to sys.stderr
1083
1251
 
@@ -1086,22 +1254,24 @@ class Cmd(cmd.Cmd):
1086
1254
  :param apply_style: If True, then ansi.style_error will be applied to the message text. Set to False in cases
1087
1255
  where the message text already has the desired style. Defaults to True.
1088
1256
  """
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)
1257
+ self.print_to(sys.stderr, msg, end=end, style=ansi.style_error if apply_style else None)
1258
+
1259
+ def psuccess(self, msg: Any = '', *, end: str = '\n') -> None:
1260
+ """Wraps poutput, but applies ansi.style_success by default
1261
+
1262
+ :param msg: object to print
1263
+ :param end: string appended after the end of the message, default a newline
1264
+ """
1265
+ msg = ansi.style_success(msg)
1266
+ self.poutput(msg, end=end)
1094
1267
 
1095
- def pwarning(self, msg: Any = '', *, end: str = '\n', apply_style: bool = True) -> None:
1268
+ def pwarning(self, msg: Any = '', *, end: str = '\n') -> None:
1096
1269
  """Wraps perror, but applies ansi.style_warning by default
1097
1270
 
1098
1271
  :param msg: object to print
1099
1272
  :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.
1102
1273
  """
1103
- if apply_style:
1104
- msg = ansi.style_warning(msg)
1274
+ msg = ansi.style_warning(msg)
1105
1275
  self.perror(msg, end=end, apply_style=False)
1106
1276
 
1107
1277
  def pexcept(self, msg: Any, *, end: str = '\n', apply_style: bool = True) -> None:
@@ -1147,7 +1317,7 @@ class Cmd(cmd.Cmd):
1147
1317
  def ppaged(self, msg: Any, *, end: str = '\n', chop: bool = False) -> None:
1148
1318
  """Print output using a pager if it would go off screen and stdout isn't currently being redirected.
1149
1319
 
1150
- Never uses a pager inside of a script (Python or text) or when output is being redirected or piped or when
1320
+ Never uses a pager inside a script (Python or text) or when output is being redirected or piped or when
1151
1321
  stdout or stdin are not a fully functional terminal.
1152
1322
 
1153
1323
  :param msg: object to print
@@ -1160,48 +1330,41 @@ class Cmd(cmd.Cmd):
1160
1330
 
1161
1331
  WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1162
1332
  """
1163
- # msg can be any type, so convert to string before checking if it's blank
1164
- msg_str = str(msg)
1165
-
1166
- # Consider None to be no data to print
1167
- if msg is None or msg_str == '':
1168
- return
1333
+ # Attempt to detect if we are not running within a fully functional terminal.
1334
+ # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect.
1335
+ functional_terminal = False
1169
1336
 
1170
- try:
1171
- import subprocess
1172
-
1173
- # Attempt to detect if we are not running within a fully functional terminal.
1174
- # Don't try to use the pager when being run by a continuous integration system like Jenkins + pexpect.
1175
- functional_terminal = False
1337
+ if self.stdin.isatty() and self.stdout.isatty():
1338
+ if sys.platform.startswith('win') or os.environ.get('TERM') is not None:
1339
+ functional_terminal = True
1176
1340
 
1177
- if self.stdin.isatty() and self.stdout.isatty():
1178
- if sys.platform.startswith('win') or os.environ.get('TERM') is not None:
1179
- functional_terminal = True
1341
+ # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python).
1342
+ # Also only attempt to use a pager if actually running in a real fully functional terminal.
1343
+ if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script():
1344
+ final_msg = f"{msg}{end}"
1345
+ if ansi.allow_style == ansi.AllowStyle.NEVER:
1346
+ final_msg = ansi.strip_style(final_msg)
1180
1347
 
1181
- # Don't attempt to use a pager that can block if redirecting or running a script (either text or Python)
1182
- # Also only attempt to use a pager if actually running in a real fully functional terminal
1183
- if functional_terminal and not self._redirecting and not self.in_pyscript() and not self.in_script():
1184
- if ansi.allow_style == ansi.AllowStyle.NEVER:
1185
- msg_str = ansi.strip_style(msg_str)
1186
- msg_str += end
1187
-
1188
- pager = self.pager
1189
- if chop:
1190
- pager = self.pager_chop
1348
+ pager = self.pager
1349
+ if chop:
1350
+ pager = self.pager_chop
1191
1351
 
1352
+ try:
1192
1353
  # Prevent KeyboardInterrupts while in the pager. The pager application will
1193
1354
  # still receive the SIGINT since it is in the same process group as us.
1194
1355
  with self.sigint_protection:
1195
- pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE)
1196
- pipe_proc.communicate(msg_str.encode('utf-8', 'replace'))
1197
- else:
1198
- self.poutput(msg_str, end=end)
1199
- except BrokenPipeError:
1200
- # This occurs if a command's output is being piped to another process and that process closes before the
1201
- # command is finished. If you would like your application to print a warning message, then set the
1202
- # broken_pipe_warning attribute to the message you want printed.`
1203
- if self.broken_pipe_warning:
1204
- sys.stderr.write(self.broken_pipe_warning)
1356
+ import subprocess
1357
+
1358
+ pipe_proc = subprocess.Popen(pager, shell=True, stdin=subprocess.PIPE, stdout=self.stdout)
1359
+ pipe_proc.communicate(final_msg.encode('utf-8', 'replace'))
1360
+ except BrokenPipeError:
1361
+ # This occurs if a command's output is being piped to another process and that process closes before the
1362
+ # command is finished. If you would like your application to print a warning message, then set the
1363
+ # broken_pipe_warning attribute to the message you want printed.`
1364
+ if self.broken_pipe_warning:
1365
+ sys.stderr.write(self.broken_pipe_warning)
1366
+ else:
1367
+ self.poutput(msg, end=end)
1205
1368
 
1206
1369
  # ----- Methods related to tab completion -----
1207
1370
 
@@ -1222,7 +1385,6 @@ class Cmd(cmd.Cmd):
1222
1385
  if rl_type == RlType.GNU:
1223
1386
  readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
1224
1387
  elif rl_type == RlType.PYREADLINE:
1225
- # noinspection PyUnresolvedReferences
1226
1388
  readline.rl.mode._display_completions = self._display_matches_pyreadline
1227
1389
 
1228
1390
  def tokens_for_completion(self, line: str, begidx: int, endidx: int) -> Tuple[List[str], List[str]]:
@@ -1289,7 +1451,6 @@ class Cmd(cmd.Cmd):
1289
1451
 
1290
1452
  return tokens, raw_tokens
1291
1453
 
1292
- # noinspection PyMethodMayBeStatic, PyUnusedLocal
1293
1454
  def basic_complete(
1294
1455
  self,
1295
1456
  text: str,
@@ -1480,7 +1641,6 @@ class Cmd(cmd.Cmd):
1480
1641
 
1481
1642
  return matches
1482
1643
 
1483
- # noinspection PyUnusedLocal
1484
1644
  def path_complete(
1485
1645
  self, text: str, line: str, begidx: int, endidx: int, *, path_filter: Optional[Callable[[str], bool]] = None
1486
1646
  ) -> List[str]:
@@ -1498,7 +1658,6 @@ class Cmd(cmd.Cmd):
1498
1658
 
1499
1659
  # Used to complete ~ and ~user strings
1500
1660
  def complete_users() -> List[str]:
1501
-
1502
1661
  users = []
1503
1662
 
1504
1663
  # Windows lacks the pwd module so we can't get a list of users.
@@ -1516,10 +1675,8 @@ class Cmd(cmd.Cmd):
1516
1675
 
1517
1676
  # Iterate through a list of users from the password database
1518
1677
  for cur_pw in pwd.getpwall():
1519
-
1520
1678
  # Check if the user has an existing home dir
1521
1679
  if os.path.isdir(cur_pw.pw_dir):
1522
-
1523
1680
  # Add a ~ to the user to match against text
1524
1681
  cur_user = '~' + cur_pw.pw_name
1525
1682
  if cur_user.startswith(text):
@@ -1605,7 +1762,6 @@ class Cmd(cmd.Cmd):
1605
1762
 
1606
1763
  # Build display_matches and add a slash to directories
1607
1764
  for index, cur_match in enumerate(matches):
1608
-
1609
1765
  # Display only the basename of this path in the tab completion suggestions
1610
1766
  self.display_matches.append(os.path.basename(cur_match))
1611
1767
 
@@ -1674,7 +1830,6 @@ class Cmd(cmd.Cmd):
1674
1830
 
1675
1831
  # Must at least have the command
1676
1832
  if len(raw_tokens) > 1:
1677
-
1678
1833
  # True when command line contains any redirection tokens
1679
1834
  has_redirection = False
1680
1835
 
@@ -1766,7 +1921,6 @@ class Cmd(cmd.Cmd):
1766
1921
  :param longest_match_length: longest printed length of the matches
1767
1922
  """
1768
1923
  if rl_type == RlType.GNU:
1769
-
1770
1924
  # Print hint if one exists and we are supposed to display it
1771
1925
  hint_printed = False
1772
1926
  if self.always_show_hint and self.completion_hint:
@@ -1806,7 +1960,6 @@ class Cmd(cmd.Cmd):
1806
1960
 
1807
1961
  # rl_display_match_list() expects matches to be in argv format where
1808
1962
  # substitution is the first element, followed by the matches, and then a NULL.
1809
- # noinspection PyCallingNonCallable,PyTypeChecker
1810
1963
  strings_array = cast(List[Optional[bytes]], (ctypes.c_char_p * (1 + len(encoded_matches) + 1))())
1811
1964
 
1812
1965
  # Copy in the encoded strings and add a NULL to the end
@@ -1826,7 +1979,6 @@ class Cmd(cmd.Cmd):
1826
1979
  :param matches: the tab completion matches to display
1827
1980
  """
1828
1981
  if rl_type == RlType.PYREADLINE:
1829
-
1830
1982
  # Print hint if one exists and we are supposed to display it
1831
1983
  hint_printed = False
1832
1984
  if self.always_show_hint and self.completion_hint:
@@ -1865,9 +2017,8 @@ class Cmd(cmd.Cmd):
1865
2017
  :param parser: the parser to examine
1866
2018
  :return: type of ArgparseCompleter
1867
2019
  """
1868
- completer_type: Optional[
1869
- Type[argparse_completer.ArgparseCompleter]
1870
- ] = parser.get_ap_completer_type() # type: ignore[attr-defined]
2020
+ Completer = Optional[Type[argparse_completer.ArgparseCompleter]]
2021
+ completer_type: Completer = parser.get_ap_completer_type() # type: ignore[attr-defined]
1871
2022
 
1872
2023
  if completer_type is None:
1873
2024
  completer_type = argparse_completer.DEFAULT_AP_COMPLETER
@@ -1898,11 +2049,14 @@ class Cmd(cmd.Cmd):
1898
2049
 
1899
2050
  expanded_line = statement.command_and_args
1900
2051
 
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
2052
+ if not expanded_line[-1:].isspace():
2053
+ # Unquoted trailing whitespace gets stripped by parse_command_only().
2054
+ # Restore it since line is only supposed to be lstripped when passed
2055
+ # to completer functions according to the Python cmd docs. Regardless
2056
+ # of what type of whitespace (' ', \n) was stripped, just append spaces
2057
+ # since shlex treats whitespace characters the same when splitting.
2058
+ rstripped_len = len(line) - len(line.rstrip())
2059
+ expanded_line += ' ' * rstripped_len
1906
2060
 
1907
2061
  # Fix the index values if expanded_line has a different size than line
1908
2062
  if len(expanded_line) != len(line):
@@ -1934,12 +2088,12 @@ class Cmd(cmd.Cmd):
1934
2088
  else:
1935
2089
  # There's no completer function, next see if the command uses argparse
1936
2090
  func = self.cmd_func(command)
1937
- argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2091
+ argparser = None if func is None else self._command_parsers.get(func)
1938
2092
 
1939
2093
  if func is not None and argparser is not None:
1940
2094
  # Get arguments for complete()
1941
2095
  preserve_quotes = getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)
1942
- cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None
2096
+ cmd_set = self.find_commandset_for_command(command)
1943
2097
 
1944
2098
  # Create the argparse completer
1945
2099
  completer_type = self._determine_ap_completer_type(argparser)
@@ -1980,7 +2134,6 @@ class Cmd(cmd.Cmd):
1980
2134
 
1981
2135
  # Check if the token being completed has an opening quote
1982
2136
  if raw_completion_token and raw_completion_token[0] in constants.QUOTES:
1983
-
1984
2137
  # Since the token is still being completed, we know the opening quote is unclosed.
1985
2138
  # Save the quote so we can add a matching closing quote later.
1986
2139
  completion_token_quote = raw_completion_token[0]
@@ -2005,7 +2158,6 @@ class Cmd(cmd.Cmd):
2005
2158
  self.completion_matches = self._redirect_complete(text, line, begidx, endidx, completer_func)
2006
2159
 
2007
2160
  if self.completion_matches:
2008
-
2009
2161
  # Eliminate duplicates
2010
2162
  self.completion_matches = utils.remove_duplicates(self.completion_matches)
2011
2163
  self.display_matches = utils.remove_duplicates(self.display_matches)
@@ -2020,7 +2172,6 @@ class Cmd(cmd.Cmd):
2020
2172
 
2021
2173
  # Check if we need to add an opening quote
2022
2174
  if not completion_token_quote:
2023
-
2024
2175
  add_quote = False
2025
2176
 
2026
2177
  # This is the tab completion text that will appear on the command line.
@@ -2073,7 +2224,6 @@ class Cmd(cmd.Cmd):
2073
2224
  :param custom_settings: used when not tab completing the main command line
2074
2225
  :return: the next possible completion for text or None
2075
2226
  """
2076
- # noinspection PyBroadException
2077
2227
  try:
2078
2228
  if state == 0:
2079
2229
  self._reset_completion_defaults()
@@ -2081,7 +2231,7 @@ class Cmd(cmd.Cmd):
2081
2231
  # Check if we are completing a multiline command
2082
2232
  if self._at_continuation_prompt:
2083
2233
  # lstrip and prepend the previously typed portion of this multiline command
2084
- lstripped_previous = self._multiline_in_progress.lstrip().replace(constants.LINE_FEED, ' ')
2234
+ lstripped_previous = self._multiline_in_progress.lstrip()
2085
2235
  line = lstripped_previous + readline.get_line_buffer()
2086
2236
 
2087
2237
  # Increment the indexes to account for the prepended text
@@ -2103,7 +2253,7 @@ class Cmd(cmd.Cmd):
2103
2253
  # from text and update the indexes. This only applies if we are at the beginning of the command line.
2104
2254
  shortcut_to_restore = ''
2105
2255
  if begidx == 0 and custom_settings is None:
2106
- for (shortcut, _) in self.statement_parser.shortcuts:
2256
+ for shortcut, _ in self.statement_parser.shortcuts:
2107
2257
  if text.startswith(shortcut):
2108
2258
  # Save the shortcut to restore later
2109
2259
  shortcut_to_restore = shortcut
@@ -2251,14 +2401,13 @@ class Cmd(cmd.Cmd):
2251
2401
  # Filter out hidden and disabled commands
2252
2402
  return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands]
2253
2403
 
2254
- # noinspection PyUnusedLocal
2255
- def sigint_handler(self, signum: int, _: FrameType) -> None:
2404
+ def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None:
2256
2405
  """Signal handler for SIGINTs which typically come from Ctrl-C events.
2257
2406
 
2258
- If you need custom SIGINT behavior, then override this function.
2407
+ If you need custom SIGINT behavior, then override this method.
2259
2408
 
2260
2409
  :param signum: signal number
2261
- :param _: required param for signal handlers
2410
+ :param _: the current stack frame or None
2262
2411
  """
2263
2412
  if self._cur_pipe_proc_reader is not None:
2264
2413
  # Pass the SIGINT to the current pipe process
@@ -2266,7 +2415,30 @@ class Cmd(cmd.Cmd):
2266
2415
 
2267
2416
  # Check if we are allowed to re-raise the KeyboardInterrupt
2268
2417
  if not self.sigint_protection:
2269
- self._raise_keyboard_interrupt()
2418
+ raise_interrupt = True
2419
+ if self.current_command is not None:
2420
+ command_set = self.find_commandset_for_command(self.current_command.command)
2421
+ if command_set is not None:
2422
+ raise_interrupt = not command_set.sigint_handler()
2423
+ if raise_interrupt:
2424
+ self._raise_keyboard_interrupt()
2425
+
2426
+ def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None:
2427
+ """
2428
+ Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac.
2429
+
2430
+ SIGHUP - received when terminal window is closed
2431
+ SIGTERM - received when this app has been requested to terminate
2432
+
2433
+ The basic purpose of this method is to call sys.exit() so our exit handler will run
2434
+ and save the persistent history file. If you need more complex behavior like killing
2435
+ threads and performing cleanup, then override this method.
2436
+
2437
+ :param signum: signal number
2438
+ :param _: the current stack frame or None
2439
+ """
2440
+ # POSIX systems add 128 to signal numbers for the exit code
2441
+ sys.exit(128 + signum)
2270
2442
 
2271
2443
  def _raise_keyboard_interrupt(self) -> None:
2272
2444
  """Helper function to raise a KeyboardInterrupt"""
@@ -2274,50 +2446,47 @@ class Cmd(cmd.Cmd):
2274
2446
 
2275
2447
  def precmd(self, statement: Union[Statement, str]) -> Statement:
2276
2448
  """Hook method executed just before the command is executed by
2277
- :meth:`~cmd2.Cmd.onecmd` and after adding it to history.
2449
+ [cmd2.Cmd.onecmd][] and after adding it to history.
2278
2450
 
2279
2451
  :param statement: subclass of str which also contains the parsed input
2280
2452
  :return: a potentially modified version of the input Statement object
2281
2453
 
2282
- See :meth:`~cmd2.Cmd.register_postparsing_hook` and
2283
- :meth:`~cmd2.Cmd.register_precmd_hook` for more robust ways
2454
+ See [cmd2.Cmd.register_postparsing_hook][] and
2455
+ [cmd2.Cmd.register_precmd_hook][] for more robust ways
2284
2456
  to run hooks before the command is executed. See
2285
- :ref:`features/hooks:Postparsing Hooks` and
2286
- :ref:`features/hooks:Precommand Hooks` for more information.
2457
+ [Hooks](../features/hooks.md) for more information.
2287
2458
  """
2288
2459
  return Statement(statement) if not isinstance(statement, Statement) else statement
2289
2460
 
2290
2461
  def postcmd(self, stop: bool, statement: Union[Statement, str]) -> bool:
2291
2462
  """Hook method executed just after a command is executed by
2292
- :meth:`~cmd2.Cmd.onecmd`.
2463
+ [cmd2.Cmd.onecmd][].
2293
2464
 
2294
2465
  :param stop: return `True` to request the command loop terminate
2295
2466
  :param statement: subclass of str which also contains the parsed input
2296
2467
 
2297
- See :meth:`~cmd2.Cmd.register_postcmd_hook` and :meth:`~cmd2.Cmd.register_cmdfinalization_hook` for more robust ways
2468
+ See [cmd2.Cmd.register_postcmd_hook][] and [cmd2.Cmd.register_cmdfinalization_hook][] for more robust ways
2298
2469
  to run hooks after the command is executed. See
2299
- :ref:`features/hooks:Postcommand Hooks` and
2300
- :ref:`features/hooks:Command Finalization Hooks` for more information.
2470
+ [Hooks](../features/hooks.md) for more information.
2301
2471
  """
2302
2472
  return stop
2303
2473
 
2304
2474
  def preloop(self) -> None:
2305
- """Hook method executed once when the :meth:`~.cmd2.Cmd.cmdloop()`
2475
+ """Hook method executed once when the [cmd2.Cmd.cmdloop][]
2306
2476
  method is called.
2307
2477
 
2308
- See :meth:`~cmd2.Cmd.register_preloop_hook` for a more robust way
2478
+ See [cmd2.Cmd.register_preloop_hook][] for a more robust way
2309
2479
  to run hooks before the command loop begins. See
2310
- :ref:`features/hooks:Application Lifecycle Hooks` for more information.
2480
+ [Hooks](../features/hooks.md) for more information.
2311
2481
  """
2312
2482
  pass
2313
2483
 
2314
2484
  def postloop(self) -> None:
2315
- """Hook method executed once when the :meth:`~.cmd2.Cmd.cmdloop()`
2316
- method is about to return.
2485
+ """Hook method executed once when the [cmd2.Cmd.cmdloop][] method is about to return.
2317
2486
 
2318
- See :meth:`~cmd2.Cmd.register_postloop_hook` for a more robust way
2487
+ See [cmd2.Cmd.register_postloop_hook][] for a more robust way
2319
2488
  to run hooks after the command loop completes. See
2320
- :ref:`features/hooks:Application Lifecycle Hooks` for more information.
2489
+ [Hooks](../features/hooks.md) for more information.
2321
2490
  """
2322
2491
  pass
2323
2492
 
@@ -2335,7 +2504,13 @@ class Cmd(cmd.Cmd):
2335
2504
  return statement.command, statement.args, statement.command_and_args
2336
2505
 
2337
2506
  def onecmd_plus_hooks(
2338
- self, line: str, *, add_to_history: bool = True, raise_keyboard_interrupt: bool = False, py_bridge_call: bool = False
2507
+ self,
2508
+ line: str,
2509
+ *,
2510
+ add_to_history: bool = True,
2511
+ raise_keyboard_interrupt: bool = False,
2512
+ py_bridge_call: bool = False,
2513
+ orig_rl_history_length: Optional[int] = None,
2339
2514
  ) -> bool:
2340
2515
  """Top-level function called by cmdloop() to handle parsing a line and running the command and all of its hooks.
2341
2516
 
@@ -2347,6 +2522,9 @@ class Cmd(cmd.Cmd):
2347
2522
  :param py_bridge_call: This should only ever be set to True by PyBridge to signify the beginning
2348
2523
  of an app() call from Python. It is used to enable/disable the storage of the
2349
2524
  command's stdout.
2525
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2526
+ This is used to assist in combining multiline readline history entries and is only
2527
+ populated by cmd2. Defaults to None.
2350
2528
  :return: True if running of commands should stop
2351
2529
  """
2352
2530
  import datetime
@@ -2356,7 +2534,7 @@ class Cmd(cmd.Cmd):
2356
2534
 
2357
2535
  try:
2358
2536
  # Convert the line into a Statement
2359
- statement = self._input_line_to_statement(line)
2537
+ statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
2360
2538
 
2361
2539
  # call the postparsing hooks
2362
2540
  postparsing_data = plugin.PostparsingData(False, statement)
@@ -2510,7 +2688,7 @@ class Cmd(cmd.Cmd):
2510
2688
 
2511
2689
  return False
2512
2690
 
2513
- def _complete_statement(self, line: str) -> Statement:
2691
+ def _complete_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2514
2692
  """Keep accepting lines of input until the command is complete.
2515
2693
 
2516
2694
  There is some pretty hacky code here to handle some quirks of
@@ -2519,10 +2697,29 @@ class Cmd(cmd.Cmd):
2519
2697
  backwards compatibility with the standard library version of cmd.
2520
2698
 
2521
2699
  :param line: the line being parsed
2700
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2701
+ This is used to assist in combining multiline readline history entries and is only
2702
+ populated by cmd2. Defaults to None.
2522
2703
  :return: the completed Statement
2523
2704
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2524
2705
  :raises: EmptyStatement when the resulting Statement is blank
2525
2706
  """
2707
+
2708
+ def combine_rl_history(statement: Statement) -> None:
2709
+ """Combine all lines of a multiline command into a single readline history entry"""
2710
+ if orig_rl_history_length is None or not statement.multiline_command:
2711
+ return
2712
+
2713
+ # Remove all previous lines added to history for this command
2714
+ while readline.get_current_history_length() > orig_rl_history_length:
2715
+ readline.remove_history_item(readline.get_current_history_length() - 1)
2716
+
2717
+ formatted_command = single_line_format(statement)
2718
+
2719
+ # If formatted command is different than the previous history item, add it
2720
+ if orig_rl_history_length == 0 or formatted_command != readline.get_history_item(orig_rl_history_length):
2721
+ readline.add_history(formatted_command)
2722
+
2526
2723
  while True:
2527
2724
  try:
2528
2725
  statement = self.statement_parser.parse(line)
@@ -2534,7 +2731,7 @@ class Cmd(cmd.Cmd):
2534
2731
  # so we are done
2535
2732
  break
2536
2733
  except Cmd2ShlexError:
2537
- # we have unclosed quotation marks, lets parse only the command
2734
+ # we have an unclosed quotation mark, let's parse only the command
2538
2735
  # and see if it's a multiline
2539
2736
  statement = self.statement_parser.parse_command_only(line)
2540
2737
  if not statement.multiline_command:
@@ -2550,6 +2747,7 @@ class Cmd(cmd.Cmd):
2550
2747
  # Save the command line up to this point for tab completion
2551
2748
  self._multiline_in_progress = line + '\n'
2552
2749
 
2750
+ # Get next line of this command
2553
2751
  nextline = self._read_command_line(self.continuation_prompt)
2554
2752
  if nextline == 'eof':
2555
2753
  # they entered either a blank line, or we hit an EOF
@@ -2558,7 +2756,14 @@ class Cmd(cmd.Cmd):
2558
2756
  # terminator
2559
2757
  nextline = '\n'
2560
2758
  self.poutput(nextline)
2561
- line = f'{self._multiline_in_progress}{nextline}'
2759
+
2760
+ line += f'\n{nextline}'
2761
+
2762
+ # Combine all history lines of this multiline command as we go.
2763
+ if nextline:
2764
+ statement = self.statement_parser.parse_command_only(line)
2765
+ combine_rl_history(statement)
2766
+
2562
2767
  except KeyboardInterrupt:
2563
2768
  self.poutput('^C')
2564
2769
  statement = self.statement_parser.parse('')
@@ -2568,13 +2773,20 @@ class Cmd(cmd.Cmd):
2568
2773
 
2569
2774
  if not statement.command:
2570
2775
  raise EmptyStatement
2776
+ else:
2777
+ # If necessary, update history with completed multiline command.
2778
+ combine_rl_history(statement)
2779
+
2571
2780
  return statement
2572
2781
 
2573
- def _input_line_to_statement(self, line: str) -> Statement:
2782
+ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
2574
2783
  """
2575
2784
  Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved
2576
2785
 
2577
2786
  :param line: the line being parsed
2787
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
2788
+ This is used to assist in combining multiline readline history entries and is only
2789
+ populated by cmd2. Defaults to None.
2578
2790
  :return: parsed command line as a Statement
2579
2791
  :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation)
2580
2792
  :raises: EmptyStatement when the resulting Statement is blank
@@ -2585,11 +2797,13 @@ class Cmd(cmd.Cmd):
2585
2797
  # Continue until all macros are resolved
2586
2798
  while True:
2587
2799
  # Make sure all input has been read and convert it to a Statement
2588
- statement = self._complete_statement(line)
2800
+ statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
2589
2801
 
2590
- # Save the fully entered line if this is the first loop iteration
2802
+ # If this is the first loop iteration, save the original line and stop
2803
+ # combining multiline history entries in the remaining iterations.
2591
2804
  if orig_line is None:
2592
2805
  orig_line = statement.raw
2806
+ orig_rl_history_length = None
2593
2807
 
2594
2808
  # Check if this command matches a macro and wasn't already processed to avoid an infinite loop
2595
2809
  if statement.command in self.macros.keys() and statement.command not in used_macros:
@@ -2734,13 +2948,8 @@ class Cmd(cmd.Cmd):
2734
2948
  sys.stdout = self.stdout = new_stdout
2735
2949
 
2736
2950
  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:
2951
+ if statement.output_to:
2952
+ # redirecting to a file
2744
2953
  # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT
2745
2954
  mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w'
2746
2955
  try:
@@ -2752,14 +2961,26 @@ class Cmd(cmd.Cmd):
2752
2961
  redir_saved_state.redirecting = True
2753
2962
  sys.stdout = self.stdout = new_stdout
2754
2963
 
2755
- # Redirecting to a paste buffer
2756
2964
  else:
2965
+ # Redirecting to a paste buffer
2966
+ # we are going to direct output to a temporary file, then read it back in and
2967
+ # put it in the paste buffer later
2968
+ if not self.allow_clipboard:
2969
+ raise RedirectionError("Clipboard access not allowed")
2970
+
2971
+ # attempt to get the paste buffer, this forces pyperclip to go figure
2972
+ # out if it can actually interact with the paste buffer, and will throw exceptions
2973
+ # if it's not gonna work. That way we throw the exception before we go
2974
+ # run the command and queue up all the output. if this is going to fail,
2975
+ # no point opening up the temporary file
2976
+ current_paste_buffer = get_paste_buffer()
2977
+ # create a temporary file to store output
2757
2978
  new_stdout = cast(TextIO, tempfile.TemporaryFile(mode="w+"))
2758
2979
  redir_saved_state.redirecting = True
2759
2980
  sys.stdout = self.stdout = new_stdout
2760
2981
 
2761
2982
  if statement.output == constants.REDIRECTION_APPEND:
2762
- self.stdout.write(get_paste_buffer())
2983
+ self.stdout.write(current_paste_buffer)
2763
2984
  self.stdout.flush()
2764
2985
 
2765
2986
  # These are updated regardless of whether the command redirected
@@ -2804,27 +3025,18 @@ class Cmd(cmd.Cmd):
2804
3025
 
2805
3026
  :param command: the name of the command
2806
3027
 
2807
- :Example:
3028
+ Example:
2808
3029
 
2809
- >>> helpfunc = self.cmd_func('help')
3030
+ ```py
3031
+ helpfunc = self.cmd_func('help')
3032
+ ```
2810
3033
 
2811
3034
  helpfunc now contains a reference to the ``do_help`` method
2812
3035
  """
2813
- func_name = self._cmd_func_name(command)
2814
- if func_name:
2815
- return cast(Optional[CommandFunc], getattr(self, func_name))
2816
- return None
3036
+ func_name = constants.COMMAND_FUNC_PREFIX + command
3037
+ func = getattr(self, func_name, None)
3038
+ return cast(CommandFunc, func) if callable(func) else None
2817
3039
 
2818
- def _cmd_func_name(self, command: str) -> str:
2819
- """Get the method name associated with a given command.
2820
-
2821
- :param command: command to look up method name which implements it
2822
- :return: method name which implements the given command
2823
- """
2824
- target = constants.COMMAND_FUNC_PREFIX + command
2825
- return target if callable(getattr(self, target, None)) else ''
2826
-
2827
- # noinspection PyMethodOverriding
2828
3040
  def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = True) -> bool:
2829
3041
  """This executes the actual do_* method for a command.
2830
3042
 
@@ -2849,7 +3061,11 @@ class Cmd(cmd.Cmd):
2849
3061
  ):
2850
3062
  self.history.append(statement)
2851
3063
 
2852
- stop = func(statement)
3064
+ try:
3065
+ self.current_command = statement
3066
+ stop = func(statement)
3067
+ finally:
3068
+ self.current_command = None
2853
3069
 
2854
3070
  else:
2855
3071
  stop = self.default(statement)
@@ -2865,15 +3081,19 @@ class Cmd(cmd.Cmd):
2865
3081
  if 'shell' not in self.exclude_from_history:
2866
3082
  self.history.append(statement)
2867
3083
 
2868
- # noinspection PyTypeChecker
2869
3084
  return self.do_shell(statement.command_and_args)
2870
3085
  else:
2871
3086
  err_msg = self.default_error.format(statement.command)
3087
+ if self.suggest_similar_command and (suggested_command := self._suggest_similar_command(statement.command)):
3088
+ err_msg += f"\n{self.default_suggestion_message.format(suggested_command)}"
2872
3089
 
2873
- # Set apply_style to False so default_error's style is not overridden
3090
+ # Set apply_style to False so styles for default_error and default_suggestion_message are not overridden
2874
3091
  self.perror(err_msg, apply_style=False)
2875
3092
  return None
2876
3093
 
3094
+ def _suggest_similar_command(self, command: str) -> Optional[str]:
3095
+ return suggest_similar(command, self.get_visible_commands())
3096
+
2877
3097
  def read_input(
2878
3098
  self,
2879
3099
  prompt: str,
@@ -2928,7 +3148,7 @@ class Cmd(cmd.Cmd):
2928
3148
  nonlocal saved_history
2929
3149
  nonlocal parser
2930
3150
 
2931
- if readline_configured: # pragma: no cover
3151
+ if readline_configured or rl_type == RlType.NONE: # pragma: no cover
2932
3152
  return
2933
3153
 
2934
3154
  # Configure tab completion
@@ -2937,7 +3157,7 @@ class Cmd(cmd.Cmd):
2937
3157
 
2938
3158
  # Disable completion
2939
3159
  if completion_mode == utils.CompletionMode.NONE:
2940
- # noinspection PyUnusedLocal
3160
+
2941
3161
  def complete_none(text: str, state: int) -> Optional[str]: # pragma: no cover
2942
3162
  return None
2943
3163
 
@@ -2968,7 +3188,6 @@ class Cmd(cmd.Cmd):
2968
3188
  if completion_mode != utils.CompletionMode.COMMANDS or history is not None:
2969
3189
  saved_history = []
2970
3190
  for i in range(1, readline.get_current_history_length() + 1):
2971
- # noinspection PyArgumentList
2972
3191
  saved_history.append(readline.get_history_item(i))
2973
3192
 
2974
3193
  readline.clear_history()
@@ -2981,7 +3200,7 @@ class Cmd(cmd.Cmd):
2981
3200
  def restore_readline() -> None:
2982
3201
  """Restore readline tab completion and history"""
2983
3202
  nonlocal readline_configured
2984
- if not readline_configured: # pragma: no cover
3203
+ if not readline_configured or rl_type == RlType.NONE: # pragma: no cover
2985
3204
  return
2986
3205
 
2987
3206
  if self._completion_supported():
@@ -3065,8 +3284,13 @@ class Cmd(cmd.Cmd):
3065
3284
  """
3066
3285
  readline_settings = _SavedReadlineSettings()
3067
3286
 
3068
- if self._completion_supported():
3287
+ if rl_type == RlType.GNU:
3288
+ # To calculate line count when printing async_alerts, we rely on commands wider than
3289
+ # the terminal to wrap across multiple lines. The default for horizontal-scroll-mode
3290
+ # is "off" but a user may have overridden it in their readline initialization file.
3291
+ readline.parse_and_bind("set horizontal-scroll-mode off")
3069
3292
 
3293
+ if self._completion_supported():
3070
3294
  # Set up readline for our tab completion needs
3071
3295
  if rl_type == RlType.GNU:
3072
3296
  # GNU readline automatically adds a closing quote if the text being completed has an opening quote.
@@ -3100,7 +3324,6 @@ class Cmd(cmd.Cmd):
3100
3324
  :param readline_settings: the readline settings to restore
3101
3325
  """
3102
3326
  if self._completion_supported():
3103
-
3104
3327
  # Restore what we changed in readline
3105
3328
  readline.set_completer(readline_settings.completer)
3106
3329
  readline.set_completer_delims(readline_settings.delims)
@@ -3109,7 +3332,6 @@ class Cmd(cmd.Cmd):
3109
3332
  readline.set_completion_display_matches_hook(None)
3110
3333
  rl_basic_quote_characters.value = readline_settings.basic_quotes
3111
3334
  elif rl_type == RlType.PYREADLINE:
3112
- # noinspection PyUnresolvedReferences
3113
3335
  readline.rl.mode._display_completions = orig_pyreadline_display
3114
3336
 
3115
3337
  def _cmdloop(self) -> None:
@@ -3131,6 +3353,13 @@ class Cmd(cmd.Cmd):
3131
3353
  self._startup_commands.clear()
3132
3354
 
3133
3355
  while not stop:
3356
+ # Used in building multiline readline history entries. Only applies
3357
+ # when command line is read by input() in a terminal.
3358
+ if rl_type != RlType.NONE and self.use_rawinput and sys.stdin.isatty():
3359
+ orig_rl_history_length = readline.get_current_history_length()
3360
+ else:
3361
+ orig_rl_history_length = None
3362
+
3134
3363
  # Get commands from user
3135
3364
  try:
3136
3365
  line = self._read_command_line(self.prompt)
@@ -3139,7 +3368,7 @@ class Cmd(cmd.Cmd):
3139
3368
  line = ''
3140
3369
 
3141
3370
  # Run the command along with all associated pre and post hooks
3142
- stop = self.onecmd_plus_hooks(line)
3371
+ stop = self.onecmd_plus_hooks(line, orig_rl_history_length=orig_rl_history_length)
3143
3372
  finally:
3144
3373
  # Get sigint protection while we restore readline settings
3145
3374
  with self.sigint_protection:
@@ -3151,11 +3380,10 @@ class Cmd(cmd.Cmd):
3151
3380
  #############################################################
3152
3381
 
3153
3382
  # Top-level parser for alias
3154
- alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3155
- alias_epilog = "See also:\n" " macro"
3383
+ alias_description = "Manage aliases\n\nAn alias is a command that enables replacement of a word by another string."
3384
+ alias_epilog = "See also:\n macro"
3156
3385
  alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3157
- alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3158
- alias_subparsers.required = True
3386
+ alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3159
3387
 
3160
3388
  # Preserve quotes since we are passing strings to other commands
3161
3389
  @with_argparser(alias_parser, preserve_quotes=True)
@@ -3320,11 +3548,10 @@ class Cmd(cmd.Cmd):
3320
3548
  #############################################################
3321
3549
 
3322
3550
  # Top-level parser for macro
3323
- macro_description = "Manage macros\n" "\n" "A macro is similar to an alias, but it can contain argument placeholders."
3324
- macro_epilog = "See also:\n" " alias"
3551
+ macro_description = "Manage macros\n\nA macro is similar to an alias, but it can contain argument placeholders."
3552
+ macro_epilog = "See also:\n alias"
3325
3553
  macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description, epilog=macro_epilog)
3326
- macro_subparsers = macro_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3327
- macro_subparsers.required = True
3554
+ macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3328
3555
 
3329
3556
  # Preserve quotes since we are passing strings to other commands
3330
3557
  @with_argparser(macro_parser, preserve_quotes=True)
@@ -3572,16 +3799,14 @@ class Cmd(cmd.Cmd):
3572
3799
  return []
3573
3800
 
3574
3801
  # Check if this command uses argparse
3575
- func = self.cmd_func(command)
3576
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3577
- if func is None or argparser is None:
3802
+ if (func := self.cmd_func(command)) is None or (argparser := self._command_parsers.get(func)) is None:
3578
3803
  return []
3579
3804
 
3580
3805
  completer = argparse_completer.DEFAULT_AP_COMPLETER(argparser, self)
3581
3806
  return completer.complete_subcommand_help(text, line, begidx, endidx, arg_tokens['subcommands'])
3582
3807
 
3583
3808
  help_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
3584
- description="List available commands or provide " "detailed help for a specific command"
3809
+ description="List available commands or provide detailed help for a specific command"
3585
3810
  )
3586
3811
  help_parser.add_argument(
3587
3812
  '-v', '--verbose', action='store_true', help="print a list of all commands with descriptions of each"
@@ -3609,7 +3834,7 @@ class Cmd(cmd.Cmd):
3609
3834
  # Getting help for a specific command
3610
3835
  func = self.cmd_func(args.command)
3611
3836
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3612
- argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3837
+ argparser = None if func is None else self._command_parsers.get(func)
3613
3838
 
3614
3839
  # If the command function uses argparse, then use argparse's help
3615
3840
  if func is not None and argparser is not None:
@@ -3654,7 +3879,7 @@ class Cmd(cmd.Cmd):
3654
3879
 
3655
3880
  def columnize(self, str_list: Optional[List[str]], display_width: int = 80) -> None:
3656
3881
  """Display a list of single-line strings as a compact set of columns.
3657
- Override of cmd's print_topics() to handle strings with ANSI style sequences and wide characters
3882
+ Override of cmd's columnize() to handle strings with ANSI style sequences and wide characters
3658
3883
 
3659
3884
  Each column is only as wide as necessary.
3660
3885
  Columns are separated by two spaces (one was not legible enough).
@@ -3731,28 +3956,29 @@ class Cmd(cmd.Cmd):
3731
3956
  def _build_command_info(self) -> Tuple[Dict[str, List[str]], List[str], List[str], List[str]]:
3732
3957
  # Get a sorted list of help topics
3733
3958
  help_topics = sorted(self.get_help_topics(), key=self.default_sort_key)
3959
+
3734
3960
  # Get a sorted list of visible command names
3735
3961
  visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key)
3736
3962
  cmds_doc: List[str] = []
3737
3963
  cmds_undoc: List[str] = []
3738
3964
  cmds_cats: Dict[str, List[str]] = {}
3739
3965
  for command in visible_commands:
3740
- func = self.cmd_func(command)
3966
+ func = cast(CommandFunc, self.cmd_func(command))
3741
3967
  has_help_func = False
3968
+ has_parser = func in self._command_parsers
3742
3969
 
3743
3970
  if command in help_topics:
3744
3971
  # Prevent the command from showing as both a command and help topic in the output
3745
3972
  help_topics.remove(command)
3746
3973
 
3747
3974
  # Non-argparse commands can have help_functions for their documentation
3748
- if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3749
- has_help_func = True
3975
+ has_help_func = not has_parser
3750
3976
 
3751
3977
  if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
3752
3978
  category: str = getattr(func, constants.CMD_ATTR_HELP_CATEGORY)
3753
3979
  cmds_cats.setdefault(category, [])
3754
3980
  cmds_cats[category].append(command)
3755
- elif func.__doc__ or has_help_func:
3981
+ elif func.__doc__ or has_help_func or has_parser:
3756
3982
  cmds_doc.append(command)
3757
3983
  else:
3758
3984
  cmds_undoc.append(command)
@@ -3787,11 +4013,17 @@ class Cmd(cmd.Cmd):
3787
4013
  # Try to get the documentation string for each command
3788
4014
  topics = self.get_help_topics()
3789
4015
  for command in cmds:
3790
- cmd_func = self.cmd_func(command)
4016
+ if (cmd_func := self.cmd_func(command)) is None:
4017
+ continue
4018
+
3791
4019
  doc: Optional[str]
3792
4020
 
4021
+ # If this is an argparse command, use its description.
4022
+ if (cmd_parser := self._command_parsers.get(cmd_func)) is not None:
4023
+ doc = cmd_parser.description
4024
+
3793
4025
  # Non-argparse commands can have help_functions for their documentation
3794
- if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
4026
+ elif command in topics:
3795
4027
  help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
3796
4028
  result = io.StringIO()
3797
4029
 
@@ -3844,7 +4076,6 @@ class Cmd(cmd.Cmd):
3844
4076
  self.poutput()
3845
4077
 
3846
4078
  # self.last_result will be set by do_quit()
3847
- # noinspection PyTypeChecker
3848
4079
  return self.do_quit('')
3849
4080
 
3850
4081
  quit_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Exit this application")
@@ -3881,7 +4112,7 @@ class Cmd(cmd.Cmd):
3881
4112
  fulloptions.append((opt[0], opt[1]))
3882
4113
  except IndexError:
3883
4114
  fulloptions.append((opt[0], opt[0]))
3884
- for (idx, (_, text)) in enumerate(fulloptions):
4115
+ for idx, (_, text) in enumerate(fulloptions):
3885
4116
  self.poutput(' %2d. %s' % (idx + 1, text))
3886
4117
 
3887
4118
  while True:
@@ -3979,12 +4210,11 @@ class Cmd(cmd.Cmd):
3979
4210
  # Try to update the settable's value
3980
4211
  try:
3981
4212
  orig_value = settable.get_value()
3982
- new_value = settable.set_value(utils.strip_quotes(args.value))
3983
- # noinspection PyBroadException
4213
+ settable.set_value(utils.strip_quotes(args.value))
3984
4214
  except Exception as ex:
3985
4215
  self.perror(f"Error setting {args.param}: {ex}")
3986
4216
  else:
3987
- self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {new_value!r}")
4217
+ self.poutput(f"{args.param} - was: {orig_value!r}\nnow: {settable.get_value()!r}")
3988
4218
  self.last_result = True
3989
4219
  return
3990
4220
 
@@ -4116,7 +4346,6 @@ class Cmd(cmd.Cmd):
4116
4346
  if rl_type != RlType.NONE:
4117
4347
  # Save cmd2 history
4118
4348
  for i in range(1, readline.get_current_history_length() + 1):
4119
- # noinspection PyArgumentList
4120
4349
  cmd2_env.history.append(readline.get_history_item(i))
4121
4350
 
4122
4351
  readline.clear_history()
@@ -4150,7 +4379,6 @@ class Cmd(cmd.Cmd):
4150
4379
  if rl_type == RlType.GNU:
4151
4380
  readline.set_completion_display_matches_hook(None)
4152
4381
  elif rl_type == RlType.PYREADLINE:
4153
- # noinspection PyUnresolvedReferences
4154
4382
  readline.rl.mode._display_completions = orig_pyreadline_display
4155
4383
 
4156
4384
  # Save off the current completer and set a new one in the Python console
@@ -4185,7 +4413,6 @@ class Cmd(cmd.Cmd):
4185
4413
  # Save py's history
4186
4414
  self._py_history.clear()
4187
4415
  for i in range(1, readline.get_current_history_length() + 1):
4188
- # noinspection PyArgumentList
4189
4416
  self._py_history.append(readline.get_history_item(i))
4190
4417
 
4191
4418
  readline.clear_history()
@@ -4229,7 +4456,8 @@ class Cmd(cmd.Cmd):
4229
4456
  PyBridge,
4230
4457
  )
4231
4458
 
4232
- py_bridge = PyBridge(self)
4459
+ add_to_history = self.scripts_add_to_history if pyscript else True
4460
+ py_bridge = PyBridge(self, add_to_history=add_to_history)
4233
4461
  saved_sys_path = None
4234
4462
 
4235
4463
  if self.in_pyscript():
@@ -4281,7 +4509,6 @@ class Cmd(cmd.Cmd):
4281
4509
 
4282
4510
  # Check if we are running Python code
4283
4511
  if py_code_to_run:
4284
- # noinspection PyBroadException
4285
4512
  try:
4286
4513
  interp.runcode(py_code_to_run) # type: ignore[arg-type]
4287
4514
  except BaseException:
@@ -4299,7 +4526,6 @@ class Cmd(cmd.Cmd):
4299
4526
 
4300
4527
  saved_cmd2_env = None
4301
4528
 
4302
- # noinspection PyBroadException
4303
4529
  try:
4304
4530
  # Get sigint protection while we set up the Python shell environment
4305
4531
  with self.sigint_protection:
@@ -4380,7 +4606,6 @@ class Cmd(cmd.Cmd):
4380
4606
 
4381
4607
  ipython_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description="Run an interactive IPython shell")
4382
4608
 
4383
- # noinspection PyPackageRequirements
4384
4609
  @with_argparser(ipython_parser)
4385
4610
  def do_ipy(self, _: argparse.Namespace) -> Optional[bool]: # pragma: no cover
4386
4611
  """
@@ -4393,9 +4618,13 @@ class Cmd(cmd.Cmd):
4393
4618
  # Detect whether IPython is installed
4394
4619
  try:
4395
4620
  import traitlets.config.loader as TraitletsLoader # type: ignore[import]
4396
- from IPython import ( # type: ignore[import]
4397
- start_ipython,
4398
- )
4621
+
4622
+ # Allow users to install ipython from a cmd2 prompt when needed and still have ipy command work
4623
+ try:
4624
+ start_ipython # noqa F823
4625
+ except NameError:
4626
+ from IPython import start_ipython # type: ignore[import]
4627
+
4399
4628
  from IPython.terminal.interactiveshell import ( # type: ignore[import]
4400
4629
  TerminalInteractiveShell,
4401
4630
  )
@@ -4436,7 +4665,7 @@ class Cmd(cmd.Cmd):
4436
4665
  )
4437
4666
 
4438
4667
  # Start IPython
4439
- start_ipython(config=config, argv=[], user_ns=local_vars)
4668
+ start_ipython(config=config, argv=[], user_ns=local_vars) # type: ignore[no-untyped-call]
4440
4669
  self.poutput("Now exiting IPython shell...")
4441
4670
 
4442
4671
  # The IPython application is a singleton and won't be recreated next time
@@ -4470,22 +4699,22 @@ class Cmd(cmd.Cmd):
4470
4699
 
4471
4700
  history_format_group = history_parser.add_argument_group(title='formatting')
4472
4701
  history_format_group.add_argument(
4473
- '-s', '--script', action='store_true', help='output commands in script format, i.e. without command\n' 'numbers'
4702
+ '-s', '--script', action='store_true', help='output commands in script format, i.e. without command\nnumbers'
4474
4703
  )
4475
4704
  history_format_group.add_argument(
4476
4705
  '-x',
4477
4706
  '--expanded',
4478
4707
  action='store_true',
4479
- help='output fully parsed commands with any aliases and\n' 'macros expanded, instead of typed commands',
4708
+ help='output fully parsed commands with any aliases and\nmacros expanded, instead of typed commands',
4480
4709
  )
4481
4710
  history_format_group.add_argument(
4482
4711
  '-v',
4483
4712
  '--verbose',
4484
4713
  action='store_true',
4485
- help='display history and include expanded commands if they\n' 'differ from the typed command',
4714
+ help='display history and include expanded commands if they\ndiffer from the typed command',
4486
4715
  )
4487
4716
  history_format_group.add_argument(
4488
- '-a', '--all', action='store_true', help='display all commands, including ones persisted from\n' 'previous sessions'
4717
+ '-a', '--all', action='store_true', help='display all commands, including ones persisted from\nprevious sessions'
4489
4718
  )
4490
4719
 
4491
4720
  history_arg_help = (
@@ -4551,8 +4780,6 @@ class Cmd(cmd.Cmd):
4551
4780
  self.last_result = True
4552
4781
  return stop
4553
4782
  elif args.edit:
4554
- import tempfile
4555
-
4556
4783
  fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
4557
4784
  fobj: TextIO
4558
4785
  with os.fdopen(fd, 'w') as fobj:
@@ -4565,7 +4792,6 @@ class Cmd(cmd.Cmd):
4565
4792
  self.run_editor(fname)
4566
4793
 
4567
4794
  # self.last_resort will be set by do_run_script()
4568
- # noinspection PyTypeChecker
4569
4795
  return self.do_run_script(utils.quote_string(fname))
4570
4796
  finally:
4571
4797
  os.remove(fname)
@@ -4625,11 +4851,9 @@ class Cmd(cmd.Cmd):
4625
4851
  previous sessions will be included. Additionally, all history will be written
4626
4852
  to this file when the application exits.
4627
4853
  """
4628
- import json
4629
- import lzma
4630
-
4631
4854
  self.history = History()
4632
- # with no persistent history, nothing else in this method is relevant
4855
+
4856
+ # With no persistent history, nothing else in this method is relevant
4633
4857
  if not hist_file:
4634
4858
  self.persistent_history_file = hist_file
4635
4859
  return
@@ -4650,64 +4874,96 @@ class Cmd(cmd.Cmd):
4650
4874
  self.perror(f"Error creating persistent history file directory '{hist_file_dir}': {ex}")
4651
4875
  return
4652
4876
 
4653
- # Read and process history file
4877
+ # Read history file
4654
4878
  try:
4655
4879
  with open(hist_file, 'rb') as fobj:
4656
4880
  compressed_bytes = fobj.read()
4657
- history_json = lzma.decompress(compressed_bytes).decode(encoding='utf-8')
4658
- self.history = History.from_json(history_json)
4659
4881
  except FileNotFoundError:
4660
- # Just use an empty history
4661
- pass
4882
+ compressed_bytes = bytes()
4662
4883
  except OSError as ex:
4663
4884
  self.perror(f"Cannot read persistent history file '{hist_file}': {ex}")
4664
4885
  return
4665
- except (json.JSONDecodeError, lzma.LZMAError, KeyError, UnicodeDecodeError, ValueError) as ex:
4886
+
4887
+ # Register a function to write history at save
4888
+ import atexit
4889
+
4890
+ self.persistent_history_file = hist_file
4891
+ atexit.register(self._persist_history)
4892
+
4893
+ # Empty or nonexistent history file. Nothing more to do.
4894
+ if not compressed_bytes:
4895
+ return
4896
+
4897
+ # Decompress history data
4898
+ try:
4899
+ import lzma as decompress_lib
4900
+
4901
+ decompress_exceptions: Tuple[type[Exception]] = (decompress_lib.LZMAError,)
4902
+ except ModuleNotFoundError: # pragma: no cover
4903
+ import bz2 as decompress_lib # type: ignore[no-redef]
4904
+
4905
+ decompress_exceptions: Tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef]
4906
+
4907
+ try:
4908
+ history_json = decompress_lib.decompress(compressed_bytes).decode(encoding='utf-8')
4909
+ except decompress_exceptions as ex:
4910
+ self.perror(
4911
+ f"Error decompressing persistent history data '{hist_file}': {ex}\n"
4912
+ f"The history file will be recreated when this application exits."
4913
+ )
4914
+ return
4915
+
4916
+ # Decode history json
4917
+ import json
4918
+
4919
+ try:
4920
+ self.history = History.from_json(history_json)
4921
+ except (json.JSONDecodeError, KeyError, ValueError) as ex:
4666
4922
  self.perror(
4667
- f"Error processing persistent history file '{hist_file}': {ex}\n"
4923
+ f"Error processing persistent history data '{hist_file}': {ex}\n"
4668
4924
  f"The history file will be recreated when this application exits."
4669
4925
  )
4926
+ return
4670
4927
 
4671
4928
  self.history.start_session()
4672
- self.persistent_history_file = hist_file
4673
4929
 
4674
- # populate readline history
4930
+ # Populate readline history
4675
4931
  if rl_type != RlType.NONE:
4676
- last = None
4677
4932
  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
4933
+ formatted_command = single_line_format(item.statement)
4690
4934
 
4691
- atexit.register(self._persist_history)
4935
+ # If formatted command is different than the previous history item, add it
4936
+ cur_history_length = readline.get_current_history_length()
4937
+ if cur_history_length == 0 or formatted_command != readline.get_history_item(cur_history_length):
4938
+ readline.add_history(formatted_command)
4692
4939
 
4693
4940
  def _persist_history(self) -> None:
4694
4941
  """Write history out to the persistent history file as compressed JSON"""
4695
- import lzma
4696
-
4697
4942
  if not self.persistent_history_file:
4698
4943
  return
4699
4944
 
4700
- self.history.truncate(self._persistent_history_length)
4701
4945
  try:
4702
- history_json = self.history.to_json()
4703
- compressed_bytes = lzma.compress(history_json.encode(encoding='utf-8'))
4946
+ import lzma as compress_lib
4947
+ except ModuleNotFoundError: # pragma: no cover
4948
+ import bz2 as compress_lib # type: ignore[no-redef]
4704
4949
 
4950
+ self.history.truncate(self._persistent_history_length)
4951
+ history_json = self.history.to_json()
4952
+ compressed_bytes = compress_lib.compress(history_json.encode(encoding='utf-8'))
4953
+
4954
+ try:
4705
4955
  with open(self.persistent_history_file, 'wb') as fobj:
4706
4956
  fobj.write(compressed_bytes)
4707
4957
  except OSError as ex:
4708
4958
  self.perror(f"Cannot write persistent history file '{self.persistent_history_file}': {ex}")
4709
4959
 
4710
- def _generate_transcript(self, history: Union[List[HistoryItem], List[str]], transcript_file: str) -> None:
4960
+ def _generate_transcript(
4961
+ self,
4962
+ history: Union[List[HistoryItem], List[str]],
4963
+ transcript_file: str,
4964
+ *,
4965
+ add_to_history: bool = True,
4966
+ ) -> None:
4711
4967
  """Generate a transcript file from a given history of commands"""
4712
4968
  self.last_result = False
4713
4969
 
@@ -4757,7 +5013,11 @@ class Cmd(cmd.Cmd):
4757
5013
 
4758
5014
  # then run the command and let the output go into our buffer
4759
5015
  try:
4760
- stop = self.onecmd_plus_hooks(history_item, raise_keyboard_interrupt=True)
5016
+ stop = self.onecmd_plus_hooks(
5017
+ history_item,
5018
+ add_to_history=add_to_history,
5019
+ raise_keyboard_interrupt=True,
5020
+ )
4761
5021
  except KeyboardInterrupt as ex:
4762
5022
  self.perror(ex)
4763
5023
  stop = True
@@ -4829,7 +5089,6 @@ class Cmd(cmd.Cmd):
4829
5089
  if file_path:
4830
5090
  command += " " + utils.quote_string(os.path.expanduser(file_path))
4831
5091
 
4832
- # noinspection PyTypeChecker
4833
5092
  self.do_shell(command)
4834
5093
 
4835
5094
  @property
@@ -4902,9 +5161,17 @@ class Cmd(cmd.Cmd):
4902
5161
 
4903
5162
  if args.transcript:
4904
5163
  # self.last_resort will be set by _generate_transcript()
4905
- self._generate_transcript(script_commands, os.path.expanduser(args.transcript))
5164
+ self._generate_transcript(
5165
+ script_commands,
5166
+ os.path.expanduser(args.transcript),
5167
+ add_to_history=self.scripts_add_to_history,
5168
+ )
4906
5169
  else:
4907
- stop = self.runcmds_plus_hooks(script_commands, stop_on_keyboard_interrupt=True)
5170
+ stop = self.runcmds_plus_hooks(
5171
+ script_commands,
5172
+ add_to_history=self.scripts_add_to_history,
5173
+ stop_on_keyboard_interrupt=True,
5174
+ )
4908
5175
  self.last_result = True
4909
5176
  return stop
4910
5177
 
@@ -4922,7 +5189,7 @@ class Cmd(cmd.Cmd):
4922
5189
  "interpreted relative to the already-running script's directory."
4923
5190
  )
4924
5191
 
4925
- relative_run_script_epilog = "Notes:\n" " This command is intended to only be used within text file scripts."
5192
+ relative_run_script_epilog = "Notes:\n This command is intended to only be used within text file scripts."
4926
5193
 
4927
5194
  relative_run_script_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(
4928
5195
  description=relative_run_script_description, epilog=relative_run_script_epilog
@@ -4941,7 +5208,6 @@ class Cmd(cmd.Cmd):
4941
5208
  relative_path = os.path.join(self._current_script_dir or '', file_path)
4942
5209
 
4943
5210
  # self.last_result will be set by do_run_script()
4944
- # noinspection PyTypeChecker
4945
5211
  return self.do_run_script(utils.quote_string(relative_path))
4946
5212
 
4947
5213
  def _run_transcript_tests(self, transcript_paths: List[str]) -> None:
@@ -4984,7 +5250,6 @@ class Cmd(cmd.Cmd):
4984
5250
  sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main()
4985
5251
  testcase = TestMyAppCase()
4986
5252
  stream = cast(TextIO, utils.StdSim(sys.stderr))
4987
- # noinspection PyTypeChecker
4988
5253
  runner = unittest.TextTestRunner(stream=stream)
4989
5254
  start_time = time.time()
4990
5255
  test_results = runner.run(testcase)
@@ -4992,8 +5257,8 @@ class Cmd(cmd.Cmd):
4992
5257
  if test_results.wasSuccessful():
4993
5258
  ansi.style_aware_write(sys.stderr, stream.read())
4994
5259
  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)
5260
+ finish_msg = utils.align_center(finish_msg, fill_char='=')
5261
+ self.psuccess(finish_msg)
4997
5262
  else:
4998
5263
  # Strip off the initial traceback which isn't particularly useful for end users
4999
5264
  error_str = stream.read()
@@ -5010,16 +5275,16 @@ class Cmd(cmd.Cmd):
5010
5275
  def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None: # pragma: no cover
5011
5276
  """
5012
5277
  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.
5278
+ To the user it appears as if an alert message is printed above the prompt and their
5279
+ current input text and cursor location is left alone.
5015
5280
 
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.
5281
+ This function needs to acquire self.terminal_lock to ensure a prompt is on screen.
5282
+ Therefore, it is best to acquire the lock before calling this function to avoid
5283
+ raising a RuntimeError.
5019
5284
 
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.
5285
+ This function is only needed when you need to print an alert or update the prompt while the
5286
+ main thread is blocking at the prompt. Therefore, this should never be called from the main
5287
+ thread. Doing so will raise a RuntimeError.
5023
5288
 
5024
5289
  :param alert_msg: the message to display to the user
5025
5290
  :param new_prompt: If you also want to change the prompt that is displayed, then include it here.
@@ -5035,7 +5300,6 @@ class Cmd(cmd.Cmd):
5035
5300
 
5036
5301
  # Sanity check that can't fail if self.terminal_lock was acquired before calling this function
5037
5302
  if self.terminal_lock.acquire(blocking=False):
5038
-
5039
5303
  # Windows terminals tend to flicker when we redraw the prompt and input lines.
5040
5304
  # To reduce how often this occurs, only update terminal if there are changes.
5041
5305
  update_terminal = False
@@ -5047,20 +5311,21 @@ class Cmd(cmd.Cmd):
5047
5311
  if new_prompt is not None:
5048
5312
  self.prompt = new_prompt
5049
5313
 
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:
5314
+ # Check if the onscreen prompt needs to be refreshed to match self.prompt.
5315
+ if self.need_prompt_refresh():
5055
5316
  update_terminal = True
5317
+ rl_set_prompt(self.prompt)
5056
5318
 
5057
5319
  if update_terminal:
5058
5320
  import shutil
5059
5321
 
5060
- # Generate the string which will replace the current prompt and input lines with the alert
5322
+ # Prior to Python 3.11 this can return 0, so use a fallback if needed.
5323
+ terminal_columns = shutil.get_terminal_size().columns or constants.DEFAULT_TERMINAL_WIDTH
5324
+
5325
+ # Print a string which replaces the onscreen prompt and input lines with the alert.
5061
5326
  terminal_str = ansi.async_alert_str(
5062
- terminal_columns=shutil.get_terminal_size().columns,
5063
- prompt=cur_onscreen_prompt,
5327
+ terminal_columns=terminal_columns,
5328
+ prompt=rl_get_display_prompt(),
5064
5329
  line=readline.get_line_buffer(),
5065
5330
  cursor_offset=rl_get_point(),
5066
5331
  alert_msg=alert_msg,
@@ -5069,12 +5334,8 @@ class Cmd(cmd.Cmd):
5069
5334
  sys.stderr.write(terminal_str)
5070
5335
  sys.stderr.flush()
5071
5336
  elif rl_type == RlType.PYREADLINE:
5072
- # noinspection PyUnresolvedReferences
5073
5337
  readline.rl.mode.console.write(terminal_str)
5074
5338
 
5075
- # Update Readline's prompt before we redraw it
5076
- rl_set_prompt(new_onscreen_prompt)
5077
-
5078
5339
  # Redraw the prompt and input lines below the alert
5079
5340
  rl_force_redisplay()
5080
5341
 
@@ -5085,23 +5346,17 @@ class Cmd(cmd.Cmd):
5085
5346
 
5086
5347
  def async_update_prompt(self, new_prompt: str) -> None: # pragma: no cover
5087
5348
  """
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.
5349
+ Update the command line prompt while the user is still typing at it.
5097
5350
 
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.
5351
+ This is good for alerting the user to system changes dynamically in between commands.
5352
+ For instance you could alter the color of the prompt to indicate a system status or increase a
5353
+ counter to report an event. If you do alter the actual text of the prompt, it is best to keep
5354
+ the prompt the same width as what's on screen. Otherwise the user's input text will be shifted
5355
+ and the update will not be seamless.
5101
5356
 
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.
5357
+ If user is at a continuation prompt while entering a multiline command, the onscreen prompt will
5358
+ not change. However, self.prompt will still be updated and display immediately after the multiline
5359
+ line command completes.
5105
5360
 
5106
5361
  :param new_prompt: what to change the prompt to
5107
5362
  :raises RuntimeError: if called from the main thread.
@@ -5109,6 +5364,32 @@ class Cmd(cmd.Cmd):
5109
5364
  """
5110
5365
  self.async_alert('', new_prompt)
5111
5366
 
5367
+ def async_refresh_prompt(self) -> None: # pragma: no cover
5368
+ """
5369
+ Refresh the oncreen prompt to match self.prompt.
5370
+
5371
+ One case where the onscreen prompt and self.prompt can get out of sync is
5372
+ when async_alert() is called while a user is in search mode (e.g. Ctrl-r).
5373
+ To prevent overwriting readline's onscreen search prompt, self.prompt is updated
5374
+ but readline's saved prompt isn't.
5375
+
5376
+ Therefore when a user aborts a search, the old prompt is still on screen until they
5377
+ press Enter or this method is called. Call need_prompt_refresh() in an async print
5378
+ thread to know when a refresh is needed.
5379
+
5380
+ :raises RuntimeError: if called from the main thread.
5381
+ :raises RuntimeError: if called while another thread holds `terminal_lock`
5382
+ """
5383
+ self.async_alert('')
5384
+
5385
+ def need_prompt_refresh(self) -> bool: # pragma: no cover
5386
+ """Check whether the onscreen prompt needs to be asynchronously refreshed to match self.prompt."""
5387
+ if not (vt100_support and self.use_rawinput):
5388
+ return False
5389
+
5390
+ # Don't overwrite a readline search prompt or a continuation prompt.
5391
+ return not rl_in_search_mode() and not self._at_continuation_prompt and self.prompt != rl_get_prompt()
5392
+
5112
5393
  @staticmethod
5113
5394
  def set_window_title(title: str) -> None: # pragma: no cover
5114
5395
  """
@@ -5139,12 +5420,13 @@ class Cmd(cmd.Cmd):
5139
5420
  if command not in self.disabled_commands:
5140
5421
  return
5141
5422
 
5423
+ cmd_func_name = constants.COMMAND_FUNC_PREFIX + command
5142
5424
  help_func_name = constants.HELP_FUNC_PREFIX + command
5143
5425
  completer_func_name = constants.COMPLETER_FUNC_PREFIX + command
5144
5426
 
5145
5427
  # Restore the command function to its original value
5146
5428
  dc = self.disabled_commands[command]
5147
- setattr(self, self._cmd_func_name(command), dc.command_function)
5429
+ setattr(self, cmd_func_name, dc.command_function)
5148
5430
 
5149
5431
  # Restore the help function to its original value
5150
5432
  if dc.help_function is None:
@@ -5192,6 +5474,7 @@ class Cmd(cmd.Cmd):
5192
5474
  if command_function is None:
5193
5475
  raise AttributeError(f"'{command}' does not refer to a command")
5194
5476
 
5477
+ cmd_func_name = constants.COMMAND_FUNC_PREFIX + command
5195
5478
  help_func_name = constants.HELP_FUNC_PREFIX + command
5196
5479
  completer_func_name = constants.COMPLETER_FUNC_PREFIX + command
5197
5480
 
@@ -5206,7 +5489,7 @@ class Cmd(cmd.Cmd):
5206
5489
  new_func = functools.partial(
5207
5490
  self._report_disabled_command_usage, message_to_print=message_to_print.replace(constants.COMMAND_NAME, command)
5208
5491
  )
5209
- setattr(self, self._cmd_func_name(command), new_func)
5492
+ setattr(self, cmd_func_name, new_func)
5210
5493
  setattr(self, help_func_name, new_func)
5211
5494
 
5212
5495
  # Set the completer to a function that returns a blank list
@@ -5252,14 +5535,21 @@ class Cmd(cmd.Cmd):
5252
5535
  """
5253
5536
  # cmdloop() expects to be run in the main thread to support extensive use of KeyboardInterrupts throughout the
5254
5537
  # 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():
5538
+ if threading.current_thread() is not threading.main_thread():
5256
5539
  raise RuntimeError("cmdloop must be run in the main thread")
5257
5540
 
5258
- # Register a SIGINT signal handler for Ctrl+C
5541
+ # Register signal handlers
5259
5542
  import signal
5260
5543
 
5261
5544
  original_sigint_handler = signal.getsignal(signal.SIGINT)
5262
- signal.signal(signal.SIGINT, self.sigint_handler) # type: ignore
5545
+ signal.signal(signal.SIGINT, self.sigint_handler)
5546
+
5547
+ if not sys.platform.startswith('win'):
5548
+ original_sighup_handler = signal.getsignal(signal.SIGHUP)
5549
+ signal.signal(signal.SIGHUP, self.termination_signal_handler)
5550
+
5551
+ original_sigterm_handler = signal.getsignal(signal.SIGTERM)
5552
+ signal.signal(signal.SIGTERM, self.termination_signal_handler)
5263
5553
 
5264
5554
  # Grab terminal lock before the command line prompt has been drawn by readline
5265
5555
  self.terminal_lock.acquire()
@@ -5293,9 +5583,13 @@ class Cmd(cmd.Cmd):
5293
5583
  # This will also zero the lock count in case cmdloop() is called again
5294
5584
  self.terminal_lock.release()
5295
5585
 
5296
- # Restore the original signal handler
5586
+ # Restore original signal handlers
5297
5587
  signal.signal(signal.SIGINT, original_sigint_handler)
5298
5588
 
5589
+ if not sys.platform.startswith('win'):
5590
+ signal.signal(signal.SIGHUP, original_sighup_handler)
5591
+ signal.signal(signal.SIGTERM, original_sigterm_handler)
5592
+
5299
5593
  return self.exit_code
5300
5594
 
5301
5595
  ###
@@ -5377,7 +5671,7 @@ class Cmd(cmd.Cmd):
5377
5671
  raise TypeError(f'{func.__name__} does not have a declared return type, expected {data_type}')
5378
5672
  if signature.return_annotation != data_type:
5379
5673
  raise TypeError(
5380
- f'{func.__name__} has incompatible return type {signature.return_annotation}, expected ' f'{data_type}'
5674
+ f'{func.__name__} has incompatible return type {signature.return_annotation}, expected {data_type}'
5381
5675
  )
5382
5676
 
5383
5677
  def register_precmd_hook(self, func: Callable[[plugin.PrecommandData], plugin.PrecommandData]) -> None:
@@ -5446,7 +5740,7 @@ class Cmd(cmd.Cmd):
5446
5740
  func_self = None
5447
5741
  candidate_sets: List[CommandSet] = []
5448
5742
  for installed_cmd_set in self._installed_command_sets:
5449
- if type(installed_cmd_set) == func_class:
5743
+ if type(installed_cmd_set) == func_class: # noqa: E721
5450
5744
  # Case 2: CommandSet is an exact type match for the function's CommandSet
5451
5745
  func_self = installed_cmd_set
5452
5746
  break