Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for async validation functions #4024

Merged
merged 6 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading