Skip to content

Commit e454b95

Browse files
feat: warn if task is being called from a component
This is a footgun, and we should warn the user about it. Although task does kind of work from a component, it will lead to unexpected behavior, (e.g. the task will not be re-run when the task finishes, or errors). So we now give a useful message to educate the user about this. Also, you can opt-out of this warning by passing check_for_render_context=False. By default we also skip the warning if the task is called from a use_memo function (since that is good behavior).
1 parent dc6f9f3 commit e454b95

File tree

3 files changed

+99
-1
lines changed

3 files changed

+99
-1
lines changed

solara/tasks.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import logging
88
import threading
9+
import warnings
910
from enum import Enum
1011
import typing
1112
from typing import (
@@ -536,6 +537,7 @@ def task(
536537
f: None = None,
537538
*,
538539
prefer_threaded: bool = ...,
540+
check_for_render_context: bool = ...,
539541
) -> Callable[[Callable[P, R]], Task[P, R]]: ...
540542

541543

@@ -544,13 +546,15 @@ def task(
544546
f: Callable[P, Union[Coroutine[Any, Any, R], R]],
545547
*,
546548
prefer_threaded: bool = ...,
549+
check_for_render_context: bool = ...,
547550
) -> Task[P, R]: ...
548551

549552

550553
def task(
551554
f: Union[None, Callable[P, Union[Coroutine[Any, Any, R], R]]] = None,
552555
*,
553556
prefer_threaded: bool = True,
557+
check_for_render_context: bool = True,
554558
) -> Union[Callable[[Callable[P, R]], Task[P, R]], Task[P, R]]:
555559
"""Decorator to turn a function or coroutine function into a task.
556560
@@ -764,12 +768,76 @@ def Page():
764768
This ensures that even when a coroutine functions calls a blocking function the UI is still responsive.
765769
On platform where threads are not supported (like Pyodide / WASM / Emscripten / PyScript), a coroutine
766770
function will always run in the current event loop.
771+
- `check_for_render_context` - bool: If true, we will check if we are in a render context, and if so, we will
772+
warn you that you should probably be using `use_task` instead of `task`.
767773
768774
```
769775
770776
"""
771777

778+
def check_if_we_should_use_use_task():
779+
import reacton.core
780+
781+
in_reacton_context = reacton.core.get_render_context(required=False) is not None
782+
if not in_reacton_context:
783+
# We are not in a reacton context, so we should not (and cannot) use use_task
784+
return
785+
from .toestand import _find_outside_solara_frame
786+
787+
frame = _find_outside_solara_frame()
788+
if frame is None:
789+
# We cannot determine which frame we are in, just skip this check
790+
return
791+
import inspect
792+
793+
tb = inspect.getframeinfo(frame)
794+
msg = """You are calling task(...) from a component, while you should probably be using use_task.
795+
796+
Reason:
797+
- task(...) creates a new task object on every render, and should only be used outside of a component.
798+
- use_task(...) returns the same task object on every render, and should be used inside a component.
799+
800+
Example:
801+
@solara.component
802+
def Page():
803+
@task # This is wrong, this creates a new task object on every render
804+
def my_task():
805+
...
806+
807+
Instead, you should do:
808+
@solara.component
809+
def Page():
810+
@use_task
811+
def my_task():
812+
...
813+
814+
"""
815+
if tb:
816+
if tb.code_context:
817+
code = tb.code_context[0]
818+
else:
819+
code = "<No code context available>"
820+
msg += f"This warning was triggered from:\n{tb.filename}:{tb.lineno}\n{code.strip()}"
821+
822+
# Check if the call is within a use_memo context by inspecting the call stack
823+
if frame:
824+
caller_frame = frame.f_back
825+
# Check a few frames up the stack (e.g., up to 5) for 'use_memo'
826+
for _ in range(5):
827+
if caller_frame is None:
828+
break
829+
func_name = caller_frame.f_code.co_name
830+
module_name = caller_frame.f_globals.get("__name__", "")
831+
if func_name == "use_memo" and (module_name.startswith("solara.") or module_name.startswith("reacton.")):
832+
# We are in a use_memo (or a context that should not trigger the warning)
833+
return
834+
caller_frame = caller_frame.f_back
835+
836+
warnings.warn(msg)
837+
772838
def wrapper(f: Union[None, Callable[P, Union[Coroutine[Any, Any, R], R]]]) -> Task[P, R]:
839+
if check_for_render_context:
840+
check_if_we_should_use_use_task()
773841
# we use wraps to make the key of the reactive variable more unique
774842
# and less likely to mixup during hot reloads
775843
assert f is not None
@@ -919,7 +987,7 @@ async def square():
919987

920988
def wrapper(f):
921989
def create_task() -> "Task[[], R]":
922-
return task(f, prefer_threaded=prefer_threaded)
990+
return task(f, prefer_threaded=prefer_threaded, check_for_render_context=False)
923991

924992
task_instance = solara.use_memo(create_task, dependencies=[])
925993
# we always update the function so we do not have stale data in the function

solara/toestand.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ def _is_internal_module(file_name: str):
343343
return (
344344
file_name_parts[-2:] == ["solara", "toestand.py"]
345345
or file_name_parts[-2:] == ["solara", "reactive.py"]
346+
or file_name_parts[-2:] == ["solara", "tasks.py"]
346347
or file_name_parts[-2:] == ["solara", "_stores.py"]
347348
or file_name_parts[-3:] == ["solara", "hooks", "use_reactive.py"]
348349
or file_name_parts[-2:] == ["reacton", "core.py"]

tests/unit/task_test.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import time
3+
import warnings
34

45
import ipyvuetify as v
56
import pytest
@@ -652,3 +653,31 @@ def Test():
652653

653654
box, rc = solara.render(Test(), handle_error=False)
654655
rc.find(children=["value = 20"]).wait_for(timeout=3)
656+
657+
658+
def test_task_decorator_warning_in_component():
659+
with pytest.warns(UserWarning, match=r"You are calling task.*"):
660+
661+
@solara.component
662+
def ComponentWithTask():
663+
@solara.tasks.task
664+
def my_task_in_component():
665+
return "done"
666+
667+
return solara.Text("ComponentWithTask")
668+
669+
solara.render(ComponentWithTask(), handle_error=False)
670+
671+
# Test that no warning is issued when task is used with use_memo
672+
with warnings.catch_warnings():
673+
warnings.simplefilter("error") # Treat all warnings as errors
674+
675+
@solara.component
676+
def ComponentWithTaskInMemo():
677+
def my_job_for_memo():
678+
return "done"
679+
680+
solara.use_memo(lambda: solara.tasks.task(my_job_for_memo), dependencies=[])
681+
return solara.Text("ComponentWithTaskInMemo")
682+
683+
solara.render_fixed(ComponentWithTaskInMemo(), handle_error=False)

0 commit comments

Comments
 (0)