Skip to content

Commit f671667

Browse files
committed
Merge branch 'history_improvements' of github.com:python-cmd2/cmd2 into history_improvements
2 parents 1c302bc + 290f224 commit f671667

File tree

10 files changed

+482
-190
lines changed

10 files changed

+482
-190
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Bug Fixes
33
* Fixed bug in how **history** command deals with multiline commands when output to a script
44
* Fixed a bug when the ``with_argument_list`` decorator is called with the optional ``preserve_quotes`` argument
5+
* Fix bug in ``perror()`` where it would try to print an exception Traceback even if none existed
56
* Enhancements
67
* Improvements to the **history** command
78
* Simplified the display format and made it more similar to **bash**
@@ -10,6 +11,16 @@
1011
* Added **-v**, **--verbose** flag
1112
* display history and include expanded commands if they differ from the typed command
1213
* Added ``matches_sort_key`` to override the default way tab completion matches are sorted
14+
* Added ``StdSim.pause_storage`` member which when True will cause ``StdSim`` to not save the output sent to it.
15+
See documentation for ``CommandResult`` in ``pyscript_bridge.py`` for reasons pausing the storage can be useful.
16+
* Added ability to disable/enable individual commands and entire categories of commands. When a command
17+
is disabled, it will not show up in the help menu or tab complete. If a user tries to run the command
18+
or call help on it, a command-specific message supplied by the developer will be printed. The following
19+
commands were added to support this feature.
20+
* ``enable_command()``
21+
* ``enable_category()``
22+
* ``disable_command()``
23+
* ``disable_category()``
1324
* Potentially breaking changes
1425
* Made ``cmd2_app`` a positional and required argument of ``AutoCompleter`` since certain functionality now
1526
requires that it can't be ``None``.

cmd2/cmd2.py

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import re
3838
import sys
3939
import threading
40+
from collections import namedtuple
4041
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Type, Union, IO
4142

4243
import colorama
@@ -279,6 +280,10 @@ class EmptyStatement(Exception):
279280
pass
280281

281282

283+
# Contains data about a disabled command which is used to restore its original functions when the command is enabled
284+
DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function'])
285+
286+
282287
class Cmd(cmd.Cmd):
283288
"""An easy but powerful framework for writing line-oriented command interpreters.
284289
@@ -521,6 +526,11 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, persistent
521526
# being printed by a command.
522527
self.terminal_lock = threading.RLock()
523528

529+
# Commands that have been disabled from use. This is to support commands that are only available
530+
# during specific states of the application. This dictionary's keys are the command names and its
531+
# values are DisabledCommand objects.
532+
self.disabled_commands = dict()
533+
524534
# ----- Methods related to presenting output to the user -----
525535

526536
@property
@@ -587,7 +597,7 @@ def perror(self, err: Union[str, Exception], traceback_war: bool = True, err_col
587597
:param err_color: (optional) color escape to output error with
588598
:param war_color: (optional) color escape to output warning with
589599
"""
590-
if self.debug:
600+
if self.debug and sys.exc_info() != (None, None, None):
591601
import traceback
592602
traceback.print_exc()
593603

@@ -1562,14 +1572,19 @@ def get_all_commands(self) -> List[str]:
15621572
if name.startswith(COMMAND_FUNC_PREFIX) and callable(getattr(self, name))]
15631573

15641574
def get_visible_commands(self) -> List[str]:
1565-
"""Returns a list of commands that have not been hidden."""
1575+
"""Returns a list of commands that have not been hidden or disabled."""
15661576
commands = self.get_all_commands()
15671577

15681578
# Remove the hidden commands
15691579
for name in self.hidden_commands:
15701580
if name in commands:
15711581
commands.remove(name)
15721582

1583+
# Remove the disabled commands
1584+
for name in self.disabled_commands:
1585+
if name in commands:
1586+
commands.remove(name)
1587+
15731588
return commands
15741589

15751590
def get_alias_names(self) -> List[str]:
@@ -1953,7 +1968,7 @@ def cmd_func_name(self, command: str) -> str:
19531968
def onecmd(self, statement: Union[Statement, str]) -> bool:
19541969
""" This executes the actual do_* method for a command.
19551970
1956-
If the command provided doesn't exist, then it executes _default() instead.
1971+
If the command provided doesn't exist, then it executes default() instead.
19571972
19581973
:param statement: intended to be a Statement instance parsed command from the input stream, alternative
19591974
acceptance of a str is present only for backward compatibility with cmd
@@ -1969,8 +1984,9 @@ def onecmd(self, statement: Union[Statement, str]) -> bool:
19691984
else:
19701985
func = self.cmd_func(statement.command)
19711986
if func:
1972-
# Since we have a valid command store it in the history
1973-
if statement.command not in self.exclude_from_history:
1987+
# Check to see if this command should be stored in history
1988+
if statement.command not in self.exclude_from_history \
1989+
and statement.command not in self.disabled_commands:
19741990
self.history.append(statement)
19751991

19761992
stop = func(statement)
@@ -3186,13 +3202,15 @@ def do_history(self, args: argparse.Namespace) -> None:
31863202

31873203
# -v must be used alone with no other options
31883204
if args.verbose:
3189-
if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script:
3205+
if args.clear or args.edit or args.output_file or args.run or args.transcript \
3206+
or args.expanded or args.script:
31903207
self.poutput("-v can not be used with any other options")
31913208
self.poutput(self.history_parser.format_usage())
31923209
return
31933210

31943211
# -s and -x can only be used if none of these options are present: [-c -r -e -o -t]
3195-
if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript):
3212+
if (args.script or args.expanded) \
3213+
and (args.clear or args.edit or args.output_file or args.run or args.transcript):
31963214
self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t")
31973215
self.poutput(self.history_parser.format_usage())
31983216
return
@@ -3605,6 +3623,95 @@ def set_window_title(self, title: str) -> None: # pragma: no cover
36053623
else:
36063624
raise RuntimeError("another thread holds terminal_lock")
36073625

3626+
def enable_command(self, command: str) -> None:
3627+
"""
3628+
Enable a command by restoring its functions
3629+
:param command: the command being enabled
3630+
"""
3631+
# If the commands is already enabled, then return
3632+
if command not in self.disabled_commands:
3633+
return
3634+
3635+
help_func_name = HELP_FUNC_PREFIX + command
3636+
3637+
# Restore the command and help functions to their original values
3638+
dc = self.disabled_commands[command]
3639+
setattr(self, self.cmd_func_name(command), dc.command_function)
3640+
3641+
if dc.help_function is None:
3642+
delattr(self, help_func_name)
3643+
else:
3644+
setattr(self, help_func_name, dc.help_function)
3645+
3646+
# Remove the disabled command entry
3647+
del self.disabled_commands[command]
3648+
3649+
def enable_category(self, category: str) -> None:
3650+
"""
3651+
Enable an entire category of commands
3652+
:param category: the category to enable
3653+
"""
3654+
for cmd_name in list(self.disabled_commands):
3655+
dc = self.disabled_commands[cmd_name]
3656+
cmd_category = getattr(dc.command_function, HELP_CATEGORY, None)
3657+
if cmd_category is not None and cmd_category == category:
3658+
self.enable_command(cmd_name)
3659+
3660+
def disable_command(self, command: str, message_to_print: str) -> None:
3661+
"""
3662+
Disable a command and overwrite its functions
3663+
:param command: the command being disabled
3664+
:param message_to_print: what to print when this command is run or help is called on it while disabled
3665+
"""
3666+
import functools
3667+
3668+
# If the commands is already disabled, then return
3669+
if command in self.disabled_commands:
3670+
return
3671+
3672+
# Make sure this is an actual command
3673+
command_function = self.cmd_func(command)
3674+
if command_function is None:
3675+
raise AttributeError("{} does not refer to a command".format(command))
3676+
3677+
help_func_name = HELP_FUNC_PREFIX + command
3678+
3679+
# Add the disabled command record
3680+
self.disabled_commands[command] = DisabledCommand(command_function=command_function,
3681+
help_function=getattr(self, help_func_name, None))
3682+
3683+
# Overwrite the command and help functions to print the message
3684+
new_func = functools.partial(self._report_disabled_command_usage, message_to_print=message_to_print)
3685+
setattr(self, self.cmd_func_name(command), new_func)
3686+
setattr(self, help_func_name, new_func)
3687+
3688+
def disable_category(self, category: str, message_to_print: str) -> None:
3689+
"""
3690+
Disable an entire category of commands
3691+
:param category: the category to disable
3692+
:param message_to_print: what to print when anything in this category is run or help is called on it
3693+
while disabled
3694+
"""
3695+
all_commands = self.get_all_commands()
3696+
3697+
for cmd_name in all_commands:
3698+
func = self.cmd_func(cmd_name)
3699+
cmd_category = getattr(func, HELP_CATEGORY, None)
3700+
3701+
# If this command is in the category, then disable it
3702+
if cmd_category is not None and cmd_category == category:
3703+
self.disable_command(cmd_name, message_to_print)
3704+
3705+
# noinspection PyUnusedLocal
3706+
def _report_disabled_command_usage(self, *args, message_to_print: str, **kwargs) -> None:
3707+
"""
3708+
Report when a disabled command has been run or had help called on it
3709+
:param args: not used
3710+
:param message_to_print: the message reporting that the command is disabled
3711+
:param kwargs: not used
3712+
"""
3713+
self.poutput(message_to_print)
3714+
36083715
def cmdloop(self, intro: Optional[str] = None) -> None:
36093716
"""This is an outer wrapper around _cmdloop() which deals with extra features provided by cmd2.
36103717

cmd2/pyscript_bridge.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,27 @@ class CommandResult(namedtuple_with_defaults('CommandResult', ['stdout', 'stderr
2525
Named tuple attributes
2626
----------------------
2727
stdout: str - Output captured from stdout while this command is executing
28-
stderr: str - Output captured from stderr while this command is executing. None if no error captured
28+
stderr: str - Output captured from stderr while this command is executing. None if no error captured.
2929
data - Data returned by the command.
3030
31+
Any combination of these fields can be used when developing a scripting API for a given command.
32+
By default stdout and stderr will be captured for you. If there is additional command specific data,
33+
then write that to cmd2's _last_result member. That becomes the data member of this tuple.
34+
35+
In some cases, the data member may contain everything needed for a command and storing stdout
36+
and stderr might just be a duplication of data that wastes memory. In that case, the StdSim can
37+
be told not to store output with its pause_storage member. While this member is True, any output
38+
sent to StdSim won't be saved in its buffer.
39+
40+
The code would look like this:
41+
if isinstance(self.stdout, StdSim):
42+
self.stdout.pause_storage = True
43+
44+
if isinstance(sys.stderr, StdSim):
45+
sys.stderr.pause_storage = True
46+
47+
See StdSim class in utils.py for more information
48+
3149
NOTE: Named tuples are immutable. So the contents are there for access, not for modification.
3250
"""
3351
def __bool__(self) -> bool:
@@ -67,25 +85,25 @@ def __call__(self, command: str, echo: Optional[bool] = None) -> CommandResult:
6785
if echo is None:
6886
echo = self.cmd_echo
6987

70-
copy_stdout = StdSim(sys.stdout, echo)
71-
copy_stderr = StdSim(sys.stderr, echo)
72-
88+
# This will be used to capture _cmd2_app.stdout and sys.stdout
7389
copy_cmd_stdout = StdSim(self._cmd2_app.stdout, echo)
7490

91+
# This will be used to capture sys.stderr
92+
copy_stderr = StdSim(sys.stderr, echo)
93+
7594
self._cmd2_app._last_result = None
7695

7796
try:
7897
self._cmd2_app.stdout = copy_cmd_stdout
79-
with redirect_stdout(copy_stdout):
98+
with redirect_stdout(copy_cmd_stdout):
8099
with redirect_stderr(copy_stderr):
81100
# Include a newline in case it's a multiline command
82101
self._cmd2_app.onecmd_plus_hooks(command + '\n')
83102
finally:
84103
self._cmd2_app.stdout = copy_cmd_stdout.inner_stream
85104

86-
# if stderr is empty, set it to None
87-
stderr = copy_stderr.getvalue() if copy_stderr.getvalue() else None
88-
89-
outbuf = copy_cmd_stdout.getvalue() if copy_cmd_stdout.getvalue() else copy_stdout.getvalue()
90-
result = CommandResult(stdout=outbuf, stderr=stderr, data=self._cmd2_app._last_result)
105+
# Save the output. If stderr is empty, set it to None.
106+
result = CommandResult(stdout=copy_cmd_stdout.getvalue(),
107+
stderr=copy_stderr.getvalue() if copy_stderr.getvalue() else None,
108+
data=self._cmd2_app._last_result)
91109
return result

cmd2/utils.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -261,28 +261,10 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
261261

262262

263263
class StdSim(object):
264-
"""Class to simulate behavior of sys.stdout or sys.stderr.
265-
264+
"""
265+
Class to simulate behavior of sys.stdout or sys.stderr.
266266
Stores contents in internal buffer and optionally echos to the inner stream it is simulating.
267267
"""
268-
class ByteBuf(object):
269-
"""Inner class which stores an actual bytes buffer and does the actual output if echo is enabled."""
270-
def __init__(self, inner_stream, echo: bool = False,
271-
encoding: str = 'utf-8', errors: str = 'replace') -> None:
272-
self.byte_buf = b''
273-
self.inner_stream = inner_stream
274-
self.echo = echo
275-
self.encoding = encoding
276-
self.errors = errors
277-
278-
def write(self, b: bytes) -> None:
279-
"""Add bytes to internal bytes buffer and if echo is True, echo contents to inner stream."""
280-
if not isinstance(b, bytes):
281-
raise TypeError('a bytes-like object is required, not {}'.format(type(b)))
282-
self.byte_buf += b
283-
if self.echo:
284-
self.inner_stream.buffer.write(b)
285-
286268
def __init__(self, inner_stream, echo: bool = False,
287269
encoding: str = 'utf-8', errors: str = 'replace') -> None:
288270
"""
@@ -292,17 +274,20 @@ def __init__(self, inner_stream, echo: bool = False,
292274
:param encoding: codec for encoding/decoding strings (defaults to utf-8)
293275
:param errors: how to handle encoding/decoding errors (defaults to replace)
294276
"""
295-
self.buffer = self.ByteBuf(inner_stream, echo)
296277
self.inner_stream = inner_stream
297278
self.echo = echo
298279
self.encoding = encoding
299280
self.errors = errors
281+
self.pause_storage = False
282+
self.buffer = ByteBuf(self)
300283

301284
def write(self, s: str) -> None:
302285
"""Add str to internal bytes buffer and if echo is True, echo contents to inner stream"""
303286
if not isinstance(s, str):
304287
raise TypeError('write() argument must be str, not {}'.format(type(s)))
305-
self.buffer.byte_buf += s.encode(encoding=self.encoding, errors=self.errors)
288+
289+
if not self.pause_storage:
290+
self.buffer.byte_buf += s.encode(encoding=self.encoding, errors=self.errors)
306291
if self.echo:
307292
self.inner_stream.write(s)
308293

@@ -337,6 +322,24 @@ def __getattr__(self, item: str):
337322
return getattr(self.inner_stream, item)
338323

339324

325+
class ByteBuf(object):
326+
"""
327+
Used by StdSim to write binary data and stores the actual bytes written
328+
"""
329+
def __init__(self, std_sim_instance: StdSim) -> None:
330+
self.byte_buf = b''
331+
self.std_sim_instance = std_sim_instance
332+
333+
def write(self, b: bytes) -> None:
334+
"""Add bytes to internal bytes buffer and if echo is True, echo contents to inner stream."""
335+
if not isinstance(b, bytes):
336+
raise TypeError('a bytes-like object is required, not {}'.format(type(b)))
337+
if not self.std_sim_instance.pause_storage:
338+
self.byte_buf += b
339+
if self.std_sim_instance.echo:
340+
self.std_sim_instance.inner_stream.buffer.write(b)
341+
342+
340343
def unquote_redirection_tokens(args: List[str]) -> None:
341344
"""
342345
Unquote redirection tokens in a list of command-line arguments

0 commit comments

Comments
 (0)