Repository: zauberzeug/nicegui
Pull request #4024 (2024-11-22 07:37:11)

Add support for async validation functions
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


Patch:
diff --git a/nicegui/elements/input.py b/nicegui/elements/input.py
index 0105c0b0..ac487d3d 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 @@ 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
 
diff --git a/nicegui/elements/mixins/validation_element.py b/nicegui/elements/mixins/validation_element.py
index 1f0e5ca1..18c6843e 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 @@ 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)
diff --git a/nicegui/elements/number.py b/nicegui/elements/number.py
index e81d09fa..e60f715a 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 @@ 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
 
diff --git a/nicegui/elements/scene.js b/nicegui/elements/scene.js
index 659e9265..c72469a1 100644
--- a/nicegui/elements/scene.js
+++ b/nicegui/elements/scene.js
@@ -413,6 +413,22 @@ export default {
       const geometry = this.objects.get(object_id).geometry;
       set_point_cloud_data(position, color, geometry);
     },
+    attach(object_id, parent_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      const parent = this.objects.get(parent_id);
+      parent.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
+    detach(object_id, x, y, z, R) {
+      if (!this.objects.has(object_id)) return;
+      const object = this.objects.get(object_id);
+      object.removeFromParent();
+      this.scene.add(object);
+      this.move(object_id, x, y, z);
+      this.rotate(object_id, R);
+    },
     move_camera(x, y, z, look_at_x, look_at_y, look_at_z, up_x, up_y, up_z, duration) {
       if (this.camera_tween) this.camera_tween.stop();
       const camera_up_changed = up_x !== null || up_y !== null || up_z !== null;
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 67a133ed..f30a608a 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,6 +196,138 @@ class Object3D:
             self._draggable()
         return self
 
+    def attach(self, parent: Object3D) -> None:
+        """Attach the object to a parent object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self.detach()
+        self.parent = parent
+        self._move_into_parent(parent)
+        self.scene.run_method('attach', self.id, parent.id, self.x, self.y, self.z, self.R)
+
+    def _move_into_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        if isinstance(parent.parent, Object3D):
+            self._move_into_parent(parent.parent)
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2_inv: List[List[float]] = [
+            [parent.R[0][0], parent.R[1][0], parent.R[2][0],
+             - parent.R[0][0] * parent.x
+             - parent.R[1][0] * parent.y
+             - parent.R[2][0] * parent.z],
+            [parent.R[0][1], parent.R[1][1], parent.R[2][1],
+             - parent.R[0][1] * parent.x
+             - parent.R[1][1] * parent.y
+             - parent.R[2][1] * parent.z],
+            [parent.R[0][2], parent.R[1][2], parent.R[2][2],
+             - parent.R[0][2] * parent.x
+             - parent.R[1][2] * parent.y
+             - parent.R[2][2] * parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2_inv[0][0] * M1[0][0] + M2_inv[0][1] * M1[1][0] + M2_inv[0][2] * M1[2][0],
+                M2_inv[0][0] * M1[0][1] + M2_inv[0][1] * M1[1][1] + M2_inv[0][2] * M1[2][1],
+                M2_inv[0][0] * M1[0][2] + M2_inv[0][1] * M1[1][2] + M2_inv[0][2] * M1[2][2],
+                M2_inv[0][0] * M1[0][3] + M2_inv[0][1] * M1[1][3] + M2_inv[0][2] * M1[2][3] + M2_inv[0][3],
+            ],
+            [
+                M2_inv[1][0] * M1[0][0] + M2_inv[1][1] * M1[1][0] + M2_inv[1][2] * M1[2][0],
+                M2_inv[1][0] * M1[0][1] + M2_inv[1][1] * M1[1][1] + M2_inv[1][2] * M1[2][1],
+                M2_inv[1][0] * M1[0][2] + M2_inv[1][1] * M1[1][2] + M2_inv[1][2] * M1[2][2],
+                M2_inv[1][0] * M1[0][3] + M2_inv[1][1] * M1[1][3] + M2_inv[1][2] * M1[2][3] + M2_inv[1][3],
+            ],
+            [
+                M2_inv[2][0] * M1[0][0] + M2_inv[2][1] * M1[1][0] + M2_inv[2][2] * M1[2][0],
+                M2_inv[2][0] * M1[0][1] + M2_inv[2][1] * M1[1][1] + M2_inv[2][2] * M1[2][1],
+                M2_inv[2][0] * M1[0][2] + M2_inv[2][1] * M1[1][2] + M2_inv[2][2] * M1[2][2],
+                M2_inv[2][0] * M1[0][3] + M2_inv[2][1] * M1[1][3] + M2_inv[2][2] * M1[2][3] + M2_inv[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+
+    def detach(self) -> None:
+        """Remove the object from its parent group object.
+
+        The position and rotation of the object are preserved so that the object does not move in space.
+
+        But note that scaling is not preserved.
+        If either the parent or the object itself is scaled, the object shape and position can change.
+        """
+        self._move_out_of_parent(self.parent)
+        self.parent = self.scene.stack[0]
+        self.scene.run_method('detach', self.id, self.x, self.y, self.z, self.R)
+
+    def _move_out_of_parent(self, parent: Union[Object3D, SceneObject]) -> None:
+        if not isinstance(parent, Object3D):
+            return
+        M1: List[List[float]] = [
+            [self.R[0][0], self.R[0][1], self.R[0][2], self.x],
+            [self.R[1][0], self.R[1][1], self.R[1][2], self.y],
+            [self.R[2][0], self.R[2][1], self.R[2][2], self.z],
+            [0, 0, 0, 1],
+        ]
+        M2: List[List[float]] = [
+            [parent.R[0][0], parent.R[0][1], parent.R[0][2], parent.x],
+            [parent.R[1][0], parent.R[1][1], parent.R[1][2], parent.y],
+            [parent.R[2][0], parent.R[2][1], parent.R[2][2], parent.z],
+            [0, 0, 0, 1],
+        ]
+        M: List[List[float]] = [
+            [
+                M2[0][0] * M1[0][0] + M2[0][1] * M1[1][0] + M2[0][2] * M1[2][0],
+                M2[0][0] * M1[0][1] + M2[0][1] * M1[1][1] + M2[0][2] * M1[2][1],
+                M2[0][0] * M1[0][2] + M2[0][1] * M1[1][2] + M2[0][2] * M1[2][2],
+                M2[0][0] * M1[0][3] + M2[0][1] * M1[1][3] + M2[0][2] * M1[2][3] + M2[0][3],
+            ],
+            [
+                M2[1][0] * M1[0][0] + M2[1][1] * M1[1][0] + M2[1][2] * M1[2][0],
+                M2[1][0] * M1[0][1] + M2[1][1] * M1[1][1] + M2[1][2] * M1[2][1],
+                M2[1][0] * M1[0][2] + M2[1][1] * M1[1][2] + M2[1][2] * M1[2][2],
+                M2[1][0] * M1[0][3] + M2[1][1] * M1[1][3] + M2[1][2] * M1[2][3] + M2[1][3],
+            ],
+            [
+                M2[2][0] * M1[0][0] + M2[2][1] * M1[1][0] + M2[2][2] * M1[2][0],
+                M2[2][0] * M1[0][1] + M2[2][1] * M1[1][1] + M2[2][2] * M1[2][1],
+                M2[2][0] * M1[0][2] + M2[2][1] * M1[1][2] + M2[2][2] * M1[2][2],
+                M2[2][0] * M1[0][3] + M2[2][1] * M1[1][3] + M2[2][2] * M1[2][3] + M2[2][3],
+            ],
+            [
+                0, 0, 0, 1,
+            ],
+        ]
+        self.x = M[0][3]
+        self.y = M[1][3]
+        self.z = M[2][3]
+        self.R = [
+            [M[0][0], M[0][1], M[0][2]],
+            [M[1][0], M[1][1], M[1][2]],
+            [M[2][0], M[2][1], M[2][2]],
+        ]
+        if isinstance(parent.parent, Object3D):
+            self._move_out_of_parent(parent.parent)
+
     @property
     def children(self) -> List[Object3D]:
         """List of children of the object."""
diff --git a/nicegui/elements/select.py b/nicegui/elements/select.py
index b5266f93..c008fe77 100644
--- a/nicegui/elements/select.py
+++ b/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
diff --git a/website/documentation/content/input_documentation.py b/website/documentation/content/input_documentation.py
index b16cedf0..ad2377a7 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)
diff --git a/website/documentation/content/scene_documentation.py b/website/documentation/content/scene_documentation.py
index 8634c10e..744be6ca 100644
--- a/website/documentation/content/scene_documentation.py
+++ b/website/documentation/content/scene_documentation.py
@@ -268,4 +268,25 @@ def custom_composed_objects() -> None:
         CoordinateSystem('custom frame').move(-2, -2, 1).rotate(0.1, 0.2, 0.3)
 
 
+@doc.demo('Attaching/detaching objects', '''
+    To add or remove objects from groups you can use the `attach` and `detach` methods.
+    The position and rotation of the object are preserved so that the object does not move in space.
+    But note that scaling is not preserved.
+    If either the parent or the object itself is scaled, the object shape and position can change.
+''')
+def attach_detach() -> None:
+    import math
+    import time
+
+    with ui.scene().classes('w-full h-64') as scene:
+        with scene.group() as group:
+            a = scene.box().move(-2)
+            b = scene.box().move(0)
+            c = scene.box().move(2)
+
+    ui.timer(0.1, lambda: group.move(y=math.sin(time.time())).rotate(0, 0, time.time()))
+    ui.button('Detach', on_click=a.detach)
+    ui.button('Attach', on_click=lambda: a.attach(group))
+
+
 doc.reference(ui.scene)
diff --git a/website/documentation/content/user_documentation.py b/website/documentation/content/user_documentation.py
index 00da5e45..991fc496 100644
--- a/website/documentation/content/user_documentation.py
+++ b/website/documentation/content/user_documentation.py
@@ -156,6 +156,32 @@ def upload_table():
             ''')
 
 
+doc.text('Autocomplete', '''
+    The `UserInteraction` object returned by `user.find(...)` provides methods to trigger events on the found elements.
+    This demo shows how to trigger a "keydown.tab" event to autocomplete an input field.
+''')
+
+
+@doc.ui
+def trigger_events():
+    with ui.row().classes('gap-4 items-stretch'):
+        with python_window(classes='w-[500px]', title='some UI code'):
+            ui.markdown('''
+                ```python
+                fruits = ['apple', 'banana', 'cherry']
+                ui.input(label='fruit', autocomplete=fruits)
+                ```
+            ''')
+        with python_window(classes='w-[500px]', title='user assertions'):
+            ui.markdown('''
+                ```python
+                await user.open('/')
+                user.find('fruit').type('a').trigger('keydown.tab')
+                await user.should_see('apple')
+                ```
+            ''')
+
+
 doc.text('Test Downloads', '''
     You can verify that a download was triggered by checking `user.downloads.http_responses`.
     By awaiting `user.downloads.next()` you can get the next download response.


Test patch:
diff --git a/nicegui/testing/user_interaction.py b/nicegui/testing/user_interaction.py
index 2801cd7e..469011e3 100644
--- a/nicegui/testing/user_interaction.py
+++ b/nicegui/testing/user_interaction.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Generic, Set, Type, TypeVar, Union
+from typing import TYPE_CHECKING, Generic, List, Set, Type, TypeVar, Union
 
 from typing_extensions import Self
 
@@ -36,6 +36,13 @@ class UserInteraction(Generic[T]):
         assert self.user.client
         with self.user.client:
             for element in self.elements:
+                if isinstance(element, ui.input) and event == 'keydown.tab':
+                    autocomplete: List[str] = element.props['_autocomplete']
+                    for option in autocomplete:
+                        if option.startswith(element.value):
+                            element.value = option
+                            break
+
                 for listener in element._event_listeners.values():  # pylint: disable=protected-access
                     if listener.type != event:
                         continue
diff --git a/tests/test_input.py b/tests/test_input.py
index c54db2d3..94cabbc9 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/tests/test_user_simulation.py b/tests/test_user_simulation.py
index e79569a0..5fbc30a8 100644
--- a/tests/test_user_simulation.py
+++ b/tests/test_user_simulation.py
@@ -410,3 +410,12 @@ async def test_validation(user: User) -> None:
     await user.should_not_see('Not a number')
     user.find(ui.input).type('some invalid entry')
     await user.should_see('Not a number')
+
+
+async def test_trigger_autocomplete(user: User) -> None:
+    ui.input(label='fruit', autocomplete=['apple', 'banana', 'cherry'])
+
+    await user.open('/')
+    await user.should_not_see('apple')
+    user.find('fruit').type('a').trigger('keydown.tab')
+    await user.should_see('apple')