From bdd9746c6614bb5d1e37099dafa75d193b160cad Mon Sep 17 00:00:00 2001 From: Robin Mueller Date: Fri, 17 Nov 2023 09:35:53 +0100 Subject: [PATCH 1/3] NestedSeparator improvements and additions 1. Allow passing an optional custom separator. 2. Allow passing all arguments of the default constructor to the `from_nested_dict` constructor as well. --- CHANGELOG | 8 ++++ src/prompt_toolkit/completion/nested.py | 41 +++++++++++----- src/prompt_toolkit/layout/controls.py | 4 +- tests/test_completion.py | 62 ++++++++++++++++++------- 4 files changed, 85 insertions(+), 30 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index c61430778..9537a4a35 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,14 @@ CHANGELOG ========= +New features: + +- `NestedCompleter` now accepts an optional separator argument which allows to specify custom + separators. Space will still be the default separator. +- `NestedCompleter.from_nested_dict` now accepts optional `ignore_case` and `separator` arguments + of the default constructor and passes them on recursively to all contained nested separator + instances. + 3.0.41: 2023-11-14 ------------------ diff --git a/src/prompt_toolkit/completion/nested.py b/src/prompt_toolkit/completion/nested.py index a1d211ab0..fac4064e3 100644 --- a/src/prompt_toolkit/completion/nested.py +++ b/src/prompt_toolkit/completion/nested.py @@ -22,22 +22,33 @@ class NestedCompleter(Completer): By combining multiple `NestedCompleter` instances, we can achieve multiple hierarchical levels of autocompletion. This is useful when `WordCompleter` - is not sufficient. + is not sufficient. The separator to trigger completion on the previously + typed word is the Space character by default, but it is also possible + to set a custom separator. If you need multiple levels, check out the `from_nested_dict` classmethod. """ def __init__( - self, options: dict[str, Completer | None], ignore_case: bool = True + self, + options: dict[str, Completer | None], + ignore_case: bool = True, + separator: str = " ", ) -> None: self.options = options self.ignore_case = ignore_case + self.separator = separator def __repr__(self) -> str: - return f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})" + return ( + f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r}, " + f" separator={self.separator!r})" + ) @classmethod - def from_nested_dict(cls, data: NestedDict) -> NestedCompleter: + def from_nested_dict( + cls, data: NestedDict, ignore_case: bool = True, separator: str = " " + ) -> NestedCompleter: """ Create a `NestedCompleter`, starting from a nested dictionary data structure, like this: @@ -66,31 +77,37 @@ def from_nested_dict(cls, data: NestedDict) -> NestedCompleter: if isinstance(value, Completer): options[key] = value elif isinstance(value, dict): - options[key] = cls.from_nested_dict(value) + options[key] = cls.from_nested_dict( + data=value, ignore_case=ignore_case, separator=separator + ) elif isinstance(value, set): - options[key] = cls.from_nested_dict({item: None for item in value}) + options[key] = cls.from_nested_dict( + data={item: None for item in value}, + ignore_case=ignore_case, + separator=separator, + ) else: assert value is None options[key] = None - return cls(options) + return cls(options=options, ignore_case=ignore_case, separator=separator) def get_completions( self, document: Document, complete_event: CompleteEvent ) -> Iterable[Completion]: # Split document. - text = document.text_before_cursor.lstrip() + text = document.text_before_cursor.lstrip(self.separator) stripped_len = len(document.text_before_cursor) - len(text) - # If there is a space, check for the first term, and use a + # If there is a separator character, check for the first term, and use a # subcompleter. - if " " in text: - first_term = text.split()[0] + if self.separator in text: + first_term = text.split(self.separator)[0] completer = self.options.get(first_term) # If we have a sub completer, use this for the completions. if completer is not None: - remaining_text = text[len(first_term) :].lstrip() + remaining_text = text[len(first_term) :].lstrip(self.separator) move_cursor = len(text) - len(remaining_text) + stripped_len new_document = Document( diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py index c30c0effa..5fa1024f0 100644 --- a/src/prompt_toolkit/layout/controls.py +++ b/src/prompt_toolkit/layout/controls.py @@ -477,7 +477,9 @@ def create_content(self, width: int, height: int) -> UIContent: def get_line(i: int) -> StyleAndTextTuples: return [] - return UIContent(get_line=get_line, line_count=100**100) # Something very big. + return UIContent( + get_line=get_line, line_count=100**100 + ) # Something very big. def is_focusable(self) -> bool: return False diff --git a/tests/test_completion.py b/tests/test_completion.py index 8b3541af0..24fc82986 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -400,19 +400,7 @@ def test_fuzzy_completer(): assert [c.text for c in completions] == ["users.txt", "accounts.txt"] -def test_nested_completer(): - completer = NestedCompleter.from_nested_dict( - { - "show": { - "version": None, - "clock": None, - "interfaces": None, - "ip": {"interface": {"brief"}}, - }, - "exit": None, - } - ) - +def _generic_test_nested_completer(completer: NestedCompleter): # Empty input. completions = completer.get_completions(Document(""), CompleteEvent()) assert {c.text for c in completions} == {"show", "exit"} @@ -426,24 +414,64 @@ def test_nested_completer(): assert {c.text for c in completions} == {"show"} # One word + space. - completions = completer.get_completions(Document("show "), CompleteEvent()) + completions = completer.get_completions( + Document(f"show{completer.separator}"), CompleteEvent() + ) assert {c.text for c in completions} == {"version", "clock", "interfaces", "ip"} # One word + space + one character. - completions = completer.get_completions(Document("show i"), CompleteEvent()) + completions = completer.get_completions( + Document(f"show{completer.separator}i"), CompleteEvent() + ) assert {c.text for c in completions} == {"ip", "interfaces"} # One space + one word + space + one character. - completions = completer.get_completions(Document(" show i"), CompleteEvent()) + completions = completer.get_completions( + Document(f"{completer.separator}show{completer.separator}i"), CompleteEvent() + ) assert {c.text for c in completions} == {"ip", "interfaces"} # Test nested set. completions = completer.get_completions( - Document("show ip interface br"), CompleteEvent() + Document( + f"show{completer.separator}ip{completer.separator}interface{completer.separator}br" + ), + CompleteEvent(), ) assert {c.text for c in completions} == {"brief"} +def test_nested_completer(): + completer = NestedCompleter.from_nested_dict( + { + "show": { + "version": None, + "clock": None, + "interfaces": None, + "ip": {"interface": {"brief"}}, + }, + "exit": None, + } + ) + _generic_test_nested_completer(completer) + + +def test_nested_completer_different_separator(): + completer = NestedCompleter.from_nested_dict( + data={ + "show": { + "version": None, + "clock": None, + "interfaces": None, + "ip": {"interface": {"brief"}}, + }, + "exit": None, + }, + separator="/", + ) + _generic_test_nested_completer(completer) + + def test_deduplicate_completer(): def create_completer(deduplicate: bool): return merge_completers( From a77b4c00d5cef523d38bb87db8ad41ae3a60078b Mon Sep 17 00:00:00 2001 From: Robin Mueller Date: Fri, 17 Nov 2023 09:38:52 +0100 Subject: [PATCH 2/3] one space too much here --- src/prompt_toolkit/completion/nested.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompt_toolkit/completion/nested.py b/src/prompt_toolkit/completion/nested.py index fac4064e3..26a7c8bc8 100644 --- a/src/prompt_toolkit/completion/nested.py +++ b/src/prompt_toolkit/completion/nested.py @@ -42,7 +42,7 @@ def __init__( def __repr__(self) -> str: return ( f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r}, " - f" separator={self.separator!r})" + f"separator={self.separator!r})" ) @classmethod From 7d43c76efec249fb72133cc021bf40640d120003 Mon Sep 17 00:00:00 2001 From: Robin Mueller Date: Fri, 17 Nov 2023 09:41:06 +0100 Subject: [PATCH 3/3] format with ruff --- src/prompt_toolkit/layout/controls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py index 5fa1024f0..c30c0effa 100644 --- a/src/prompt_toolkit/layout/controls.py +++ b/src/prompt_toolkit/layout/controls.py @@ -477,9 +477,7 @@ def create_content(self, width: int, height: int) -> UIContent: def get_line(i: int) -> StyleAndTextTuples: return [] - return UIContent( - get_line=get_line, line_count=100**100 - ) # Something very big. + return UIContent(get_line=get_line, line_count=100**100) # Something very big. def is_focusable(self) -> bool: return False