diff --git a/.gitignore b/.gitignore index a913e78fb..76c8c8b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,7 @@ docs/source/reference/ _dev/ tests/playwright/deploys/**/requirements.txt test-results/ +shiny_bookmarks/ # setuptools_scm shiny/_version.py diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index ed225f878..57b6cf86f 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -98,6 +98,23 @@ quartodoc: - ui.input_file - ui.download_button - ui.download_link + - title: Bookmarking + desc: Saving and restoring app state + contents: + - ui.input_bookmark_button + - bookmark.restore_input + - bookmark.Bookmark + - bookmark.BookmarkState + - bookmark.RestoreState + - kind: page + path: bookmark_integration + summary: + name: "Integration" + desc: "Decorators to set save and restore directories." + flatten: true + contents: + - bookmark.set_global_save_dir_fn + - bookmark.set_global_restore_dir_fn - title: Chat interface desc: Build a chatbot interface contents: diff --git a/shiny/_app.py b/shiny/_app.py index 2929192cc..6eeba7b68 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -6,7 +6,7 @@ from contextlib import AsyncExitStack, asynccontextmanager from inspect import signature from pathlib import Path -from typing import Any, Callable, Mapping, Optional, TypeVar, cast +from typing import Any, Callable, Literal, Mapping, Optional, TypeVar, cast import starlette.applications import starlette.exceptions @@ -30,6 +30,15 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length +from .bookmark import _global as bookmark_global_state +from .bookmark._global import as_bookmark_dir_fn +from .bookmark._restore_state import RestoreContext, restore_context +from .bookmark._types import ( + BookmarkDirFn, + BookmarkRestoreDirFn, + BookmarkSaveDirFn, + BookmarkStore, +) from .html_dependencies import jquery_deps, require_deps, shiny_deps from .http_staticfiles import FileResponse, StaticFiles from .session._session import AppSession, Inputs, Outputs, Session, session_context @@ -106,6 +115,10 @@ def server(input: Inputs, output: Outputs, session: Session): ui: RenderedHTML | Callable[[Request], Tag | TagList] server: Callable[[Inputs, Outputs, Session], None] + _bookmark_save_dir_fn: BookmarkSaveDirFn | None + _bookmark_restore_dir_fn: BookmarkRestoreDirFn | None + _bookmark_store: BookmarkStore + def __init__( self, ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path, @@ -114,6 +127,8 @@ def __init__( ), *, static_assets: Optional[str | Path | Mapping[str, str | Path]] = None, + # Document type as Literal to have clearer type hints to App author + bookmark_store: Literal["url", "server", "disable"] = "disable", debug: bool = False, ) -> None: # Used to store callbacks to be called when the app is shutting down (according @@ -133,6 +148,8 @@ def __init__( "`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)" ) + self._init_bookmarking(bookmark_store=bookmark_store, ui=ui) + self._debug: bool = debug # Settings that the user can change after creating the App object. @@ -167,7 +184,7 @@ def __init__( self._sessions: dict[str, AppSession] = {} - self._sessions_needing_flush: dict[int, AppSession] = {} + # self._sessions_needing_flush: dict[int, AppSession] = {} self._registered_dependencies: dict[str, HTMLDependency] = {} self._dependency_handler = starlette.routing.Router() @@ -353,8 +370,18 @@ async def _on_root_request_cb(self, request: Request) -> Response: request for / occurs. """ ui: RenderedHTML + if self.bookmark_store == "disable": + restore_ctx = RestoreContext() + else: + restore_ctx = await RestoreContext.from_query_string( + request.url.query, app=self + ) + if callable(self.ui): - ui = self._render_page(self.ui(request), self.lib_prefix) + # At this point, if `app.bookmark_store != "disable"`, then we've already + # checked that `ui` is a function (in `App._init_bookmarking()`). No need to throw warning if `ui` is _not_ a function. + with restore_context(restore_ctx): + ui = self._render_page(self.ui(request), self.lib_prefix) else: ui = self.ui return HTMLResponse(content=ui["html"]) @@ -466,6 +493,30 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML: return rendered + # ========================================================================== + # Bookmarking + # ========================================================================== + + def _init_bookmarking(self, *, bookmark_store: BookmarkStore, ui: Any) -> None: + self._bookmark_save_dir_fn = bookmark_global_state.bookmark_save_dir + self._bookmark_restore_dir_fn = bookmark_global_state.bookmark_restore_dir + self._bookmark_store = bookmark_store + + if bookmark_store != "disable" and not callable(ui): + raise TypeError( + "App(ui=) must be a function that accepts a request object to allow the UI to be properly reconstructed from a bookmarked state." + ) + + @property + def bookmark_store(self) -> BookmarkStore: + return self._bookmark_store + + def set_bookmark_save_dir_fn(self, bookmark_save_dir_fn: BookmarkDirFn): + self._bookmark_save_dir_fn = as_bookmark_dir_fn(bookmark_save_dir_fn) + + def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn): + self._bookmark_restore_dir_fn = as_bookmark_dir_fn(bookmark_restore_dir_fn) + def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]) -> bool: if ( diff --git a/shiny/_main_create.py b/shiny/_main_create.py index 8156521df..8704d50b6 100644 --- a/shiny/_main_create.py +++ b/shiny/_main_create.py @@ -775,8 +775,7 @@ def copy_template_files( ) sys.exit(1) - if not dest_dir.exists(): - dest_dir.mkdir() + dest_dir.mkdir(parents=True, exist_ok=True) for item in template.path.iterdir(): if item.is_file(): diff --git a/shiny/_namespaces.py b/shiny/_namespaces.py index 93ab1e7ed..18361a51c 100644 --- a/shiny/_namespaces.py +++ b/shiny/_namespaces.py @@ -7,6 +7,8 @@ class ResolvedId(str): + _sep: str = "-" # Shared object for all instances + def __call__(self, id: Id) -> ResolvedId: if isinstance(id, ResolvedId): return id @@ -16,7 +18,7 @@ def __call__(self, id: Id) -> ResolvedId: if self == "": return ResolvedId(id) else: - return ResolvedId(self + "-" + id) + return ResolvedId(str(self) + self._sep + id) Root: ResolvedId = ResolvedId("") diff --git a/shiny/_utils.py b/shiny/_utils.py index ad84667de..fc0eaa21f 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -281,7 +281,7 @@ async def fn_async(*args: P.args, **kwargs: P.kwargs) -> R: return fn_async -# # TODO-barret-future; Q: Keep code? +# # TODO: Barret - Future: Q: Keep code? # class WrapAsync(Generic[P, R]): # """ # Make a function asynchronous. @@ -517,11 +517,11 @@ async def __anext__(self): # ============================================================================== class Callbacks: def __init__(self) -> None: - self._callbacks: dict[int, tuple[Callable[[], None], bool]] = {} + self._callbacks: dict[int, tuple[Callable[..., None], bool]] = {} self._id: int = 0 def register( - self, fn: Callable[[], None], once: bool = False + self, fn: Callable[..., None], once: bool = False ) -> Callable[[], None]: self._id += 1 id = self._id @@ -533,14 +533,14 @@ def _(): return _ - def invoke(self) -> None: + def invoke(self, *args: Any, **kwargs: Any) -> None: # The list() wrapper is necessary to force collection of all the items before # iteration begins. This is necessary because self._callbacks may be mutated # by callbacks. for id, value in list(self._callbacks.items()): fn, once = value try: - fn() + fn(*args, **kwargs) finally: if once: if id in self._callbacks: @@ -550,32 +550,35 @@ def count(self) -> int: return len(self._callbacks) +CancelCallback = Callable[[], None] + + class AsyncCallbacks: def __init__(self) -> None: - self._callbacks: dict[int, tuple[Callable[[], Awaitable[None]], bool]] = {} + self._callbacks: dict[int, tuple[Callable[..., Awaitable[None]], bool]] = {} self._id: int = 0 def register( - self, fn: Callable[[], Awaitable[None]], once: bool = False - ) -> Callable[[], None]: + self, fn: Callable[..., Awaitable[None]], once: bool = False + ) -> CancelCallback: self._id += 1 id = self._id self._callbacks[id] = (fn, once) - def _(): + def cancel_callback(): if id in self._callbacks: del self._callbacks[id] - return _ + return cancel_callback - async def invoke(self) -> None: + async def invoke(self, *args: Any, **kwargs: Any) -> None: # The list() wrapper is necessary to force collection of all the items before # iteration begins. This is necessary because self._callbacks may be mutated # by callbacks. for id, value in list(self._callbacks.items()): fn, once = value try: - await fn() + await fn(*args, **kwargs) finally: if once: if id in self._callbacks: diff --git a/shiny/api-examples/bookmark_callbacks/app.py b/shiny/api-examples/bookmark_callbacks/app.py new file mode 100644 index 000000000..62744cd24 --- /dev/null +++ b/shiny/api-examples/bookmark_callbacks/app.py @@ -0,0 +1,87 @@ +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny.bookmark import BookmarkState + + +# App UI **must** be a function to ensure that each user restores their own UI values. +def app_ui(request: Request): + return ui.page_fluid( + ui.markdown( + "Directions: " + "\n1. Change the radio buttons below" + "\n2. Refresh your browser." + "\n3. The radio buttons should be restored to their previous state." + "\n4. Check the console messages for bookmarking events." + ), + ui.hr(), + ui.input_radio_buttons( + "letter", + "Choose a letter (Store in Bookmark 'input')", + choices=["A", "B", "C"], + ), + ui.input_radio_buttons( + "letter_values", + "Choose a letter (Stored in Bookmark 'values' as lowercase)", + choices=["A", "B", "C"], + ), + "Selection:", + ui.output_code("letters"), + ) + + +def server(input: Inputs, output: Outputs, session: Session): + + # Exclude `"letter_values"` from being saved in the bookmark as we'll store it manually for example's sake + # Append or adjust this list as needed. + session.bookmark.exclude.append("letter_values") + + lowercase_letter = reactive.value() + + @reactive.effect + @reactive.event(input.letter_values) + async def _(): + lowercase_letter.set(input.letter_values().lower()) + + @render.code + def letters(): + return str([input.letter(), lowercase_letter()]) + + # When the user interacts with the input, we will bookmark the state. + @reactive.effect + @reactive.event(input.letter, lowercase_letter, ignore_init=True) + async def _(): + await session.bookmark() + + # Before saving state, we can adjust the bookmark state values object + @session.bookmark.on_bookmark + async def _(state: BookmarkState): + print("Bookmark state:", state.input, state.values, state.dir) + with reactive.isolate(): + state.values["lowercase"] = lowercase_letter() + + # After saving state, we will update the query string with the bookmark URL. + @session.bookmark.on_bookmarked + async def _(url: str): + print("Bookmarked url:", url) + await session.bookmark.update_query_string(url) + + @session.bookmark.on_restore + def _(state: BookmarkState): + print("Restore state:", state.input, state.values, state.dir) + + # Update the radio button selection based on the restored state. + if "lowercase" in state.values: + uppercase = state.values["lowercase"].upper() + # This may produce a small blip in the UI as the original value was restored on the client's HTML request, _then_ a message is received by the client to update the value. + ui.update_radio_buttons("letter_values", selected=uppercase) + + @session.bookmark.on_restored + def _(state: BookmarkState): + # For rare cases, you can update the UI after the session has been fully restored. + print("Restored state:", state.input, state.values, state.dir) + + +# Make sure to set the bookmark_store to `"url"` (or `"server"`) +# to store the bookmark information/key in the URL query string. +app = App(app_ui, server, bookmark_store="url") diff --git a/shiny/api-examples/input_bookmark_button/app-core.py b/shiny/api-examples/input_bookmark_button/app-core.py new file mode 100644 index 000000000..5c1eaef15 --- /dev/null +++ b/shiny/api-examples/input_bookmark_button/app-core.py @@ -0,0 +1,33 @@ +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, ui + + +# App UI **must** be a function to ensure that each user restores their own UI values. +def app_ui(request: Request): + return ui.page_fluid( + ui.markdown( + "Directions: " + "\n1. Change the radio button selection below" + "\n2. Save the bookmark." + "\n3. Then, refresh your browser page to see the radio button selection has been restored." + ), + ui.hr(), + ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]), + ui.input_bookmark_button(label="Save bookmark!"), + ) + + +def server(input: Inputs, output: Outputs, session: Session): + + # @reactive.effect + # @reactive.event(input.letter, ignore_init=True) + # async def _(): + # await session.bookmark() + + @session.bookmark.on_bookmarked + async def _(url: str): + await session.bookmark.update_query_string(url) + + +app = App(app_ui, server, bookmark_store="url") diff --git a/shiny/api-examples/input_bookmark_button/app-express.py b/shiny/api-examples/input_bookmark_button/app-express.py new file mode 100644 index 000000000..1221fc997 --- /dev/null +++ b/shiny/api-examples/input_bookmark_button/app-express.py @@ -0,0 +1,20 @@ +from shiny.express import app_opts, session, ui + +app_opts(bookmark_store="url") + + +ui.markdown( + "Directions: " + "\n1. Change the radio button selection below" + "\n2. Save the bookmark." + "\n3. Then, refresh your browser page to see the radio button selection has been restored." +) + + +ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]) +ui.input_bookmark_button() + + +@session.bookmark.on_bookmarked +async def _(url: str): + await session.bookmark.update_query_string(url) diff --git a/shiny/api-examples/restore_input/app.py b/shiny/api-examples/restore_input/app.py new file mode 100644 index 000000000..d1c3c2ec1 --- /dev/null +++ b/shiny/api-examples/restore_input/app.py @@ -0,0 +1,47 @@ +from htmltools import css, tags +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, ui +from shiny.bookmark import restore_input +from shiny.module import resolve_id + + +def custom_input_text( + id: str, + value: str = "", +) -> ui.Tag: + + resolved_id = resolve_id(id) + return tags.div( + tags.label(tags.strong("Custom input text:")), + tags.textarea( + restore_input(resolved_id, value), + id=resolved_id, + type="text", + placeholder="Type here...", + style=css(width="400px", height="3hr"), + ), + class_="shiny-input-container", + ) + + +# App UI **must** be a function to ensure that each user restores their own UI values. +def app_ui(request: Request): + return ui.page_fluid( + custom_input_text( + "myid", + value="Change this value, then click bookmark and refresh the page.", + ), + ui.input_bookmark_button(), + ) + + +def server(input: Inputs, output: Outputs, session: Session): + + @session.bookmark.on_bookmarked + async def _(url: str): + await session.bookmark.update_query_string(url) + + +# `bookmark_store` (`"url"` or `"server"`) must be passed to the `App` constructor to enable bookmarking. +app = App(app_ui, server, bookmark_store="url") diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py new file mode 100644 index 000000000..c74ffd6e0 --- /dev/null +++ b/shiny/bookmark/__init__.py @@ -0,0 +1,29 @@ +from ._bookmark import ( + Bookmark, + BookmarkApp, + BookmarkExpressStub, + BookmarkProxy, +) +from ._button import input_bookmark_button +from ._global import set_global_restore_dir_fn, set_global_save_dir_fn +from ._restore_state import RestoreContext, RestoreState, restore_input +from ._save_state import BookmarkState + +__all__ = ( + # _bookmark + "Bookmark", + "BookmarkApp", + "BookmarkProxy", + "BookmarkExpressStub", + # _button + "input_bookmark_button", + # _external + "set_global_save_dir_fn", + "set_global_restore_dir_fn", + # _restore_state + "RestoreContext", + "RestoreState", + "restore_input", + # _save_state + "BookmarkState", +) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py new file mode 100644 index 000000000..31f78c065 --- /dev/null +++ b/shiny/bookmark/_bookmark.py @@ -0,0 +1,991 @@ +from __future__ import annotations + +import warnings +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Awaitable, Callable, Literal + +from .._docstring import add_example +from .._utils import AsyncCallbacks, CancelCallback, wrap_async +from ._button import BOOKMARK_ID +from ._restore_state import RestoreState +from ._save_state import BookmarkState +from ._types import BookmarkStore + +# TODO: Barret - Bookmark state +# bookmark -> save/load interface +# * √ global hooks +# * √ default local functions +# save/load interface -> register functions +# * `shiny.bookmark.globals` +# register interface -> Make interface for Connect +# * TODO: implement in Connect PR +# bookmark -> save state +# save state -> {inputs, values, exclude} +# {inputs} -> custom serializer +# * √ Hook to `Inputs.set_serializer(id, fn)` +# * √ `Inputs._serialize()` to create a dict +# {values} -> dict (where as in R is an environment) +# * √ values is a dict! +# {exclude} -> Requires `session.setBookmarkExclude(names)`, `session.getBookmarkExclude()` +# * √ `session.bookmark_exclude: list[str]` value! +# * √ `session._get_bookmark_exclude()` & `session._bookmark_exclude_fn` +# Using a `.bookmark_exclude = []` and `._get_bookmark_exclude()` helper that accesses a `._bookmark_exclude_fns` list of functions which return scoped bookmark excluded values +# Enable bookmarking hooks: +# * √ `session.bookmark_store`: `url`, `server`, `disable` +# Session hooks -> `onBookmark()`, `onBookmarked()`, `onRestore(), `onRestored()` +# * √ `session.on_bookmark()` # Takes the save state +# * Cancel callback +# * √ `session.on_bookmarked()` # Takes a url +# * Cancel callback +# * `session.onRestore()` +# * `session.onRestored()` +# Session hooks -> Require list of callback functions for each +# * √ Session hooks -> Calling hooks in proper locations with info +# * √ Session hook -> Call bookmark "right now": `doBookmark()` +# * √ `session.do_bookmark()` +# Session updates -> Require updates for `SessionProxy` object +# * √ `doBookmark()` -> Update query string +# * √ Update query string + +# bookmark -> restore state +# restore state -> {inputs, values} +# restore {inputs} -> Update all inputs given restored value + +# Shinylive! +# Get query string from parent frame / tab +# * Ignore the code itself +# * May need to escape (all?) the parameters to avoid collisions with `h=` or `code=`. +# Set query string to parent frame / tab + + +if TYPE_CHECKING: + from ..express._stub_session import ExpressStubSession + from ..module import ResolvedId + from ..session._session import AppSession, SessionProxy + from . import RestoreContext +else: + from typing import Any + + RestoreContext = Any + SessionProxy = Any + AppSession = Any + ResolvedId = Any + ExpressStubSession = Any + + +class Bookmark(ABC): + + _on_get_exclude: list[Callable[[], list[str]]] + """Callbacks that BookmarkProxy classes utilize to help determine the list of inputs to exclude from bookmarking.""" + exclude: list[str] + """A list of scoped Input names to exclude from bookmarking.""" + + _on_bookmark_callbacks: AsyncCallbacks + _on_bookmarked_callbacks: AsyncCallbacks + _on_restore_callbacks: AsyncCallbacks + _on_restored_callbacks: AsyncCallbacks + + @add_example("input_bookmark_button") + async def __call__(self) -> None: + await self.do_bookmark() + + def __init__(self): + + super().__init__() + + self._on_get_exclude = [] + self.exclude = [] + + self._on_bookmark_callbacks = AsyncCallbacks() + self._on_bookmarked_callbacks = AsyncCallbacks() + self._on_restore_callbacks = AsyncCallbacks() + self._on_restored_callbacks = AsyncCallbacks() + + # Making this a read only property as app authors will not be able to change how the session is restored as the server function will run after the session has been restored. + @property + @abstractmethod + def store(self) -> BookmarkStore: + """ + App's bookmark store value + + Possible values: + * `"url"`: Save / reload the bookmark state in the URL. + * `"server"`: Save / reload the bookmark state on the server. + * `"disable"` (default): Bookmarking is diabled. + """ + ... + + @property + @abstractmethod + def _restore_context(self) -> RestoreContext | None: + """ + A read-only value of the session's RestoreContext object. + """ + ... + + # # TODO: Barret - Implement this?!? + # @abstractmethod + # async def get_url(self) -> str: + # ... + + # # TODO: Barret - Implement this?!? + # # `session.bookmark.on_bookmarked(session.bookmark.update_query_string)` + # # `session.bookmark.on_bookmarked(session.bookmark.show_modal)` + # await def show_modal(self, url: Optional[str] = None) -> None: + # if url is None: + # url:str = self._get_encoded_url() + + # await session.insert_ui(modal_with_url(url)) + + @add_example("bookmark_callbacks") + def on_bookmark( + self, + callback: ( + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] + ), + /, + ) -> CancelCallback: + """ + Registers a function that will be called just before bookmarking state. + + This callback will be executed **before** the bookmark state is saved serverside or in the URL. + + Parameters + ---------- + callback + The callback function to call when the session is bookmarked. + This method should accept a single argument, which is a + :class:`~shiny.bookmark._bookmark.ShinySaveState` object. + """ + return self._on_bookmark_callbacks.register(wrap_async(callback)) + + @add_example("bookmark_callbacks") + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> CancelCallback: + """ + Registers a function that will be called just after bookmarking state. + + This callback will be executed **after** the bookmark state is saved serverside or in the URL. + + Parameters + ---------- + callback + The callback function to call when the session is bookmarked. + This method should accept a single argument, the string representing the query parameter component of the URL. + """ + return self._on_bookmarked_callbacks.register(wrap_async(callback)) + + def on_restore( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + """ + Registers a function that will be called just before restoring state. + + This callback will be executed **before** the bookmark state is restored. + """ + return self._on_restore_callbacks.register(wrap_async(callback)) + + def on_restored( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + """ + Registers a function that will be called just after restoring state. + + This callback will be executed **after** the bookmark state is restored. + """ + return self._on_restored_callbacks.register(wrap_async(callback)) + + @abstractmethod + async def update_query_string( + self, + query_string: str, + mode: Literal["replace", "push"] = "replace", + ) -> None: + """ + Update the query string of the current URL. + + Parameters + ---------- + query_string + The query string to set. + mode + Whether to replace the current URL or push a new one. Pushing a new value will add to the user's browser history. + """ + ... + + @abstractmethod + async def do_bookmark(self) -> None: + """ + Perform bookmarking. + + This method will also call the `on_bookmark` and `on_bookmarked` callbacks to alter the bookmark state. Then, the bookmark state will be either saved to the server or encoded in the URL, depending on the `.store` option. + + No actions will be performed if the `.store` option is set to `"disable"`. + + Note: this method is called when `session.bookmark()` is executed. + """ + ... + + +class BookmarkApp(Bookmark): + _session: AppSession + """ + The root session object (most likely a `AppSession` object). + """ + _restore_context_value: RestoreContext + """ + Placeholder value that should only be manually set within the session's `init` websocket message. + """ + + def __init__(self, session: AppSession): + from ..session._session import AppSession + + assert isinstance(session, AppSession) + super().__init__() + + self._session = session + # self._restore_context_value = None + + # Making this a read only property as app authors will not be able to change how the session is restored as the server function will run after the session has been restored. + @property + def store(self) -> BookmarkStore: + + return self._session.app.bookmark_store + + @property + def _restore_context(self) -> RestoreContext | None: + return self._restore_context_value + + def _set_restore_context(self, restore_context: RestoreContext): + """ + Set the session's RestoreContext object. + + This should only be done within the `init` websocket message. + """ + self._restore_context_value = restore_context + + def _create_effects(self) -> None: + """ + Create the bookmarking `@reactive.effect`s for the session. + + Effects: + * Call `session.bookmark()` on the bookmark button click. + * Show an error message if the restore context has an error. + * Invoke the `@session.bookmark.on_restore` callbacks at the beginning of the flush cycle. + * Invoke the `@session.bookmark.on_restored` callbacks after the flush cycle completes. + """ + # Get bookmarking config + if self.store == "disable": + return + + session = self._session + + from .. import reactive + from ..session import session_context + from ..ui._notification import notification_show + + with session_context(session): + + # Fires when the bookmark button is clicked. + @reactive.effect + @reactive.event(session.input[BOOKMARK_ID]) + async def _(): + await session.bookmark() + + # If there was an error initializing the current restore context, show + # notification in the client. + @reactive.effect + def init_error_message(): + if self._restore_context and self._restore_context._init_error_msg: + notification_show( + f"Error in RestoreContext initialization: {self._restore_context._init_error_msg}", + duration=None, + type="error", + ) + + # Run the on_restore function at the beginning of the flush cycle, but after + # the server function has been executed. + @reactive.effect(priority=1000000) + async def invoke_on_restore_callbacks(): + if self._on_restore_callbacks.count() == 0: + return + + with session_context(session): + + try: + # ?withLogErrors + with reactive.isolate(): + if self._restore_context and self._restore_context.active: + restore_state = self._restore_context.as_state() + await self._on_restore_callbacks.invoke(restore_state) + except Exception as e: + warnings.warn( + f"Error calling on_restore callback: {e}", + stacklevel=2, + ) + notification_show( + f"Error calling on_restore callback: {e}", + duration=None, + type="error", + ) + + # Run the on_restored function after the flush cycle completes and + # information is sent to the client. + @session.on_flushed + async def invoke_on_restored_callbacks(): + if self._on_restored_callbacks.count() == 0: + return + + with session_context(session): + try: + with reactive.isolate(): + if self._restore_context and self._restore_context.active: + restore_state = self._restore_context.as_state() + await self._on_restored_callbacks.invoke(restore_state) + except Exception as e: + warnings.warn( + f"Error calling on_restored callback: {e}", + stacklevel=2, + ) + notification_show( + f"Error calling on_restored callback: {e}", + duration=None, + type="error", + ) + + return + + def on_bookmark( + self, + callback: ( + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] + ), + /, + ) -> CancelCallback: + return self._on_bookmark_callbacks.register(wrap_async(callback)) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> CancelCallback: + return self._on_bookmarked_callbacks.register(wrap_async(callback)) + + def on_restore( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restore_callbacks.register(wrap_async(callback)) + + def on_restored( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restored_callbacks.register(wrap_async(callback)) + + async def update_query_string( + self, + query_string: str, + mode: Literal["replace", "push"] = "replace", + ) -> None: + if mode not in {"replace", "push"}: + raise ValueError(f"Invalid mode: {mode}") + await self._session._send_message( + { + "updateQueryString": { + "queryString": query_string, + "mode": mode, + } + } + ) + + def _get_bookmark_exclude(self) -> list[str]: + """ + Get the list of inputs excluded from being bookmarked. + """ + + scoped_excludes: list[str] = [] + for proxy_exclude_fn in self._on_get_exclude: + scoped_excludes.extend(proxy_exclude_fn()) + # Remove duplicates + return list(set([*self.exclude, *scoped_excludes])) + + async def do_bookmark(self) -> None: + + if self.store == "disable": + # If you have a bookmark button or request a bookmark to be saved, + # then it should be saved. (Present a warning telling author how to fix it) + warnings.warn( + "Saving the bookmark state has been requested. " + 'However, bookmarking is current set to `"disable"`. ' + "Please enable bookmarking by setting " + "`shiny.App(bookmark_store=)` or " + "`shiny.express.app_opts(bookmark_store=)`", + stacklevel=2, + ) + return + + try: + # ?withLogErrors + from ..bookmark._bookmark import BookmarkState + from ..session import session_context + + async def root_state_on_save(state: BookmarkState) -> None: + with session_context(self._session): + await self._on_bookmark_callbacks.invoke(state) + + root_state = BookmarkState( + input=self._session.input, + exclude=self._get_bookmark_exclude(), + on_save=root_state_on_save, + ) + + if self.store == "server": + query_string = await root_state._save_state(app=self._session.app) + elif self.store == "url": + query_string = await root_state._encode_state() + # # Can we have browser storage? + # elif self.store == "browser": + # get_json object + # get consistent storage value (not session id) + # send object to browser storage + # return server-like-id url value + else: + raise ValueError("Unknown bookmark store: " + self.store) + + clientdata = self._session.clientdata + + port = str(clientdata.url_port()) + full_url = "".join( + [ + clientdata.url_protocol(), + "//", + clientdata.url_hostname(), + ":" if port else "", + port, + clientdata.url_pathname(), + "?", + query_string, + ] + ) + + # If onBookmarked callback was provided, invoke it; if not call + # the default. + if self._on_bookmarked_callbacks.count() > 0: + with session_context(self._session): + await self._on_bookmarked_callbacks.invoke(full_url) + else: + # `session.bookmark.show_modal(url)` + + # showBookmarkUrlModal(url) + # This action feels weird. I don't believe it should occur + # Instead, I believe it should update the query string automatically. + # `session.bookmark.update_query_string(url)` + raise NotImplementedError("Show bookmark modal not implemented") + except Exception as e: + msg = f"Error bookmarking state: {e}" + from ..ui._notification import notification_show + + notification_show(msg, duration=None, type="error") + + +class BookmarkProxy(Bookmark): + + _ns: ResolvedId + _session: SessionProxy + + def __init__(self, session_proxy: SessionProxy): + from ..session._session import SessionProxy + + assert isinstance(session_proxy, SessionProxy) + super().__init__() + + self._ns = session_proxy.ns + self._session = session_proxy + + # Maybe `._get_bookmark_exclude()` should be used instead of`proxy_exclude_fns`? + self._session._parent.bookmark._on_get_exclude.append( + lambda: [str(self._ns(name)) for name in self.exclude] + ) + + # When scope is created, register these bookmarking callbacks on the main + # session object. They will invoke the scope's own callbacks, if any are + # present. + + # The goal of this method is to save the scope's values. All namespaced inputs + # will already exist within the `root_state`. + self._session._parent.bookmark.on_bookmark(self._scoped_on_bookmark) + + from ..session import session_context + + @self._session._parent.bookmark.on_bookmarked + async def scoped_on_bookmarked(url: str) -> None: + if self._on_bookmarked_callbacks.count() == 0: + return + + with session_context(self._session): + await self._on_bookmarked_callbacks.invoke(url) + + ns_prefix = str(self._ns + self._ns._sep) + + @self._session._parent.bookmark.on_restore + async def scoped_on_restore(restore_state: RestoreState) -> None: + if self._on_restore_callbacks.count() == 0: + return + + scoped_restore_state = restore_state._state_within_namespace(ns_prefix) + + with session_context(self._session): + await self._on_restore_callbacks.invoke(scoped_restore_state) + + @self._session._parent.bookmark.on_restored + async def scoped_on_restored(restore_state: RestoreState) -> None: + if self._on_restored_callbacks.count() == 0: + return + + scoped_restore_state = restore_state._state_within_namespace(ns_prefix) + with session_context(self._session): + await self._on_restored_callbacks.invoke(scoped_restore_state) + + async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None: + # Exit if no user-defined callbacks. + if self._on_bookmark_callbacks.count() == 0: + return + + from ..bookmark._bookmark import BookmarkState + + scoped_state = BookmarkState( + input=self._session.input, + exclude=self.exclude, + on_save=None, + ) + + # Make subdir for scope + # TODO: Barret; Is this for uploaded files?!? + if root_state.dir is not None: + scope_subpath = self._ns + scoped_state.dir = Path(root_state.dir) / scope_subpath + scoped_state.dir.mkdir(parents=True, exist_ok=True) + + if not scoped_state.dir.exists(): + raise FileNotFoundError( + f"Scope directory could not be created for {scope_subpath}" + ) + + # Invoke the callback on the scopeState object + from ..session import session_context + + with session_context(self._session): + await self._on_bookmark_callbacks.invoke(scoped_state) + + # Copy `values` from scoped_state to root_state (adding namespace) + if scoped_state.values: + for key, value in scoped_state.values.items(): + if key.strip() == "": + raise ValueError("All scope values must be named.") + root_state.values[str(self._ns(key))] = value + + @property + def store(self) -> BookmarkStore: + return self._session._parent.bookmark.store + + @property + def _restore_context(self) -> RestoreContext | None: + return self._session._parent.bookmark._restore_context + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + await self._session._parent.bookmark.update_query_string(query_string, mode) + + async def do_bookmark(self) -> None: + await self._session._parent.bookmark.do_bookmark() + + +class BookmarkExpressStub(Bookmark): + + def __init__(self, session: ExpressStubSession) -> None: + super().__init__() + + from ..express._stub_session import ExpressStubSession + + assert isinstance(session, ExpressStubSession) + + @property + def store(self) -> BookmarkStore: + return "disable" + + @property + def _restore_context(self) -> RestoreContext | None: + # no-op within ExpressStub + return None + + def on_bookmark( + self, + callback: ( + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] + ), + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + # no-op within ExpressStub + return None + + async def do_bookmark(self) -> None: + # no-op within ExpressStub + return None + + def on_restore( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None + + def on_restored( + self, + callback: ( + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] + ), + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None + + +# #' Generate a modal dialog that displays a URL +# #' +# #' The modal dialog generated by `urlModal` will display the URL in a +# #' textarea input, and the URL text will be selected so that it can be easily +# #' copied. The result from `urlModal` should be passed to the +# #' [showModal()] function to display it in the browser. +# #' +# #' @param url A URL to display in the dialog box. +# #' @param title A title for the dialog box. +# #' @param subtitle Text to display underneath URL. +# #' @export +# urlModal <- function(url, title = "Bookmarked application link", subtitle = NULL) { + +# subtitleTag <- tagList( +# br(), +# span(class = "text-muted", subtitle), +# span(id = "shiny-bookmark-copy-text", class = "text-muted") +# ) + +# modalDialog( +# title = title, +# easyClose = TRUE, +# tags$textarea(class = "form-control", rows = "1", style = "resize: none;", +# readonly = "readonly", +# url +# ), +# subtitleTag, +# # Need separate show and shown listeners. The show listener sizes the +# # textarea just as the modal starts to fade in. The 200ms delay is needed +# # because if we try to resize earlier, it can't calculate the text height +# # (scrollHeight will be reported as zero). The shown listener selects the +# # text; it's needed because because selection has to be done after the fade- +# # in is completed. +# tags$script( +# "$('#shiny-modal'). +# one('show.bs.modal', function() { +# setTimeout(function() { +# var $textarea = $('#shiny-modal textarea'); +# $textarea.innerHeight($textarea[0].scrollHeight); +# }, 200); +# }); +# $('#shiny-modal') +# .one('shown.bs.modal', function() { +# $('#shiny-modal textarea').select().focus(); +# }); +# $('#shiny-bookmark-copy-text') +# .text(function() { +# if (/Mac/i.test(navigator.userAgent)) { +# return 'Press \u2318-C to copy.'; +# } else { +# return 'Press Ctrl-C to copy.'; +# } +# }); +# " +# ) +# ) +# } + + +# #' Display a modal dialog for bookmarking +# #' +# #' This is a wrapper function for [urlModal()] that is automatically +# #' called if an application is bookmarked but no other [onBookmark()] +# #' callback was set. It displays a modal dialog with the bookmark URL, along +# #' with a subtitle that is appropriate for the type of bookmarking used ("url" +# #' or "server"). +# #' +# #' @param url A URL to show in the modal dialog. +# #' @export +# showBookmarkUrlModal <- function(url) { +# store <- getShinyOption("bookmarkStore", default = "") +# if (store == "url") { +# subtitle <- "This link stores the current state of this application." +# } else if (store == "server") { +# subtitle <- "The current state of this application has been stored on the server." +# } else { +# subtitle <- NULL +# } + +# showModal(urlModal(url, subtitle = subtitle)) +# } + +# #' Enable bookmarking for a Shiny application +# #' +# #' @description +# #' +# #' There are two types of bookmarking: saving an application's state to disk on +# #' the server, and encoding the application's state in a URL. For state that has +# #' been saved to disk, the state can be restored with the corresponding state +# #' ID. For URL-encoded state, the state of the application is encoded in the +# #' URL, and no server-side storage is needed. +# #' +# #' URL-encoded bookmarking is appropriate for applications where there not many +# #' input values that need to be recorded. Some browsers have a length limit for +# #' URLs of about 2000 characters, and if there are many inputs, the length of +# #' the URL can exceed that limit. +# #' +# #' Saved-on-server bookmarking is appropriate when there are many inputs, or +# #' when the bookmarked state requires storing files. +# #' +# #' @details +# #' +# #' For restoring state to work properly, the UI must be a function that takes +# #' one argument, `request`. In most Shiny applications, the UI is not a +# #' function; it might have the form `fluidPage(....)`. Converting it to a +# #' function is as simple as wrapping it in a function, as in +# #' \code{function(request) \{ fluidPage(....) \}}. +# #' +# #' By default, all input values will be bookmarked, except for the values of +# #' passwordInputs. fileInputs will be saved if the state is saved on a server, +# #' but not if the state is encoded in a URL. +# #' +# #' When bookmarking state, arbitrary values can be stored, by passing a function +# #' as the `onBookmark` argument. That function will be passed a +# #' `ShinySaveState` object. The `values` field of the object is a list +# #' which can be manipulated to save extra information. Additionally, if the +# #' state is being saved on the server, and the `dir` field of that object +# #' can be used to save extra information to files in that directory. +# #' +# #' For saved-to-server state, this is how the state directory is chosen: +# #' \itemize{ +# #' \item If running in a hosting environment such as Shiny Server or +# #' Connect, the hosting environment will choose the directory. +# #' \item If running an app in a directory with [runApp()], the +# #' saved states will be saved in a subdirectory of the app called +# #' shiny_bookmarks. +# #' \item If running a Shiny app object that is generated from code (not run +# #' from a directory), the saved states will be saved in a subdirectory of +# #' the current working directory called shiny_bookmarks. +# #' } +# #' +# #' When used with [shinyApp()], this function must be called before +# #' `shinyApp()`, or in the `shinyApp()`'s `onStart` function. An +# #' alternative to calling the `enableBookmarking()` function is to use the +# #' `enableBookmarking` *argument* for `shinyApp()`. See examples +# #' below. +# #' +# #' @param store Either `"url"`, which encodes all of the relevant values in +# #' a URL, `"server"`, which saves to disk on the server, or +# #' `"disable"`, which disables any previously-enabled bookmarking. +# #' +# #' @seealso [onBookmark()], [onBookmarked()], +# #' [onRestore()], and [onRestored()] for registering +# #' callback functions that are invoked when the state is bookmarked or +# #' restored. +# #' +# #' Also see [updateQueryString()]. +# #' +# #' @export +# #' @examples +# #' ## Only run these examples in interactive R sessions +# #' if (interactive()) { +# #' +# #' # Basic example with state encoded in URL +# #' ui <- function(request) { +# #' fluidPage( +# #' textInput("txt", "Text"), +# #' checkboxInput("chk", "Checkbox"), +# #' bookmarkButton() +# #' ) +# #' } +# #' server <- function(input, output, session) { } +# #' enableBookmarking("url") +# #' shinyApp(ui, server) +# #' +# #' +# #' # An alternative to calling enableBookmarking(): use shinyApp's +# #' # enableBookmarking argument +# #' shinyApp(ui, server, enableBookmarking = "url") +# #' +# #' +# #' # Same basic example with state saved to disk +# #' enableBookmarking("server") +# #' shinyApp(ui, server) +# #' +# #' +# #' # Save/restore arbitrary values +# #' ui <- function(req) { +# #' fluidPage( +# #' textInput("txt", "Text"), +# #' checkboxInput("chk", "Checkbox"), +# #' bookmarkButton(), +# #' br(), +# #' textOutput("lastSaved") +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' vals <- reactiveValues(savedTime = NULL) +# #' output$lastSaved <- renderText({ +# #' if (!is.null(vals$savedTime)) +# #' paste("Last saved at", vals$savedTime) +# #' else +# #' "" +# #' }) +# #' +# #' onBookmark(function(state) { +# #' vals$savedTime <- Sys.time() +# #' # state is a mutable reference object, and we can add arbitrary values +# #' # to it. +# #' state$values$time <- vals$savedTime +# #' }) +# #' onRestore(function(state) { +# #' vals$savedTime <- state$values$time +# #' }) +# #' } +# #' enableBookmarking(store = "url") +# #' shinyApp(ui, server) +# #' +# #' +# #' # Usable with dynamic UI (set the slider, then change the text input, +# #' # click the bookmark button) +# #' ui <- function(request) { +# #' fluidPage( +# #' sliderInput("slider", "Slider", 1, 100, 50), +# #' uiOutput("ui"), +# #' bookmarkButton() +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' output$ui <- renderUI({ +# #' textInput("txt", "Text", input$slider) +# #' }) +# #' } +# #' enableBookmarking("url") +# #' shinyApp(ui, server) +# #' +# #' +# #' # Exclude specific inputs (The only input that will be saved in this +# #' # example is chk) +# #' ui <- function(request) { +# #' fluidPage( +# #' passwordInput("pw", "Password"), # Passwords are never saved +# #' sliderInput("slider", "Slider", 1, 100, 50), # Manually excluded below +# #' checkboxInput("chk", "Checkbox"), +# #' bookmarkButton() +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' setBookmarkExclude("slider") +# #' } +# #' enableBookmarking("url") +# #' shinyApp(ui, server) +# #' +# #' +# #' # Update the browser's location bar every time an input changes. This should +# #' # not be used with enableBookmarking("server"), because that would create a +# #' # new saved state on disk every time the user changes an input. +# #' ui <- function(req) { +# #' fluidPage( +# #' textInput("txt", "Text"), +# #' checkboxInput("chk", "Checkbox") +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' observe({ +# #' # Trigger this observer every time an input changes +# #' reactiveValuesToList(input) +# #' session$doBookmark() +# #' }) +# #' onBookmarked(function(url) { +# #' updateQueryString(url) +# #' }) +# #' } +# #' enableBookmarking("url") +# #' shinyApp(ui, server) +# #' +# #' +# #' # Save/restore uploaded files +# #' ui <- function(request) { +# #' fluidPage( +# #' sidebarLayout( +# #' sidebarPanel( +# #' fileInput("file1", "Choose CSV File", multiple = TRUE, +# #' accept = c( +# #' "text/csv", +# #' "text/comma-separated-values,text/plain", +# #' ".csv" +# #' ) +# #' ), +# #' tags$hr(), +# #' checkboxInput("header", "Header", TRUE), +# #' bookmarkButton() +# #' ), +# #' mainPanel( +# #' tableOutput("contents") +# #' ) +# #' ) +# #' ) +# #' } +# #' server <- function(input, output) { +# #' output$contents <- renderTable({ +# #' inFile <- input$file1 +# #' if (is.null(inFile)) +# #' return(NULL) +# #' +# #' if (nrow(inFile) == 1) { +# #' read.csv(inFile$datapath, header = input$header) +# #' } else { +# #' data.frame(x = "multiple files") +# #' } +# #' }) +# #' } +# #' enableBookmarking("server") +# #' shinyApp(ui, server) +# #' +# #' } +# enableBookmarking <- function(store = c("url", "server", "disable")) { +# store <- match.arg(store) +# shinyOptions(bookmarkStore = store) +# } diff --git a/shiny/bookmark/_bookmark_state.py b/shiny/bookmark/_bookmark_state.py new file mode 100644 index 000000000..206d1745e --- /dev/null +++ b/shiny/bookmark/_bookmark_state.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def _local_dir(id: str) -> Path: + # Try to save/load from current working directory as we do not know where the + # app file is located + return Path(os.getcwd()) / "shiny_bookmarks" / id + + +async def local_save_dir(id: str) -> Path: + state_dir = _local_dir(id) + state_dir.mkdir(parents=True, exist_ok=True) + return state_dir + + +async def local_restore_dir(id: str) -> Path: + return _local_dir(id) diff --git a/shiny/bookmark/_button.py b/shiny/bookmark/_button.py new file mode 100644 index 000000000..cf2097976 --- /dev/null +++ b/shiny/bookmark/_button.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Optional + +from htmltools import HTML, Tag, TagAttrValue, TagChild + +from .._docstring import add_example +from ..module import resolve_id +from ..types import MISSING, MISSING_TYPE +from ..ui._input_action_button import input_action_button + +BOOKMARK_ID = "._bookmark_" + + +@add_example() +def input_bookmark_button( + label: TagChild = "Bookmark...", + *, + icon: TagChild | MISSING_TYPE = MISSING, + width: Optional[str] = None, + disabled: bool = False, + id: str = BOOKMARK_ID, + title: str = "Bookmark this application's state and get a URL for sharing.", + **kwargs: TagAttrValue, +) -> Tag: + """ + Button for bookmarking/sharing. + + A `bookmarkButton` is a [input_action_button()] with a default label that consists of a link icon and the text "Bookmark...". It is meant to be used for bookmarking state. + + Parameters + ---------- + label + The button label. + icon + The icon to display on the button. + width + The CSS width, e.g. '400px', or '100%'. + disabled + Whether the button is disabled. + id + An ID for the bookmark button. The only time it is necessary to set the ID unless you have more than one bookmark button in your application. If you specify an input ID, it should be excluded from bookmarking with `session.bookmark.exclude.append(ID)`, and you must create a reactive effect that performs the bookmarking (`session.bookmark()`) when the button is pressed. + title + A tooltip that is shown when the mouse cursor hovers over the button. + kwargs + Additional attributes for the button. + + Returns + ------- + : + A UI element. + + See Also + -------- + * :func:`~shiny.ui.input_action_button` + * :func:`~shiny.ui.input_action_link` + * :func:`~shiny.reactive.event` + """ + resolved_id = resolve_id(id) + if isinstance(icon, MISSING_TYPE): + icon = HTML("🔗") + + return input_action_button( + resolved_id, + label, + icon=icon, + title=title, + width=width, + disabled=disabled, + **kwargs, + ) diff --git a/shiny/bookmark/_global.py b/shiny/bookmark/_global.py new file mode 100644 index 000000000..ed9741c23 --- /dev/null +++ b/shiny/bookmark/_global.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import overload + +from .._utils import wrap_async +from ._types import ( + BookmarkDirFn, + BookmarkDirFnAsync, + BookmarkRestoreDirFn, + BookmarkSaveDirFn, +) + +# WARNING! This file contains global state! +# During App initialization, the save_dir and restore_dir functions are conventionally set +# to read-only on the App. + + +bookmark_save_dir: BookmarkSaveDirFn | None = None +bookmark_restore_dir: BookmarkRestoreDirFn | None = None + + +@overload +def as_bookmark_dir_fn(fn: BookmarkDirFn) -> BookmarkDirFnAsync: + pass + + +@overload +def as_bookmark_dir_fn(fn: None) -> None: + pass + + +def as_bookmark_dir_fn(fn: BookmarkDirFn | None) -> BookmarkDirFnAsync | None: + if fn is None: + return None + return wrap_async(fn) + + +def set_global_save_dir_fn(fn: BookmarkDirFn): + """ + Set the global bookmark save directory function. + + This function is NOT intended to be used by app authors. Instead, it is a last resort option for hosted invironments to adjust how bookmarks are saved. + + Parameters + ---------- + fn : BookmarkDirFn + The function that will be used to determine the directory where bookmarks are saved. This function should create the directory (`pathlib.Path` object) that is returned. + + Examples + -------- + ```python + from pathlib import Path + from shiny.bookmark import set_global_save_dir_fn, set_global_restore_dir_fn + + bookmark_dir = Path(__file__).parent / "bookmarks" + + def save_bookmark_dir(id: str) -> Path: + save_dir = bookmark_dir / id + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir + + def restore_bookmark_dir(id: str) -> Path: + return bookmark_dir / id + + # Set global defaults for bookmark saving and restoring. + set_global_restore_dir_fn(restore_bookmark_dir) + set_global_save_dir_fn(save_bookmark_dir) + + app = App(app_ui, server, bookmark_store="server") + ``` + + + See Also + -------- + * `~shiny.bookmark.set_global_restore_dir_fn` : Set the global bookmark restore directory function + """ + global bookmark_save_dir + + bookmark_save_dir = as_bookmark_dir_fn(fn) + return fn + + +def set_global_restore_dir_fn(fn: BookmarkDirFn): + """ + Set the global bookmark restore directory function. + + This function is NOT intended to be used by app authors. Instead, it is a last resort option for hosted invironments to adjust how bookmarks are restored. + + Parameters + ---------- + fn : BookmarkDirFn + The function that will be used to determine the directory (`pathlib.Path` object) where bookmarks are restored from. + + Examples + -------- + ```python + from pathlib import Path + from shiny.bookmark import set_global_save_dir_fn, set_global_restore_dir_fn + + bookmark_dir = Path(__file__).parent / "bookmarks" + + def save_bookmark_dir(id: str) -> Path: + save_dir = bookmark_dir / id + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir + + def restore_bookmark_dir(id: str) -> Path: + return bookmark_dir / id + + # Set global defaults for bookmark saving and restoring. + set_global_restore_dir_fn(restore_bookmark_dir) + set_global_save_dir_fn(save_bookmark_dir) + + app = App(app_ui, server, bookmark_store="server") + ``` + + See Also + -------- + * `~shiny.bookmark.set_global_save_dir_fn` : Set the global bookmark save directory function. + """ + global bookmark_restore_dir + + bookmark_restore_dir = as_bookmark_dir_fn(fn) + return fn diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py new file mode 100644 index 000000000..0cdc2f85e --- /dev/null +++ b/shiny/bookmark/_restore_state.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +import warnings +from contextlib import contextmanager +from contextvars import ContextVar, Token +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, Optional +from urllib.parse import parse_qs, parse_qsl + +from .._docstring import add_example +from ..module import ResolvedId +from ._bookmark_state import local_restore_dir +from ._types import BookmarkRestoreDirFn +from ._utils import from_json_file, from_json_str, in_shiny_server + +if TYPE_CHECKING: + from .._app import App + + +class RestoreState: + input: dict[str, Any] + values: dict[str, Any] + dir: Path | None + + def __init__( + self, + *, + input: dict[str, Any], + values: dict[str, Any], + dir: Path | None, + ): + self.input = input + self.values = values + self.dir = dir + + def _name_has_namespace(self, name: str, prefix: str) -> bool: + return name.startswith(prefix) + + def _un_namespace(self, name: str, prefix: str) -> str: + if not self._name_has_namespace(name, prefix): + raise ValueError(f"Name (`{name}`) does not have namespace: `{prefix}`") + + return name.removeprefix(prefix) + + def _state_within_namespace(self, prefix: str) -> "RestoreState": + # Given a restore state object, return a modified version that's scoped to this + # namespace. + + # Keep only `input` that are in the scope, and rename them + input = { + self._un_namespace(name, prefix): value + for name, value in self.input.items() + if self._name_has_namespace(name, prefix) + } + + # Keep only `values` that are in the scope, and rename them + values = { + self._un_namespace(name, prefix): value + for name, value in self.values.items() + if self._name_has_namespace(name, prefix) + } + + # TODO: Barret; Is this for file inputs?!? + dir = self.dir + if dir is not None: + dir = dir / prefix + # Here was a check for if dir doesn't exist, then dir <- NULL + # But this is confounded with url vs file system, so we'll just + # assume that the directory exists. + # if not dir.exists(): + # dir = None + + return RestoreState(input=input, values=values, dir=dir) + + +class RestoreContext: + active: bool + """This will be set to TRUE if there's actually a state to restore""" + _init_error_msg: str | None + """ + This is set to an error message string in case there was an initialization + error. Later, after the app has started on the client, the server can send + this message as a notification on the client. + """ + + # This is a RestoreInputSet for input values. This is a key-value store with + # some special handling. + input: "RestoreInputSet" + + # Directory for extra files, if restoring from state that was saved to disk. + dir: Path | None + + # For values other than input values. These values don't need the special + # handling that's needed for input values, because they're only accessed + # from the onRestore function. + values: dict[str, Any] + + def __init__(self): + self.reset() + + def reset(self) -> None: + self.active = False + self._init_error_msg = None + self.input = RestoreInputSet() + self.values = {} + self.dir = None + + @staticmethod + async def from_query_string(query_string: str, *, app: App) -> "RestoreContext": + res_ctx = RestoreContext() + + if query_string.startswith("?"): + query_string = query_string[1:] + + try: + # withLogErrors + + query_string_dict = parse_qs(query_string) + if ( + "__subapp__" in query_string_dict + and query_string_dict["__subapp__"] + and query_string_dict["__subapp__"][0] == "1" + ): + # Ignore subapps in shiny docs + res_ctx.reset() + + elif "_state_id_" in query_string_dict and query_string_dict["_state_id_"]: + # If we have a "_state_id_" key, restore from saved state and + # ignore other key/value pairs. If not, restore from key/value + # pairs in the query string. + res_ctx.active = True + await res_ctx._load_state_qs(query_string, app=app) + + else: + # The query string contains the saved keys and values + res_ctx.active = True + await res_ctx._decode_state_qs(query_string) + + except Exception as e: + res_ctx.reset() + res_ctx._init_error_msg = str(e) + print(e) + + return res_ctx + + # def set( + # self, + # *, + # active: bool = False, + # init_error_msg: str | None = None, + # input_: dict[str, Any] = {}, + # values: dict[str, Any] = {}, + # dir_: Path | None = None, + # ) -> None: + # self.active = active + # self._init_error_msg = init_error_msg + # self.input = RestoreInputSet() + # self.input._values = input_ + # self.values = values + # self.dir = dir_ + + # This should be called before a restore context is popped off the stack. + def flush_pending(self) -> None: + self.input.flush_pending() + + def as_state(self) -> RestoreState: + """ + Returns a dict representation of the RestoreContext object. This is passed + to the app author's onRestore function. An important difference between + the RestoreContext object and the dict is that the former's `input` field + is a RestoreInputSet object, while the latter's `input` field is just a + list. + """ + return RestoreState( + # Shallow copy + input={**self.input.as_dict()}, + # Shallow copy + values={**self.values}, + dir=self.dir, + ) + + async def _load_state_qs(self, query_string: str, *, app: App) -> None: + """Given a query string with a _state_id_, load saved state with that ID.""" + values = parse_qs(query_string) + id = values.get("_state_id_", None) + + if not id: + raise ValueError("Missing `_state_id_` from query string") + + id = id[0] + + load_bookmark_fn: BookmarkRestoreDirFn | None = app._bookmark_restore_dir_fn + + if load_bookmark_fn is None: + if in_shiny_server(): + raise NotImplementedError( + "The hosting environment does not support server-side bookmarking." + ) + else: + # We're running Shiny locally. + load_bookmark_fn = local_restore_dir + + # Load the state from disk. + self.dir = Path(await load_bookmark_fn(id)) + + if not self.dir.exists(): + raise RuntimeError("Bookmarked state directory does not exist.") + + input_values = from_json_file(self.dir / "input.json") + self.input = RestoreInputSet(input_values) + + values_file = self.dir / "values.json" + if values_file.exists(): + self.values = from_json_file(values_file) + # End load state from disk + + return + + async def _decode_state_qs(self, query_string: str) -> None: + """Given a query string with values encoded in it, restore saved state from those values.""" + # Remove leading '?' + if query_string.startswith("?"): + query_string = query_string[1:] + + qs_pairs = parse_qsl(query_string, keep_blank_values=True) + + inputs_count = 0 + values_count = 0 + storing_to: Literal["ignore", "inputs", "values"] = "ignore" + input_vals: dict[str, Any] = {} + value_vals: dict[str, Any] = {} + + # For every query string pair, store the inputs / values in the appropriate + # dictionary. + # Error if multiple '_inputs_' or '_values_' found (respectively). + for qs_key, qs_value in qs_pairs: + if qs_key == "_inputs_": + inputs_count += 1 + storing_to = "inputs" + if inputs_count > 1: + raise ValueError( + "Invalid state string: more than one '_inputs_' found" + ) + elif qs_key == "_values_": + values_count += 1 + storing_to = "values" + if values_count > 1: + raise ValueError( + "Invalid state string: more than one '_values_' found" + ) + else: + + if storing_to == "ignore": + continue + + try: + if storing_to == "inputs": + input_vals[qs_key] = from_json_str(qs_value) + elif storing_to == "values": + value_vals[qs_key] = from_json_str(qs_value) + except Exception as e: + warnings.warn( + f'Failed to parse URL parameter "{qs_key}"', stacklevel=3 + ) + print(e, storing_to, qs_key, qs_value) + + self.input = RestoreInputSet(input_vals) + self.values = value_vals + + +class RestoreInputSet: + """ + Restore input set. + + This is basically a key-value store, except for one important difference: When the + user `get()`s a value, the value is marked as pending; when `._flush_pending()` is + called, those pending values are marked as used. When a value is marked as used, + `get()` will not return it, unless called with `force=True`. This is to make sure + that a particular value can be restored only within a single call to `with + restore_context(ctx):`. Without this, if a value is restored in a dynamic UI, it + could completely prevent any other (non- restored) kvalue from being used. + """ + + _values: dict[ResolvedId, Any] + _pending: set[ResolvedId] + """Names of values which have been marked as pending""" + _used: set[ResolvedId] + """Names of values which have been used""" + + def __init__(self, values: Optional[dict[str, Any]] = None): + + if values is None: + self._values = {} + else: + self._values = {ResolvedId(key): value for key, value in values.items()} + self._pending = set() + self._used = set() + + def exists(self, name: ResolvedId) -> bool: + return name in self._values + + def available(self, name: ResolvedId) -> bool: + return self.exists(name) and not self.is_used(name) + + def is_pending(self, name: ResolvedId) -> bool: + return name in self._pending + + def is_used(self, name: ResolvedId) -> bool: + return name in self._used + + # Get a value. If `force` is TRUE, get the value without checking whether + # has been used, and without marking it as pending. + def get(self, name: ResolvedId, force: bool = False) -> Any: + if force: + return self._values[name] + + if not self.available(name): + return None + + self._pending.add(name) + return self._values[name] + + # Take pending names and mark them as used, then clear pending list. + def flush_pending(self) -> None: + self._used.update(self._pending) + self._pending.clear() + + def as_dict(self) -> dict[str, Any]: + return {str(key): value for key, value in self._values.items()} + + +# ############################################################################# +# Restore context stack +# ############################################################################# + +# import queue +# restore_ctx_stack = queue.LifoQueue() + + +_current_restore_context: ContextVar[Optional[RestoreContext]] = ContextVar( + "current_restore_context", + default=None, +) + + +# `with restore_context(r_ctx): ...` +@contextmanager +def restore_context(restore_ctx: RestoreContext | None): + token: Token[RestoreContext | None] = _current_restore_context.set(restore_ctx) + try: + + yield + finally: + if isinstance(restore_ctx, RestoreContext): + restore_ctx.flush_pending() + _current_restore_context.reset(token) + + +def has_current_restore_context() -> bool: + if _current_restore_context.get() is not None: + return True + from ..session import get_current_session + + cur_session = get_current_session() + if cur_session is not None and cur_session.bookmark._restore_context is not None: + return True + return False + + +# Call to access the current restore context. First look on the restore +# context stack, and if not found, then see if there's one on the current +# reactive domain. In practice, the only time there will be a restore context +# on the stack is when executing the UI function; when executing server code, +# the restore context will be attached to the domain/session. +def get_current_restore_context() -> RestoreContext | None: + ctx = _current_restore_context.get() + if ctx is not None: + return ctx + + from ..session import get_current_session + + cur_session = get_current_session() + if cur_session is None or cur_session.bookmark._restore_context is None: + raise RuntimeError("No restore context found") + + ctx = cur_session.bookmark._restore_context + return ctx + + +@add_example() +def restore_input(resolved_id: ResolvedId, default: Any) -> Any: + """ + Restore an input value + + This restores an input value from the current restore context. It should be + called early on inside of input functions (like `input_text()`). + + Parameters + ---------- + id + Name of the input value to restore. (This calling this within a module, it should be the unresolved ID value (e.g. `"id"`), not the resolved ID value (e.g. `"mymod-id"`). + default + A default value to use, if there's no value to restore. + """ + if not isinstance(resolved_id, ResolvedId): + raise TypeError( + "Expected `resolved_id` to be of type `ResolvedId` which is returned from `shiny.module.resolve_id(id)`." + ) + # Will run even if the domain is missing + if not has_current_restore_context(): + return default + + # Requires a domain or restore context + ctx = get_current_restore_context() + if isinstance(ctx, RestoreContext): + old_inputs = ctx.input + if old_inputs.available(resolved_id): + return old_inputs.get(resolved_id) + + return default diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py new file mode 100644 index 000000000..8d3cabe4e --- /dev/null +++ b/shiny/bookmark/_save_state.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Awaitable, Callable +from urllib.parse import urlencode as urllib_urlencode + +from .._utils import private_random_id +from ..reactive import isolate +from ._bookmark_state import local_save_dir +from ._types import BookmarkSaveDirFn +from ._utils import in_shiny_server, to_json_file, to_json_str + +if TYPE_CHECKING: + from .. import Inputs + from .._app import App + + +class BookmarkState: + # session: ? + # * Would get us access to inputs, possibly app dir, registered on save / load classes (?), exclude + # + input: Inputs + values: dict[str, Any] + exclude: list[str] + # _bookmark_: A special value that is always excluded from the bookmark. + on_save: ( + Callable[["BookmarkState"], Awaitable[None]] | None + ) # A callback to invoke during the saving process. + + # These are set not in initialize(), but by external functions that modify + # the ShinySaveState object. + dir: Path | None + + def __init__( + self, + input: Inputs, + exclude: list[str], + on_save: Callable[["BookmarkState"], Awaitable[None]] | None, + ): + self.input = input + self.exclude = exclude + self.on_save = on_save + self.dir = None # This will be set by external functions. + self.values = {} + + self._always_exclude: list[str] = [] + + async def _call_on_save(self): + # Allow user-supplied save function to do things like add state$values, or + # save data to state dir. + if self.on_save: + with isolate(): + await self.on_save(self) + + async def _save_state(self, *, app: App) -> str: + """ + Save a bookmark state to disk (JSON). + + Returns + ------- + str + A query string which can be used to restore the session. + """ + id = private_random_id(prefix="", bytes=8) + + # Get the save directory from the `bookmark_save_dir` function. + # Then we invoke `.on_save(state)` via `._call_on_save() with the directory set + # to `self.dir`. + + # This will be defined by the hosting environment if it supports bookmarking. + save_bookmark_fn: BookmarkSaveDirFn | None = app._bookmark_save_dir_fn + + if save_bookmark_fn is None: + if in_shiny_server(): + raise NotImplementedError( + "The hosting environment does not support server-side bookmarking." + ) + else: + # We're running Shiny locally. + save_bookmark_fn = local_save_dir + + # Save the state to disk. + self.dir = Path(await save_bookmark_fn(id)) + await self._call_on_save() + + input_values_json = await self.input._serialize( + exclude=self.exclude, + state_dir=self.dir, + ) + assert self.dir is not None + + to_json_file(input_values_json, self.dir / "input.json") + + if len(self.values) > 0: + to_json_file(self.values, self.dir / "values.json") + # End save to disk + + # No need to encode URI component as it is only ascii characters. + return f"_state_id_={id}" + + async def _encode_state(self) -> str: + """ + Encode the state to a URL. + + This does not save to disk! + + Returns + ------- + str + A query string which can be used to restore the session. + """ + # Allow user-supplied onSave function to do things like add state$values. + await self._call_on_save() + + input_values_serialized = await self.input._serialize( + exclude=self.exclude, + # Do not include directory as we are not saving to disk. + state_dir=None, + ) + + # Using an array to construct string to avoid multiple serial concatenations. + qs_str_parts: list[str] = [] + + # If any input values are present, add them. + if len(input_values_serialized) > 0: + input_qs = urllib_urlencode( + { + key: to_json_str(value) + for key, value in input_values_serialized.items() + } + ) + + qs_str_parts.append("_inputs_&") + qs_str_parts.append(input_qs) + + if len(self.values) > 0: + if len(qs_str_parts) > 0: + qs_str_parts.append("&") + + # print("\n\nself.values", self.values) + values_qs = urllib_urlencode( + {key: to_json_str(value) for key, value in self.values.items()} + ) + + qs_str_parts.append("_values_&") + qs_str_parts.append(values_qs) + + return "".join(qs_str_parts) diff --git a/shiny/bookmark/_serializers.py b/shiny/bookmark/_serializers.py new file mode 100644 index 000000000..c30207f05 --- /dev/null +++ b/shiny/bookmark/_serializers.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path +from shutil import copyfile +from typing import Any, TypeVar + +from typing_extensions import TypeIs + + +class Unserializable: ... + + +T = TypeVar("T") + + +def is_unserializable(x: Any) -> TypeIs[Unserializable]: + return isinstance(x, Unserializable) + + +async def serializer_unserializable( + value: Any = None, state_dir: Path | None = None +) -> Unserializable: + return Unserializable() + + +async def serializer_default(value: T, state_dir: Path | None) -> T: + return value + + +# TODO: Barret - Integrate +def serializer_file_input( + value: Any, + state_dir: Path | None, +) -> Any | Unserializable: + if state_dir is None: + return Unserializable() + + # TODO: Barret - Double check this logic! + + # `value` is a data frame. When persisting files, we need to copy the file to + # the persistent dir and then strip the original path before saving. + datapath = Path(value["datapath"]) + new_paths = state_dir / datapath.name + + if new_paths.exists(): + new_paths.unlink() + copyfile(datapath, new_paths) + + value["datapath"] = new_paths.name + + return value diff --git a/shiny/bookmark/_types.py b/shiny/bookmark/_types.py new file mode 100644 index 000000000..e676e5776 --- /dev/null +++ b/shiny/bookmark/_types.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Awaitable, Callable, Literal, Union + +# Q: Can we merge how bookmark dirs are saved / loaded?... it's the same directory! However, the save will return a possibly new path. Restoring will return an existing path. +# A: No. Keep them separate. The save function may need to create a new directory, while the load function will always return an existing directory. + +BookmarkDirFn = Union[Callable[[str], Awaitable[Path]], Callable[[str], Path]] +BookmarkDirFnAsync = Callable[[str], Awaitable[Path]] + +BookmarkSaveDirFn = BookmarkDirFnAsync +BookmarkRestoreDirFn = BookmarkDirFnAsync + + +BookmarkStore = Literal["url", "server", "disable"] diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py new file mode 100644 index 000000000..4ed5d1ac5 --- /dev/null +++ b/shiny/bookmark/_utils.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import orjson + + +# https://github.com/rstudio/shiny/blob/f55c26af4a0493b082d2967aca6d36b90795adf1/R/server.R#L510-L514 +def in_shiny_server() -> bool: + shiny_port = os.environ.get("SHINY_PORT") + if shiny_port is None or shiny_port == "": + return False + + return True + + +def to_json_str(x: Any) -> str: + return orjson.dumps(x).decode() + + +def from_json_str(x: str) -> Any: + return orjson.loads(x) + + +# When saving to a file, use plain text json. +# (It's possible that we could store bytes, but unknown if there's any security benefit.) +# +# This makes the file contents independent of the json library used and +# independent of the python version being used +# (ex: pickle files are not compatible between python versions) +def to_json_file(x: Any, file: Path) -> None: + file.write_text(to_json_str(x), encoding="utf-8") + + +def from_json_file(file: Path) -> Any: + return from_json_str(file.read_text(encoding="utf-8")) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index aeb35766f..548fb04d6 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -7,14 +7,16 @@ import types from importlib.machinery import ModuleSpec from pathlib import Path -from typing import Mapping, Sequence, cast +from typing import Literal, Mapping, Sequence, cast from htmltools import Tag, TagList +from starlette.requests import Request from .._app import App from .._docstring import no_example from .._typing_extensions import NotRequired, TypedDict from .._utils import import_module_from_path +from ..bookmark._types import BookmarkStore from ..session import Inputs, Outputs, Session, get_current_session, session_context from ..types import MISSING, MISSING_TYPE from ._is_express import find_magic_comment_mode @@ -115,13 +117,13 @@ def create_express_app(file: Path, package_name: str) -> App: file = file.resolve() + stub_session = ExpressStubSession() try: globals_file = file.parent / "globals.py" if globals_file.is_file(): with session_context(None): import_module_from_path("globals", globals_file) - stub_session = ExpressStubSession() with session_context(stub_session): # We tagify here, instead of waiting for the App object to do it when it wraps # the UI in a HTMLDocument and calls render() on it. This is because @@ -134,6 +136,17 @@ def create_express_app(file: Path, package_name: str) -> App: except AttributeError as e: raise RuntimeError(e) from e + express_bookmark_store = stub_session.app_opts.get("bookmark_store", "disable") + if express_bookmark_store != "disable": + # If bookmarking is enabled, wrap UI in function to automatically leverage UI + # functions to restore their values + def app_ui_wrapper(request: Request): + # Stub session used to pass `app_opts()` checks. + with session_context(ExpressStubSession()): + return run_express(file, package_name).tagify() + + app_ui = app_ui_wrapper + def express_server(input: Inputs, output: Outputs, session: Session): try: run_express(file, package_name) @@ -290,6 +303,7 @@ def __getattr__(self, name: str): class AppOpts(TypedDict): static_assets: NotRequired[dict[str, Path]] + bookmark_store: NotRequired[BookmarkStore] debug: NotRequired[bool] @@ -297,6 +311,7 @@ class AppOpts(TypedDict): def app_opts( *, static_assets: str | Path | Mapping[str, str | Path] | MISSING_TYPE = MISSING, + bookmark_store: Literal["url", "server", "disable"] | MISSING_TYPE = MISSING, debug: bool | MISSING_TYPE = MISSING, ): """ @@ -314,6 +329,12 @@ def app_opts( that mount point. In Shiny Express, if there is a `www` subdirectory of the directory containing the app file, it will automatically be mounted at `/`, even without needing to set the option here. + bookmark_store + Where to store the bookmark state. + + * `"url"`: Encode the bookmark state in the URL. + * `"server"`: Store the bookmark state on the server. + * `"disable"`: Disable bookmarking. debug Whether to enable debug mode. """ @@ -340,6 +361,9 @@ def app_opts( stub_session.app_opts["static_assets"] = static_assets_paths + if not isinstance(bookmark_store, MISSING_TYPE): + stub_session.app_opts["bookmark_store"] = bookmark_store + if not isinstance(debug, MISSING_TYPE): stub_session.app_opts["debug"] = debug @@ -358,6 +382,9 @@ def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: elif "static_assets" in app_opts_new: app_opts["static_assets"] = app_opts_new["static_assets"].copy() + if "bookmark_store" in app_opts_new: + app_opts["bookmark_store"] = app_opts_new["bookmark_store"] + if "debug" in app_opts_new: app_opts["debug"] = app_opts_new["debug"] diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index a5e3e9809..11f11bd21 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -5,6 +5,7 @@ from htmltools import TagChild from .._namespaces import Id, Root +from ..bookmark import BookmarkExpressStub from ..module import ResolvedId from ..session import Inputs, Outputs, Session from ..session._session import SessionProxy @@ -45,6 +46,8 @@ def __init__(self, ns: ResolvedId = Root): # Application-level (not session-level) options that may be set via app_opts(). self.app_opts: AppOpts = {} + self.bookmark = BookmarkExpressStub(self) + def is_stub_session(self) -> Literal[True]: return True diff --git a/shiny/express/ui/__init__.py b/shiny/express/ui/__init__.py index 2edfa3c47..62a43de79 100644 --- a/shiny/express/ui/__init__.py +++ b/shiny/express/ui/__init__.py @@ -53,6 +53,7 @@ hover_opts, include_css, include_js, + input_bookmark_button, input_action_button, input_action_link, input_checkbox, @@ -211,6 +212,7 @@ "include_js", "input_action_button", "input_action_link", + "input_bookmark_button", "input_checkbox", "input_checkbox_group", "input_switch", diff --git a/shiny/input_handler.py b/shiny/input_handler.py index 38eff369d..2e234b91a 100644 --- a/shiny/input_handler.py +++ b/shiny/input_handler.py @@ -151,12 +151,14 @@ def _(value: str, name: ResolvedId, session: Session) -> str: # TODO: implement when we have bookmarking +# TODO: Barret: Input handler for passwords @input_handlers.add("shiny.password") def _(value: str, name: ResolvedId, session: Session) -> str: return value # TODO: implement when we have bookmarking +# TODO: Barret: Input handler for file inputs @input_handlers.add("shiny.file") def _(value: Any, name: ResolvedId, session: Session) -> Any: return value diff --git a/shiny/playwright/controller/_navs.py b/shiny/playwright/controller/_navs.py index a12c163ff..474f9e040 100644 --- a/shiny/playwright/controller/_navs.py +++ b/shiny/playwright/controller/_navs.py @@ -6,8 +6,7 @@ from playwright.sync_api import expect as playwright_expect from typing_extensions import Literal -from shiny.types import ListOrTuple - +from ...types import ListOrTuple from .._types import PatternOrStr, Timeout from ..expect import expect_to_have_class, expect_to_have_style from ..expect._internal import expect_attribute_to_have_value diff --git a/shiny/playwright/controller/_output.py b/shiny/playwright/controller/_output.py index a65896980..614407b83 100644 --- a/shiny/playwright/controller/_output.py +++ b/shiny/playwright/controller/_output.py @@ -6,8 +6,7 @@ from playwright.sync_api import Locator, Page from playwright.sync_api import expect as playwright_expect -from shiny.render._data_frame import ColumnFilter, ColumnSort - +from ...render._data_frame import ColumnFilter, ColumnSort from .._types import AttrValue, ListPatternOrStr, PatternOrStr, StyleValue, Timeout from ..expect import expect_not_to_have_class, expect_to_have_class from ..expect._internal import ( diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 89ebf043f..8f9b270a1 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,7 +1,6 @@ from __future__ import annotations __all__ = ("Session", "Inputs", "Outputs", "ClientData") - import asyncio import contextlib import dataclasses @@ -44,11 +43,17 @@ from .._namespaces import Id, Root from .._typing_extensions import NotRequired, TypedDict from .._utils import wrap_async +from ..bookmark import BookmarkApp, BookmarkProxy +from ..bookmark._button import BOOKMARK_ID +from ..bookmark._restore_state import RestoreContext from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..module import ResolvedId -from ..reactive import Effect_, Value, effect, flush, isolate -from ..reactive._core import lock, on_flushed +from ..reactive import Effect_, Value, effect +from ..reactive import flush as reactive_flush +from ..reactive import isolate +from ..reactive._core import lock +from ..reactive._core import on_flushed as reactive_on_flushed from ..render.renderer import Renderer, RendererT from ..types import ( Jsonifiable, @@ -61,6 +66,8 @@ if TYPE_CHECKING: from .._app import App + from ..bookmark import Bookmark + from ..bookmark._serializers import Unserializable class ConnectionState(enum.Enum): @@ -172,6 +179,11 @@ class Session(ABC): input: Inputs output: Outputs clientdata: ClientData + + # Could be done with a weak ref dict from root to all children. Then we could just + # iterate over all modules and check the `.bookmark_exclude` list of each proxy + # session. + bookmark: Bookmark user: str | None groups: list[str] | None @@ -523,6 +535,8 @@ def __init__( self.output: Outputs = Outputs(self, self.ns, outputs=dict()) self.clientdata: ClientData = ClientData(self) + self.bookmark: Bookmark = BookmarkApp(self) + self.user: str | None = None self.groups: list[str] | None = None credentials_json: str = "" @@ -623,14 +637,31 @@ def verify_state(expected_state: ConnectionState) -> None: if message_obj["method"] == "init": verify_state(ConnectionState.Start) + # BOOKMARKS! + if not isinstance(self.bookmark, BookmarkApp): + raise RuntimeError("`.bookmark` must be a BookmarkApp") + + if ".clientdata_url_search" in message_obj["data"]: + self.bookmark._set_restore_context( + await RestoreContext.from_query_string( + message_obj["data"][".clientdata_url_search"], + app=self.app, + ) + ) + else: + self.bookmark._set_restore_context(RestoreContext()) + # When a reactive flush occurs, flush the session's outputs, # errors, etc. to the client. Note that this is # `reactive._core.on_flushed`, not `self.on_flushed`. - unreg = on_flushed(self._flush) + unreg = reactive_on_flushed(self._flush) # When the session ends, stop flushing outputs on reactive # flush. stack.callback(unreg) + # Set up bookmark callbacks here + self.bookmark._create_effects() + conn_state = ConnectionState.Running message_obj = typing.cast(ClientMessageInit, message_obj) self._manage_inputs(message_obj["data"]) @@ -638,6 +669,11 @@ def verify_state(expected_state: ConnectionState) -> None: with session_context(self): self.app.server(self.input, self.output, self) + # TODO: Remove this call to reactive_flush() once https://github.com/posit-dev/py-shiny/issues/1889 is fixed + # Workaround: Any `on_flushed()` calls from bookmark's `on_restored()` will be flushed here + if self.bookmark.store != "disable": + await reactive_flush() + elif message_obj["method"] == "update": verify_state(ConnectionState.Running) @@ -663,7 +699,7 @@ def verify_state(expected_state: ConnectionState) -> None: self._request_flush() - await flush() + await reactive_flush() except ConnectionClosed: ... @@ -1009,6 +1045,10 @@ def _request_flush(self) -> None: async def _flush(self) -> None: with session_context(self): + # This is the only place in the session where the RestoreContext is flushed. + if self.bookmark._restore_context: + self.bookmark._restore_context.flush_pending() + # Flush the callbacks await self._flush_callbacks.invoke() try: @@ -1161,6 +1201,8 @@ class UpdateProgressMessage(TypedDict): class SessionProxy(Session): def __init__(self, parent: Session, ns: ResolvedId) -> None: + super().__init__() + self._parent = parent self.app = parent.app self.id = parent.id @@ -1174,6 +1216,8 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._outbound_message_queues = parent._outbound_message_queues self._downloads = parent._downloads + self.bookmark = BookmarkProxy(self) + def _is_hidden(self, name: str) -> bool: return self._parent._is_hidden(name) @@ -1307,11 +1351,25 @@ class Inputs: it can be accessed via `input["x"]()` or ``input.x()``. """ + _serializers: dict[ + str, + Callable[ + [Any, Path | None], + Awaitable[Any | Unserializable], + ], + ] + """ + A dictionary of serializers for input values. + + Set this value via `Inputs.set_serializer(id, fn)`. + """ + def __init__( self, values: dict[str, Value[Any]], ns: Callable[[str], str] = Root ) -> None: self._map = values self._ns = ns + self._serializers = {} def __setitem__(self, key: str, value: Value[Any]) -> None: if not isinstance(value, reactive.Value): @@ -1334,14 +1392,14 @@ def __delitem__(self, key: str) -> None: # Allow access of values as attributes. def __setattr__(self, attr: str, value: Value[Any]) -> None: - if attr in ("_map", "_ns"): + if attr in ("_map", "_ns", "_serializers"): super().__setattr__(attr, value) return self.__setitem__(attr, value) def __getattr__(self, attr: str) -> Value[Any]: - if attr in ("_map", "_ns"): + if attr in ("_map", "_ns", "_serializers"): return object.__getattribute__(self, attr) return self.__getitem__(attr) @@ -1358,6 +1416,75 @@ def __contains__(self, key: str) -> bool: def __dir__(self): return list(self._map.keys()) + # This method can not be on the `Value` class as the _value_ may not exist when the + # "creating" method is executed. + # Ex: File inputs do not _make_ the input reactive value. The browser does when the + # client sets the value. + def set_serializer( + self, + id: str, + fn: ( + Callable[ + [Any, Path | None], + Awaitable[Any | Unserializable], + ] + | Callable[ + [Any, Path | None], + Any | Unserializable, + ] + ), + ) -> None: + """ + Add a function for serializing an input before bookmarking application state + + Parameters + ---------- + id + The ID of the input value. + fn + A function that takes the input value and returns a modified value. The + returned value will be used for test snapshots and bookmarking. + """ + self._serializers[id] = wrap_async(fn) + + async def _serialize( + self, + /, + *, + exclude: list[str], + state_dir: Path | None, + ) -> dict[str, Any]: + from ..bookmark._serializers import Unserializable, serializer_default + + exclude_set = set(exclude) + serialized_values: dict[str, Any] = {} + + with reactive.isolate(): + + for key, value in self._map.items(): + # TODO: Barret - Q: Should this be ignoring any Input key that starts with a "."? + if key.startswith(".clientdata_"): + continue + # Ignore all bookmark inputs + if key == BOOKMARK_ID or key.endswith( + f"{ResolvedId._sep}{BOOKMARK_ID}" + ): + continue + if key in exclude_set: + continue + val = value() + + # Possibly apply custom serialization given the input id + serializer = self._serializers.get(key, serializer_default) + serialized_value = await serializer(val, state_dir) + + # Filter out any values that were marked as unserializable. + if isinstance(serialized_value, Unserializable): + continue + serialized_values[str(key)] = serialized_value + + return serialized_values + @add_example() class ClientData: diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index 5bc52c33d..aaca477b0 100644 --- a/shiny/session/_utils.py +++ b/shiny/session/_utils.py @@ -57,7 +57,7 @@ def get_current_session() -> Optional[Session]: @contextmanager -def session_context(session: Optional[Session]): +def session_context(session: Session | None): """ A context manager for current session. diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 9ca59cfa9..9cbf0d74c 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -31,15 +31,16 @@ tags, ) -# The css module is for internal use, so we won't re-export it. -from . import css # noqa: F401 # pyright: ignore[reportUnusedImport] +from ..bookmark._button import input_bookmark_button +# The css module is for internal use, so we won't re-export it. # Expose the fill module for extended usage: ex: ui.fill.as_fill_item(x). -from . import fill - # Export busy_indicators module -from . import busy_indicators - +from . import ( # noqa: F401 + busy_indicators, + css, # pyright: ignore[reportUnusedImport] + fill, +) from ._accordion import ( AccordionPanel, accordion, @@ -365,4 +366,6 @@ "fill", # utils "js_eval", + # bookmark + "input_bookmark_button", ) diff --git a/shiny/ui/_input_action_button.py b/shiny/ui/_input_action_button.py index b0909327f..8cd9cca46 100644 --- a/shiny/ui/_input_action_button.py +++ b/shiny/ui/_input_action_button.py @@ -54,6 +54,7 @@ def input_action_button( -------- * :func:`~shiny.ui.update_action_button` * :func:`~shiny.ui.input_action_link` + * :func:`~shiny.ui.input_bookmark_button` * :func:`~shiny.reactive.event` """ diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index 3ba0e0c71..a4ee620bf 100644 --- a/shiny/ui/_input_check_radio.py +++ b/shiny/ui/_input_check_radio.py @@ -11,6 +11,7 @@ from htmltools import Tag, TagChild, css, div, span, tags from .._docstring import add_example +from ..bookmark import restore_input from ..module import resolve_id from ._html_deps_shinyverse import components_dependencies from ._utils import shiny_input_label @@ -287,11 +288,12 @@ def input_radio_buttons( resolved_id = resolve_id(id) input_label = shiny_input_label(resolved_id, label) + options = _generate_options( id=resolved_id, type="radio", choices=choices, - selected=selected, + selected=restore_input(resolved_id, selected), inline=inline, ) return div( diff --git a/shiny/ui/_input_task_button.py b/shiny/ui/_input_task_button.py index 1afeb0c59..43d229d82 100644 --- a/shiny/ui/_input_task_button.py +++ b/shiny/ui/_input_task_button.py @@ -7,13 +7,12 @@ from htmltools import HTML, Tag, TagAttrValue, TagChild, css, tags -from shiny.types import MISSING, MISSING_TYPE - from .._docstring import add_example from .._typing_extensions import ParamSpec from ..module import resolve_id from ..reactive._extended_task import ExtendedTask from ..reactive._reactives import effect +from ..types import MISSING, MISSING_TYPE from ._html_deps_py_shiny import spin_dependency from ._html_deps_shinyverse import components_dependencies diff --git a/tests/playwright/shiny/bookmark/dir/.gitignore b/tests/playwright/shiny/bookmark/dir/.gitignore new file mode 100644 index 000000000..b22e6189e --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/.gitignore @@ -0,0 +1 @@ +bookmarks-*/ diff --git a/tests/playwright/shiny/bookmark/dir/app-attr.py b/tests/playwright/shiny/bookmark/dir/app-attr.py new file mode 100644 index 000000000..c81261517 --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/app-attr.py @@ -0,0 +1,74 @@ +import shutil +from pathlib import Path + +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny._utils import rand_hex + + +def app_ui(request: Request): + return ui.page_fluid( + ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]), + ui.h3("Has saved:"), + ui.output_code("called_saved"), + ui.h3("Has restored:"), + ui.output_code("called_restored"), + ) + + +def server(input: Inputs, ouput: Outputs, session: Session): + + @reactive.effect + @reactive.event(input.letter, ignore_init=True) + async def _(): + await session.bookmark() + + @session.bookmark.on_bookmarked + async def _(url: str): + await session.bookmark.update_query_string(url) + + @render.code + def called_saved(): + reactive.invalidate_later(1) + return str(did_save) + + @render.code + def called_restored(): + reactive.invalidate_later(1) + return str(did_restore) + + +did_save = False +did_restore = False + +# Note: +# This is a "temp" directory that is only used for testing and is cleaned up on app +# shutdown. This should NOT be standard behavior of a hosting environment. Instead, it +# should have a persistent directory that can be restored over time. +bookmark_dir = Path(__file__).parent / f"bookmarks-{rand_hex(8)}" +bookmark_dir.mkdir(exist_ok=True) + +app = App(app_ui, server, bookmark_store="server") + + +app.on_shutdown(lambda: shutil.rmtree(bookmark_dir)) + + +def restore_bookmark_dir(id: str) -> Path: + global did_restore + did_restore = True + return bookmark_dir / id + + +def save_bookmark_dir(id: str) -> Path: + global did_save + did_save = True + save_dir = bookmark_dir / id + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir + + +# Same exact app as `app-global.py`, except we're using `App` functions to set the save and restore directories. +app.set_bookmark_save_dir_fn(save_bookmark_dir) +app.set_bookmark_restore_dir_fn(restore_bookmark_dir) diff --git a/tests/playwright/shiny/bookmark/dir/app-global.py b/tests/playwright/shiny/bookmark/dir/app-global.py new file mode 100644 index 000000000..5d2bbd9ea --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/app-global.py @@ -0,0 +1,76 @@ +import shutil +from pathlib import Path + +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny._utils import rand_hex +from shiny.bookmark import set_global_restore_dir_fn, set_global_save_dir_fn + + +def app_ui(request: Request): + return ui.page_fluid( + ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]), + ui.h3("Has saved:"), + ui.output_code("called_saved"), + ui.h3("Has restored:"), + ui.output_code("called_restored"), + ) + + +def server(input: Inputs, ouput: Outputs, session: Session): + + @reactive.effect + @reactive.event(input.letter, ignore_init=True) + async def _(): + await session.bookmark() + + @session.bookmark.on_bookmarked + async def _(url: str): + await session.bookmark.update_query_string(url) + + @render.code + def called_saved(): + reactive.invalidate_later(1) + return str(did_save) + + @render.code + def called_restored(): + reactive.invalidate_later(1) + return str(did_restore) + + +did_save = False +did_restore = False + +# Note: +# This is a "temp" directory that is only used for testing and is cleaned up on app +# shutdown. This should NOT be standard behavior of a hosting environment. Instead, it +# should have a persistent directory that can be restored over time. +bookmark_dir = Path(__file__).parent / f"bookmarks-{rand_hex(8)}" +bookmark_dir.mkdir(exist_ok=True) + + +def restore_bookmark_dir(id: str) -> Path: + global did_restore + did_restore = True + return bookmark_dir / id + + +def save_bookmark_dir(id: str) -> Path: + global did_save + did_save = True + save_dir = bookmark_dir / id + save_dir.mkdir(parents=True, exist_ok=True) + return save_dir + + +# Same exact app as `app-attr.py`, except we're using global functions to set the save and restore directories. +set_global_restore_dir_fn(restore_bookmark_dir) +set_global_save_dir_fn(save_bookmark_dir) + + +app = App(app_ui, server, bookmark_store="server") + + +app.on_shutdown(lambda: shutil.rmtree(bookmark_dir)) diff --git a/tests/playwright/shiny/bookmark/dir/test_bookmark_global.py b/tests/playwright/shiny/bookmark/dir/test_bookmark_global.py new file mode 100644 index 000000000..a4422843c --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/test_bookmark_global.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import pytest +from playwright.sync_api import Page + +from shiny.playwright.controller import InputRadioButtons, OutputCode +from shiny.run import ShinyAppProc, run_shiny_app + + +@pytest.mark.parametrize("app_name", ["app-attr.py", "app-global.py"]) +def test_bookmark_modules(page: Page, app_name: str): + + app: ShinyAppProc = run_shiny_app( + Path(__file__).parent / app_name, + wait_for_start=True, + ) + + try: + + page.goto(app.url) + + called_saved = OutputCode(page, "called_saved") + called_restored = OutputCode(page, "called_restored") + called_saved.expect_value("False") + called_restored.expect_value("False") + + letter = InputRadioButtons(page, "letter") + letter.expect_selected("A") + letter.set("B") + + called_saved.expect_value("True") + called_restored.expect_value("False") + + page.reload() + + called_restored.expect_value("True") + + letter.expect_selected("B") + assert "_state_id_" in page.url + + finally: + app.close() diff --git a/tests/playwright/shiny/bookmark/modules/app-core-recursive.py b/tests/playwright/shiny/bookmark/modules/app-core-recursive.py new file mode 100644 index 000000000..a3dfaf70d --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/app-core-recursive.py @@ -0,0 +1,129 @@ +import os +from typing import Literal + +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui +from shiny.bookmark import BookmarkState +from shiny.bookmark._restore_state import RestoreState + + +@module.ui +def mod_btn(idx: int = 1): + return ui.TagList( + ui.h3(f"Module {idx}"), + ui.layout_column_wrap( + ui.TagList( + ui.input_radio_buttons( + "btn1", "Button Input", choices=["a", "b", "c"], selected="a" + ), + ui.input_radio_buttons( + "btn2", + "Button Value", + choices=["a", "b", "c"], + selected="a", + ), + ), + ui.output_ui("ui_html"), + ui.output_code("value"), + width="200px", + ), + ui.hr(), + mod_btn(f"sub{idx}", idx - 1) if idx > 0 else None, + ) + + +@module.server +def btn_server(input: Inputs, output: Outputs, session: Session, idx: int = 1): + + @render.ui + def ui_html(): + return ui.TagList( + ui.input_radio_buttons( + "dyn1", "Dynamic Input", choices=["a", "b", "c"], selected="a" + ), + ui.input_radio_buttons( + "dyn2", "Dynamic Value", choices=["a", "b", "c"], selected="a" + ), + ) + + @render.code + def value(): + value_arr = [input.btn1(), input.btn2(), input.dyn1(), input.dyn2()] + return f"{value_arr}" + + @reactive.effect + @reactive.event(input.btn1, input.btn2, input.dyn1, input.dyn2, ignore_init=True) + async def _(): + # print("app-Bookmarking!") + await session.bookmark() + + session.bookmark.exclude.append("btn2") + session.bookmark.exclude.append("dyn2") + + @session.bookmark.on_bookmark + def _(state: BookmarkState) -> None: + state.values["btn2"] = input.btn2() + state.values["dyn2"] = input.dyn2() + + @session.bookmark.on_restore + def _(restore_state: RestoreState) -> None: + # print("app-Restore state:", restore_state.values) + + if "btn2" in restore_state.values: + + ui.update_radio_buttons("btn2", selected=restore_state.values["btn2"]) + + if "dyn2" in restore_state.values: + + ui.update_radio_buttons("dyn2", selected=restore_state.values["dyn2"]) + + if idx > 0: + btn_server(f"sub{idx}", idx - 1) + + +k = 2 + + +def app_ui(request: Request) -> ui.Tag: + # print("app-Making UI") + return ui.page_fixed( + ui.output_code("bookmark_store"), + "Click Buttons to update bookmark", + mod_btn(f"mod{k - 1}", k - 1), + ) + + +# Needs access to the restore context to the dynamic UI +def server(input: Inputs, output: Outputs, session: Session): + + btn_server(f"mod{k - 1}", k - 1) + + @render.code + def bookmark_store(): + return f"{session.bookmark.store}" + + @session.bookmark.on_bookmark + async def on_bookmark(state: BookmarkState) -> None: + print( + "app-On Bookmark", + "\nInputs: ", + await state.input._serialize(exclude=state.exclude, state_dir=None), + "\nValues: ", + state.values, + "\n\n", + ) + # session.bookmark.update_query_string() + + pass + + session.bookmark.on_bookmarked(session.bookmark.update_query_string) + # session.bookmark.on_bookmarked(session.bookmark.show_modal) + + +SHINY_BOOKMARK_STORE: Literal["url", "server"] = os.getenv( + "SHINY_BOOKMARK_STORE", "url" +) # pyright: ignore[reportAssignmentType] +if SHINY_BOOKMARK_STORE not in ["url", "server"]: + raise ValueError("SHINY_BOOKMARK_STORE must be either 'url' or 'server'") +app = App(app_ui, server, bookmark_store=SHINY_BOOKMARK_STORE, debug=False) diff --git a/tests/playwright/shiny/bookmark/modules/app-core.py b/tests/playwright/shiny/bookmark/modules/app-core.py new file mode 100644 index 000000000..d39b1ada5 --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/app-core.py @@ -0,0 +1,140 @@ +import os +from typing import Literal + +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, module, reactive, render, ui +from shiny.bookmark import BookmarkState +from shiny.bookmark._restore_state import RestoreState + + +@module.ui +def mod_btn(idx: int): + return ui.TagList( + ui.h3(f"Module {idx}"), + ui.layout_column_wrap( + ui.TagList( + ui.input_radio_buttons( + "btn1", + "Button Input", + choices=["a", "b", "c"], + selected="a", + ), + ui.input_radio_buttons( + "btn2", + "Button Value", + choices=["a", "b", "c"], + selected="a", + ), + ), + ui.output_ui("ui_html"), + ui.output_code("value"), + width="200px", + # fill=True, + # fillable=True, + # height="75px", + ), + ui.hr(), + ) + + +@module.server +def btn_server(input: Inputs, output: Outputs, session: Session, idx: int = 3): + + @render.ui + def ui_html(): + return ui.TagList( + ui.input_radio_buttons( + "dyn1", "Dynamic Input", choices=["a", "b", "c"], selected="a" + ), + ui.input_radio_buttons( + "dyn2", "Dynamic Value", choices=["a", "b", "c"], selected="a" + ), + ) + + @render.code + def value(): + value_arr = [input.btn1(), input.btn2(), input.dyn1(), input.dyn2()] + return f"{value_arr}" + + @reactive.effect + @reactive.event(input.btn1, input.btn2, input.dyn1, input.dyn2, ignore_init=True) + async def _(): + # print("app-Bookmarking!") + await session.bookmark() + + session.bookmark.exclude.append("btn2") + session.bookmark.exclude.append("dyn2") + + @session.bookmark.on_bookmark + def _(state: BookmarkState) -> None: + state.values["btn2"] = input.btn2() + state.values["dyn2"] = input.dyn2() + + @session.bookmark.on_restore + def _(restore_state: RestoreState) -> None: + # print("app-Restore state:", restore_state.values) + + if "btn2" in restore_state.values: + + ui.update_radio_buttons("btn2", selected=restore_state.values["btn2"]) + + if "dyn2" in restore_state.values: + + ui.update_radio_buttons("dyn2", selected=restore_state.values["dyn2"]) + + +k = 2 + + +def app_ui(request: Request) -> ui.Tag: + # print("app-Making UI") + return ui.page_fixed( + ui.output_code("bookmark_store"), + "Click Button to update bookmark", + # ui.input_action_button("btn", "Button"), + *[mod_btn(f"mod{i}", i) for i in reversed(range(k))], + # ui.input_radio_buttons("btn", "Button", choices=["a", "b", "c"], selected="a"), + # ui.output_code("code"), + # ui.input_bookmark_button(), + ) + + +# Needs access to the restore context to the dynamic UI +def server(input: Inputs, output: Outputs, session: Session): + + @render.code + def bookmark_store(): + return f"{session.bookmark.store}" + + for i in reversed(range(k)): + btn_server(f"mod{i}", i) + + @session.bookmark.on_bookmark + async def on_bookmark(state: BookmarkState) -> None: + # print( + # "app-On Bookmark", + # "\nInputs: ", + # await state.input._serialize(exclude=state.exclude, state_dir=None), + # "\nValues: ", + # state.values, + # "\n\n", + # ) + # session.bookmark.update_query_string() + + pass + + session.bookmark.on_bookmarked(session.bookmark.update_query_string) + # session.bookmark.on_bookmarked(session.bookmark.show_modal) + + # @render.code + # def code(): + # return f"{input.btn()}" + + +SHINY_BOOKMARK_STORE: Literal["url", "server"] = os.getenv( + "SHINY_BOOKMARK_STORE", "url" +) # pyright: ignore[reportAssignmentType] +if SHINY_BOOKMARK_STORE not in ["url", "server"]: + raise ValueError("SHINY_BOOKMARK_STORE must be either 'url' or 'server'") +app = App(app_ui, server, bookmark_store=SHINY_BOOKMARK_STORE, debug=False) diff --git a/tests/playwright/shiny/bookmark/modules/app-express.py b/tests/playwright/shiny/bookmark/modules/app-express.py new file mode 100644 index 000000000..8733cce22 --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/app-express.py @@ -0,0 +1,121 @@ +import os +from typing import Literal + +from shiny import Inputs, Outputs, Session, reactive, render +from shiny.bookmark import BookmarkState +from shiny.bookmark._restore_state import RestoreState +from shiny.express import app_opts, module, session, ui + +SHINY_BOOKMARK_STORE: Literal["url", "server"] = os.getenv( + "SHINY_BOOKMARK_STORE", "url" +) # pyright: ignore[reportAssignmentType] +if SHINY_BOOKMARK_STORE not in ["url", "server"]: + raise ValueError("SHINY_BOOKMARK_STORE must be either 'url' or 'server'") + +app_opts(bookmark_store=SHINY_BOOKMARK_STORE) + + +@render.code +def bookmark_store(): + return f"{session.bookmark.store}" + + +@module +def recursive_mod(input: Inputs, output: Outputs, session: Session, recurse: int = 3): + + ui.h3(f"Module {recurse}") + with ui.layout_column_wrap(width="200px"): + ui.TagList( + ui.input_radio_buttons( + "btn1", "Button Input", choices=["a", "b", "c"], selected="a" + ), + ui.input_radio_buttons( + "btn2", + "Button Value", + choices=["a", "b", "c"], + selected="a", + ), + ) + + @render.ui + def ui_html(): + return ui.TagList( + ui.input_radio_buttons( + "dyn1", "Dynamic Input", choices=["a", "b", "c"], selected="a" + ), + ui.input_radio_buttons( + "dyn2", "Dynamic Value", choices=["a", "b", "c"], selected="a" + ), + ) + + @render.code + def value(): + value_arr = [input.btn1(), input.btn2(), input.dyn1(), input.dyn2()] + return f"{value_arr}" + + ui.hr() + + @reactive.effect + @reactive.event(input.btn1, input.btn2, input.dyn1, input.dyn2, ignore_init=True) + async def _(): + # print("app-Bookmarking!") + await session.bookmark() + + session.bookmark.exclude.append("btn2") + session.bookmark.exclude.append("dyn2") + + @session.bookmark.on_bookmark + def _(state: BookmarkState) -> None: + state.values["btn2"] = input.btn2() + state.values["dyn2"] = input.dyn2() + + @session.bookmark.on_restore + def _(restore_state: RestoreState) -> None: + # print("app-Restore state:", restore_state.values) + + if "btn2" in restore_state.values: + + ui.update_radio_buttons("btn2", selected=restore_state.values["btn2"]) + + if "dyn2" in restore_state.values: + + ui.update_radio_buttons("dyn2", selected=restore_state.values["dyn2"]) + + +"Click Button to update bookmark" + +k = 2 +for i in reversed(range(k)): + recursive_mod(f"mod{i}", i) + + +# ui.input_radio_buttons("btn", "Button", choices=["a", "b", "c"], selected="a") + + +# @render.code +# def code(): +# return f"{input.btn()}" + + +# ui.input_bookmark_button() + + +# @session.bookmark.on_bookmark +# async def on_bookmark(state: BookmarkState) -> None: +# print( +# "app-On Bookmark", +# "\nInputs: ", +# await state.input._serialize(exclude=state.exclude, state_dir=None), +# "\nValues: ", +# state.values, +# "\n\n", +# ) +# # session.bookmark.update_query_string() + + +@session.bookmark.on_bookmarked +async def _(url: str): + await session.bookmark.update_query_string(url) + + +# session.bookmark.on_bookmarked(session.bookmark.show_modal) diff --git a/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py new file mode 100644 index 000000000..31306d9cc --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py @@ -0,0 +1,81 @@ +import os +from pathlib import Path + +import pytest +from playwright.sync_api import Page + +from shiny.playwright.controller import InputRadioButtons, OutputCode +from shiny.run import ShinyAppProc, run_shiny_app + + +@pytest.mark.parametrize( + "app_name,mod0_key,mod1_key", + [ + # Express mode + ("app-express.py", "mod0", "mod1"), + # Core mode + ("app-core.py", "mod0", "mod1"), + # Recursive modules within core mode + ("app-core-recursive.py", "mod1-sub1", "mod1"), + ], +) +@pytest.mark.parametrize("bookmark_store", ["url", "server"]) +def test_bookmark_modules( + page: Page, + bookmark_store: str, + app_name: str, + mod0_key: str, + mod1_key: str, +) -> None: + + # Set environment variable before the app starts + os.environ["SHINY_BOOKMARK_STORE"] = bookmark_store + + app: ShinyAppProc = run_shiny_app( + Path(__file__).parent / app_name, + wait_for_start=True, + ) + + try: + + page.goto(app.url) + + OutputCode(page, "bookmark_store").expect_value(bookmark_store) + + def expect_mod(mod_key: str, values: list[str]): + assert len(values) == 4 + OutputCode(page, f"{mod_key}-value").expect_value(str(values)) + InputRadioButtons(page, f"{mod_key}-btn1").expect_selected(values[0]) + InputRadioButtons(page, f"{mod_key}-btn2").expect_selected(values[1]) + InputRadioButtons(page, f"{mod_key}-dyn1").expect_selected(values[2]) + InputRadioButtons(page, f"{mod_key}-dyn2").expect_selected(values[3]) + + def set_mod(mod_key: str, values: list[str]): + assert len(values) == 4 + InputRadioButtons(page, f"{mod_key}-btn1").set(values[0]) + InputRadioButtons(page, f"{mod_key}-btn2").set(values[1]) + InputRadioButtons(page, f"{mod_key}-dyn1").set(values[2]) + InputRadioButtons(page, f"{mod_key}-dyn2").set(values[3]) + + expect_mod(mod0_key, ["a", "a", "a", "a"]) + expect_mod(mod1_key, ["a", "a", "a", "a"]) + + set_mod(mod0_key, ["b", "b", "c", "c"]) + + expect_mod(mod0_key, ["b", "b", "c", "c"]) + expect_mod(mod1_key, ["a", "a", "a", "a"]) + + page.reload() + + expect_mod(mod0_key, ["b", "b", "c", "c"]) + expect_mod(mod1_key, ["a", "a", "a", "a"]) + + if bookmark_store == "url": + assert "_inputs_" in page.url + assert "_values_" in page.url + if bookmark_store == "server": + assert "_state_id_" in page.url + + finally: + app.close() + os.environ.pop("SHINY_BOOKMARK_STORE") diff --git a/tests/playwright/shiny/components/card-input/test_card-input.py b/tests/playwright/shiny/components/card-input/test_card-input.py index b2a25eaa8..4d2ae64a7 100644 --- a/tests/playwright/shiny/components/card-input/test_card-input.py +++ b/tests/playwright/shiny/components/card-input/test_card-input.py @@ -19,33 +19,38 @@ def test_card_input(page: Page, app_path: str, sel_card: str, sel_vb: str) -> No Path(__file__).parent / app_path, wait_for_start=True ) - page.goto(sa.url) + try: + page.goto(sa.url) - card = controller.Card(page, sel_card) - vb = controller.ValueBox(page, sel_vb) - out_card = controller.OutputCode(page, "out_card") - out_vb = controller.OutputCode(page, "out_value_box") + card = controller.Card(page, sel_card) + vb = controller.ValueBox(page, sel_vb) + out_card = controller.OutputCode(page, "out_card") + out_vb = controller.OutputCode(page, "out_value_box") - # Open and close card full screen, check input value ------ - card.expect_full_screen(False) - out_card.expect_value("False") + # Open and close card full screen, check input value ------ + card.expect_full_screen(False) + out_card.expect_value("False") - card.set_full_screen(True) - card.expect_full_screen(True) - out_card.expect_value("True") + card.set_full_screen(True) + card.expect_full_screen(True) + out_card.expect_value("True") - card.set_full_screen(False) - card.expect_full_screen(False) - out_card.expect_value("False") + card.set_full_screen(False) + card.expect_full_screen(False) + out_card.expect_value("False") - # Open and close value box full screen, check input value ------ - vb.expect_full_screen(False) - out_vb.expect_value("False") + # Open and close value box full screen, check input value ------ + vb.expect_full_screen(False) + out_vb.expect_value("False") - vb.set_full_screen(True) - vb.expect_full_screen(True) - out_vb.expect_value("True") + vb.set_full_screen(True) + vb.expect_full_screen(True) + out_vb.expect_value("True") - vb.set_full_screen(False) - vb.expect_full_screen(False) - out_vb.expect_value("False") + vb.set_full_screen(False) + vb.expect_full_screen(False) + out_vb.expect_value("False") + + finally: + + sa.close() diff --git a/tests/playwright/shiny/components/chat/icon/test_chat_icon.py b/tests/playwright/shiny/components/chat/icon/test_chat_icon.py index 360f680d5..4828f420e 100644 --- a/tests/playwright/shiny/components/chat/icon/test_chat_icon.py +++ b/tests/playwright/shiny/components/chat/icon/test_chat_icon.py @@ -15,7 +15,7 @@ def __init__(self, page: Page, id: str, classes: str): def expect_last_message_icon_to_have_classes(self, classes: Optional[str] = None): last_msg_icon = self.chat.loc_latest_message.locator(".message-icon > *").first - expect(last_msg_icon).to_have_class(classes or self.classes) + expect(last_msg_icon).to_have_class(classes or self.classes, timeout=30 * 1000) @skip_on_webkit @@ -30,7 +30,7 @@ def test_validate_chat_basic(page: Page, local_app: ShinyAppProc) -> None: ] for mod in chats: - expect(mod.chat.loc).to_be_visible(timeout=30 * 100) + expect(mod.chat.loc).to_be_visible(timeout=30 * 1000) mod.expect_last_message_icon_to_have_classes() mod.chat.set_user_input(f"Hi {mod.id}.") diff --git a/tests/pytest/test_render_data_frame_tbl_data.py b/tests/pytest/test_render_data_frame_tbl_data.py index c0e488f7b..84fdbaf62 100644 --- a/tests/pytest/test_render_data_frame_tbl_data.py +++ b/tests/pytest/test_render_data_frame_tbl_data.py @@ -1,4 +1,4 @@ -# TODO-barret: ts code to stringify objects? +# TODO: Barret: ts code to stringify objects? from __future__ import annotations