While trying to test a urwid-based question with input validation and autocomplete suggestions, I am experiencing some difficulties. The code that I am trying to test using pytest is given in the two files below:
InputValidationQuestion.py
from typing import List
import urwid
from typeguard import typechecked
from tui_labeller.file_read_write_helper import write_to_file
@typechecked
def get_filtered_suggestions(
*, input_text: str, available_suggestions: List[str]
) -> List[str]:
"""
Filter suggestions based on input text, matching from start with wildcard support.
Special case: '*' alone shows all available suggestions.
Args:
input_text (str): The text entered by user, can include '*' as wildcard
available_suggestions (list): List of possible suggestion strings
Returns:
list: Filtered suggestions based on input criteria
"""
input_text = input_text.strip()
# Special case: if input is '*', return all suggestions
if input_text == "*":
return available_suggestions
# If no input, return all suggestions
if not input_text:
return available_suggestions
# Handle wildcard case
if "*" in input_text:
# Split input by wildcard
parts = input_text.lower().split("*")
prefix = parts[0] # What comes before the wildcard
# Filter suggestions
filtered = [
suggestion
for suggestion in available_suggestions
if suggestion.lower().startswith(prefix)
and all(part in suggestion.lower() for part in parts[1:] if part)
]
else:
# Original filtering for non-wildcard case
filtered = [
suggestion
for suggestion in available_suggestions
if suggestion.lower().startswith(input_text.lower())
]
# If no matches found, return ['-']
return filtered if filtered else ["-"]
class InputValidationQuestion(urwid.Edit):
def __init__(
self, caption, suggestions=None, autocomplete_box=None, pile=None
):
super().__init__(caption=caption)
self.suggestions = suggestions or []
self.autocomplete_box = autocomplete_box
self.pile = pile
self._in_autocomplete: bool = False
def handle_autocomplete(self, key, size):
"""Handle autocomplete logic based on input key and suggestions.
Args:
key: The pressed key
size: The size parameter for keypress
Returns:
The result of keypress or None if handled
Raises:
ValueError: When autocomplete conditions aren't met
"""
if not self.suggestions:
write_to_file(
filename="eg.txt",
content=f"self.suggestions={self.suggestions}",
append=True,
)
return super().keypress(size, key)
# Handle automatic substitution when '*' yields single match
if "*" in self.edit_text:
matches = [s for s in self.suggestions if self._match_pattern(s)]
if len(matches) == 1:
self.set_edit_text(matches[0])
self.owner.set_attr_map({None: "normal"})
write_to_file(
filename="eg.txt",
content=f"self.edit_text={self.edit_text}",
append=True,
)
return None
elif len(matches) == 0:
raise ValueError("No matches found for pattern")
# TODO: do stuff here.
# If multiple matches, continue to tab handling
# Handle tab key press
if key == "tab":
matches = [s for s in self.suggestions if self._match_pattern(s)]
if len(matches) == 1:
self.set_edit_text(matches[0])
self.owner.set_attr_map({None: "normal"})
return None
elif len(matches) == 0:
raise ValueError("No matching suggestion found")
else:
raise ValueError("Multiple ambiguous suggestions available")
return super().keypress(size, key)
def valid_char(self, ch):
return len(ch) == 1 and (ch.isalpha() or ch in [":", "*"])
def keypress(self, size, key):
write_to_file(
filename="eg.txt",
content=f"key={key}, self.edit_text={self.edit_text}",
append=True,
)
if key in ["tab", "*"]:
return self.handle_autocomplete(key, size)
elif key == "enter":
return "enter"
elif key in ("up", "down"):
if self.pile:
current_pos = self.pile.focus_position
new_pos = current_pos - 1 if key == "up" else current_pos + 1
if 0 <= new_pos < len(self.pile.contents) - 2:
self.pile.focus_position = new_pos
focused_widget = self.pile.focus
if isinstance(focused_widget, urwid.AttrMap):
focused_widget.base_widget.update_autocomplete()
return None
return key
elif key in ("delete", "backspace", "left", "right"):
write_to_file(
filename="eg.txt",
content=f"self.edit_text={self.edit_text}",
append=True,
)
result = super().keypress(size, key)
self.update_autocomplete()
return result
elif self.valid_char(key):
result = super().keypress(size, key)
self.update_autocomplete()
return result
return None
def _match_pattern(self, suggestion):
pattern = self.edit_text.lower().replace("*", ".*")
return bool(re.match(f"^{pattern}$", suggestion.lower()))
def update_autocomplete(self):
if self._in_autocomplete: # Prevent recursion
return
if not self.autocomplete_box:
return
self._in_autocomplete = True # Set flag
try:
remaining_suggestions = get_filtered_suggestions(
input_text=self.edit_text,
available_suggestions=self.suggestions,
)
suggestions_text = ", ".join(remaining_suggestions)
write_to_file(
filename="eg.txt",
content=f"suggestions_text={suggestions_text}",
append=True,
)
self.autocomplete_box.base_widget.set_text(suggestions_text)
self.autocomplete_box.base_widget._invalidate()
if "*" in self.edit_text:
if len(remaining_suggestions) == 1:
# Use set_edit_text instead of direct assignment to avoid triggering signals
self.set_edit_text(remaining_suggestions[0])
else:
self.owner.set_attr_map({None: "normal"})
finally:
self._in_autocomplete = False # Reset flag
class QuestionApp:
def __init__(self):
self.questions = [
("Question 1: ", ["apple", "apricot", "avocado"]),
("Question 2: ", ["banana", "blueberry", "blackberry"]),
("Question 3: ", ["cat", "caterpillar", "cactus"]),
]
self.palette = [
("normal", "white", "black"),
("highlight", "white", "dark red"),
("autocomplete", "yellow", "dark blue"),
]
self.autocomplete_box = urwid.AttrMap(
urwid.Text("", align="left"), "autocomplete"
)
self.pile = urwid.Pile([])
self.inputs = []
for question, suggestions in self.questions:
edit = InputValidationQuestion(
question, suggestions, self.autocomplete_box, self.pile
)
attr_edit = urwid.AttrMap(edit, "normal")
edit.owner = attr_edit
self.inputs.append(attr_edit)
self.pile.contents = [
(self.inputs[0], ("pack", None)),
(self.inputs[1], ("pack", None)),
(self.inputs[2], ("pack", None)),
(urwid.Divider(), ("pack", None)),
(
urwid.Columns(
[(30, urwid.Text("Autocomplete: ")), self.autocomplete_box]
),
("pack", None),
),
]
self.fill = urwid.Filler(self.pile, valign="top")
self.loop = urwid.MainLoop(
self.fill, self.palette, unhandled_input=self.handle_input
)
def handle_input(self, key):
print(f"Unhandled input: {key}")
write_to_file(
filename="eg.txt", content=f"Unhandled input: {key}", append=False
)
# TODO: if cursor is at the first question and up is pressed, go to last question.
# TODO: if cursor is at the last question and down is pressed, go to first question.
raise ValueError(f"STOPPED at:{key}")
def run(self):
def update_autocomplete(widget, new_text):
widget.update_autocomplete()
for input_widget in self.inputs:
urwid.connect_signal(
input_widget.base_widget, "change", update_autocomplete
)
if self.inputs:
self.pile.focus_position = 0
self.inputs[0].base_widget.update_autocomplete()
self.loop.run()
InputValidationQuestions.py:
import urwid
from tui_labeller.file_read_write_helper import write_to_file
from tui_labeller.tuis.urwid.InputValidationQuestion import (
InputValidationQuestion,
)
class InputValidationQuestions:
def __init__(self):
self.questions = [
("Question 1: ", ["apple", "apricot", "avocado"]),
("Question 2: ", ["banana", "blueberry", "blackberry"]),
("Question 3: ", ["cat", "caterpillar", "cactus"]),
]
self.palette = [
("normal", "white", "black"),
("highlight", "white", "dark red"),
("autocomplete", "yellow", "dark blue"),
]
self.autocomplete_box = urwid.AttrMap(
urwid.Text("", align="left"), "autocomplete"
)
self.pile = urwid.Pile([])
self.inputs = []
for question, suggestions in self.questions:
edit = InputValidationQuestion(
question, suggestions, self.autocomplete_box, self.pile
)
attr_edit = urwid.AttrMap(edit, "normal")
edit.owner = attr_edit
self.inputs.append(attr_edit)
self.pile.contents = [
(self.inputs[0], ("pack", None)),
(self.inputs[1], ("pack", None)),
(self.inputs[2], ("pack", None)),
(urwid.Divider(), ("pack", None)),
(
urwid.Columns(
[(30, urwid.Text("Autocomplete: ")), self.autocomplete_box]
),
("pack", None),
),
]
self.fill = urwid.Filler(self.pile, valign="top")
self.loop = urwid.MainLoop(
self.fill, self.palette, unhandled_input=self.handle_input
)
def handle_input(self, key):
print(f"Unhandled input: {key}")
write_to_file(
filename="eg.txt", content=f"Unhandled input: {key}", append=False
)
# TODO: if cursor is at the first question and up is pressed, go to last question.
# TODO: if cursor is at the last question and down is pressed, go to first question.
raise ValueError(f"STOPPED at:{key}")
def run(self):
def update_autocomplete(widget, new_text):
widget.update_autocomplete()
for input_widget in self.inputs:
urwid.connect_signal(
input_widget.base_widget, "change", update_autocomplete
)
if self.inputs:
self.pile.focus_position = 0
self.inputs[0].base_widget.update_autocomplete()
self.loop.run()
These are ran with:
from typeguard import typechecked
from src.tui_labeller.tuis.urwid.InputValidationQuestions import (
InputValidationQuestions,
)
app = InputValidationQuestions()
app.run()
Expected behaviour
The class handles incremental input (e.g., typing "a,v," selects "avocado") in a TUI app launched with python -m src.tui_labeller -i <image> -o <dir> -t urwid. In the real run, "a,v," works, and pressing Enter raises a ValueError: STOPPED at:enter, which is expected/fine.
Test behaviour
However, my test fails because the handle_input method raises ValueError: STOPPED at:a on the first keypress ("a"), stopping further processing.
Test Code
Below is the code that attempts to test the code above:
import urwid
import pytest
from tui_labeller.tuis.urwid.SUBSTITUECLASSHERE import SUBSTITUECLASSHERE
@pytest.fixture
def app():
app = SUBSTITUECLASSHERE()
app.loop.screen = urwid.raw_display.Screen()
return app
def test_avocado_selection(app):
input_sequence = ["a", "v", "*"]
for key in input_sequence:
app.loop.process_input([key])
if hasattr(app.loop, "unhandled_input") and app.loop.unhandled_input:
app.loop.unhandled_input(key)
# How to check "avocado" is selected? Edit widget, ListBox, or custom attribute?
def test_enter_error_handling(app):
input_sequence = ["a", "v", "*", "enter"]
with pytest.raises(ValueError, match="STOPPED at:enter"):
for key in input_sequence:
app.loop.process_input([key])
if hasattr(app.loop, "unhandled_input") and app.loop.unhandled_input:
app.loop.unhandled_input(key)
The test fails with:
ValueError: STOPPED at:a
AssertionError: Regex pattern 'STOPPED at:enter' did not match 'STOPPED at:a'
Question
How do I modify the test to:
- Process "a,v,*" to select "avocado" without raising errors early,
- Verify the selection (what widget/attribute holds it?)