diff --git a/CHANGELOG.md b/CHANGELOG.md index c06aa5be5..3e7da2d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] - YYYY-MM-DD + +### Bug fixes + +* Fixed `input_task_button` not working in a Shiny module. (#1108) + + ## [0.7.1] - 2024-02-05 ### Bug fixes diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 6b089e721..17cdbbf21 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -55,6 +55,7 @@ def express_server(input: Inputs, output: Outputs, session: Session): except Exception: import traceback + # Starting in Python 3.10 this could be traceback.print_exception(e) traceback.print_exception(*sys.exc_info()) raise diff --git a/shiny/input_handler.py b/shiny/input_handler.py index 0b27fb806..5a45bba5c 100644 --- a/shiny/input_handler.py +++ b/shiny/input_handler.py @@ -10,9 +10,10 @@ if TYPE_CHECKING: from .session import Session +from .module import ResolvedId from .types import ActionButtonValue -InputHandlerType = Callable[[Any, str, "Session"], Any] +InputHandlerType = Callable[[Any, ResolvedId, "Session"], Any] class _InputHandlers(Dict[str, InputHandlerType]): @@ -31,7 +32,9 @@ def _(func: InputHandlerType): def remove(self, type: str) -> None: del self[type] - def _process_value(self, type: str, value: Any, name: str, session: Session) -> Any: + def _process_value( + self, type: str, value: Any, name: ResolvedId, session: Session + ) -> Any: handler = self.get(type) if handler is None: raise ValueError("No input handler registered for type: " + type) @@ -94,27 +97,31 @@ def _(value, name, session): @input_handlers.add("shiny.date") -def _(value: str | list[str], name: str, session: Session) -> date | tuple[date, date]: +def _( + value: str | list[str], name: ResolvedId, session: Session +) -> date | tuple[date, date]: if isinstance(value, str): return datetime.strptime(value, "%Y-%m-%d").date() - return tuple( # pyright: ignore[reportReturnType] - datetime.strptime(v, "%Y-%m-%d").date() for v in value + return ( + datetime.strptime(value[0], "%Y-%m-%d").date(), + datetime.strptime(value[1], "%Y-%m-%d").date(), ) @input_handlers.add("shiny.datetime") def _( - value: int | float | list[int] | list[float], name: str, session: Session + value: int | float | list[int] | list[float], name: ResolvedId, session: Session ) -> datetime | tuple[datetime, datetime]: if isinstance(value, (int, float)): return datetime.utcfromtimestamp(value) - return tuple( - datetime.utcfromtimestamp(v) for v in value # pyright: ignore[reportReturnType] + return ( + datetime.utcfromtimestamp(value[0]), + datetime.utcfromtimestamp(value[1]), ) @input_handlers.add("shiny.action") -def _(value: int, name: str, session: Session) -> ActionButtonValue: +def _(value: int, name: ResolvedId, session: Session) -> ActionButtonValue: # TODO: ActionButtonValue() class can probably be removed return ActionButtonValue(value) @@ -124,17 +131,17 @@ def _(value: int, name: str, session: Session) -> ActionButtonValue: @input_handlers.add("shiny.number") -def _(value: str, name: str, session: Session) -> str: +def _(value: str, name: ResolvedId, session: Session) -> str: return value # TODO: implement when we have bookmarking @input_handlers.add("shiny.password") -def _(value: str, name: str, session: Session) -> str: +def _(value: str, name: ResolvedId, session: Session) -> str: return value # TODO: implement when we have bookmarking @input_handlers.add("shiny.file") -def _(value: Any, name: str, session: Session) -> Any: +def _(value: Any, name: ResolvedId, session: Session) -> Any: return value diff --git a/shiny/module.py b/shiny/module.py index b3e0643a4..c4001b3b9 100644 --- a/shiny/module.py +++ b/shiny/module.py @@ -1,11 +1,17 @@ from __future__ import annotations -__all__ = ("current_namespace", "resolve_id", "ui", "server") +__all__ = ("current_namespace", "resolve_id", "ui", "server", "ResolvedId") from typing import TYPE_CHECKING, Callable, TypeVar from ._docstring import no_example -from ._namespaces import Id, current_namespace, namespace_context, resolve_id +from ._namespaces import ( + Id, + ResolvedId, + current_namespace, + namespace_context, + resolve_id, +) from ._typing_extensions import Concatenate, ParamSpec if TYPE_CHECKING: diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 5ba9bbd93..d10e749fa 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -11,6 +11,7 @@ import json import os import re +import sys import traceback import typing import urllib.parse @@ -213,9 +214,9 @@ def __init__( self._outbound_message_queues = OutBoundMessageQueues() - self._message_handlers: dict[ - str, Callable[..., Awaitable[object]] - ] = self._create_message_handlers() + self._message_handlers: dict[str, Callable[..., Awaitable[object]]] = ( + self._create_message_handlers() + ) self._file_upload_manager: FileUploadManager = FileUploadManager() self._on_ended_callbacks = _utils.AsyncCallbacks() self._has_run_session_end_tasks: bool = False @@ -334,6 +335,8 @@ def verify_state(expected_state: ConnectionState) -> None: ... except Exception as e: try: + # Starting in Python 3.10 this could be traceback.print_exception(e) + traceback.print_exception(*sys.exc_info()) self._send_error_response(str(e)) except Exception: pass @@ -351,7 +354,9 @@ def _manage_inputs(self, data: dict[str, object]) -> None: + key ) if len(keys) == 2: - val = input_handlers._process_value(keys[1], val, keys[0], self) + val = input_handlers._process_value( + keys[1], val, ResolvedId(keys[0]), self + ) # The keys[0] value is already a fully namespaced id; make that explicit by # wrapping it in ResolvedId, otherwise self.input will throw an id @@ -603,26 +608,22 @@ def _send_remove_ui(self, selector: str, multiple: bool) -> None: @overload def _send_progress( self, type: Literal["binding"], message: BindingProgressMessage - ) -> None: - ... + ) -> None: ... @overload def _send_progress( self, type: Literal["open"], message: OpenProgressMessage - ) -> None: - ... + ) -> None: ... @overload def _send_progress( self, type: Literal["close"], message: CloseProgressMessage - ) -> None: - ... + ) -> None: ... @overload def _send_progress( self, type: Literal["update"], message: UpdateProgressMessage - ) -> None: - ... + ) -> None: ... def _send_progress(self, type: str, message: object) -> None: msg: dict[str, object] = {"progress": {"type": type, "message": message}} @@ -1032,8 +1033,7 @@ def __init__( self._suspend_when_hidden = suspend_when_hidden @overload - def __call__(self, renderer: RendererT) -> RendererT: - ... + def __call__(self, renderer: RendererT) -> RendererT: ... @overload def __call__( @@ -1042,8 +1042,7 @@ def __call__( id: Optional[str] = None, suspend_when_hidden: bool = True, priority: int = 0, - ) -> Callable[[RendererT], RendererT]: - ... + ) -> Callable[[RendererT], RendererT]: ... def __call__( self, diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index c979876c1..e86f00507 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -158,7 +158,9 @@ def update_task_button( @input_handlers.add("bslib.taskbutton") -def _(value: dict[str, object], name: str, session: Session) -> ActionButtonValue: +def _( + value: dict[str, object], name: ResolvedId, session: Session +) -> ActionButtonValue: if value["autoReset"]: @session.on_flush @@ -166,7 +168,7 @@ def callback() -> None: # This is input_task_button's auto-reset feature: unless the button has # opted out using set_task_button_manual_reset(), we should reset after a # flush cycle where a bslib.taskbutton value is seen. - if ResolvedId(name) not in manual_task_reset_buttons: + if name not in manual_task_reset_buttons: update_task_button(name, state="ready", session=session) return ActionButtonValue(cast(int, value["value"])) @@ -1010,9 +1012,11 @@ def update_tooltip( drop_none( { "method": "update", - "title": require_active_session(session)._process_ui(TagList(*args)) - if len(args) > 0 - else None, + "title": ( + require_active_session(session)._process_ui(TagList(*args)) + if len(args) > 0 + else None + ), } ), ) @@ -1069,9 +1073,9 @@ def update_popover( drop_none( { "method": "update", - "content": session._process_ui(TagList(*args)) - if len(args) > 0 - else None, + "content": ( + session._process_ui(TagList(*args)) if len(args) > 0 else None + ), "header": session._process_ui(title) if title is not None else None, }, ), diff --git a/tests/playwright/shiny/inputs/input_task_button2/app.py b/tests/playwright/shiny/inputs/input_task_button2/app.py new file mode 100644 index 000000000..a89f51216 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_task_button2/app.py @@ -0,0 +1,36 @@ +import time + +from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui + + +@module.ui +def button_ui(): + return ui.TagList( + ui.input_task_button("btn", label="Go"), + ui.output_text("text_counter"), + ) + + +@module.server +def button_server(input: Inputs, output: Outputs, session: Session): + counter = reactive.Value(0) + + @render.text + def text_counter(): + return f"Button clicked {counter()} times" + + @reactive.effect + @reactive.event(input.btn) + def increment_counter(): + time.sleep(0.5) + counter.set(counter() + 1) + + +app_ui = ui.page_fluid(button_ui("mod1")) + + +def server(input: Inputs, output: Outputs, session: Session): + button_server("mod1") + + +app = App(app_ui, server) diff --git a/tests/playwright/shiny/inputs/input_task_button2/test_input_task_button2.py b/tests/playwright/shiny/inputs/input_task_button2/test_input_task_button2.py new file mode 100644 index 000000000..85da00177 --- /dev/null +++ b/tests/playwright/shiny/inputs/input_task_button2/test_input_task_button2.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from conftest import ShinyAppProc +from controls import InputTaskButton, OutputText +from playwright.sync_api import Page + + +def click_extended_task_button( + button: InputTaskButton, +) -> None: + button.expect_state("ready") + button.click(timeout=0) + button.expect_state("busy", timeout=0) + button.expect_state("ready", timeout=0) + + +def test_input_action_task_button(page: Page, local_app: ShinyAppProc) -> None: + page.goto(local_app.url) + + OutputText(page, "mod1-text_counter").expect_value("Button clicked 0 times") + + # Extended task + button_task = InputTaskButton(page, "mod1-btn") + button_task.expect_label_ready("Go") + button_task.expect_auto_reset(True) + click_extended_task_button(button_task) + + OutputText(page, "mod1-text_counter").expect_value("Button clicked 1 times")