Răsfoiți Sursa

Add support for async validation functions (#4024)

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
Falko Schindler 5 luni în urmă
părinte
comite
1786e0a008

+ 3 - 3
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 @@ class Input(ValidationElement, DisableableElement, component='input.js'):
                  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
 

+ 30 - 9
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 @@ class ValidationElement(ValueElement):
         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 @@ class ValidationElement(ValueElement):
         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 @@ class ValidationElement(ValueElement):
     def _handle_value_change(self, value: Any) -> None:
         super()._handle_value_change(value)
         if self._auto_validation:
-            self.validate()
+            self.validate(return_result=False)

+ 3 - 3
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 @@ class Number(ValidationElement, DisableableElement):
                  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
 

+ 2 - 2
nicegui/elements/select.py

@@ -5,7 +5,7 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Union
 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 @@ class Select(ValidationElement, ChoiceElement, DisableableElement, component='se
                  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

+ 23 - 7
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):

+ 7 - 0
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)