Skip to content

Commit 9416464

Browse files
committed
Made is so command_parsers.get() returns parsers for disabled commands.
1 parent f216ed4 commit 9416464

2 files changed

Lines changed: 125 additions & 19 deletions

File tree

cmd2/cmd2.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -617,10 +617,12 @@ def __init__(
617617

618618
# Commands disabled during specific application states
619619
# Key: Command name | Value: DisabledCommand object
620+
# NOTE: Use disable_command() and enable_command() to modify this dictionary.
620621
self.disabled_commands: dict[str, DisabledCommand] = {}
621622

622623
# Categories of commands to be disabled
623624
# Key: Category name | Value: Message to display
625+
# NOTE: Use disable_category() and enable_category() to modify this dictionary.
624626
self.disabled_categories: dict[str, str] = {}
625627

626628
# Command parsers for this Cmd instance.
@@ -1148,9 +1150,8 @@ def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPar
11481150
"""Tokenize a command string and resolve the associated root parser and relative subcommand path.
11491151
11501152
This helper handles the initial resolution of a command string (e.g., 'foo bar baz') by
1151-
identifying 'foo' as the root command (even if disabled), retrieving its associated
1152-
parser, and returning any remaining tokens (['bar', 'baz']) as a path relative
1153-
to that parser for further traversal.
1153+
identifying 'foo' as the root command, retrieving its associated parser, and returning
1154+
any remaining tokens (['bar', 'baz']) as a path relative to that parser for further traversal.
11541155
11551156
:param command: full space-delimited command path leading to a parser (e.g. 'foo' or 'foo bar')
11561157
:return: a tuple containing the Cmd2ArgumentParser for the root command and a list of
@@ -1166,11 +1167,7 @@ def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPar
11661167
subcommand_path = tokens[1:]
11671168

11681169
# Search for the base command function and verify it has an argparser defined
1169-
if root_command in self.disabled_commands:
1170-
command_func = self.disabled_commands[root_command].command_function
1171-
else:
1172-
command_func = self.get_command_func(root_command)
1173-
1170+
command_func = self.get_command_func(root_command)
11741171
if command_func is None:
11751172
raise ValueError(f"Root command '{root_command}' not found")
11761173

@@ -4314,8 +4311,21 @@ def do_help(self, args: argparse.Namespace) -> None:
43144311

43154312
else:
43164313
# Getting help for a specific command
4317-
func = self.get_command_func(args.command)
4314+
disabled = args.command in self.disabled_commands
43184315
help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
4316+
4317+
# If the command is disabled, then call the help function which was
4318+
# overwritten by disable_command() to print the disabled message.
4319+
if disabled:
4320+
if help_func is not None:
4321+
help_func()
4322+
else:
4323+
# This is a defensive fallback in case someone disabled a command
4324+
# without using disable_command() resulting in no help function.
4325+
self._report_disabled_command_usage(message_to_print=f"{args.command} is currently disabled.")
4326+
return
4327+
4328+
func = self.get_command_func(args.command)
43194329
argparser = None if func is None else self.command_parsers.get(func)
43204330

43214331
# If the command function uses argparse, then use argparse's help
@@ -5652,15 +5662,27 @@ def disable_command(self, command: str, message_to_print: str) -> None:
56525662
completer_function=getattr(self, completer_func_name, None),
56535663
)
56545664

5655-
# Overwrite the command and help functions to print the message
5656-
new_func = functools.partial(
5657-
self._report_disabled_command_usage, message_to_print=message_to_print.replace(constants.COMMAND_NAME, command)
5658-
)
5659-
setattr(self, cmd_func_name, new_func)
5660-
setattr(self, help_func_name, new_func)
5665+
# Overwrite command function to print the message
5666+
message_to_print = message_to_print.replace(constants.COMMAND_NAME, command)
5667+
new_cmd_func = functools.partial(self._report_disabled_command_usage, message_to_print=message_to_print)
56615668

5662-
# Set the completer to a function that returns a blank list
5663-
setattr(self, completer_func_name, lambda *_args, **_kwargs: [])
5669+
# Preserve the metadata of the original command function
5670+
functools.update_wrapper(new_cmd_func, command_function)
5671+
setattr(self, cmd_func_name, new_cmd_func)
5672+
5673+
# Overwrite the help function to print the message
5674+
new_help_func = functools.partial(self._report_disabled_command_usage, message_to_print=message_to_print)
5675+
if (help_function := self.disabled_commands[command].help_function) is not None:
5676+
# Preserve the metadata of the original help function
5677+
functools.update_wrapper(new_help_func, help_function)
5678+
setattr(self, help_func_name, new_help_func)
5679+
5680+
# Set the completer to a function that returns a nothing
5681+
new_completer_func = functools.partial(self._disabled_completer)
5682+
if (completer_function := self.disabled_commands[command].completer_function) is not None:
5683+
# Preserve the metadata of the original completer function
5684+
functools.update_wrapper(new_completer_func, completer_function)
5685+
setattr(self, completer_func_name, new_completer_func)
56645686

56655687
def disable_category(self, category: str, message_to_print: str) -> None:
56665688
"""Disable an entire category of commands.
@@ -5685,14 +5707,23 @@ def disable_category(self, category: str, message_to_print: str) -> None:
56855707
self.disabled_categories[category] = message_to_print
56865708

56875709
def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_kwargs: Any) -> None:
5688-
"""Report when a disabled command has been run or had help called on it.
5710+
"""Report when a disabled command or its help function is run.
56895711
56905712
:param _args: not used
56915713
:param message_to_print: the message reporting that the command is disabled
56925714
:param _kwargs: not used
56935715
"""
56945716
self.perror(message_to_print, style=None)
56955717

5718+
def _disabled_completer(self, *_args: Any, **_kwargs: Any) -> Completions:
5719+
"""Completer function for a disabled command.
5720+
5721+
:param _args: not used
5722+
:param _kwargs: not used
5723+
:return: an empty Completions object
5724+
"""
5725+
return Completions()
5726+
56965727
def cmdloop(self, intro: RenderableType = "") -> int:
56975728
"""Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop().
56985729

tests/test_cmd2.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3837,6 +3837,10 @@ def do_is_not_decorated(self, arg) -> None:
38373837
"""This will be in the DEFAULT_CATEGORY."""
38383838
self.poutput("The real is_not_decorated")
38393839

3840+
@cmd2.with_argparser(cmd2.Cmd2ArgumentParser())
3841+
def do_argparse_command(self, args) -> None:
3842+
"""Help for argparse_command"""
3843+
38403844

38413845
class DisableCommandSet(CommandSet[cmd2.Cmd]):
38423846
"""Test registering a command which is in a disabled category"""
@@ -4008,14 +4012,85 @@ def test_disabled_command_not_in_history(disable_commands_app) -> None:
40084012
assert saved_len == len(disable_commands_app.history)
40094013

40104014

4011-
def test_disabled_message_command_name(disable_commands_app) -> None:
4015+
def test_get_parser_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
4016+
# Get parser before disabling
4017+
parser_before = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
4018+
assert parser_before is not None
4019+
4020+
# Disable command
4021+
disable_commands_app.disable_command("argparse_command", "Disabled")
4022+
4023+
# Get parser after disabling - this is what was failing (returning None)
4024+
parser_after = disable_commands_app.command_parsers.get(disable_commands_app.do_argparse_command)
4025+
assert parser_after is not None
4026+
assert parser_after is parser_before
4027+
4028+
4029+
def test_metadata_preservation_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
4030+
orig_cmd_func = disable_commands_app.do_has_helper_func
4031+
orig_help = disable_commands_app.help_has_helper_func
4032+
orig_complete = disable_commands_app.complete_has_helper_func
4033+
4034+
disable_commands_app.disable_command("has_helper_func", "Disabled")
4035+
4036+
# Names and qualnames should be preserved
4037+
assert disable_commands_app.do_has_helper_func.__name__ == orig_cmd_func.__name__
4038+
assert disable_commands_app.do_has_helper_func.__qualname__ == orig_cmd_func.__qualname__
4039+
4040+
assert disable_commands_app.help_has_helper_func.__name__ == orig_help.__name__
4041+
assert disable_commands_app.help_has_helper_func.__qualname__ == orig_help.__qualname__
4042+
4043+
assert disable_commands_app.complete_has_helper_func.__name__ == orig_complete.__name__
4044+
assert disable_commands_app.complete_has_helper_func.__qualname__ == orig_complete.__qualname__
4045+
4046+
# Docstrings should be preserved
4047+
assert disable_commands_app.do_has_helper_func.__doc__ == orig_cmd_func.__doc__
4048+
assert disable_commands_app.help_has_helper_func.__doc__ == orig_help.__doc__
4049+
assert disable_commands_app.complete_has_helper_func.__doc__ == orig_complete.__doc__
4050+
4051+
4052+
def test_disabled_completer_returns_empty(disable_commands_app: DisableCommandsApp) -> None:
4053+
disable_commands_app.disable_command("has_helper_func", "Disabled")
4054+
completions = disable_commands_app.complete_has_helper_func("", "has_helper_func ", 16, 16)
4055+
assert len(completions) == 0
4056+
4057+
4058+
def test_disabled_message_command_name(disable_commands_app: DisableCommandsApp) -> None:
40124059
message_to_print = f"{COMMAND_NAME} is currently disabled"
40134060
disable_commands_app.disable_command("has_helper_func", message_to_print)
40144061

40154062
_out, err = run_cmd(disable_commands_app, "has_helper_func")
40164063
assert err[0].startswith("has_helper_func is currently disabled")
40174064

40184065

4066+
def test_help_argparse_command_while_disabled(disable_commands_app: DisableCommandsApp) -> None:
4067+
message_to_print = "This command is disabled"
4068+
disable_commands_app.disable_command("argparse_command", message_to_print)
4069+
4070+
# help <command> should show the disabled message
4071+
_out, err = run_cmd(disable_commands_app, "help argparse_command")
4072+
assert err[0].startswith(message_to_print)
4073+
4074+
# Re-enabling should restore the real help
4075+
disable_commands_app.enable_command("argparse_command")
4076+
out, _err = run_cmd(disable_commands_app, "help argparse_command")
4077+
assert "Usage: argparse_command" in out[0]
4078+
4079+
4080+
def test_help_disabled_no_help_func(base_app: cmd2.Cmd) -> None:
4081+
from cmd2.cmd2 import DisabledCommand
4082+
4083+
# Manually disable a command without a help function to trigger the defensive fallback
4084+
command = "quit"
4085+
command_func = base_app.get_command_func(command)
4086+
base_app.disabled_commands[command] = DisabledCommand(
4087+
command_function=command_func, help_function=None, completer_function=None
4088+
)
4089+
4090+
_out, err = run_cmd(base_app, f"help {command}")
4091+
assert err[0].startswith(f"{command} is currently disabled.")
4092+
4093+
40194094
def test_register_command_in_enabled_category(disable_commands_app) -> None:
40204095
# Enable commands which are decorated with a category
40214096
disable_commands_app.enable_category(DisableCommandSet.category_name)

0 commit comments

Comments
 (0)