Skip to content

Commit e0f371c

Browse files
committed
Fixed CompletionItem.text re-populating from value after being cleared.
1 parent 143a99e commit e0f371c

2 files changed

Lines changed: 156 additions & 15 deletions

File tree

cmd2/completion.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131
from . import rich_utils as ru
3232

3333

34+
class _UnsetStr(str):
35+
"""Internal sentinel to distinguish between an unset and an explicit empty string."""
36+
37+
__slots__ = ()
38+
39+
40+
_UNSET_STR = _UnsetStr("")
41+
42+
3443
@dataclass(frozen=True, slots=True, kw_only=True)
3544
class CompletionItem:
3645
"""A single completion result."""
@@ -39,17 +48,20 @@ class CompletionItem:
3948
# control sequences (like ^J or ^I) in the completion menu.
4049
_CONTROL_WHITESPACE_RE = re.compile(r"\r\n|[\n\r\t\f\v]")
4150

42-
# The underlying object this completion represents (e.g., str, int, Path).
43-
# This is used to support argparse choices validation.
51+
# The core object this completion represents (e.g., str, int, Path).
52+
# This serves as the default source for the completion string and is used
53+
# to support object-based validation when used in argparse choices.
4454
value: Any = field(kw_only=False)
4555

46-
# The actual string that will be inserted into the command line.
47-
# If not provided, it defaults to str(value).
48-
text: str = ""
56+
# The actual completion string. If not provided, defaults to str(value).
57+
# This can be used to provide a human-friendly alias for complex objects in
58+
# an argparse choices list (requires a matching 'type' converter for validation).
59+
text: str = _UNSET_STR
4960

5061
# Optional string for displaying the completion differently in the completion menu.
5162
# This can contain ANSI style sequences. A plain version is stored in display_plain.
52-
display: str = ""
63+
# If not provided, defaults to the (possibly computed) value of 'text'.
64+
display: str = _UNSET_STR
5365

5466
# Optional meta information about completion which displays in the completion menu.
5567
# This can contain ANSI style sequences. A plain version is stored in display_meta_plain.
@@ -60,9 +72,8 @@ class CompletionItem:
6072
table_data: Sequence[Any] = field(default_factory=tuple)
6173

6274
# Plain text versions of display fields (stripped of ANSI) for sorting/filtering.
63-
# These are set in __post_init__().
64-
display_plain: str = field(init=False)
65-
display_meta_plain: str = field(init=False)
75+
display_plain: str = field(default="", init=False)
76+
display_meta_plain: str = field(default="", init=False)
6677

6778
@classmethod
6879
def _clean_display(cls, val: str) -> str:
@@ -77,13 +88,21 @@ def _clean_display(cls, val: str) -> str:
7788
return cls._CONTROL_WHITESPACE_RE.sub(" ", val)
7889

7990
def __post_init__(self) -> None:
80-
"""Finalize the object after initialization."""
81-
# Derive text from value if it wasn't explicitly provided
82-
if not self.text:
91+
"""Finalize the object after initialization.
92+
93+
By using the sentinel pattern to distinguish between a field that was never
94+
set and one explicitly blanked out, this handles the two-stage lifecycle:
95+
96+
1. Initial creation (usually by a developer-provided choices_provider or completer).
97+
2. Post-processing by cmd2 via dataclasses.replace(), which may modify fields or
98+
explicitly set them to empty strings.
99+
"""
100+
# If the completion string was not provided, derive it from value.
101+
if isinstance(self.text, _UnsetStr):
83102
object.__setattr__(self, "text", str(self.value))
84103

85-
# Ensure display is never blank.
86-
if not self.display:
104+
# If the display string was not provided, use text.
105+
if isinstance(self.display, _UnsetStr):
87106
object.__setattr__(self, "display", self.text)
88107

89108
# Clean display and display_meta
@@ -163,7 +182,7 @@ class CompletionResultsBase:
163182

164183
# True if every item in this collection has a numeric display string.
165184
# Used for sorting and alignment.
166-
numeric_display: bool = field(init=False)
185+
numeric_display: bool = field(default=False, init=False)
167186

168187
def __post_init__(self) -> None:
169188
"""Finalize the object after initialization."""

tests/test_completion.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
file system paths, and shell commands.
55
"""
66

7+
import argparse
78
import dataclasses
89
import enum
910
import os
@@ -15,6 +16,7 @@
1516

1617
import cmd2
1718
from cmd2 import (
19+
Choices,
1820
CompletionItem,
1921
Completions,
2022
utils,
@@ -1300,3 +1302,123 @@ def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None:
13001302

13011303
completions = scu_app.complete(text, line, begidx, endidx)
13021304
assert not completions
1305+
1306+
1307+
def test_set_completion_item_text() -> None:
1308+
"""Test setting CompletionItem.text and how it affects CompletionItem.display."""
1309+
value = 5
1310+
1311+
# Don't provide text
1312+
item = CompletionItem(value=value)
1313+
assert item.text == str(value)
1314+
1315+
# Provide text
1316+
item = CompletionItem(value=value, text="my_text")
1317+
assert item.text == "my_text"
1318+
1319+
# Provide blank text
1320+
item = CompletionItem(value=value, text="")
1321+
assert item.text == ""
1322+
1323+
1324+
def test_replace_completion_item_text() -> None:
1325+
"""Test replacing the value of CompletionItem.text"""
1326+
value = 5
1327+
1328+
# Replace text value
1329+
item = CompletionItem(value=value, text="my_text")
1330+
updated_item = dataclasses.replace(item, text="new_text")
1331+
assert item.text == "my_text"
1332+
assert item.display == "my_text"
1333+
1334+
# Text should be updated and display should be the same
1335+
assert updated_item.text == "new_text"
1336+
assert updated_item.display == "my_text"
1337+
1338+
# Replace text value with blank
1339+
item = CompletionItem(value=value, text="my_text")
1340+
updated_item = dataclasses.replace(item, text="")
1341+
assert item.text == "my_text"
1342+
assert item.display == "my_text"
1343+
1344+
# Text should be updated and display should be the same
1345+
assert updated_item.text == ""
1346+
assert updated_item.display == "my_text"
1347+
1348+
1349+
def test_set_completion_item_display() -> None:
1350+
"""Test setting CompletionItem.display and how it is affected by CompletionItem.text."""
1351+
value = 5
1352+
1353+
# Don't provide text or display
1354+
value = 5
1355+
item = CompletionItem(value=value)
1356+
assert item.text == str(value)
1357+
assert item.display == item.text
1358+
1359+
# Don't provide display but provide text
1360+
item = CompletionItem(value=value, text="my_text")
1361+
assert item.text == "my_text"
1362+
assert item.display == item.text
1363+
1364+
# Provide display
1365+
item = CompletionItem(value=value, text="my_text", display="my_display")
1366+
assert item.text == "my_text"
1367+
assert item.display == "my_display"
1368+
1369+
# Provide blank display
1370+
item = CompletionItem(value=value, text="my_text", display="")
1371+
assert item.text == "my_text"
1372+
assert item.display == ""
1373+
1374+
1375+
def test_replace_completion_item_display() -> None:
1376+
"""Test replacing the value of CompletionItem.display"""
1377+
value = 5
1378+
1379+
# Replace display value
1380+
item = CompletionItem(value=value, display="my_display")
1381+
updated_item = dataclasses.replace(item, display="new_display")
1382+
1383+
assert item.display == "my_display"
1384+
assert updated_item.display == "new_display"
1385+
1386+
# Replace display value with blank
1387+
item = CompletionItem(value=value, display="my_display")
1388+
updated_item = dataclasses.replace(item, display="")
1389+
1390+
assert item.display == "my_display"
1391+
assert updated_item.display == ""
1392+
1393+
1394+
def test_full_prefix_removal() -> None:
1395+
"""Verify that Cmd._perform_completion() can clear item.text when
1396+
text_to_remove matches item.text exactly. This occurs when completing
1397+
a nested quoted string where the command line already contains the
1398+
full unquoted content of the completion match.
1399+
"""
1400+
1401+
class TestApp(cmd2.Cmd):
1402+
def get_choices(self) -> Choices:
1403+
"""Return choices."""
1404+
choices = ["'This is a quoted item'"]
1405+
return cmd2.Choices.from_values(choices)
1406+
1407+
parser = cmd2.Cmd2ArgumentParser()
1408+
parser.add_argument("arg", choices_provider=get_choices)
1409+
1410+
@cmd2.with_argparser(parser)
1411+
def do_command(self, args: argparse.Namespace) -> None:
1412+
"""Test stuff."""
1413+
1414+
text = ""
1415+
line = "command \"'This is a quoted item'"
1416+
endidx = len(line)
1417+
begidx = endidx
1418+
1419+
app = TestApp()
1420+
completions = app.complete(text, line, begidx, endidx)
1421+
assert len(completions) == 1
1422+
1423+
item = completions[0]
1424+
assert item.text == ""

0 commit comments

Comments
 (0)