diff --git a/CHANGELOG.md b/CHANGELOG.md index 1001a91ce..ef10d11bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ `subcommand_title` or `subcommand_description` still gives the subcommands a dedicated section ([#1715](https://github.com/python-cmd2/cmd2/issues/1715)). - Fix `@with_annotated` decorator so using `ArgumentBlock` works with groups + - Fixed bug where already sorted `choices_provider` results were being re-sorted. ## 4.1.2 (July 16, 2026) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index f40631634..32466ad3e 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -694,7 +694,7 @@ def print_help(self, tokens: Sequence[str], file: IO[str] | None = None) -> None self._parser.print_help(file) def _choices_to_items(self, arg_state: _ArgumentState) -> list[CompletionItem]: - """Convert choices from action to list of CompletionItems.""" + """Convert an action's choices to a list of CompletionItems.""" if arg_state.action.choices is None: return [] @@ -785,13 +785,15 @@ def _complete_arg( cmd_set, ) all_choices = list(choices_provider(*args, **kwargs)) + sort = False # choices_provider results are already sorted, so don't re-sort them else: all_choices = self._choices_to_items(arg_state) + sort = True # Filter used values and run basic completion used_values = consumed_arg_values.get(arg_state.action.dest, []) filtered = [choice for choice in all_choices if choice.text not in used_values] - completions = self._cmd_app.basic_complete(text, line, begidx, endidx, filtered) + completions = self._cmd_app.basic_complete(text, line, begidx, endidx, filtered, sort=sort) return self._build_completion_table(arg_state, completions) diff --git a/examples/modular_commandsets.py b/examples/modular_commandsets.py index 3d9900d61..64536fab5 100755 --- a/examples/modular_commandsets.py +++ b/examples/modular_commandsets.py @@ -18,6 +18,7 @@ ) from cmd2 import ( + Choices, Cmd, Cmd2ArgumentParser, CommandSet, @@ -31,9 +32,9 @@ def __init__(self, command_sets: Iterable[CommandSet] | None = None) -> None: super().__init__(command_sets=command_sets) self.sport_item_strs = ["Bat", "Basket", "Basketball", "Football", "Space Ball"] - def choices_provider(self) -> list[str]: + def choices_provider(self) -> Choices: """A choices provider is useful when the choice list is based on instance data of your application.""" - return self.sport_item_strs + return Choices.from_values(self.sport_item_strs) # Parser for example command example_parser = Cmd2ArgumentParser( diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index ffb794b50..522d62670 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -1433,3 +1433,55 @@ def test_add_parser_custom_completer() -> None: name="custom_completer", completer_class=CustomCompleter ) assert custom_completer_parser.completer_class is CustomCompleter + + +def test_completer_preserves_custom_order() -> None: + """Test that completer returning Completions with is_sorted=True preserves custom item ordering.""" + custom_order = ("zebra", "apple", "banana") + + class CustomApp(cmd2.Cmd): + def custom_completer(self, text: str, line: str, begidx: int, endidx: int) -> Completions: + return Completions.from_values(custom_order, is_sorted=True) + + parser = Cmd2ArgumentParser() + parser.add_argument("--custom", completer=custom_completer) + + @with_argparser(parser) + def do_test(self, args: argparse.Namespace) -> None: + pass + + app = CustomApp() + text = "" + line = f"test --custom {text}" + endidx = len(line) + begidx = endidx - len(text) + + completions = app.complete(text, line, begidx, endidx) + + assert completions.to_strings() == custom_order + + +def test_choices_provider_preserves_custom_order() -> None: + """Test that choices_provider returning Choices with is_sorted=True preserves custom item ordering.""" + custom_order = ("zebra", "apple", "banana") + + class CustomApp(cmd2.Cmd): + def custom_provider(self) -> Choices: + return Choices.from_values(custom_order, is_sorted=True) + + parser = Cmd2ArgumentParser() + parser.add_argument("--custom", choices_provider=custom_provider) + + @with_argparser(parser) + def do_test(self, args: argparse.Namespace) -> None: + pass + + app = CustomApp() + text = "" + line = f"test --custom {text}" + endidx = len(line) + begidx = endidx - len(text) + + completions = app.complete(text, line, begidx, endidx) + + assert completions.to_strings() == custom_order