Skip to content

Commit 860adb9

Browse files
committed
Merge branch 'main' into prompt-toolkit
2 parents daf4dd4 + 48bfc0d commit 860adb9

File tree

5 files changed

+178
-62
lines changed

5 files changed

+178
-62
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ shell, and the option for a persistent bottom bar that can display realtime stat
4747
- **max_column_completion_results**: (int) the maximum number of completion results to
4848
display in a single column
4949

50+
## 3.1.3 (TBD)
51+
52+
- Bug Fixes
53+
- Fixed issue where `delimiter_complete()` could cause more matches than display matches
54+
- Fixed issue where `CommandSet` registration did not respect disabled categories
55+
5056
## 3.1.2 (January 26, 2026)
5157

5258
- Bug Fixes

cmd2/cmd2.py

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -635,11 +635,14 @@ def _(event: Any) -> None: # pragma: no cover
635635
self._in_prompt = False
636636
self._in_prompt_lock = threading.Lock()
637637

638-
# Commands that have been disabled from use. This is to support commands that are only available
639-
# during specific states of the application. This dictionary's keys are the command names and its
640-
# values are DisabledCommand objects.
638+
# Commands disabled during specific application states
639+
# Key: Command name | Value: DisabledCommand object
641640
self.disabled_commands: dict[str, DisabledCommand] = {}
642641

642+
# Categories of commands to be disabled
643+
# Key: Category name | Value: Message to display
644+
self.disabled_categories: dict[str, str] = {}
645+
643646
# The default key for sorting string results. Its default value performs a case-insensitive alphabetical sort.
644647
# If natural sorting is preferred, then set this to NATURAL_SORT_KEY.
645648
# cmd2 uses this key for sorting:
@@ -830,6 +833,12 @@ def register_command_set(self, cmdset: CommandSet) -> None:
830833
if default_category and not hasattr(command_method, constants.CMD_ATTR_HELP_CATEGORY):
831834
utils.categorize(command_method, default_category)
832835

836+
# If this command is in a disabled category, then disable it
837+
command_category = getattr(command_method, constants.CMD_ATTR_HELP_CATEGORY, None)
838+
if command_category in self.disabled_categories:
839+
message_to_print = self.disabled_categories[command_category]
840+
self.disable_command(command, message_to_print)
841+
833842
self._installed_command_sets.add(cmdset)
834843

835844
self._register_subcommands(cmdset)
@@ -1876,31 +1885,45 @@ def delimiter_complete(
18761885
:return: a list of possible tab completions
18771886
"""
18781887
matches = self.basic_complete(text, line, begidx, endidx, match_against)
1888+
if not matches:
1889+
return []
18791890

1880-
# Display only the portion of the match that's being completed based on delimiter
1881-
if matches:
1882-
# Set this to True for proper quoting of matches with spaces
1883-
self.matches_delimited = True
1891+
# Set this to True for proper quoting of matches with spaces
1892+
self.matches_delimited = True
18841893

1885-
# Get the common beginning for the matches
1886-
common_prefix = os.path.commonprefix(matches)
1887-
prefix_tokens = common_prefix.split(delimiter)
1894+
# Get the common beginning for the matches
1895+
common_prefix = os.path.commonprefix(matches)
1896+
prefix_tokens = common_prefix.split(delimiter)
18881897

1889-
# Calculate what portion of the match we are completing
1890-
display_token_index = 0
1891-
if prefix_tokens:
1892-
display_token_index = len(prefix_tokens) - 1
1898+
# Calculate what portion of the match we are completing
1899+
display_token_index = 0
1900+
if prefix_tokens:
1901+
display_token_index = len(prefix_tokens) - 1
18931902

1894-
# Get this portion for each match and store them in self.display_matches
1895-
for cur_match in matches:
1896-
match_tokens = cur_match.split(delimiter)
1897-
display_token = match_tokens[display_token_index]
1903+
# Remove from each match everything after where the user is completing.
1904+
# This approach can result in duplicates so we will filter those out.
1905+
unique_results: dict[str, str] = {}
18981906

1899-
if not display_token:
1900-
display_token = delimiter
1901-
self.display_matches.append(display_token)
1907+
for cur_match in matches:
1908+
match_tokens = cur_match.split(delimiter)
19021909

1903-
return matches
1910+
filtered_match = delimiter.join(match_tokens[: display_token_index + 1])
1911+
display_match = match_tokens[display_token_index]
1912+
1913+
# If there are more tokens, then we aren't done completing a full item
1914+
if len(match_tokens) > display_token_index + 1:
1915+
filtered_match += delimiter
1916+
display_match += delimiter
1917+
self.allow_appended_space = False
1918+
self.allow_closing_quote = False
1919+
1920+
if filtered_match not in unique_results:
1921+
unique_results[filtered_match] = display_match
1922+
1923+
filtered_matches = list(unique_results.keys())
1924+
self.display_matches = list(unique_results.values())
1925+
1926+
return filtered_matches
19041927

19051928
def flag_based_complete(
19061929
self,
@@ -5620,7 +5643,7 @@ def enable_command(self, command: str) -> None:
56205643
56215644
:param command: the command being enabled
56225645
"""
5623-
# If the commands is already enabled, then return
5646+
# If the command is already enabled, then return
56245647
if command not in self.disabled_commands:
56255648
return
56265649

@@ -5652,11 +5675,17 @@ def enable_category(self, category: str) -> None:
56525675
56535676
:param category: the category to enable
56545677
"""
5678+
# If the category is already enabled, then return
5679+
if category not in self.disabled_categories:
5680+
return
5681+
56555682
for cmd_name in list(self.disabled_commands):
56565683
func = self.disabled_commands[cmd_name].command_function
56575684
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
56585685
self.enable_command(cmd_name)
56595686

5687+
del self.disabled_categories[category]
5688+
56605689
def disable_command(self, command: str, message_to_print: str) -> None:
56615690
"""Disable a command and overwrite its functions.
56625691
@@ -5667,7 +5696,7 @@ def disable_command(self, command: str, message_to_print: str) -> None:
56675696
command being disabled.
56685697
ex: message_to_print = f"{cmd2.COMMAND_NAME} is currently disabled"
56695698
"""
5670-
# If the commands is already disabled, then return
5699+
# If the command is already disabled, then return
56715700
if command in self.disabled_commands:
56725701
return
56735702

@@ -5706,13 +5735,19 @@ def disable_category(self, category: str, message_to_print: str) -> None:
57065735
of the command being disabled.
57075736
ex: message_to_print = f"{cmd2.COMMAND_NAME} is currently disabled"
57085737
"""
5738+
# If the category is already disabled, then return
5739+
if category in self.disabled_categories:
5740+
return
5741+
57095742
all_commands = self.get_all_commands()
57105743

57115744
for cmd_name in all_commands:
57125745
func = self.cmd_func(cmd_name)
57135746
if getattr(func, constants.CMD_ATTR_HELP_CATEGORY, None) == category:
57145747
self.disable_command(cmd_name, message_to_print)
57155748

5749+
self.disabled_categories[category] = message_to_print
5750+
57165751
def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_kwargs: Any) -> None:
57175752
"""Report when a disabled command has been run or had help called on it.
57185753

cmd2/utils.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Shared utility functions."""
22

33
import argparse
4-
import collections
54
import contextlib
65
import functools
76
import glob
@@ -192,10 +191,7 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]:
192191
:param list_to_prune: the list being pruned of duplicates
193192
:return: The pruned list
194193
"""
195-
temp_dict: collections.OrderedDict[_T, Any] = collections.OrderedDict()
196-
for item in list_to_prune:
197-
temp_dict[item] = None
198-
194+
temp_dict = dict.fromkeys(list_to_prune)
199195
return list(temp_dict.keys())
200196

201197

tests/test_cmd2.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
COMMAND_NAME,
2121
Cmd2Style,
2222
Color,
23+
CommandSet,
2324
RichPrintKwargs,
2425
clipboard,
2526
constants,
@@ -3304,6 +3305,16 @@ def do_has_no_helper_funcs(self, arg) -> None:
33043305
self.poutput("The real has_no_helper_funcs")
33053306

33063307

3308+
class DisableCommandSet(CommandSet):
3309+
"""Test registering a command which is in a disabled category"""
3310+
3311+
category_name = "CommandSet Test Category"
3312+
3313+
@cmd2.with_category(category_name)
3314+
def do_new_command(self, arg) -> None:
3315+
self._cmd.poutput("CommandSet function is enabled")
3316+
3317+
33073318
@pytest.fixture
33083319
def disable_commands_app():
33093320
return DisableCommandsApp()
@@ -3407,7 +3418,7 @@ def test_enable_enabled_command(disable_commands_app) -> None:
34073418
saved_len = len(disable_commands_app.disabled_commands)
34083419
disable_commands_app.enable_command('has_helper_funcs')
34093420

3410-
# The number of disabled_commands should not have changed
3421+
# The number of disabled commands should not have changed
34113422
assert saved_len == len(disable_commands_app.disabled_commands)
34123423

34133424

@@ -3421,7 +3432,7 @@ def test_disable_command_twice(disable_commands_app) -> None:
34213432
message_to_print = 'These commands are currently disabled'
34223433
disable_commands_app.disable_command('has_helper_funcs', message_to_print)
34233434

3424-
# The length of disabled_commands should have increased one
3435+
# The number of disabled commands should have increased one
34253436
new_len = len(disable_commands_app.disabled_commands)
34263437
assert saved_len == new_len - 1
34273438
saved_len = new_len
@@ -3449,6 +3460,50 @@ def test_disabled_message_command_name(disable_commands_app) -> None:
34493460
assert err[0].startswith('has_helper_funcs is currently disabled')
34503461

34513462

3463+
def test_register_command_in_enabled_category(disable_commands_app) -> None:
3464+
disable_commands_app.enable_category(DisableCommandSet.category_name)
3465+
cs = DisableCommandSet()
3466+
disable_commands_app.register_command_set(cs)
3467+
3468+
out, _err = run_cmd(disable_commands_app, 'new_command')
3469+
assert out[0] == "CommandSet function is enabled"
3470+
3471+
3472+
def test_register_command_in_disabled_category(disable_commands_app) -> None:
3473+
message_to_print = "CommandSet function is disabled"
3474+
disable_commands_app.disable_category(DisableCommandSet.category_name, message_to_print)
3475+
cs = DisableCommandSet()
3476+
disable_commands_app.register_command_set(cs)
3477+
3478+
_out, err = run_cmd(disable_commands_app, 'new_command')
3479+
assert err[0] == message_to_print
3480+
3481+
3482+
def test_enable_enabled_category(disable_commands_app) -> None:
3483+
# Test enabling a category that is not disabled
3484+
saved_len = len(disable_commands_app.disabled_categories)
3485+
disable_commands_app.enable_category('Test Category')
3486+
3487+
# The number of disabled categories should not have changed
3488+
assert saved_len == len(disable_commands_app.disabled_categories)
3489+
3490+
3491+
def test_disable_category_twice(disable_commands_app) -> None:
3492+
saved_len = len(disable_commands_app.disabled_categories)
3493+
message_to_print = 'These commands are currently disabled'
3494+
disable_commands_app.disable_category('Test Category', message_to_print)
3495+
3496+
# The number of disabled categories should have increased one
3497+
new_len = len(disable_commands_app.disabled_categories)
3498+
assert saved_len == new_len - 1
3499+
saved_len = new_len
3500+
3501+
# Disable again and the length should not change
3502+
disable_commands_app.disable_category('Test Category', message_to_print)
3503+
new_len = len(disable_commands_app.disabled_categories)
3504+
assert saved_len == new_len
3505+
3506+
34523507
@pytest.mark.parametrize('silence_startup_script', [True, False])
34533508
def test_startup_script(request, capsys, silence_startup_script) -> None:
34543509
test_dir = os.path.dirname(request.module.__file__)

0 commit comments

Comments
 (0)