Przeglądaj źródła

Pass instance from target, if `ui.state` is refreshing (#4691)

This PR fixes #4690 on the case of `ui.state` refreshing the wrong
`ui.refreshable` instance, when there are multiple instances, ~~but
introduces a new issue that now `clock1.time.refresh` refreshes
whoever's `ui.state` was last refreshed, requiring
`clock1.time.refresh(_instance=clock1)` (printing "!!!Refresh called
without _instance, while self.instance is not None (culprit of Local
Scope B problem)")~~ Resolved.

Pass instance from target, if `ui.state` is refreshing, or else
`_instance=self.instance` takes precedence in:

```py
    def __getattribute__(self, __name: str) -> Any:
        attribute = object.__getattribute__(self, __name)
        if __name == 'refresh':
            def refresh(*args: Any, _instance=self.instance, **kwargs: Any) -> None:
                self.instance = _instance
                attribute(*args, **kwargs)
            return refresh
        return attribute
```

This PR would not affect Global scope, Local scope A and C, since for
those, `def __get__(self, instance, _) -> Self:` was never ran, and
`self.instance` is always `None`.

Test script: 

```py
from datetime import datetime
import random
from nicegui import ui
GLOBALCOUNT = 0


def random_string():
    return str(random.random())[:9]


def time_primitive(refresh_message=""):
    global GLOBALCOUNT
    rand, set_rand = ui.state(random_string())
    with ui.row():
        now_time = datetime.now()
        ui.label(f'#{GLOBALCOUNT}').classes('font-mono')
        ui.label(f'Time: {now_time}').classes('font-mono')
        ui.label(f'Rand: {rand}').classes('font-mono')
        ui.button('Set Rand', on_click=lambda: set_rand(random_string()))
    print(GLOBALCOUNT, 'time_primitive', refresh_message, now_time, rand)
    GLOBALCOUNT += 1


class Clock:
    def __init__(self, refresh_message=""):
        self.refresh_message = refresh_message

    @ui.refreshable_method
    def time(self):
        time_primitive(self.refresh_message)


@ui.page('/local_refreshable_b')
def demo():
    ui.label('Local refreshable and State demo B').classes('text-2xl')
    clock1 = Clock("clock1")
    clock1.time()
    with ui.row():
        ui.label(str(clock1))
        ui.button('Refresh', on_click=clock1.time.refresh)
    clock2 = Clock("clock2")
    clock2.time()
    with ui.row():
        ui.label(str(clock2))
        ui.button('Refresh', on_click=clock2.time.refresh)
    ui.button('print empty line in output', on_click=lambda: print(''))


ui.link('Local refreshable B', '/local_refreshable_b')

ui.run(port=9000)
```

If you want to see how I came up with the solution, here is the
`refreshable.py` with probably the most aggressive print debugging
you'll ever see.

<details>

<summary>`refreshable.py`</summary>

```py
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, ClassVar, Dict, Generic, List, Optional, Tuple, TypeVar, cast

from typing_extensions import Concatenate, ParamSpec, Self

from .. import background_tasks, core
from ..client import Client
from ..dataclasses import KWONLY_SLOTS
from ..element import Element
from ..helpers import is_coroutine_function

_S = TypeVar('_S')
_T = TypeVar('_T')
_P = ParamSpec('_P')


@dataclass(**KWONLY_SLOTS)
class RefreshableTarget:
    container: RefreshableContainer
    refreshable: refreshable
    instance: Any
    args: Tuple[Any, ...]
    kwargs: Dict[str, Any]

    current_target: ClassVar[Optional[RefreshableTarget]] = None
    locals: List[Any] = field(default_factory=list)
    next_index: int = 0

    def run(self, func: Callable[..., _T]) -> _T:
        """Run the function and return the result."""
        RefreshableTarget.current_target = self
        self.next_index = 0
        # pylint: disable=no-else-return
        if is_coroutine_function(func):
            async def wait_for_result() -> Any:
                with self.container:
                    if self.instance is None:
                        result = func(*self.args, **self.kwargs)
                    else:
                        result = func(self.instance, *self.args, **self.kwargs)
                    assert isinstance(result, Awaitable)
                    return await result
            return wait_for_result()  # type: ignore
        else:
            with self.container:
                if self.instance is None:
                    return func(*self.args, **self.kwargs)
                else:
                    return func(self.instance, *self.args, **self.kwargs)


class RefreshableContainer(Element, component='refreshable.js'):
    pass


class refreshable(Generic[_P, _T]):

    def __init__(self, func: Callable[_P, _T]) -> None:
        """Refreshable UI functions

        The ``@ui.refreshable`` decorator allows you to create functions that have a ``refresh`` method.
        This method will automatically delete all elements created by the function and recreate them.

        For decorating refreshable methods in classes, there is a ``@ui.refreshable_method`` decorator,
        which is equivalent but prevents static type checking errors.
        """
        self.func = func
        self.instance = None
        self.targets: List[RefreshableTarget] = []

    def __get__(self, instance, _) -> Self:
        print()
        print(self)
        print('__get__ ', self.instance if self.instance else "None".ljust(len(str(instance))),
              '->', instance, 'SAME' if self.instance == instance else 'DIFF')
        self.instance = instance
        return self

    def __getattribute__(self, __name: str) -> Any:
        attribute = object.__getattribute__(self, __name)
        if __name == 'refresh':
            CAPTUREVAL = self.instance
            print("Defined CAPTUREVAL as", CAPTUREVAL)
            def refresh(*args: Any, _instance="DIDNOTPASSAVALUE", **kwargs: Any) -> None:
                print()
                if _instance == "DIDNOTPASSAVALUE"
                    _instance = CAPTUREVAL
                    print('!!!Refresh called without _instance, while self.instance is not None (culprit of Local Scope B problem)')
                print(self)
                print('__geta__', self.instance if self.instance else "None".ljust(len(str(_instance))), '->', _instance,
                      'SAME' if self.instance == _instance else 'DIFF')
                self.instance = _instance
                attribute(*args, **kwargs)
            return refresh
        return attribute

    def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T:
        self.prune()
        target = RefreshableTarget(container=RefreshableContainer(), refreshable=self, instance=self.instance,
                                   args=args, kwargs=kwargs)
        self.targets.append(target)
        return target.run(self.func)

    def refresh(self, *args: Any, **kwargs: Any) -> None:
        """Refresh the UI elements created by this function.

        This method accepts the same arguments as the function itself or a subset of them.
        It will combine the arguments passed to the function with the arguments passed to this method.
        """
        self.prune()
        for target in self.targets:
            print()
            print(self)
            print('Chk Tgt ', target.instance, '==' if target.instance == self.instance else '!=', self.instance)
            if target.instance != self.instance:
                continue
            target.container.clear()
            target.args = args or target.args
            target.kwargs.update(kwargs)
            try:
                result = target.run(self.func)
            except TypeError as e:
                if 'got multiple values for argument' in str(e):
                    function = str(e).split()[0].split('.')[-1]
                    parameter = str(e).split()[-1]
                    raise TypeError(f'{parameter} needs to be consistently passed to {function} '
                                    'either as positional or as keyword argument') from e
                raise
            if is_coroutine_function(self.func):
                assert isinstance(result, Awaitable)
                if core.loop and core.loop.is_running():
                    background_tasks.create(result, name=f'refresh {self.func.__name__}')
                else:
                    core.app.on_startup(result)

    def prune(self) -> None:
        """Remove all targets that are no longer on a page with a client connection.

        This method is called automatically before each refresh.
        """
        self.targets = [
            target
            for target in self.targets
            if target.container.client.id in Client.instances and target.container.id in target.container.client.elements
        ]


class refreshable_method(Generic[_S, _P, _T], refreshable[_P, _T]):

    def __init__(self, func: Callable[Concatenate[_S, _P], _T]) -> None:
        """Refreshable UI methods

        The `@ui.refreshable_method` decorator allows you to create methods that have a `refresh` method.
        This method will automatically delete all elements created by the function and recreate them.
        """
        print('##### refreshable_method', self, func)
        super().__init__(func)  # type: ignore


def state(value: Any) -> Tuple[Any, Callable[[Any], None]]:
    """Create a state variable that automatically updates its refreshable UI container.

    :param value: The initial value of the state variable.

    :return: A tuple containing the current value and a function to update the value.
    """
    target = cast(RefreshableTarget, RefreshableTarget.current_target)

    print("Target at build time", target, target.instance)

    if target.next_index >= len(target.locals):
        target.locals.append(value)
    else:
        value = target.locals[target.next_index]

    def set_value(new_value: Any, index=target.next_index) -> None:
        if target.locals[index] == new_value:
            return
        target.locals[index] = new_value
        print('__geta__ from here???')
        target.refreshable.refresh(_instance=target.instance)

    target.next_index += 1

    return value, set_value
```

</details> 

Notice the page loads like this: 

```
##### refreshable_method <nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30> <function Clock.time at 0x000001EB8988C180>
NiceGUI ready to go on ...

<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__  None                                             -> <__mp_main__.Clock object at 0x000001EB899E3320> DIFF
Target at build time RefreshableTarget(container=<nicegui.functions.refreshable.RefreshableContainer object at 0x000001EB898C72C0>, refreshable=<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>, instance=<__mp_main__.Clock object at 0x000001EB899E3320>, args=(), kwargs={}, locals=[], next_index=0) <__mp_main__.Clock object at 0x000001EB899E3320>
0 time_primitive clock1 2025-05-03 14:45:35.170569 0.6963193

<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__  <__mp_main__.Clock object at 0x000001EB899E3320> -> <__mp_main__.Clock object at 0x000001EB899E3320> SAME

<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__  <__mp_main__.Clock object at 0x000001EB899E3320> -> <__mp_main__.Clock object at 0x000001EB899E3F50> DIFF
Target at build time RefreshableTarget(container=<nicegui.functions.refreshable.RefreshableContainer object at 0x000001EB899E3EF0>, refreshable=<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>, instance=<__mp_main__.Clock object at 0x000001EB899E3F50>, args=(), kwargs={}, locals=[], next_index=0) <__mp_main__.Clock object at 0x000001EB899E3F50>
1 time_primitive clock2 2025-05-03 14:45:35.171835 0.0067774

<nicegui.functions.refreshable.refreshable_method object at 0x000001EB8984FB30>
__get__  <__mp_main__.Clock object at 0x000001EB899E3F50> -> <__mp_main__.Clock object at 0x000001EB899E3F50> SAME
```

So you see how `__get__` overwrote the target, but each state got it's
own copy of `RefreshableTarget` which it can leverage.

Revert `target.refreshable.refresh(_instance=target.instance)` back to
`target.refreshable.refresh()` and you will see "!!!Refresh called
without _instance, while self.instance is not None (culprit of Local
Scope B problem)" in the console, because (1) `__get__` was never
called, and (2) did not pass in `_instance`, so it just uses whatever
target was set, which is wrong.
Evan Chan 1 tydzień temu
rodzic
commit
291d2919a1
1 zmienionych plików z 1 dodań i 1 usunięć
  1. 1 1
      nicegui/functions/refreshable.py

+ 1 - 1
nicegui/functions/refreshable.py

@@ -160,7 +160,7 @@ def state(value: Any) -> Tuple[Any, Callable[[Any], None]]:
         if target.locals[index] == new_value:
             return
         target.locals[index] = new_value
-        target.refreshable.refresh()
+        target.refreshable.refresh(_instance=target.instance)
 
     target.next_index += 1