From 2a56dfa88c33ee37dcab8327cd56dcf1e434766c Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 12:06:36 -0600 Subject: [PATCH 1/6] Fix input_task_button for modules --- shiny/input_handler.py | 31 +++++++++++++++++++------------ shiny/module.py | 10 ++++++++-- shiny/session/_session.py | 5 ++++- shiny/ui/_input_update.py | 26 ++++++++++++++------------ 4 files changed, 45 insertions(+), 27 deletions(-) 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..eb2640ade 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -334,6 +334,7 @@ def verify_state(expected_state: ConnectionState) -> None: ... except Exception as e: try: + traceback.print_exception(e) self._send_error_response(str(e)) except Exception: pass @@ -351,7 +352,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 diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index c979876c1..e7c8a7760 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, }, ), @@ -1088,13 +1092,11 @@ def update_popover( @overload -def _normalize_show_value(show: None) -> Literal["toggle"]: - ... +def _normalize_show_value(show: None) -> Literal["toggle"]: ... @overload -def _normalize_show_value(show: bool) -> Literal["show", "hide"]: - ... +def _normalize_show_value(show: bool) -> Literal["show", "hide"]: ... def _normalize_show_value(show: bool | None) -> Literal["toggle", "show", "hide"]: From a9248a11476dd589eaf996f97caccfe972ee8f60 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 12:22:43 -0600 Subject: [PATCH 2/6] Auto-format code with correct black version --- shiny/ui/_input_update.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index e7c8a7760..e86f00507 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -1092,11 +1092,13 @@ def update_popover( @overload -def _normalize_show_value(show: None) -> Literal["toggle"]: ... +def _normalize_show_value(show: None) -> Literal["toggle"]: + ... @overload -def _normalize_show_value(show: bool) -> Literal["show", "hide"]: ... +def _normalize_show_value(show: bool) -> Literal["show", "hide"]: + ... def _normalize_show_value(show: bool | None) -> Literal["toggle", "show", "hide"]: From f3ec7c034d46d064fa4fc00a6d8c62c8d1fc9db9 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 12:40:07 -0600 Subject: [PATCH 3/6] Fix CI for Python 3.9 --- shiny/session/_session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index eb2640ade..b18a3088f 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 @@ -334,7 +335,7 @@ def verify_state(expected_state: ConnectionState) -> None: ... except Exception as e: try: - traceback.print_exception(e) + traceback.print_exception(*sys.exc_info()) self._send_error_response(str(e)) except Exception: pass From c5cf523c543e764e7d13da3d3eee21c66d917371 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 12:50:58 -0600 Subject: [PATCH 4/6] Add unit tests --- .../shiny/inputs/input_task_button2/app.py | 36 +++++++++++++++++++ .../test_input_task_button2.py | 28 +++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/playwright/shiny/inputs/input_task_button2/app.py create mode 100644 tests/playwright/shiny/inputs/input_task_button2/test_input_task_button2.py 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") From 4936598f80f36f7a50fd718c5df223f3a963e2b3 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 12:52:54 -0600 Subject: [PATCH 5/6] Update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 7b2f43febca57de379e47ed7651e2e77cef14db8 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Mon, 5 Feb 2024 13:16:31 -0600 Subject: [PATCH 6/6] Comment --- shiny/express/_run.py | 1 + shiny/session/_session.py | 25 ++++++++++--------------- 2 files changed, 11 insertions(+), 15 deletions(-) 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/session/_session.py b/shiny/session/_session.py index b18a3088f..d10e749fa 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -214,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 @@ -335,6 +335,7 @@ 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: @@ -607,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}} @@ -1036,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__( @@ -1046,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,