Skip to content

Commit

Permalink
Add support for async validation functions (#4024)
Browse files Browse the repository at this point in the history
This PR implements feature request #4004, allowing to use async
validation functions. Because the current API allows to call the
`validate()` method synchronously, we need to await async validations in
a background task and raise if a return value is expected.

Example:
```py
async def validate(value: str):
    await asyncio.sleep(0.5)
    return 'Too short' if len(value) < 3 else None

ui.input(validation=validate)
```

Open tasks:

- [x] documentation
- [x] pytest
  • Loading branch information
falkoschindler authored Nov 22, 2024
1 parent f5d1ef3 commit 1786e0a
Show file tree
Hide file tree
Showing 6 changed files with 68 additions and 24 deletions.
6 changes: 3 additions & 3 deletions nicegui/elements/input.py
Original file line number Diff line number Diff line change
@@ -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'):
Expand All @@ -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
Expand Down
39 changes: 30 additions & 9 deletions nicegui/elements/mixins/validation_element.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
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
super().__init__(**kwargs)
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]:
Expand All @@ -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):
Expand All @@ -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)
6 changes: 3 additions & 3 deletions nicegui/elements/number.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions nicegui/elements/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand All @@ -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
Expand Down
30 changes: 23 additions & 7 deletions tests/test_input.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions website/documentation/content/input_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 1786e0a

Please sign in to comment.