diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py index 0105c0b0a..ac487d3df 100644 --- a/nicegui/elements/input.py +++ b/nicegui/elements/input.py @@ -1,9 +1,9 @@ -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, List, Optional, Union from ..events import Handler, ValueChangeEventArguments from .icon import Icon from .mixins.disableable_element import DisableableElement -from .mixins.validation_element import ValidationElement +from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction class Input(ValidationElement, DisableableElement, component='input.js'): @@ -18,7 +18,7 @@ def __init__(self, password_toggle_button: bool = False, on_change: Optional[Handler[ValueChangeEventArguments]] = None, autocomplete: Optional[List[str]] = None, - validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None, + validation: Optional[Union[ValidationFunction, ValidationDict]] = None, ) -> None: """Text Input diff --git a/nicegui/elements/mixins/validation_element.py b/nicegui/elements/mixins/validation_element.py index 1f0e5ca1d..18c6843ec 100644 --- a/nicegui/elements/mixins/validation_element.py +++ b/nicegui/elements/mixins/validation_element.py @@ -1,13 +1,17 @@ -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Awaitable, Callable, Dict, Optional, Union from typing_extensions import Self +from ... import background_tasks, helpers from .value_element import ValueElement +ValidationFunction = Callable[[Any], Union[Optional[str], Awaitable[Optional[str]]]] +ValidationDict = Dict[str, Callable[[Any], bool]] + class ValidationElement(ValueElement): - def __init__(self, validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]], **kwargs: Any) -> None: + def __init__(self, validation: Optional[Union[ValidationFunction, ValidationDict]], **kwargs: Any) -> None: self._validation = validation self._auto_validation = True self._error: Optional[str] = None @@ -15,18 +19,18 @@ def __init__(self, validation: Optional[Union[Callable[..., Optional[str]], Dict self._props['error'] = None if validation is None else False # NOTE: reserve bottom space for error message @property - def validation(self) -> Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]]: + def validation(self) -> Optional[Union[ValidationFunction, ValidationDict]]: """The validation function or dictionary of validation functions.""" return self._validation @validation.setter - def validation(self, validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]]) -> None: + def validation(self, validation: Optional[Union[ValidationFunction, ValidationDict]]) -> None: """Sets the validation function or dictionary of validation functions. :param validation: validation function or dictionary of validation functions (``None`` to disable validation) """ self._validation = validation - self.validate() + self.validate(return_result=False) @property def error(self) -> Optional[str]: @@ -47,13 +51,30 @@ def error(self, error: Optional[str]) -> None: self._props['error-message'] = error self.update() - def validate(self) -> bool: + def validate(self, *, return_result: bool = True) -> bool: """Validate the current value and set the error message if necessary. - :return: True if the value is valid, False otherwise + For async validation functions, ``return_result`` must be set to ``False`` and the return value will be ``True``, + independently of the validation result which is evaluated in the background. + + :param return_result: whether to return the result of the validation (default: ``True``) + :return: whether the validation was successful (always ``True`` for async validation functions) """ + if helpers.is_coroutine_function(self._validation): + async def await_error(): + assert callable(self._validation) + result = self._validation(self.value) + assert isinstance(result, Awaitable) + self.error = await result + if return_result: + raise NotImplementedError('The validate method cannot return results for async validation functions.') + background_tasks.create(await_error()) + return True + if callable(self._validation): - self.error = self._validation(self.value) + result = self._validation(self.value) + assert not isinstance(result, Awaitable) + self.error = result return self.error is None if isinstance(self._validation, dict): @@ -73,4 +94,4 @@ def without_auto_validation(self) -> Self: def _handle_value_change(self, value: Any) -> None: super()._handle_value_change(value) if self._auto_validation: - self.validate() + self.validate(return_result=False) diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py index e81d09fac..e60f715a1 100644 --- a/nicegui/elements/number.py +++ b/nicegui/elements/number.py @@ -1,8 +1,8 @@ -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Optional, Union from ..events import GenericEventArguments, Handler, ValueChangeEventArguments from .mixins.disableable_element import DisableableElement -from .mixins.validation_element import ValidationElement +from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction class Number(ValidationElement, DisableableElement): @@ -20,7 +20,7 @@ def __init__(self, suffix: Optional[str] = None, format: Optional[str] = None, # pylint: disable=redefined-builtin on_change: Optional[Handler[ValueChangeEventArguments]] = None, - validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None, + validation: Optional[Union[ValidationFunction, ValidationDict]] = None, ) -> None: """Number Input diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py index b5266f93a..c008fe771 100644 --- a/nicegui/elements/select.py +++ b/nicegui/elements/select.py @@ -5,7 +5,7 @@ from ..events import GenericEventArguments, Handler, ValueChangeEventArguments from .choice_element import ChoiceElement from .mixins.disableable_element import DisableableElement -from .mixins.validation_element import ValidationElement +from .mixins.validation_element import ValidationDict, ValidationElement, ValidationFunction class Select(ValidationElement, ChoiceElement, DisableableElement, component='select.js'): @@ -19,7 +19,7 @@ def __init__(self, new_value_mode: Optional[Literal['add', 'add-unique', 'toggle']] = None, multiple: bool = False, clearable: bool = False, - validation: Optional[Union[Callable[..., Optional[str]], Dict[str, Callable[..., bool]]]] = None, + validation: Optional[Union[ValidationFunction, ValidationDict]] = None, key_generator: Optional[Union[Callable[[Any], Any], Iterator[Any]]] = None, ) -> None: """Dropdown Selection diff --git a/tests/test_input.py b/tests/test_input.py index c54db2d3e..94cabbc95 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -1,3 +1,6 @@ +import asyncio +from typing import Literal, Optional + import pytest from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -54,12 +57,25 @@ def test_toggle_button(screen: Screen): assert element.get_attribute('type') == 'password' -@pytest.mark.parametrize('use_callable', [False, True]) -def test_input_validation(use_callable: bool, screen: Screen): - if use_callable: +@pytest.mark.parametrize('method', ['dict', 'sync', 'async']) +def test_input_validation(method: Literal['dict', 'sync', 'async'], screen: Screen): + if method == 'sync': input_ = ui.input('Name', validation=lambda x: 'Short' if len(x) < 3 else 'Still short' if len(x) < 5 else None) - else: + elif method == 'dict': input_ = ui.input('Name', validation={'Short': lambda x: len(x) >= 3, 'Still short': lambda x: len(x) >= 5}) + else: + async def validate(x: str) -> Optional[str]: + await asyncio.sleep(0.1) + return 'Short' if len(x) < 3 else 'Still short' if len(x) < 5 else None + input_ = ui.input('Name', validation=validate) + + def assert_validation(expected: bool): + if method == 'async': + with pytest.raises(NotImplementedError): + input_.validate() + assert input_.validate(return_result=False) + else: + assert input_.validate() == expected screen.open('/') screen.should_contain('Name') @@ -68,19 +84,19 @@ def test_input_validation(use_callable: bool, screen: Screen): element.send_keys('Jo') screen.should_contain('Short') assert input_.error == 'Short' - assert not input_.validate() + assert_validation(False) element.send_keys('hn') screen.should_contain('Still short') assert input_.error == 'Still short' - assert not input_.validate() + assert_validation(False) element.send_keys(' Doe') screen.wait(1.0) screen.should_not_contain('Short') screen.should_not_contain('Still short') assert input_.error is None - assert input_.validate() + assert_validation(True) def test_input_with_multi_word_error_message(screen: Screen): diff --git a/website/documentation/content/input_documentation.py b/website/documentation/content/input_documentation.py index b16cedf0d..ad2377a71 100644 --- a/website/documentation/content/input_documentation.py +++ b/website/documentation/content/input_documentation.py @@ -47,6 +47,13 @@ def styling(): - by passing a callable that returns an error message or `None`, or - by passing a dictionary that maps error messages to callables that return `True` if the input is valid. + + The callable validation function can also be an async coroutine. + In this case, the validation is performed asynchronously in the background. + + You can use the `validate` method of the input element to trigger the validation manually. + It returns `True` if the input is valid, and an error message otherwise. + For async validation functions, the return value must be explicitly disabled by setting `return_result=False`. ''') def validation(): ui.input('Name', validation=lambda value: 'Too short' if len(value) < 5 else None)