diff --git a/solara/hooks/__init__.py b/solara/hooks/__init__.py index 726bdc079..ffc833bf4 100644 --- a/solara/hooks/__init__.py +++ b/solara/hooks/__init__.py @@ -1,2 +1,4 @@ from .dataframe import * # noqa: #F401 F403 from .misc import * # noqa: #F401 F403 +from .use_reactive import use_reactive # noqa: #F401 F403 +from .use_thread import use_thread # noqa: #F401 F403 diff --git a/solara/hooks/misc.py b/solara/hooks/misc.py index d71d9b977..817d15fdb 100644 --- a/solara/hooks/misc.py +++ b/solara/hooks/misc.py @@ -1,32 +1,24 @@ import contextlib import dataclasses -import functools -import inspect import io import json import logging import os -import sys # import tempfile import threading import time import urllib.request import uuid -from typing import IO, Any, Callable, Iterator, Optional, Tuple, TypeVar, Union, cast - -import reacton +from typing import IO, Any, Callable, Tuple, TypeVar, Union, cast import solara -from solara.datatypes import FileContentResult, Result, ResultState - -SOLARA_ALLOW_OTHER_TRACER = os.environ.get("SOLARA_ALLOW_OTHER_TRACER", False) in (True, "True", "true", "1") +from solara.datatypes import FileContentResult, Result logger = logging.getLogger("react-ipywidgets.extra.hooks") chunk_size_default = 1024**2 __all__ = [ - "use_thread", "use_download", "use_fetch", "use_json_load", @@ -36,7 +28,6 @@ "use_unique_key", "use_state_or_update", "use_previous", - "use_reactive", ] T = TypeVar("T") U = TypeVar("U") @@ -56,12 +47,6 @@ def __exit__(self, *excinfo): pass -# inherit from BaseException so less change of being caught -# in an except -class CancelledError(BaseException): - pass - - def use_retry(*actions: Callable[[], Any]): counter, set_counter = solara.use_state(0) @@ -73,155 +58,11 @@ def retry(): return counter, retry -def use_thread( - callback=Union[ - Callable[[threading.Event], T], - Iterator[Callable[[threading.Event], T]], - Callable[[], T], - Iterator[Callable[[], T]], - ], - dependencies=[], - intrusive_cancel=True, -) -> Result[T]: - def make_event(*_ignore_dependencies): - return threading.Event() - - def make_lock(): - return threading.Lock() - - lock: threading.Lock = solara.use_memo(make_lock, []) - updater = use_force_update() - result_state, set_result_state = solara.use_state(ResultState.INITIAL) - error = solara.use_ref(cast(Optional[Exception], None)) - result = solara.use_ref(cast(Optional[T], None)) - running_thread = solara.use_ref(cast(Optional[threading.Thread], None)) - counter, retry = use_retry() - cancel: threading.Event = solara.use_memo(make_event, [*dependencies, counter]) - - @contextlib.contextmanager - def cancel_guard(): - if not intrusive_cancel: - yield - return - - def tracefunc(frame, event, arg): - # this gets called at least for every line executed - if cancel.is_set(): - rc = reacton.core._get_render_context(required=False) - # we do not want to cancel the rendering cycle - if rc is None or not rc._is_rendering: - # this will bubble up - raise CancelledError() - if prev and SOLARA_ALLOW_OTHER_TRACER: - prev(frame, event, arg) - # keep tracing: - return tracefunc - - # see https://docs.python.org/3/library/sys.html#sys.settrace - # it is for the calling thread only - # not every Python implementation has it - prev = None - if hasattr(sys, "gettrace"): - prev = sys.gettrace() - if hasattr(sys, "settrace"): - sys.settrace(tracefunc) - try: - yield - finally: - if hasattr(sys, "settrace"): - sys.settrace(prev) - - def run(): - set_result_state(ResultState.STARTING) - - def runner(): - wait_for_thread = None - with lock: - # if there is a current thread already, we'll need - # to wait for it. copy the ref, and set ourselves - # as the current one - if running_thread.current: - wait_for_thread = running_thread.current - running_thread.current = threading.current_thread() - if wait_for_thread is not None: - set_result_state(ResultState.WAITING) - # don't start before the previous is stopped - try: - wait_for_thread.join() - except: # noqa - pass - if threading.current_thread() != running_thread.current: - # in case a new thread was started that also was waiting for the previous - # thread to st stop, we can finish this - return - # we previously set current to None, but if we do not do that, we can still render the old value - # while we can still show a loading indicator using the .state - # result.current = None - set_result_state(ResultState.RUNNING) - - sig = inspect.signature(callback) - if sig.parameters: - f = functools.partial(callback, cancel) - else: - f = callback - try: - try: - # we only use the cancel_guard context manager around - # the function calls to f. We don't want to guard around - # a call to react, since that might slow down rendering - # during rendering - with cancel_guard(): - value = f() - if inspect.isgenerator(value): - while True: - try: - with cancel_guard(): - result.current = next(value) - error.current = None - except StopIteration: - break - # assigning to the ref doesn't trigger a rerender, so do it manually - updater() - if threading.current_thread() == running_thread.current: - set_result_state(ResultState.FINISHED) - else: - result.current = value - error.current = None - if threading.current_thread() == running_thread.current: - set_result_state(ResultState.FINISHED) - except Exception as e: - error.current = e - if threading.current_thread() == running_thread.current: - logger.exception(e) - set_result_state(ResultState.ERROR) - return - except CancelledError: - pass - # this means this thread is cancelled not be request, but because - # a new thread is running, we can ignore this - finally: - if threading.current_thread() == running_thread.current: - running_thread.current = None - logger.info("thread done!") - if cancel.is_set(): - set_result_state(ResultState.CANCELLED) - - logger.info("starting thread: %r", runner) - thread = threading.Thread(target=runner, daemon=True) - thread.start() - - def cleanup(): - cancel.set() # cleanup for use effect - - return cleanup - - solara.use_side_effect(run, dependencies + [counter]) - return Result[T](value=result.current, error=error.current, state=result_state, cancel=cancel.set, _retry=retry) - - def use_download( f: MaybeResult[Union[str, os.PathLike, IO]], url, expected_size=None, delay=None, return_content=False, chunk_size=chunk_size_default ) -> Result: + from .use_thread import use_thread + if not isinstance(f, Result): f = Result(value=f) assert isinstance(f, Result) @@ -299,6 +140,8 @@ def ensure_result(input: MaybeResult[T]) -> Result[T]: def make_use_thread(f: Callable[[T], U]): + from .use_thread import use_thread + def use_result(input: MaybeResult[T]) -> Result[U]: input_result = ensure_result(input) @@ -402,106 +245,3 @@ def assign(): solara.use_effect(assign, [value]) return ref.current - - -def use_reactive( - value: Union[T, solara.Reactive[T]], - on_change: Optional[Callable[[T], None]] = None, -) -> solara.Reactive[T]: - """Creates a reactive variable with the a local component scope. - - It is a useful alternative to `use_state` when you want to use a - reactive variable for the component state. - See also [our documentation on state management](/docs/fundamentals/state-management). - - If the variable passed is a reactive variable, it will be returned instead and no - new reactive variable will be created. This is useful for implementing component - that accept either a reactive variable or a normal value along with an optional `on_change` - callback. - - ## Arguments: - - * value (Union[T, solara.Reactive[T]]): The value of the - reactive variable. If a reactive variable is provided, it will be - used directly. Otherwise, a new reactive variable will be created - with the provided initial value. If the argument passed changes - the reactive variable will be updated. - - * on_change (Optional[Callable[[T], None]]): An optional callback function - that will be called when the reactive variable's value changes. - - Returns: - solara.Reactive[T]: A reactive variable with the specified initial value - or the provided reactive variable. - - ## Examples - - ### Replacement for use_state - ```solara - import solara - - @solara.component - def ReusableComponent(): - color = solara.use_reactive("red") # another possibility - solara.Select(label="Color",values=["red", "green", "blue", "orange"], - value=color) - solara.Markdown("### Solara is awesome", style={"color": color.value}) - - @solara.component - def Page(): - # this component is used twice, but each instance has its own state - ReusableComponent() - ReusableComponent() - - ``` - - ### Flexible arguments - - The `MyComponent` component can be passed a reactive variable or a normal - Python variable and a `on_value` callback. - - ```python - import solara - from typing import Union, Optional, Callable - - @solara.component - def MyComponent(value: Union[T, solara.Reactive[T]], - on_value: Optional[Callable[[T], None]] = None, - ): - reactive_value = solara.use_reactive(value, on_value_change) - # Use the `reactive_value` in the component - ``` - """ - - on_change_ref = solara.use_ref(on_change) - on_change_ref.current = on_change - - def create(): - if not isinstance(value, solara.Reactive): - return solara.reactive(value) - - reactive_value = solara.use_memo(create, dependencies=[]) - if isinstance(value, solara.Reactive): - reactive_value = value - assert reactive_value is not None - updating = solara.use_ref(False) - - def forward_on_change(): - def forward(value): - if on_change_ref.current and not updating.current: - on_change_ref.current(value) - - return reactive_value.subscribe(forward) - - def update(): - updating.current = True - try: - if not isinstance(value, solara.Reactive): - reactive_value.value = value - finally: - updating.current = False - - solara.use_memo(update, [value]) - solara.use_effect(forward_on_change, []) - - return reactive_value diff --git a/solara/hooks/use_reactive.py b/solara/hooks/use_reactive.py new file mode 100644 index 000000000..f67bb16f1 --- /dev/null +++ b/solara/hooks/use_reactive.py @@ -0,0 +1,108 @@ +from typing import Callable, Optional, TypeVar, Union + +import solara + +T = TypeVar("T") + + +def use_reactive( + value: Union[T, solara.Reactive[T]], + on_change: Optional[Callable[[T], None]] = None, +) -> solara.Reactive[T]: + """Creates a reactive variable with the a local component scope. + + It is a useful alternative to `use_state` when you want to use a + reactive variable for the component state. + See also [our documentation on state management](/docs/fundamentals/state-management). + + If the variable passed is a reactive variable, it will be returned instead and no + new reactive variable will be created. This is useful for implementing component + that accept either a reactive variable or a normal value along with an optional `on_change` + callback. + + ## Arguments: + + * value (Union[T, solara.Reactive[T]]): The value of the + reactive variable. If a reactive variable is provided, it will be + used directly. Otherwise, a new reactive variable will be created + with the provided initial value. If the argument passed changes + the reactive variable will be updated. + + * on_change (Optional[Callable[[T], None]]): An optional callback function + that will be called when the reactive variable's value changes. + + Returns: + solara.Reactive[T]: A reactive variable with the specified initial value + or the provided reactive variable. + + ## Examples + + ### Replacement for use_state + ```solara + import solara + + @solara.component + def ReusableComponent(): + color = solara.use_reactive("red") # another possibility + solara.Select(label="Color",values=["red", "green", "blue", "orange"], + value=color) + solara.Markdown("### Solara is awesome", style={"color": color.value}) + + @solara.component + def Page(): + # this component is used twice, but each instance has its own state + ReusableComponent() + ReusableComponent() + + ``` + + ### Flexible arguments + + The `MyComponent` component can be passed a reactive variable or a normal + Python variable and a `on_value` callback. + + ```python + import solara + from typing import Union, Optional, Callable + + @solara.component + def MyComponent(value: Union[T, solara.Reactive[T]], + on_value: Optional[Callable[[T], None]] = None, + ): + reactive_value = solara.use_reactive(value, on_value_change) + # Use the `reactive_value` in the component + ``` + """ + + on_change_ref = solara.use_ref(on_change) + on_change_ref.current = on_change + + def create(): + if not isinstance(value, solara.Reactive): + return solara.reactive(value) + + reactive_value = solara.use_memo(create, dependencies=[]) + if isinstance(value, solara.Reactive): + reactive_value = value + assert reactive_value is not None + updating = solara.use_ref(False) + + def forward_on_change(): + def forward(value): + if on_change_ref.current and not updating.current: + on_change_ref.current(value) + + return reactive_value.subscribe(forward) + + def update(): + updating.current = True + try: + if not isinstance(value, solara.Reactive): + reactive_value.value = value + finally: + updating.current = False + + solara.use_memo(update, [value]) + solara.use_effect(forward_on_change, []) + + return reactive_value diff --git a/solara/hooks/use_thread.py b/solara/hooks/use_thread.py new file mode 100644 index 000000000..034f197de --- /dev/null +++ b/solara/hooks/use_thread.py @@ -0,0 +1,171 @@ +import contextlib +import functools +import inspect +import logging +import os +import sys +import threading +from typing import Callable, Iterator, Optional, TypeVar, Union, cast + +import reacton + +import solara +from solara.datatypes import Result, ResultState + +SOLARA_ALLOW_OTHER_TRACER = os.environ.get("SOLARA_ALLOW_OTHER_TRACER", False) in (True, "True", "true", "1") +T = TypeVar("T") +logger = logging.getLogger("solara.hooks.use_thread") + + +# inherit from BaseException so less change of being caught +# in an except +class CancelledError(BaseException): + pass + + +def use_thread( + callback=Union[ + Callable[[threading.Event], T], + Iterator[Callable[[threading.Event], T]], + Callable[[], T], + Iterator[Callable[[], T]], + ], + dependencies=[], + intrusive_cancel=True, +) -> Result[T]: + from .misc import use_force_update, use_retry + + def make_event(*_ignore_dependencies): + return threading.Event() + + def make_lock(): + return threading.Lock() + + lock: threading.Lock = solara.use_memo(make_lock, []) + updater = use_force_update() + result_state, set_result_state = solara.use_state(ResultState.INITIAL) + error = solara.use_ref(cast(Optional[Exception], None)) + result = solara.use_ref(cast(Optional[T], None)) + running_thread = solara.use_ref(cast(Optional[threading.Thread], None)) + counter, retry = use_retry() + cancel: threading.Event = solara.use_memo(make_event, [*dependencies, counter]) + + @contextlib.contextmanager + def cancel_guard(): + if not intrusive_cancel: + yield + return + + def tracefunc(frame, event, arg): + # this gets called at least for every line executed + if cancel.is_set(): + rc = reacton.core._get_render_context(required=False) + # we do not want to cancel the rendering cycle + if rc is None or not rc._is_rendering: + # this will bubble up + raise CancelledError() + if prev and SOLARA_ALLOW_OTHER_TRACER: + prev(frame, event, arg) + # keep tracing: + return tracefunc + + # see https://docs.python.org/3/library/sys.html#sys.settrace + # it is for the calling thread only + # not every Python implementation has it + prev = None + if hasattr(sys, "gettrace"): + prev = sys.gettrace() + if hasattr(sys, "settrace"): + sys.settrace(tracefunc) + try: + yield + finally: + if hasattr(sys, "settrace"): + sys.settrace(prev) + + def run(): + set_result_state(ResultState.STARTING) + + def runner(): + wait_for_thread = None + with lock: + # if there is a current thread already, we'll need + # to wait for it. copy the ref, and set ourselves + # as the current one + if running_thread.current: + wait_for_thread = running_thread.current + running_thread.current = threading.current_thread() + if wait_for_thread is not None: + set_result_state(ResultState.WAITING) + # don't start before the previous is stopped + try: + wait_for_thread.join() + except: # noqa + pass + if threading.current_thread() != running_thread.current: + # in case a new thread was started that also was waiting for the previous + # thread to st stop, we can finish this + return + # we previously set current to None, but if we do not do that, we can still render the old value + # while we can still show a loading indicator using the .state + # result.current = None + set_result_state(ResultState.RUNNING) + + sig = inspect.signature(callback) + if sig.parameters: + f = functools.partial(callback, cancel) + else: + f = callback + try: + try: + # we only use the cancel_guard context manager around + # the function calls to f. We don't want to guard around + # a call to react, since that might slow down rendering + # during rendering + with cancel_guard(): + value = f() + if inspect.isgenerator(value): + while True: + try: + with cancel_guard(): + result.current = next(value) + error.current = None + except StopIteration: + break + # assigning to the ref doesn't trigger a rerender, so do it manually + updater() + if threading.current_thread() == running_thread.current: + set_result_state(ResultState.FINISHED) + else: + result.current = value + error.current = None + if threading.current_thread() == running_thread.current: + set_result_state(ResultState.FINISHED) + except Exception as e: + error.current = e + if threading.current_thread() == running_thread.current: + logger.exception(e) + set_result_state(ResultState.ERROR) + return + except CancelledError: + pass + # this means this thread is cancelled not be request, but because + # a new thread is running, we can ignore this + finally: + if threading.current_thread() == running_thread.current: + running_thread.current = None + logger.info("thread done!") + if cancel.is_set(): + set_result_state(ResultState.CANCELLED) + + logger.info("starting thread: %r", runner) + thread = threading.Thread(target=runner, daemon=True) + thread.start() + + def cleanup(): + cancel.set() # cleanup for use effect + + return cleanup + + solara.use_side_effect(run, dependencies + [counter]) + return Result[T](value=result.current, error=error.current, state=result_state, cancel=cancel.set, _retry=retry) diff --git a/tests/unit/hooks_test.py b/tests/unit/hooks_test.py index 35f14affe..8648efa74 100644 --- a/tests/unit/hooks_test.py +++ b/tests/unit/hooks_test.py @@ -9,7 +9,7 @@ import solara from solara.datatypes import FileContentResult -from solara.hooks.misc import use_download, use_fetch, use_json, use_thread +from solara.hooks import use_download, use_fetch, use_json, use_thread from .common import busy_wait_compare