Skip to content

Commit 8c77f75

Browse files
committed
Fixed not being able to clear CompletionItem.text.
Reduced number of dataclasses.replace() operations of completion classes by adding private setters.
1 parent 143a99e commit 8c77f75

4 files changed

Lines changed: 83 additions & 59 deletions

File tree

cmd2/cmd2.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import argparse
3030
import contextlib
3131
import copy
32-
import dataclasses
3332
import functools
3433
import glob
3534
import inspect
@@ -2515,31 +2514,27 @@ def _perform_completion(
25152514
if not completions:
25162515
return Completions()
25172516

2518-
_add_opening_quote = False
2519-
_quote_char = completion_token_quote
2517+
add_opening_quote = False
2518+
quote_char = completion_token_quote
25202519

25212520
# Check if we need to add an opening quote
25222521
if not completion_token_quote:
25232522
matches = completions.to_strings()
25242523

25252524
if any(" " in match for match in matches):
2526-
_add_opening_quote = True
2525+
add_opening_quote = True
25272526

25282527
# Determine best quote (single vs double) based on text content
2529-
_quote_char = "'" if any('"' in t for t in matches) else '"'
2528+
quote_char = "'" if any('"' in t for t in matches) else '"'
25302529

25312530
# Check if we need to remove text from the beginning of completions
25322531
elif text_to_remove:
2533-
new_items = [
2534-
dataclasses.replace(
2535-
item,
2536-
text=item.text.replace(text_to_remove, "", 1),
2537-
)
2538-
for item in completions
2539-
]
2540-
completions = dataclasses.replace(completions, items=new_items)
2532+
for item in completions:
2533+
item._set_text(item.text.replace(text_to_remove, "", 1))
25412534

2542-
return dataclasses.replace(completions, _add_opening_quote=_add_opening_quote, _quote_char=_quote_char)
2535+
completions._set_add_opening_quote(add_opening_quote)
2536+
completions._set_quote_char(quote_char)
2537+
return completions
25432538

25442539
def complete(
25452540
self,
@@ -2599,21 +2594,12 @@ def complete(
25992594
# Check if we need to restore a shortcut in the completion text
26002595
# so it doesn't get erased from the command line.
26012596
if completions and shortcut_to_restore:
2602-
new_items = [
2603-
dataclasses.replace(
2604-
item,
2605-
text=shortcut_to_restore + item.text,
2606-
)
2607-
for item in completions
2608-
]
2597+
for item in completions:
2598+
item._set_text(shortcut_to_restore + item.text)
26092599

2610-
# Update items and set _quote_from_offset so that any auto-inserted
2600+
# Update search text offset so that any auto-inserted
26112601
# opening quote is placed after the shortcut.
2612-
completions = dataclasses.replace(
2613-
completions,
2614-
items=new_items,
2615-
_search_text_offset=len(shortcut_to_restore),
2616-
)
2602+
completions._set_search_text_offset(len(shortcut_to_restore))
26172603

26182604
# Swap between COLUMN and MULTI_COLUMN style based on the number of matches.
26192605
if len(completions) > self.max_column_completion_results:

cmd2/completion.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Provides classes and functions related to command-line completion."""
22

3+
import copy
34
import re
45
import sys
56
from collections.abc import (
@@ -39,12 +40,15 @@ class CompletionItem:
3940
# control sequences (like ^J or ^I) in the completion menu.
4041
_CONTROL_WHITESPACE_RE = re.compile(r"\r\n|[\n\r\t\f\v]")
4142

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

46-
# The actual string that will be inserted into the command line.
47-
# If not provided, it defaults to str(value).
48+
# The actual completion string. If this is empty, it defaults to str(value)
49+
# during initialization. This can be used to provide a human-friendly alias
50+
# for complex objects in an argparse choices list (requires a matching
51+
# 'type' converter for validation).
4852
text: str = ""
4953

5054
# Optional string for displaying the completion differently in the completion menu.
@@ -59,10 +63,16 @@ class CompletionItem:
5963
# argument's table_columns. This is stored internally as a tuple.
6064
table_data: Sequence[Any] = field(default_factory=tuple)
6165

66+
def _set_text(self, text: str) -> None:
67+
"""Update the completion string.
68+
69+
Used internally by cmd2 to prepare the value for the command line.
70+
"""
71+
object.__setattr__(self, "text", text)
72+
6273
# 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)
74+
display_plain: str = field(default="", init=False)
75+
display_meta_plain: str = field(default="", init=False)
6676

6777
@classmethod
6878
def _clean_display(cls, val: str) -> str:
@@ -78,7 +88,7 @@ def _clean_display(cls, val: str) -> str:
7888

7989
def __post_init__(self) -> None:
8090
"""Finalize the object after initialization."""
81-
# Derive text from value if it wasn't explicitly provided
91+
# If the completion string is empty, derive it from value
8292
if not self.text:
8393
object.__setattr__(self, "text", str(self.value))
8494

@@ -163,13 +173,15 @@ class CompletionResultsBase:
163173

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

168178
def __post_init__(self) -> None:
169179
"""Finalize the object after initialization."""
170180
from . import utils
171181

172-
unique_items = utils.remove_duplicates(self.items)
182+
# Remove duplicates and then copy the items so any changes made during completion
183+
# don't affect the originals (e.g., persistent CompletionItems in a choices list)
184+
unique_items = [copy.copy(item) for item in utils.remove_duplicates(self.items)]
173185

174186
# Determine if all items have numeric display strings
175187
numeric_display = bool(unique_items) and all(self._NUMERIC_RE.match(i.display_plain) for i in unique_items)
@@ -262,18 +274,34 @@ class Completions(CompletionResultsBase):
262274
# This flag is ignored if there are multiple matches.
263275
allow_finalization: bool = True
264276

265-
#####################################################################
266-
# The following fields are used internally by cmd2 to handle
267-
# automatic quoting and are not intended for user modification.
268-
#####################################################################
269-
270277
# Whether to add an opening quote to the matches.
271-
_add_opening_quote: bool = False
278+
_add_opening_quote: bool = field(default=False, init=False)
272279

273280
# The starting index of the user-provided search text within a full match.
274281
# This accounts for leading shortcuts (e.g., in '?cmd', the offset is 1).
275282
# Used to ensure opening quotes are inserted after the shortcut rather than before it.
276-
_search_text_offset: int = 0
283+
_search_text_offset: int = field(default=0, init=False)
277284

278285
# The quote character to use if adding an opening or closing quote to the matches.
279-
_quote_char: str = ""
286+
_quote_char: str = field(default="", init=False)
287+
288+
def _set_add_opening_quote(self, value: bool) -> None:
289+
"""Set whether to add an opening quote.
290+
291+
Used internally by cmd2 for automatic quoting.
292+
"""
293+
object.__setattr__(self, "_add_opening_quote", value)
294+
295+
def _set_search_text_offset(self, value: int) -> None:
296+
"""Set the search text offset.
297+
298+
Used internally by cmd2 for automatic quoting.
299+
"""
300+
object.__setattr__(self, "_search_text_offset", value)
301+
302+
def _set_quote_char(self, value: str) -> None:
303+
"""Set the quote character.
304+
305+
Used internally by cmd2 for automatic quoting.
306+
"""
307+
object.__setattr__(self, "_quote_char", value)

tests/test_completion.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,3 +1300,14 @@ def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None:
13001300

13011301
completions = scu_app.complete(text, line, begidx, endidx)
13021302
assert not completions
1303+
1304+
1305+
def test_completion_item_blank_text() -> None:
1306+
# Verify that CompletionItem correctly handles blank text when set via _set_text
1307+
value = "something"
1308+
item = CompletionItem(value=value)
1309+
assert item.text == value
1310+
1311+
item._set_text("")
1312+
assert item.text == ""
1313+
assert item.display == value

tests/test_pt_utils.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,11 @@ def test_get_completions_add_opening_quote_and_abort(self, line, match, search_t
400400

401401
# Set up matches
402402
completion_items = [cmd2.CompletionItem(match)]
403-
cmd2_completions = cmd2.Completions(
404-
completion_items,
405-
_add_opening_quote=True,
406-
_search_text_offset=search_text_offset,
407-
_quote_char='"',
408-
)
403+
cmd2_completions = cmd2.Completions(completion_items)
404+
cmd2_completions._set_add_opening_quote(True)
405+
cmd2_completions._set_search_text_offset(search_text_offset)
406+
cmd2_completions._set_quote_char('"')
407+
409408
mock_cmd_app.complete.return_value = cmd2_completions
410409

411410
# Call get_completions
@@ -442,12 +441,11 @@ def test_get_completions_add_opening_quote_and_return_results(
442441
# Set up matches
443442
completion_items = [cmd2.CompletionItem(match) for match in matches]
444443

445-
cmd2_completions = cmd2.Completions(
446-
completion_items,
447-
_add_opening_quote=True,
448-
_search_text_offset=search_text_offset,
449-
_quote_char=quote_char,
450-
)
444+
cmd2_completions = cmd2.Completions(completion_items)
445+
cmd2_completions._set_add_opening_quote(True)
446+
cmd2_completions._set_search_text_offset(search_text_offset)
447+
cmd2_completions._set_quote_char(quote_char)
448+
451449
mock_cmd_app.complete.return_value = cmd2_completions
452450

453451
# Call get_completions
@@ -483,7 +481,8 @@ def test_get_completions_allow_finalization(
483481

484482
# Set up matches
485483
completion_items = [cmd2.CompletionItem(match)]
486-
cmd2_completions = cmd2.Completions(completion_items, _quote_char=quote_char)
484+
cmd2_completions = cmd2.Completions(completion_items)
485+
cmd2_completions._set_quote_char(quote_char)
487486
mock_cmd_app.complete.return_value = cmd2_completions
488487

489488
# Call get_completions and compare results
@@ -515,8 +514,8 @@ def test_get_completions_do_not_allow_finalization(
515514
cmd2_completions = cmd2.Completions(
516515
completion_items,
517516
allow_finalization=False,
518-
_quote_char=quote_char,
519517
)
518+
cmd2_completions._set_quote_char(quote_char)
520519
mock_cmd_app.complete.return_value = cmd2_completions
521520

522521
# Call get_completions and compare results

0 commit comments

Comments
 (0)