Skip to content

Commit 58a3aa4

Browse files
committed
Merge branch 'main' into 1627-silence-settable
2 parents 45f17c4 + 03db5e4 commit 58a3aa4

8 files changed

Lines changed: 199 additions & 24 deletions

File tree

cmd2/argparse_utils.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ def _SubParsersAction_remove_parser( # noqa: N802
516516
:raises ValueError: if the subcommand doesn't exist
517517
"""
518518
if name not in self._name_parser_map:
519-
raise ValueError(f"Subcommand '{name}' not found")
519+
raise ValueError(f"Subcommand '{name}' does not exist")
520520

521521
subparser = self._name_parser_map[name]
522522

@@ -684,12 +684,12 @@ def update_prog(self, prog: str) -> None:
684684
# add_parser() will have the correct prog value.
685685
subparsers_action._prog_prefix = self._build_subparsers_prog_prefix(positionals)
686686

687-
# subparsers_action.choices includes aliases. Since primary names are inserted first,
688-
# we skip already updated parsers to ensure primary names are used in 'prog'.
687+
# subparsers_action._name_parser_map includes aliases. Since primary names are inserted
688+
# first, we skip already updated parsers to ensure primary names are used in 'prog'.
689689
updated_parsers: set[Cmd2ArgumentParser] = set()
690690

691691
# Set the prog value for each subcommand's parser
692-
for subcmd_name, subcmd_parser in subparsers_action.choices.items():
692+
for subcmd_name, subcmd_parser in subparsers_action._name_parser_map.items():
693693
if subcmd_parser in updated_parsers:
694694
continue
695695

@@ -707,9 +707,9 @@ def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
707707
parser = self
708708
for name in subcommand_path:
709709
subparsers_action = parser.get_subparsers_action()
710-
if name not in subparsers_action.choices:
711-
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
712-
parser = subparsers_action.choices[name]
710+
if name not in subparsers_action._name_parser_map:
711+
raise ValueError(f"Subcommand '{name}' does not exist for '{parser.prog}'")
712+
parser = subparsers_action._name_parser_map[name]
713713
return parser
714714

715715
def attach_subcommand(
@@ -729,7 +729,8 @@ def attach_subcommand(
729729
:raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
730730
1. Cmd2ArgumentParser
731731
2. The parser_class configured for the target subcommand group
732-
:raises ValueError: if the command path is invalid or doesn't support subcommands
732+
:raises ValueError: if the command path is invalid, doesn't support subcommands, or the
733+
subcommand already exists
733734
"""
734735
if not isinstance(subcommand_parser, Cmd2ArgumentParser):
735736
raise TypeError(
@@ -751,6 +752,12 @@ def attach_subcommand(
751752
f"Received: '{type(subcommand_parser).__name__}'."
752753
)
753754

755+
# Do not overwrite existing subcommands or aliases
756+
all_names = (subcommand, *add_parser_kwargs.get("aliases", ()))
757+
for name in all_names:
758+
if name in subparsers_action._name_parser_map:
759+
raise ValueError(f"Subcommand '{name}' already exists for '{target_parser.prog}'")
760+
754761
# Use add_parser to register the subcommand name and any aliases
755762
placeholder_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
756763

@@ -783,7 +790,7 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
783790
subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined]
784791
)
785792
except ValueError:
786-
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'") from None
793+
raise ValueError(f"Subcommand '{subcommand}' does not exist for '{target_parser.prog}'") from None
787794

788795
def error(self, message: str) -> NoReturn:
789796
"""Override that applies custom formatting to the error message."""

cmd2/cmd2.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,7 @@ def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPar
11741174
# Search for the base command function and verify it has an argparser defined
11751175
command_func = self.get_command_func(root_command)
11761176
if command_func is None:
1177-
raise ValueError(f"Root command '{root_command}' not found")
1177+
raise ValueError(f"Root command '{root_command}' does not exist")
11781178

11791179
root_parser = self.command_parsers.get(command_func)
11801180
if root_parser is None:
@@ -1199,7 +1199,8 @@ def attach_subcommand(
11991199
:raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
12001200
1. Cmd2ArgumentParser
12011201
2. The parser_class configured for the target subcommand group
1202-
:raises ValueError: if the command path is invalid or doesn't support subcommands
1202+
:raises ValueError: if the command path is invalid, doesn't support subcommands, or the
1203+
subcommand already exists
12031204
"""
12041205
root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(command)
12051206
root_parser.attach_subcommand(subcommand_path, subcommand, subcommand_parser, **add_parser_kwargs)

cmd2/completion.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,16 @@ class CompletionItem:
4949
# control sequences (like ^J or ^I) in the completion menu.
5050
_CONTROL_WHITESPACE_RE = re.compile(r"\r\n|[\n\r\t\f\v]")
5151

52-
# The core object this completion represents (e.g., str, int, Path).
53-
# This serves as the default source for the completion string and is used
54-
# to support object-based validation when used in argparse choices.
52+
# The source input for the completion. This is used to initialize the 'text'
53+
# field (defaults to str(value)). The original object is also preserved to
54+
# support object-based validation when this CompletionItem is used as an
55+
# argparse choice.
5556
value: Any = field(kw_only=False)
5657

57-
# The actual completion string. If not provided, defaults to str(value).
58-
# This can be used to provide a human-friendly alias for complex objects in
59-
# an argparse choices list (requires a matching 'type' converter for validation).
58+
# The string matched against user input and inserted into the command line.
59+
# Defaults to str(value). This should only be set manually if this
60+
# CompletionItem is used as an argparse choice and you want the choice
61+
# string to differ from str(value).
6062
text: str = _UNSET_STR
6163

6264
# Optional string for displaying the completion differently in the completion menu.

cmd2/decorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,9 @@ def as_subcommand_to(
360360
:param subcommand: Subcommand name
361361
:param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand
362362
:param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to.
363-
This is passed as the help argument to subparsers.add_parser().
364-
:param aliases: Alternative names for this subcommand. This is passed as the alias argument to
365-
subparsers.add_parser().
363+
If not None, this is passed as the 'help' argument to subparsers.add_parser().
364+
:param aliases: Alternative names for this subcommand. If a non-empty sequence is provided, it is passed
365+
as the 'aliases' argument to subparsers.add_parser().
366366
:param add_parser_kwargs: other registration-specific kwargs for add_parser()
367367
(e.g. deprecated [Python 3.13+])
368368
:return: a decorator which configures the target function to be a subcommand handler

examples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ each:
3434
- Example that demonstrates the `CommandSet` features for modularizing commands and demonstrates
3535
all main capabilities including basic CommandSets, dynamic loading an unloading, using
3636
subcommands, etc.
37+
- [completion_item_choices.py](https://github.com/python-cmd2/cmd2/blob/main/examples/completion_item_choices.py)
38+
- Demonstrates using CompletionItem instances as elements in an argparse choices list.
3739
- [custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py)
3840
- Demonstrates how to create your own custom `Cmd2ArgumentParser`
3941
- [custom_types.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_types.py)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python
2+
"""
3+
Demonstrates using CompletionItem instances as elements in an argparse choices list.
4+
5+
Technical Note:
6+
Using 'choices' is best for fixed datasets that do not change during the
7+
application's lifecycle. For dynamic data (e.g., results from a database or
8+
file system), use a 'choices_provider' instead.
9+
10+
Key strengths of this approach:
11+
1. Command handlers receive fully-typed domain objects directly in the
12+
argparse.Namespace, eliminating manual lookups from string keys.
13+
2. Choices carry tab-completion UI enhancements (display_meta, table_data)
14+
that are not supported by standard argparse string choices.
15+
3. Provides a single source of truth for completion UI, input validation,
16+
and object mapping.
17+
18+
This demo showcases two distinct approaches:
19+
1. Simple: Using CompletionItems with basic types (ints) to add UI metadata
20+
(display_meta) while letting argparse handle standard type conversion.
21+
2. Advanced: Using a custom 'text' alias and a type converter to map a friendly
22+
string (e.g., 'alice') directly to a complex object (Account).
23+
"""
24+
25+
import argparse
26+
import sys
27+
from typing import (
28+
ClassVar,
29+
cast,
30+
)
31+
32+
from cmd2 import (
33+
Cmd,
34+
Cmd2ArgumentParser,
35+
CompletionItem,
36+
with_argparser,
37+
)
38+
39+
# -----------------------------------------------------------------------------
40+
# Simple Example: Basic types with UI metadata
41+
# -----------------------------------------------------------------------------
42+
# Integers with metadata. No 'text' override or custom type converter needed.
43+
# argparse will handle 'type=int' and validate it against the CompletionItem.value.
44+
id_choices = [
45+
CompletionItem(101, display_meta="Alice's Account"),
46+
CompletionItem(202, display_meta="Bob's Account"),
47+
]
48+
49+
50+
# -----------------------------------------------------------------------------
51+
# Advanced Example: Mapping friendly aliases to objects
52+
# -----------------------------------------------------------------------------
53+
class Account:
54+
"""A complex object that we want to select by a friendly name."""
55+
56+
def __init__(self, account_id: int, owner: str):
57+
self.account_id = account_id
58+
self.owner = owner
59+
60+
def __eq__(self, other: object) -> bool:
61+
if isinstance(other, Account):
62+
return self.account_id == other.account_id
63+
return False
64+
65+
def __hash__(self) -> int:
66+
return hash(self.account_id)
67+
68+
def __repr__(self) -> str:
69+
return f"Account(id={self.account_id}, owner='{self.owner}')"
70+
71+
72+
# Map friendly 'text' aliases to the actual object 'value'.
73+
# The user types 'alice' or 'bob' (tab-completion), but the parsed value will be the Account object.
74+
accounts = [
75+
Account(101, "Alice"),
76+
Account(202, "Bob"),
77+
]
78+
account_choices = [
79+
CompletionItem(
80+
acc,
81+
text=acc.owner.lower(),
82+
display_meta=f"ID: {acc.account_id}",
83+
)
84+
for acc in accounts
85+
]
86+
87+
88+
def account_lookup(name: str) -> Account:
89+
"""Type converter that looks up an Account by its friendly name."""
90+
for item in account_choices:
91+
if item.text == name:
92+
return cast(Account, item.value)
93+
raise argparse.ArgumentTypeError(f"invalid account: {name}")
94+
95+
96+
# -----------------------------------------------------------------------------
97+
# Demo Application
98+
# -----------------------------------------------------------------------------
99+
class ChoicesDemo(Cmd):
100+
"""Demo cmd2 application."""
101+
102+
DEFAULT_CATEGORY: ClassVar[str] = "Demo Commands"
103+
104+
def __init__(self) -> None:
105+
super().__init__()
106+
self.intro = (
107+
"Welcome to the CompletionItem Choices Demo!\n"
108+
"Try 'simple' followed by [TAB] to see basic metadata.\n"
109+
"Try 'advanced' followed by [TAB] to see custom string mapping."
110+
)
111+
112+
# Simple Command: argparse handles the int conversion, CompletionItem handles the UI
113+
simple_parser = Cmd2ArgumentParser()
114+
simple_parser.add_argument(
115+
"account_id",
116+
type=int,
117+
choices=id_choices,
118+
help="Select an account ID (tab-complete to see metadata)",
119+
)
120+
121+
@with_argparser(simple_parser)
122+
def do_simple(self, args: argparse.Namespace) -> None:
123+
"""Show an account ID selection (Simple Case)."""
124+
# argparse converted the input to an int, and validated it against the CompletionItem.value
125+
self.poutput(f"Selected Account ID: {args.account_id} (Type: {type(args.account_id).__name__})")
126+
127+
# Advanced Command: Custom lookup and custom 'text' mapping
128+
advanced_parser = Cmd2ArgumentParser()
129+
advanced_parser.add_argument(
130+
"account",
131+
type=account_lookup,
132+
choices=account_choices,
133+
help="Select an account by owner name (tab-complete to see friendly names)",
134+
)
135+
136+
@with_argparser(advanced_parser)
137+
def do_advanced(self, args: argparse.Namespace) -> None:
138+
"""Show a custom string selection (Advanced Case)."""
139+
# args.account is the full Account object
140+
self.poutput(f"Selected Account: {args.account!r} (Type: {type(args.account).__name__})")
141+
142+
143+
if __name__ == "__main__":
144+
app = ChoicesDemo()
145+
sys.exit(app.cmdloop())

tests/test_argparse_utils.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,20 +453,26 @@ def test_subcommand_attachment_errors() -> None:
453453
root_parser.add_subparsers()
454454

455455
# Verify ValueError when path is invalid (find_parser() fails)
456-
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):
456+
with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
457457
root_parser.attach_subcommand(["nonexistent"], "anything", child_parser)
458-
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):
458+
with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
459459
root_parser.detach_subcommand(["nonexistent"], "anything")
460460

461461
# Verify ValueError when path is valid but subcommand name is wrong
462-
with pytest.raises(ValueError, match="Subcommand 'fake' not found in 'root'"):
462+
with pytest.raises(ValueError, match="Subcommand 'fake' does not exist for 'root'"):
463463
root_parser.detach_subcommand([], "fake")
464464

465465
# Verify TypeError when attaching a non-Cmd2ArgumentParser type
466466
ap_parser = argparse.ArgumentParser(prog="non-cmd2-parser")
467467
with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser' \(or a subclass\)"):
468468
root_parser.attach_subcommand([], "sub", ap_parser) # type: ignore[arg-type]
469469

470+
# Verify ValueError when subcommand name already exists
471+
sub_parser = Cmd2ArgumentParser(prog="sub")
472+
root_parser.attach_subcommand([], "sub", sub_parser)
473+
with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'root'"):
474+
root_parser.attach_subcommand([], "sub", sub_parser)
475+
470476

471477
def test_subcommand_attachment_parser_class_override() -> None:
472478
class MyParser(Cmd2ArgumentParser):

tests/test_cmd2.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4585,6 +4585,13 @@ class SubcmdErrorApp(cmd2.Cmd):
45854585
def __init__(self) -> None:
45864586
super().__init__()
45874587

4588+
test_parser = cmd2.Cmd2ArgumentParser()
4589+
test_parser.add_subparsers(required=True)
4590+
4591+
@cmd2.with_argparser(test_parser)
4592+
def do_test(self, _statement: cmd2.Statement) -> None:
4593+
pass
4594+
45884595
def do_no_argparse(self, _statement: cmd2.Statement) -> None:
45894596
pass
45904597

@@ -4595,9 +4602,14 @@ def do_no_argparse(self, _statement: cmd2.Statement) -> None:
45954602
app.attach_subcommand("", "sub", cmd2.Cmd2ArgumentParser())
45964603

45974604
# Test non-existent command
4598-
with pytest.raises(ValueError, match="Root command 'fake' not found"):
4605+
with pytest.raises(ValueError, match="Root command 'fake' does not exist"):
45994606
app.attach_subcommand("fake", "sub", cmd2.Cmd2ArgumentParser())
46004607

46014608
# Test command that doesn't use argparse
46024609
with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"):
46034610
app.attach_subcommand("no_argparse", "sub", cmd2.Cmd2ArgumentParser())
4611+
4612+
# Test duplicate subcommand
4613+
app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())
4614+
with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'test'"):
4615+
app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())

0 commit comments

Comments
 (0)