From d3b5c4515b31f33ad16bd9431bd4385b28a6dcfa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Feb 2025 16:23:28 -0500 Subject: [PATCH 01/62] Input serializers and serializer method --- shiny/session/_session.py | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 1333aa310..62624e664 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,5 +1,7 @@ from __future__ import annotations +from shiny.bookmark._serializers import Unserializable + __all__ = ("Session", "Inputs", "Outputs", "ClientData") import asyncio @@ -1357,6 +1359,75 @@ def __contains__(self, key: str) -> bool: def __dir__(self): return list(self._map.keys()) + _serializers: dict[ + str, + Callable[ + [Any, Path | None], + Awaitable[Any | Unserializable], + ], + ] + + # 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 serializer_default + + exclude_set = set(exclude) + serialized_values: dict[str, Any] = {} + + with reactive.isolate(): + + for key, value in self._map.items(): + 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[key] = serialized_value + + return serialized_values + @add_example() class ClientData: From e43d33c26d2132cafff208f6a796d9bd5e81e3a4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Feb 2025 16:23:50 -0500 Subject: [PATCH 02/62] Add utils; `is_hosted()` needs to be inspected --- shiny/bookmark/_utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 shiny/bookmark/_utils.py diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py new file mode 100644 index 000000000..32e774a3e --- /dev/null +++ b/shiny/bookmark/_utils.py @@ -0,0 +1,21 @@ +import os +from typing import Any + +import orjson + + +def is_hosted() -> bool: + # Can't look at SHINY_PORT, as we already set it in shiny/_main.py's `run_app()` + + # TODO: Support shinyapps.io or use `SHINY_PORT` how R-shiny did + + # Instead, looking for the presence of the environment variable that Connect sets + # (*_Connect) or Shiny Server sets (SHINY_APP) + for env_var in ("POSIT_CONNECT", "RSTUDIO_CONNECT", "SHINY_APP"): + if env_var in os.environ: + return True + return False + + +def to_json(x: Any) -> dict[str, Any]: + return orjson.loads(orjson.dumps(x)) From 3136d3dcee1440db36111423f1a9a5d7017aaa12 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Feb 2025 16:24:44 -0500 Subject: [PATCH 03/62] Create _serializers.py --- shiny/bookmark/_serializers.py | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 shiny/bookmark/_serializers.py diff --git a/shiny/bookmark/_serializers.py b/shiny/bookmark/_serializers.py new file mode 100644 index 000000000..c24c258f0 --- /dev/null +++ b/shiny/bookmark/_serializers.py @@ -0,0 +1,49 @@ +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 From 1ef9e82c0cc734978a820ad4a0f1dae5bb0a29d0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Feb 2025 16:25:19 -0500 Subject: [PATCH 04/62] Create _save_state.py --- shiny/bookmark/_save_state.py | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 shiny/bookmark/_save_state.py diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py new file mode 100644 index 000000000..6d26a58ee --- /dev/null +++ b/shiny/bookmark/_save_state.py @@ -0,0 +1,95 @@ +# TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 + +import os +from abc import ABC, abstractmethod +from pathlib import Path + + +class SaveState(ABC): + """ + Class for saving and restoring state to/from disk. + """ + + @abstractmethod + async def save_dir( + self, + id: str, + # write_files: Callable[[Path], Awaitable[None]], + ) -> Path: + """ + Construct directory for saving state. + + Parameters + ---------- + id + The unique identifier for the state. + + Returns + ------- + Path + Directory location for saving state. This directory must exist. + """ + # write_files + # A async function that writes the state to a serializable location. The method receives a path object and + ... + + @abstractmethod + async def load_dir( + self, + id: str, + # read_files: Callable[[Path], Awaitable[None]], + ) -> Path: + """ + Construct directory for loading state. + + Parameters + ---------- + id + The unique identifier for the state. + + Returns + ------- + Path | None + Directory location for loading state. If `None`, state loading will be ignored. If a `Path`, the directory must exist. + """ + ... + + +class SaveStateLocal(SaveState): + """ + Function wrappers for saving and restoring state to/from disk when running Shiny + locally. + """ + + def _local_dir(self, 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 save_dir(self, id: str) -> Path: + state_dir = self._local_dir(id) + if not state_dir.exists(): + state_dir.mkdir(parents=True) + return state_dir + + async def load_dir(self, id: str) -> Path: + return self._local_dir(id) + + # async def save( + # self, + # id: str, + # write_files: Callable[[Path], Awaitable[None]], + # ) -> None: + # state_dir = self._local_dir(id) + # if not state_dir.exists(): + # state_dir.mkdir(parents=True) + + # await write_files(state_dir) + + # async def load( + # self, + # id: str, + # read_files: Callable[[Path], Awaitable[None]], + # ) -> None: + # await read_files(self._local_dir(id)) + # await read_files(self._local_dir(id)) From e3d6a7fe8bcf1794dc46c0ae28a2fcdbe99c1456 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 25 Feb 2025 16:26:01 -0500 Subject: [PATCH 05/62] First pass at `ShinySaveState` class; Implemented `_save_state()` and `_encode_state()` --- shiny/bookmark/__init__.py | 5 + shiny/bookmark/_bookmark.py | 1268 +++++++++++++++++++++++++++++++++++ 2 files changed, 1273 insertions(+) create mode 100644 shiny/bookmark/__init__.py create mode 100644 shiny/bookmark/_bookmark.py diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py new file mode 100644 index 000000000..fa2fb55a7 --- /dev/null +++ b/shiny/bookmark/__init__.py @@ -0,0 +1,5 @@ +from _bookmark import ShinySaveState + +from ._save_state import SaveState + +__all__ = ("SaveState", "ShinySaveState") diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py new file mode 100644 index 000000000..f8562cade --- /dev/null +++ b/shiny/bookmark/_bookmark.py @@ -0,0 +1,1268 @@ +# TODO: +# bookmark -> save/load interface +# * √ base class +# * √ local +# save/load interface -> register interface +# * implement; Q on approach! +# register interface -> Make interface for Connect +# * 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.setBookmarkExclude(names)` +# * `session.getBookmarkExclude()` +# Session hooks -> `onBookmark()`, `onBookmarked()`, `onRestore(), `onRestored()` +# * `session.onBookmark()` +# * `session.onBookmarked()` +# * `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.doBookmark()` +# Session updates -> Require updates for `SessionProxy` object +# `doBookmark()` -> Update query string +# * Update query string + +# bookmark -> restore state +# restore state -> {inputs, values, exclude} +# restore {inputs} -> Update all inputs given restored value + +import pickle +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlencode as urllib_urlencode + +from .. import Inputs +from .._utils import private_random_id +from ..reactive import isolate +from ._save_state import SaveState, SaveStateLocal +from ._utils import is_hosted, to_json + + +class ShinySaveState: + # session: ? + # WOuld get us access to inputs, possibly app dir, registered on save / load classes. + input: Inputs + values: dict[str, Any] + exclude: list[str] + on_save: ( + Callable[["ShinySaveState"], 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[["ShinySaveState"], 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 = {} + + def _call_on_save(self): + # Allow user-supplied onSave function to do things like add state$values, or + # save data to state dir. + if self.on_save: + with isolate(): + self.on_save(self) + + # def _get_save_interface(self) -> Callable[[str, ]] + + async def _save_state(self) -> str: + """ + Save a state to disk (pickle). + + Returns + ------- + str + A query string which can be used to restore the session. + """ + id = private_random_id(prefix="", bytes=3) + + # TODO: barret move code to single call location + # A function for saving the state object to disk, given a directory to save + # to. + async def save_state_to_dir(state_dir: Path) -> None: + self.dir = state_dir + + self._call_on_save() + + if self.exclude.index("._bookmark_") == -1: + self.exclude.append("._bookmark_") + + input_values_json = self.input._serialize( + exclude=self.exclude, state_dir=None + ) + assert self.dir is not None + with open(self.dir / "input.pickle", "wb") as f: + pickle.dump(input_values_json, f) + + if len(self.values) > 0: + with open(self.dir / "values.pickle", "wb") as f: + pickle.dump(self.values, f) + + return + + # Pass the saveState function to the save interface function, which will + # invoke saveState after preparing the directory. + + # TODO: FUTURE - Get the save interface from the session object? + # Look for a save.interface function. This will be defined by the hosting + # environment if it supports bookmarking. + save_interface_loaded: SaveState | None = None + + if save_interface_loaded is None: + if is_hosted(): + # TODO: Barret + raise NotImplementedError( + "The hosting environment does not support server-side bookmarking." + ) + else: + # We're running Shiny locally. + save_interface_loaded = SaveStateLocal() + + if not isinstance(save_interface_loaded, SaveState): + raise TypeError( + "The save interface retrieved must be an instance of `shiny.bookmark.SaveState`." + ) + + save_dir = Path(await save_interface_loaded.save_dir(id)) + await save_state_to_dir(save_dir) + + # 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. + self._call_on_save() + + if self.exclude.index("._bookmark_") == -1: + self.exclude.append("._bookmark_") + + input_values_serialized = await self.input._serialize( + exclude=self.exclude, state_dir=None + ) + + qs_str_parts: list[str] = [] + + # If any input values are present, add them. + if len(input_values_serialized) > 0: + input_qs = urllib_urlencode(to_json(input_values_serialized)) + + 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("&") + + values_qs = urllib_urlencode(to_json(self.values)) + + qs_str_parts.append("_values_&") + qs_str_parts.append(values_qs) + + return "".join(qs_str_parts) + + +# RestoreContext <- R6Class("RestoreContext", +# public = list( +# # This will be set to TRUE if there's actually a state to restore +# active = FALSE, + +# # 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. +# initErrorMessage = NULL, + +# # This is a RestoreInputSet for input values. This is a key-value store with +# # some special handling. +# input = NULL, + +# # Directory for extra files, if restoring from state that was saved to disk. +# dir = NULL, + +# # For values other than input values. These values don't need the special +# # phandling that's needed for input values, because they're only accessed +# # from the onRestore function. +# values = NULL, + +# initialize = function(queryString = NULL) { +# self$reset() # Need this to initialize self$input + +# if (!is.null(queryString) && nzchar(queryString)) { +# tryCatch( +# withLogErrors({ +# qsValues <- parseQueryString(queryString, nested = TRUE) + +# if (!is.null(qsValues[["__subapp__"]]) && qsValues[["__subapp__"]] == 1) { +# # Ignore subapps in shiny docs +# self$reset() + +# } else if (!is.null(qsValues[["_state_id_"]]) && nzchar(qsValues[["_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. +# self$active <- TRUE +# private$loadStateQueryString(queryString) + +# } else { +# # The query string contains the saved keys and values +# self$active <- TRUE +# private$decodeStateQueryString(queryString) +# } +# }), +# error = function(e) { +# # If there's an error in restoring problem, just reset these values +# self$reset() +# self$initErrorMessage <- e$message +# warning(e$message) +# } +# ) +# } +# }, + +# reset = function() { +# self$active <- FALSE +# self$initErrorMessage <- NULL +# self$input <- RestoreInputSet$new(list()) +# self$values <- new.env(parent = emptyenv()) +# self$dir <- NULL +# }, + +# # Completely replace the state +# set = function(active = FALSE, initErrorMessage = NULL, input = list(), values = list(), dir = NULL) { +# # Validate all inputs +# stopifnot(is.logical(active)) +# stopifnot(is.null(initErrorMessage) || is.character(initErrorMessage)) +# stopifnot(is.list(input)) +# stopifnot(is.list(values)) +# stopifnot(is.null(dir) || is.character(dir)) + +# self$active <- active +# self$initErrorMessage <- initErrorMessage +# self$input <- RestoreInputSet$new(input) +# self$values <- list2env2(values, parent = emptyenv()) +# self$dir <- dir +# }, + +# # This should be called before a restore context is popped off the stack. +# flushPending = function() { +# self$input$flushPending() +# }, + + +# # Returns a list representation of the RestoreContext object. This is passed +# # to the app author's onRestore function. An important difference between +# # the RestoreContext object and the list is that the former's `input` field +# # is a RestoreInputSet object, while the latter's `input` field is just a +# # list. +# asList = function() { +# list( +# input = self$input$asList(), +# dir = self$dir, +# values = self$values +# ) +# } +# ), + +# private = list( +# # Given a query string with a _state_id_, load saved state with that ID. +# loadStateQueryString = function(queryString) { +# values <- parseQueryString(queryString, nested = TRUE) +# id <- values[["_state_id_"]] + +# # Check that id has only alphanumeric chars +# if (grepl("[^a-zA-Z0-9]", id)) { +# stop("Invalid state id: ", id) +# } + +# # This function is passed to the loadInterface function; given a +# # directory, it will load state from that directory +# loadFun <- function(stateDir) { +# self$dir <- stateDir + +# if (!dirExists(stateDir)) { +# stop("Bookmarked state directory does not exist.") +# } + +# tryCatch({ +# inputValues <- readRDS(file.path(stateDir, "input.rds")) +# self$input <- RestoreInputSet$new(inputValues) +# }, +# error = function(e) { +# stop("Error reading input values file.") +# } +# ) + +# valuesFile <- file.path(stateDir, "values.rds") +# if (file.exists(valuesFile)) { +# tryCatch({ +# self$values <- readRDS(valuesFile) +# }, +# error = function(e) { +# stop("Error reading values file.") +# } +# ) +# } +# } + +# # Look for a load.interface function. This will be defined by the hosting +# # environment if it supports bookmarking. +# loadInterface <- getShinyOption("load.interface", default = NULL) + +# if (is.null(loadInterface)) { +# if (inShinyServer()) { +# # We're in a version of Shiny Server/Connect that doesn't have +# # bookmarking support. +# loadInterface <- function(id, callback) { +# stop("The hosting environment does not support saved-to-server bookmarking.") +# } + +# } else { +# # We're running Shiny locally. +# loadInterface <- loadInterfaceLocal +# } +# } + +# loadInterface(id, loadFun) + +# invisible() +# }, + +# # Given a query string with values encoded in it, restore saved state +# # from those values. +# decodeStateQueryString = function(queryString) { +# # Remove leading '?' +# if (substr(queryString, 1, 1) == '?') +# queryString <- substr(queryString, 2, nchar(queryString)) + +# # The "=" after "_inputs_" is optional. Shiny doesn't generate URLs with +# # "=", but httr always adds "=". +# inputs_reg <- "(^|&)_inputs_=?(&|$)" +# values_reg <- "(^|&)_values_=?(&|$)" + +# # Error if multiple '_inputs_' or '_values_'. This is needed because +# # strsplit won't add an entry if the search pattern is at the end of a +# # string. +# if (length(gregexpr(inputs_reg, queryString)[[1]]) > 1) +# stop("Invalid state string: more than one '_inputs_' found") +# if (length(gregexpr(values_reg, queryString)[[1]]) > 1) +# stop("Invalid state string: more than one '_values_' found") + +# # Look for _inputs_ and store following content in inputStr +# splitStr <- strsplit(queryString, inputs_reg)[[1]] +# if (length(splitStr) == 2) { +# inputStr <- splitStr[2] +# # Remove any _values_ (and content after _values_) that may come after +# # _inputs_ +# inputStr <- strsplit(inputStr, values_reg)[[1]][1] + +# } else { +# inputStr <- "" +# } + +# # Look for _values_ and store following content in valueStr +# splitStr <- strsplit(queryString, values_reg)[[1]] +# if (length(splitStr) == 2) { +# valueStr <- splitStr[2] +# # Remove any _inputs_ (and content after _inputs_) that may come after +# # _values_ +# valueStr <- strsplit(valueStr, inputs_reg)[[1]][1] + +# } else { +# valueStr <- "" +# } + + +# inputs <- parseQueryString(inputStr, nested = TRUE) +# values <- parseQueryString(valueStr, nested = TRUE) + +# valuesFromJSON <- function(vals) { +# varsUnparsed <- c() +# valsParsed <- mapply(names(vals), vals, SIMPLIFY = FALSE, +# FUN = function(name, value) { +# tryCatch( +# safeFromJSON(value), +# error = function(e) { +# varsUnparsed <<- c(varsUnparsed, name) +# warning("Failed to parse URL parameter \"", name, "\"") +# } +# ) +# } +# ) +# valsParsed[varsUnparsed] <- NULL +# valsParsed +# } + +# inputs <- valuesFromJSON(inputs) +# self$input <- RestoreInputSet$new(inputs) + +# values <- valuesFromJSON(values) +# self$values <- list2env2(values, self$values) +# } +# ) +# ) + + +# # 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 `flushPending()` 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 `withRestoreContext()`. Without this, if +# # a value is restored in a dynamic UI, it could completely prevent any other +# # (non- restored) kvalue from being used. +# RestoreInputSet <- R6Class("RestoreInputSet", +# private = list( +# values = NULL, +# pending = character(0), +# used = character(0) # Names of values which have been used +# ), + +# public = list( +# initialize = function(values) { +# private$values <- list2env2(values, parent = emptyenv()) +# }, + +# exists = function(name) { +# exists(name, envir = private$values) +# }, + +# # Return TRUE if the value exists and has not been marked as used. +# available = function(name) { +# self$exists(name) && !self$isUsed(name) +# }, + +# isPending = function(name) { +# name %in% private$pending +# }, + +# isUsed = function(name) { +# name %in% private$used +# }, + +# # Get a value. If `force` is TRUE, get the value without checking whether +# # has been used, and without marking it as pending. +# get = function(name, force = FALSE) { +# if (force) +# return(private$values[[name]]) + +# if (!self$available(name)) +# return(NULL) + +# # Mark this name as pending. Use unique so that it's not added twice. +# private$pending <- unique(c(private$pending, name)) +# private$values[[name]] +# }, + +# # Take pending names and mark them as used, then clear pending list. +# flushPending = function() { +# private$used <- unique(c(private$used, private$pending)) +# private$pending <- character(0) +# }, + +# asList = function() { +# as.list.environment(private$values, all.names = TRUE) +# } +# ) +# ) + +# restoreCtxStack <- NULL +# on_load({ +# restoreCtxStack <- fastmap::faststack() +# }) + +# withRestoreContext <- function(ctx, expr) { +# restoreCtxStack$push(ctx) + +# on.exit({ +# # Mark pending names as used +# restoreCtxStack$peek()$flushPending() +# restoreCtxStack$pop() +# }, add = TRUE) + +# force(expr) +# } + +# # Is there a current restore context? +# hasCurrentRestoreContext <- function() { +# if (restoreCtxStack$size() > 0) +# return(TRUE) +# domain <- getDefaultReactiveDomain() +# if (!is.null(domain) && !is.null(domain$restoreContext)) +# 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. +# getCurrentRestoreContext <- function() { +# ctx <- restoreCtxStack$peek() +# if (is.null(ctx)) { +# domain <- getDefaultReactiveDomain() + +# if (is.null(domain) || is.null(domain$restoreContext)) { +# stop("No restore context found") +# } + +# ctx <- domain$restoreContext +# } +# ctx +# } + +# #' 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 [textInput()]). +# #' +# #' @param id Name of the input value to restore. +# #' @param default A default value to use, if there's no value to restore. +# #' +# #' @export +# restoreInput <- function(id, default) { +# # Need to evaluate `default` in case it contains reactives like input$x. If we +# # don't, then the calling code won't take a reactive dependency on input$x +# # when restoring a value. +# force(default) + +# if (!hasCurrentRestoreContext()) { +# return(default) +# } + +# oldInputs <- getCurrentRestoreContext()$input +# if (oldInputs$available(id)) { +# oldInputs$get(id) +# } else { +# default +# } +# } + +# #' Update URL in browser's location bar +# #' +# #' This function updates the client browser's query string in the location bar. +# #' It typically is called from an observer. Note that this will not work in +# #' Internet Explorer 9 and below. +# #' +# #' For `mode = "push"`, only three updates are currently allowed: +# #' \enumerate{ +# #' \item the query string (format: `?param1=val1¶m2=val2`) +# #' \item the hash (format: `#hash`) +# #' \item both the query string and the hash +# #' (format: `?param1=val1¶m2=val2#hash`) +# #' } +# #' +# #' In other words, if `mode = "push"`, the `queryString` must start +# #' with either `?` or with `#`. +# #' +# #' A technical curiosity: under the hood, this function is calling the HTML5 +# #' history API (which is where the names for the `mode` argument come from). +# #' When `mode = "replace"`, the function called is +# #' `window.history.replaceState(null, null, queryString)`. +# #' When `mode = "push"`, the function called is +# #' `window.history.pushState(null, null, queryString)`. +# #' +# #' @param queryString The new query string to show in the location bar. +# #' @param mode When the query string is updated, should the current history +# #' entry be replaced (default), or should a new history entry be pushed onto +# #' the history stack? The former should only be used in a live bookmarking +# #' context. The latter is useful if you want to navigate between states using +# #' the browser's back and forward buttons. See Examples. +# #' @param session A Shiny session object. +# #' @seealso [enableBookmarking()], [getQueryString()] +# #' @examples +# #' ## Only run these examples in interactive sessions +# #' if (interactive()) { +# #' +# #' ## App 1: Doing "live" bookmarking +# #' ## 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. +# #' enableBookmarking("url") +# #' shinyApp( +# #' 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) +# #' }) +# #' } +# #' ) +# #' +# #' ## App 2: Printing the value of the query string +# #' ## (Use the back and forward buttons to see how the browser +# #' ## keeps a record of each state) +# #' shinyApp( +# #' ui = fluidPage( +# #' textInput("txt", "Enter new query string"), +# #' helpText("Format: ?param1=val1¶m2=val2"), +# #' actionButton("go", "Update"), +# #' hr(), +# #' verbatimTextOutput("query") +# #' ), +# #' server = function(input, output, session) { +# #' observeEvent(input$go, { +# #' updateQueryString(input$txt, mode = "push") +# #' }) +# #' output$query <- renderText({ +# #' query <- getQueryString() +# #' queryText <- paste(names(query), query, +# #' sep = "=", collapse=", ") +# #' paste("Your query string is:\n", queryText) +# #' }) +# #' } +# #' ) +# #' } +# #' @export +# updateQueryString <- function(queryString, mode = c("replace", "push"), +# session = getDefaultReactiveDomain()) { +# mode <- match.arg(mode) +# session$updateQueryString(queryString, mode) +# } + +# #' Create a button for bookmarking/sharing +# #' +# #' A `bookmarkButton` is a [actionButton()] with a default label +# #' that consists of a link icon and the text "Bookmark...". It is meant to be +# #' used for bookmarking state. +# #' +# #' @inheritParams actionButton +# #' @param title A tooltip that is shown when the mouse cursor hovers over the +# #' button. +# #' @param 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 +# #' [setBookmarkExclude()], and you must create an observer that +# #' does the bookmarking when the button is pressed. See the examples below. +# #' +# #' @seealso [enableBookmarking()] for more examples. +# #' +# #' @examples +# #' ## Only run these examples in interactive sessions +# #' if (interactive()) { +# #' +# #' # This example shows how to use multiple bookmark buttons. If you only need +# #' # a single bookmark button, see examples in ?enableBookmarking. +# #' ui <- function(request) { +# #' fluidPage( +# #' tabsetPanel(id = "tabs", +# #' tabPanel("One", +# #' checkboxInput("chk1", "Checkbox 1"), +# #' bookmarkButton(id = "bookmark1") +# #' ), +# #' tabPanel("Two", +# #' checkboxInput("chk2", "Checkbox 2"), +# #' bookmarkButton(id = "bookmark2") +# #' ) +# #' ) +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' # Need to exclude the buttons from themselves being bookmarked +# #' setBookmarkExclude(c("bookmark1", "bookmark2")) +# #' +# #' # Trigger bookmarking with either button +# #' observeEvent(input$bookmark1, { +# #' session$doBookmark() +# #' }) +# #' observeEvent(input$bookmark2, { +# #' session$doBookmark() +# #' }) +# #' } +# #' enableBookmarking(store = "url") +# #' shinyApp(ui, server) +# #' } +# #' @export +# bookmarkButton <- function(label = "Bookmark...", +# icon = shiny::icon("link", lib = "glyphicon"), +# title = "Bookmark this application's state and get a URL for sharing.", +# ..., +# id = "._bookmark_") +# { +# actionButton(id, label, icon, title = title, ...) +# } + + +# #' 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) +# } + + +# #' Exclude inputs from bookmarking +# #' +# #' This function tells Shiny which inputs should be excluded from bookmarking. +# #' It should be called from inside the application's server function. +# #' +# #' This function can also be called from a module's server function, in which +# #' case it will exclude inputs with the specified names, from that module. It +# #' will not affect inputs from other modules or from the top level of the Shiny +# #' application. +# #' +# #' @param names A character vector containing names of inputs to exclude from +# #' bookmarking. +# #' @param session A shiny session object. +# #' @seealso [enableBookmarking()] for examples. +# #' @export +# setBookmarkExclude <- function(names = character(0), session = getDefaultReactiveDomain()) { +# session$setBookmarkExclude(names) +# } + + +# #' Add callbacks for Shiny session bookmarking events +# #' +# #' @description +# #' +# #' These functions are for registering callbacks on Shiny session events. They +# #' should be called within an application's server function. +# #' +# #' \itemize{ +# #' \item `onBookmark` registers a function that will be called just +# #' before Shiny bookmarks state. +# #' \item `onBookmarked` registers a function that will be called just +# #' after Shiny bookmarks state. +# #' \item `onRestore` registers a function that will be called when a +# #' session is restored, after the server function executes, but before all +# #' other reactives, observers and render functions are run. +# #' \item `onRestored` registers a function that will be called after a +# #' session is restored. This is similar to `onRestore`, but it will be +# #' called after all reactives, observers, and render functions run, and +# #' after results are sent to the client browser. `onRestored` +# #' callbacks can be useful for sending update messages to the client +# #' browser. +# #' } +# #' +# #' @details +# #' +# #' All of these functions return a function which can be called with no +# #' arguments to cancel the registration. +# #' +# #' The callback function that is passed to these functions should take one +# #' argument, typically named "state" (for `onBookmark`, `onRestore`, +# #' and `onRestored`) or "url" (for `onBookmarked`). +# #' +# #' For `onBookmark`, the state object has three relevant fields. The +# #' `values` field is an environment which can be used to save arbitrary +# #' values (see examples). If the state is being saved to disk (as opposed to +# #' being encoded in a URL), the `dir` field contains the name of a +# #' directory which can be used to store extra files. Finally, the state object +# #' has an `input` field, which is simply the application's `input` +# #' object. It can be read, but not modified. +# #' +# #' For `onRestore` and `onRestored`, the state object is a list. This +# #' list contains `input`, which is a named list of input values to restore, +# #' `values`, which is an environment containing arbitrary values that were +# #' saved in `onBookmark`, and `dir`, the name of the directory that +# #' the state is being restored from, and which could have been used to save +# #' extra files. +# #' +# #' For `onBookmarked`, the callback function receives a string with the +# #' bookmark URL. This callback function should be used to display UI in the +# #' client browser with the bookmark URL. If no callback function is registered, +# #' then Shiny will by default display a modal dialog with the bookmark URL. +# #' +# #' @section Modules: +# #' +# #' These callbacks may also be used in Shiny modules. When used this way, the +# #' inputs and values will automatically be namespaced for the module, and the +# #' callback functions registered for the module will only be able to see the +# #' module's inputs and values. +# #' +# #' @param fun A callback function which takes one argument. +# #' @param session A shiny session object. +# #' @seealso enableBookmarking for general information on bookmarking. +# #' +# #' @examples +# #' ## Only run these examples in interactive sessions +# #' if (interactive()) { +# #' +# #' # Basic use of onBookmark and onRestore: This app saves the time in its +# #' # arbitrary values, and restores that time when the app is restored. +# #' ui <- function(req) { +# #' fluidPage( +# #' textInput("txt", "Input text"), +# #' bookmarkButton() +# #' ) +# #' } +# #' server <- function(input, output) { +# #' onBookmark(function(state) { +# #' savedTime <- as.character(Sys.time()) +# #' cat("Last saved at", savedTime, "\n") +# #' # state is a mutable reference object, and we can add arbitrary values to +# #' # it. +# #' state$values$time <- savedTime +# #' }) +# #' +# #' onRestore(function(state) { +# #' cat("Restoring from state bookmarked at", state$values$time, "\n") +# #' }) +# #' } +# #' enableBookmarking("url") +# #' shinyApp(ui, server) +# #' +# #' +# #' +# # This app illustrates two things: saving values in a file using state$dir, and +# # using an onRestored callback to call an input updater function. (In real use +# # cases, it probably makes sense to save content to a file only if it's much +# # larger.) +# #' ui <- function(req) { +# #' fluidPage( +# #' textInput("txt", "Input text"), +# #' bookmarkButton() +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' lastUpdateTime <- NULL +# #' +# #' observeEvent(input$txt, { +# #' updateTextInput(session, "txt", +# #' label = paste0("Input text (Changed ", as.character(Sys.time()), ")") +# #' ) +# #' }) +# #' +# #' onBookmark(function(state) { +# #' # Save content to a file +# #' messageFile <- file.path(state$dir, "message.txt") +# #' cat(as.character(Sys.time()), file = messageFile) +# #' }) +# #' +# #' onRestored(function(state) { +# #' # Read the file +# #' messageFile <- file.path(state$dir, "message.txt") +# #' timeText <- readChar(messageFile, 1000) +# #' +# #' # updateTextInput must be called in onRestored, as opposed to onRestore, +# #' # because onRestored happens after the client browser is ready. +# #' updateTextInput(session, "txt", +# #' label = paste0("Input text (Changed ", timeText, ")") +# #' ) +# #' }) +# #' } +# #' # "server" bookmarking is needed for writing to disk. +# #' enableBookmarking("server") +# #' shinyApp(ui, server) +# #' +# #' +# #' # This app has a module, and both the module and the main app code have +# #' # onBookmark and onRestore functions which write and read state$values$hash. The +# #' # module's version of state$values$hash does not conflict with the app's version +# #' # of state$values$hash. +# #' # +# #' # A basic module that captializes text. +# #' capitalizerUI <- function(id) { +# #' ns <- NS(id) +# #' wellPanel( +# #' h4("Text captializer module"), +# #' textInput(ns("text"), "Enter text:"), +# #' verbatimTextOutput(ns("out")) +# #' ) +# #' } +# #' capitalizerServer <- function(input, output, session) { +# #' output$out <- renderText({ +# #' toupper(input$text) +# #' }) +# #' onBookmark(function(state) { +# #' state$values$hash <- rlang::hash(input$text) +# #' }) +# #' onRestore(function(state) { +# #' if (identical(rlang::hash(input$text), state$values$hash)) { +# #' message("Module's input text matches hash ", state$values$hash) +# #' } else { +# #' message("Module's input text does not match hash ", state$values$hash) +# #' } +# #' }) +# #' } +# #' # Main app code +# #' ui <- function(request) { +# #' fluidPage( +# #' sidebarLayout( +# #' sidebarPanel( +# #' capitalizerUI("tc"), +# #' textInput("text", "Enter text (not in module):"), +# #' bookmarkButton() +# #' ), +# #' mainPanel() +# #' ) +# #' ) +# #' } +# #' server <- function(input, output, session) { +# #' callModule(capitalizerServer, "tc") +# #' onBookmark(function(state) { +# #' state$values$hash <- rlang::hash(input$text) +# #' }) +# #' onRestore(function(state) { +# #' if (identical(rlang::hash(input$text), state$values$hash)) { +# #' message("App's input text matches hash ", state$values$hash) +# #' } else { +# #' message("App's input text does not match hash ", state$values$hash) +# #' } +# #' }) +# #' } +# #' enableBookmarking(store = "url") +# #' shinyApp(ui, server) +# #' } +# #' @export +# onBookmark <- function(fun, session = getDefaultReactiveDomain()) { +# session$onBookmark(fun) +# } + +# #' @rdname onBookmark +# #' @export +# onBookmarked <- function(fun, session = getDefaultReactiveDomain()) { +# session$onBookmarked(fun) +# } + +# #' @rdname onBookmark +# #' @export +# onRestore <- function(fun, session = getDefaultReactiveDomain()) { +# session$onRestore(fun) +# } + +# #' @rdname onBookmark +# #' @export +# onRestored <- function(fun, session = getDefaultReactiveDomain()) { +# session$onRestored(fun) +# } +# } From 8600686d2cf9b52ff1986ede3586bd7044fe0f44 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 26 Feb 2025 13:15:08 -0500 Subject: [PATCH 06/62] typo --- shiny/bookmark/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index fa2fb55a7..2c71dd17c 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -1,4 +1,4 @@ -from _bookmark import ShinySaveState +from ._bookmark import ShinySaveState from ._save_state import SaveState From 505e027302243dbaf87f1cd1b4efc4ddfe5f00be Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 26 Feb 2025 13:15:33 -0500 Subject: [PATCH 07/62] Fix circular dep --- shiny/session/_session.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 62624e664..22e73b8f0 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,7 +1,5 @@ from __future__ import annotations -from shiny.bookmark._serializers import Unserializable - __all__ = ("Session", "Inputs", "Outputs", "ClientData") import asyncio @@ -1405,7 +1403,7 @@ async def _serialize( exclude: list[str], state_dir: Path | None, ) -> dict[str, Any]: - from ..bookmark._serializers import serializer_default + from ..bookmark._serializers import Unserializable, serializer_default exclude_set = set(exclude) serialized_values: dict[str, Any] = {} From 25ba8b4cae6d9a7d7a37d25fe2b312bc2fae568b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 26 Feb 2025 13:17:03 -0500 Subject: [PATCH 08/62] Set up bookmark exclude and bookmark store --- shiny/express/_stub_session.py | 9 ++++ shiny/session/_session.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index f2e83e45d..2c853cc65 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -44,12 +44,21 @@ 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_exclude = [] + self.bookmark_store = "disable" # TODO: Is this correct? + def is_stub_session(self) -> Literal[True]: return True async def close(self, code: int = 1001) -> None: return + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError("Please call this only from a real session object") + + def do_bookmark(self) -> None: + raise NotImplementedError("Please call this only from a real session object") + # This is needed so that Outputs don't throw an error. def _is_hidden(self, name: str) -> bool: return False diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 22e73b8f0..641e3d164 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -59,9 +59,14 @@ from ._utils import RenderedDeps, read_thunk_opt, session_context if TYPE_CHECKING: + from shiny.bookmark._serializers import Unserializable + from .._app import App +BookmarkStore = Literal["url", "server", "disable"] + + class ConnectionState(enum.Enum): Start = 0 Running = 1 @@ -171,6 +176,9 @@ class Session(ABC): input: Inputs output: Outputs clientdata: ClientData + bookmark_exclude: list[str] + bookmark_store: BookmarkStore + _bookmark_exclude_fns: list[Callable[[], list[str]]] user: str | None groups: list[str] | None @@ -477,6 +485,38 @@ def _increment_busy_count(self) -> None: ... @abstractmethod def _decrement_busy_count(self) -> None: ... + @abstractmethod + def _get_bookmark_exclude(self) -> list[str]: + """ + Retrieve the list of inputs excluded from being bookmarked. + """ + ... + + @abstractmethod + 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 `bookmark_store` option. + + No actions will be performed if the `bookmark_store` option is set to `"disable"`. + """ + ... + + # @abstractmethod + # def set_bookmark_exclude(self, *names: str) -> None: + # """ + # Exclude inputs from being bookmarked. + # """ + # ... + + # @abstractmethod + # def get_bookmark_exclude(self) -> list[str]: + # """ + # Get the list of inputs excluded from being bookmarked. + # """ + # ... + # ====================================================================================== # AppSession @@ -522,6 +562,10 @@ def __init__( self.output: Outputs = Outputs(self, self.ns, outputs=dict()) self.clientdata: ClientData = ClientData(self) + self.bookmark_exclude: list[str] = [] + self.bookmark_store = "disable" + self._bookmark_exclude_fns: list[Callable[[], list[str]]] = [] + self.user: str | None = None self.groups: list[str] | None = None credentials_json: str = "" @@ -711,6 +755,21 @@ def _is_hidden(self, name: str) -> bool: return hidden_value_obj() + # ========================================================================== + # Bookmarking + # ========================================================================== + + def _get_bookmark_exclude(self) -> list[str]: + """ + Get the list of inputs excluded from being bookmarked. + """ + scoped_excludes: list[str] = [] + for exclude_fn in self._bookmark_exclude_fns: + # Call the function and append the result to the list + scoped_excludes.extend(exclude_fn()) + # Remove duplicates + return list(set([*self.bookmark_exclude, *scoped_excludes])) + # ========================================================================== # Message handlers # ========================================================================== @@ -1173,9 +1232,32 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._outbound_message_queues = parent._outbound_message_queues self._downloads = parent._downloads + self.bookmark_exclude: list[str] = [] + + def ns_bookmark_exclude() -> list[str]: + return [self.ns(name) for name in self.bookmark_exclude] + + self._parent._bookmark_exclude_fns.append(ns_bookmark_exclude) + def _is_hidden(self, name: str) -> bool: return self._parent._is_hidden(name) + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError( + "Please call `._get_bookmark_exclude()` from the root session only." + ) + + @property + def bookmark_store(self) -> BookmarkStore: + return self._parent.bookmark_store + + @bookmark_store.setter + def bookmark_store( # pyright: ignore[reportIncompatibleVariableOverride] + self, + value: BookmarkStore, + ) -> None: + self._parent.bookmark_store = value + def on_ended( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], From dddafe6bd392bcf5a2b8fa3f452389a882843e5f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 26 Feb 2025 13:17:23 -0500 Subject: [PATCH 09/62] Update _bookmark.py --- shiny/bookmark/_bookmark.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index f8562cade..d9591a88f 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -14,11 +14,16 @@ # {values} -> dict (where as in R is an environment) # √ values is a dict! # {exclude} -> Requires `session.setBookmarkExclude(names)`, `session.getBookmarkExclude()` -# * `session.setBookmarkExclude(names)` +# * `session.setBookmarkExclude(names)` TODO: # * `session.getBookmarkExclude()` +# * `session.bookmark_exclude` value? +# 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: +# * types: `url`, `server`, `disable` +# * where to store it? `session` object feels too late. `App` may not exist yet. # Session hooks -> `onBookmark()`, `onBookmarked()`, `onRestore(), `onRestored()` -# * `session.onBookmark()` -# * `session.onBookmarked()` +# * `session.onBookmark()` TODO: +# * `session.onBookmarked()` TODO: # * `session.onRestore()` # * `session.onRestored()` # Session hooks -> Require list of callback functions for each @@ -47,7 +52,8 @@ class ShinySaveState: # session: ? - # WOuld get us access to inputs, possibly app dir, registered on save / load classes. + # * Would get us access to inputs, possibly app dir, registered on save / load classes (?), exclude + # input: Inputs values: dict[str, Any] exclude: list[str] @@ -78,7 +84,10 @@ def _call_on_save(self): with isolate(): self.on_save(self) - # def _get_save_interface(self) -> Callable[[str, ]] + def _exclude_bookmark_value(self): + # If the bookmark value is not in the exclude list, add it. + if "._bookmark_" not in self.exclude: + self.exclude.append("._bookmark_") async def _save_state(self) -> str: """ @@ -99,11 +108,11 @@ async def save_state_to_dir(state_dir: Path) -> None: self._call_on_save() - if self.exclude.index("._bookmark_") == -1: - self.exclude.append("._bookmark_") + self._exclude_bookmark_value() input_values_json = self.input._serialize( - exclude=self.exclude, state_dir=None + exclude=self.exclude, + state_dir=self.dir, ) assert self.dir is not None with open(self.dir / "input.pickle", "wb") as f: @@ -158,13 +167,15 @@ async def _encode_state(self) -> str: # Allow user-supplied onSave function to do things like add state$values. self._call_on_save() - if self.exclude.index("._bookmark_") == -1: - self.exclude.append("._bookmark_") + self._exclude_bookmark_value() input_values_serialized = await self.input._serialize( - exclude=self.exclude, state_dir=None + 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. From 5681da8521f3ce639b3c5e18981b6961a8f17fba Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 10:48:30 -0500 Subject: [PATCH 10/62] `Callbacks` and `AsyncCallbacks` added param support --- shiny/_utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/shiny/_utils.py b/shiny/_utils.py index ad84667de..699d1fc0b 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -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: @@ -552,11 +552,11 @@ def count(self) -> int: 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 + self, fn: Callable[..., Awaitable[None]], once: bool = False ) -> Callable[[], None]: self._id += 1 id = self._id @@ -568,14 +568,14 @@ def _(): return _ - 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: From 2f80153c3465976a1ca19a68674e7aceef054e10 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 12:48:20 -0500 Subject: [PATCH 11/62] `session.do_bookmark()` is functional in `"url"` and `"server"` --- shiny/bookmark/_bookmark.py | 51 ++++--- shiny/session/_session.py | 286 ++++++++++++++++++++++++++++++++++-- 2 files changed, 300 insertions(+), 37 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index d9591a88f..dd0f589bf 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -14,33 +14,37 @@ # {values} -> dict (where as in R is an environment) # √ values is a dict! # {exclude} -> Requires `session.setBookmarkExclude(names)`, `session.getBookmarkExclude()` -# * `session.setBookmarkExclude(names)` TODO: -# * `session.getBookmarkExclude()` -# * `session.bookmark_exclude` value? +# √ `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: -# * types: `url`, `server`, `disable` -# * where to store it? `session` object feels too late. `App` may not exist yet. +# * √ `session.bookmark_store`: `url`, `server`, `disable` # Session hooks -> `onBookmark()`, `onBookmarked()`, `onRestore(), `onRestored()` -# * `session.onBookmark()` TODO: -# * `session.onBookmarked()` TODO: +# * √ `session.on_bookmark()` # Takes the save state +# * √ `session.on_bookmarked()` # Takes a url # * `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.doBookmark()` +# * √ 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 +# * √ `doBookmark()` -> Update query string +# * √ Update query string # bookmark -> restore state # restore state -> {inputs, values, exclude} # 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 + import pickle from pathlib import Path -from typing import Any, Callable +from typing import Any, Awaitable, Callable from urllib.parse import urlencode as urllib_urlencode from .. import Inputs @@ -57,8 +61,9 @@ class ShinySaveState: input: Inputs values: dict[str, Any] exclude: list[str] + # _bookmark_: A special value that is always excluded from the bookmark. on_save: ( - Callable[["ShinySaveState"], None] | None + Callable[["ShinySaveState"], Awaitable[None]] | None ) # A callback to invoke during the saving process. # These are set not in initialize(), but by external functions that modify @@ -69,7 +74,7 @@ def __init__( self, input: Inputs, exclude: list[str], - on_save: Callable[["ShinySaveState"], None] | None, + on_save: Callable[["ShinySaveState"], Awaitable[None]] | None, ): self.input = input self.exclude = exclude @@ -77,12 +82,14 @@ def __init__( self.dir = None # This will be set by external functions. self.values = {} - def _call_on_save(self): - # Allow user-supplied onSave function to do things like add state$values, or + self._always_exclude: list[str] = ["._bookmark_"] + + 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(): - self.on_save(self) + await self.on_save(self) def _exclude_bookmark_value(self): # If the bookmark value is not in the exclude list, add it. @@ -98,7 +105,7 @@ async def _save_state(self) -> str: str A query string which can be used to restore the session. """ - id = private_random_id(prefix="", bytes=3) + id = private_random_id(prefix="", bytes=8) # TODO: barret move code to single call location # A function for saving the state object to disk, given a directory to save @@ -106,11 +113,11 @@ async def _save_state(self) -> str: async def save_state_to_dir(state_dir: Path) -> None: self.dir = state_dir - self._call_on_save() + await self._call_on_save() self._exclude_bookmark_value() - input_values_json = self.input._serialize( + input_values_json = await self.input._serialize( exclude=self.exclude, state_dir=self.dir, ) @@ -165,7 +172,7 @@ async def _encode_state(self) -> str: A query string which can be used to restore the session. """ # Allow user-supplied onSave function to do things like add state$values. - self._call_on_save() + await self._call_on_save() self._exclude_bookmark_value() diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 641e3d164..89e12b4f1 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -62,6 +62,7 @@ from shiny.bookmark._serializers import Unserializable from .._app import App + from ..bookmark._bookmark import ShinySaveState BookmarkStore = Literal["url", "server", "disable"] @@ -176,9 +177,13 @@ 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. + _get_proxy_bookmark_exclude_fns: list[Callable[[], list[str]]] bookmark_exclude: list[str] bookmark_store: BookmarkStore - _bookmark_exclude_fns: list[Callable[[], list[str]]] user: str | None groups: list[str] | None @@ -493,7 +498,63 @@ def _get_bookmark_exclude(self) -> list[str]: ... @abstractmethod - def do_bookmark(self) -> None: + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + """ + 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. + """ + ... + + @abstractmethod + def on_bookmarked( + self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + ) -> None: + """ + 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. + """ + ... + + @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. @@ -564,7 +625,9 @@ def __init__( self.bookmark_exclude: list[str] = [] self.bookmark_store = "disable" - self._bookmark_exclude_fns: list[Callable[[], list[str]]] = [] + self._get_proxy_bookmark_exclude_fns: list[Callable[[], list[str]]] = [] + self._on_bookmark_callbacks = _utils.AsyncCallbacks() + self._on_bookmarked_callbacks = _utils.AsyncCallbacks() self.user: str | None = None self.groups: list[str] | None = None @@ -763,13 +826,128 @@ def _get_bookmark_exclude(self) -> list[str]: """ Get the list of inputs excluded from being bookmarked. """ - scoped_excludes: list[str] = [] - for exclude_fn in self._bookmark_exclude_fns: - # Call the function and append the result to the list - scoped_excludes.extend(exclude_fn()) + + scoped_excludes: list[str] = [ + ".clientdata_pixelratio", + ".clientdata_url_protocol", + ".clientdata_url_hostname", + ".clientdata_url_port", + ".clientdata_url_pathname", + ".clientdata_url_search", + ".clientdata_url_hash_initial", + ".clientdata_url_hash", + ".clientdata_singletons", + ] + for proxy_exclude_fn in self._get_proxy_bookmark_exclude_fns: + scoped_excludes.extend(proxy_exclude_fn()) # Remove duplicates return list(set([*self.bookmark_exclude, *scoped_excludes])) + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._on_bookmark_callbacks.register(wrap_async(callback)) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> None: + self._on_bookmarked_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._send_message( + {"updateQueryString": {"queryString": query_string, "mode": mode}} + ) + + async def do_bookmark(self) -> None: + + if self.bookmark_store == "disable": + return + + try: + # ?withLogErrors + from ..bookmark._bookmark import ShinySaveState + + async def root_state_on_save(state: ShinySaveState) -> None: + await self._on_bookmark_callbacks.invoke(state) + + root_state = ShinySaveState( + input=self.input, + exclude=self._get_bookmark_exclude(), + on_save=root_state_on_save, + ) + + if self.bookmark_store == "server": + query_string = await root_state._save_state() + elif self.bookmark_store == "url": + query_string = await root_state._encode_state() + else: + raise ValueError("Unknown bookmark store: " + self.bookmark_store) + + port = str(self.clientdata.url_port()) + full_url = "".join( + [ + self.clientdata.url_protocol(), + "//", + self.clientdata.url_hostname(), + ":" if port else "", + port, + self.clientdata.url_pathname(), + "?", + query_string, + ] + ) + + # If onBookmarked callback was provided, invoke it; if not call + # the default. + if self._on_bookmarked_callbacks.count() > 0: + await self._on_bookmarked_callbacks.invoke(full_url) + else: + # showBookmarkUrlModal(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") + # TODO: Barret - Remove this! + raise RuntimeError("Error bookmarking state") from e + + # def set_bookmark_exclude(self, *names: str) -> None: + # """ + # Exclude inputs from being bookmarked. + # """ + # for name in names: + # if not isinstance(name, str): + # raise TypeError( + # "Bookmark exclude names must be strings. Received" + str(name) + # ) + # # Get unique values and store as list + # self._bookmark_exclude = list(set(names)) + + # def get_bookmark_exclude(self) -> list[str]: + # """ + # Get the list of inputs excluded from being bookmarked. + # """ + # scoped_excludes: list[str] = [] + # for exclude_fn in self._bookmark_exclude_fns: + # # Call the function and append the result to the list + # scoped_excludes.extend(exclude_fn()) + # # Remove duplicates + # return list(set([*self._bookmark_exclude, *scoped_excludes])) + # ========================================================================== # Message handlers # ========================================================================== @@ -1219,6 +1397,8 @@ class UpdateProgressMessage(TypedDict): class SessionProxy(Session): def __init__(self, parent: Session, ns: ResolvedId) -> None: + # TODO: Barret - Q: Why are we storing `parent`? It really feels like all `._parent` should be replaced with `.root_scope()` or `._root`, really + # TODO: Barret - Q: Why is there no super().__init__()? Why don't we proxy to the root on get/set? self._parent = parent self.app = parent.app self.id = parent.id @@ -1232,12 +1412,10 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._outbound_message_queues = parent._outbound_message_queues self._downloads = parent._downloads - self.bookmark_exclude: list[str] = [] + self._init_bookmark() - def ns_bookmark_exclude() -> list[str]: - return [self.ns(name) for name in self.bookmark_exclude] - - self._parent._bookmark_exclude_fns.append(ns_bookmark_exclude) + # init value last + self._root = parent.root_scope() def _is_hidden(self, name: str) -> bool: return self._parent._is_hidden(name) @@ -1247,6 +1425,83 @@ def _get_bookmark_exclude(self) -> list[str]: "Please call `._get_bookmark_exclude()` from the root session only." ) + def _init_bookmark(self) -> None: + + self.bookmark_exclude: list[str] = [] + self._on_bookmark_callbacks = _utils.AsyncCallbacks() + + def ns_bookmark_exclude() -> list[str]: + # TODO: Barret - Double check that this works with nested modules! + return [self.ns(name) for name in self.bookmark_exclude] + + self._root._get_proxy_bookmark_exclude_fns.append(ns_bookmark_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`. + async def scoped_on_bookmark(root_state: ShinySaveState) -> None: + # Exit if no user-defined callbacks. + if self._on_bookmark_callbacks.count() == 0: + return + + from ..bookmark._bookmark import ShinySaveState + + scoped_state = ShinySaveState( + input=self.input, + exclude=self.bookmark_exclude, + on_save=None, + ) + + # Make subdir for scope + if root_state.dir is not None: + scope_subpath = self.ns("") + scoped_state.dir = Path(root_state.dir) / scope_subpath + if not os.path.exists(scoped_state.dir): + raise FileNotFoundError( + f"Scope directory could not be created for {scope_subpath}" + ) + + # Invoke the callback on the scopeState object + 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[self.ns(key)] = value + + self._root.on_bookmark(scoped_on_bookmark) + + # TODO: Barret - Implement restore scoped state! + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._root.on_bookmark(callback) + + def on_bookmarked( + self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + ) -> None: + # TODO: Barret - Q: Shouldn't we implement this? `session._root.on_bookmark()` + raise NotImplementedError( + "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().on_bookmark()`." + ) + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + await self._root.update_query_string(query_string, mode) + + async def do_bookmark(self) -> None: + await self._root.do_bookmark() + @property def bookmark_store(self) -> BookmarkStore: return self._parent.bookmark_store @@ -1393,6 +1648,7 @@ def __init__( ) -> None: self._map = values self._ns = ns + self._serializers = {} def __setitem__(self, key: str, value: Value[Any]) -> None: if not isinstance(value, reactive.Value): @@ -1415,14 +1671,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) @@ -1504,7 +1760,7 @@ async def _serialize( # Filter out any values that were marked as unserializable. if isinstance(serialized_value, Unserializable): continue - serialized_values[key] = serialized_value + serialized_values[str(key)] = serialized_value return serialized_values From a40f268ff2e607952a33e3df26171ba92cc81dee Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 17:13:22 -0500 Subject: [PATCH 12/62] Use a Bookmark class --- shiny/bookmark/_bookmark.py | 2 + shiny/express/_stub_session.py | 45 ++- shiny/session/_session.py | 667 +++++++++++++++++---------------- 3 files changed, 382 insertions(+), 332 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index dd0f589bf..a74183719 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -21,7 +21,9 @@ # * √ `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 diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index 2c853cc65..10c0eda33 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -4,9 +4,11 @@ from htmltools import TagChild +from shiny.bookmark._bookmark import ShinySaveState + from .._namespaces import Id, ResolvedId, Root from ..session import Inputs, Outputs, Session -from ..session._session import SessionProxy +from ..session._session import Bookmark, SessionProxy if TYPE_CHECKING: from ..session._session import DownloadHandler, DynamicRouteHandler, RenderedDeps @@ -44,8 +46,10 @@ 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_exclude = [] - self.bookmark_store = "disable" # TODO: Is this correct? + self.bookmark = BookmarkExpressStub(self) + + self.exclude = [] + self.store = "disable" # TODO: Is this correct? def is_stub_session(self) -> Literal[True]: return True @@ -53,12 +57,6 @@ def is_stub_session(self) -> Literal[True]: async def close(self, code: int = 1001) -> None: return - def _get_bookmark_exclude(self) -> list[str]: - raise NotImplementedError("Please call this only from a real session object") - - def do_bookmark(self) -> None: - raise NotImplementedError("Please call this only from a real session object") - # This is needed so that Outputs don't throw an error. def _is_hidden(self, name: str) -> bool: return False @@ -147,3 +145,32 @@ def download( encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: return lambda x: None + + +class BookmarkExpressStub(Bookmark): + + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError("Please call this only from a real session object") + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + ) -> None: + raise NotImplementedError("Please call this only from a real session object") + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + ) -> None: + raise NotImplementedError("Please call this only from a real session object") + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + raise NotImplementedError("Please call this only from a real session object") + + async def do_bookmark(self) -> None: + raise NotImplementedError("Please call this only from a real session object") diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 89e12b4f1..fc2f8ccf6 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -162,6 +162,343 @@ def add_input_message(self, id: str, message: dict[str, Any]) -> None: self.input_messages.append({"id": id, "message": message}) +class Bookmark(ABC): + + _root_session: Session + + store: BookmarkStore + + _proxy_exclude_fns: list[Callable[[], list[str]]] + exclude: list[str] + + _on_bookmark_callbacks: _utils.AsyncCallbacks + _on_bookmarked_callbacks: _utils.AsyncCallbacks + + async def __call__(self) -> None: + await self._root_bookmark.do_bookmark() + + @property + def _root_bookmark(self) -> Bookmark: + return self._root_session.bookmark + + def __init__(self, root_session: Session): + super().__init__() + self._root_session = root_session + + # # TODO: Barret - Implement this?!? + # @abstractmethod + # async def get_url(self) -> str: + # ... + + # # `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)) + + @abstractmethod + def _get_bookmark_exclude(self) -> list[str]: + """ + Retrieve the list of inputs excluded from being bookmarked. + """ + ... + + @abstractmethod + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + """ + 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. + """ + ... + + @abstractmethod + def on_bookmarked( + self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + ) -> None: + """ + 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. + """ + ... + + @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 + # TODO: Barret - Q: Rename to `update()`? `session.bookmark.update()`? + 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"`. + """ + ... + + +class BookmarkApp(Bookmark): + def __init__(self, root_session: Session): + + super().__init__(root_session) + + self.store = "disable" + self.exclude = [] + self._proxy_exclude_fns = [] + self._on_bookmark_callbacks = _utils.AsyncCallbacks() + self._on_bookmarked_callbacks = _utils.AsyncCallbacks() + + 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._proxy_exclude_fns: + scoped_excludes.extend(proxy_exclude_fn()) + # Remove duplicates + return list(set([*self.exclude, *scoped_excludes])) + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._on_bookmark_callbacks.register(wrap_async(callback)) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> None: + self._on_bookmarked_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._root_session._send_message( + { + "updateQueryString": { + "queryString": query_string, + "mode": mode, + } + } + ) + + async def do_bookmark(self) -> None: + + if self.store == "disable": + return + + try: + # ?withLogErrors + from ..bookmark._bookmark import ShinySaveState + + async def root_state_on_save(state: ShinySaveState) -> None: + await self._on_bookmark_callbacks.invoke(state) + + root_state = ShinySaveState( + input=self._root_session.input, + exclude=self._get_bookmark_exclude(), + on_save=root_state_on_save, + ) + + if self.store == "server": + query_string = await root_state._save_state() + elif self.store == "url": + query_string = await root_state._encode_state() + else: + raise ValueError("Unknown bookmark store: " + self.store) + + clientdata = self._root_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: + 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") + # TODO: Barret - Remove this! + raise RuntimeError("Error bookmarking state") from e + + +class BookmarkProxy(Bookmark): + + _ns: ResolvedId + + def __init__(self, root_session: Session, ns: ResolvedId): + super().__init__(root_session) + + self._ns = ns + + self.exclude = [] + self._proxy_exclude_fns = [] + self._on_bookmark_callbacks = _utils.AsyncCallbacks() + self._on_bookmarked_callbacks = _utils.AsyncCallbacks() + + # TODO: Barret - Double check that this works with nested modules! + self._root_session.bookmark._proxy_exclude_fns.append( + lambda: [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`. + async def scoped_on_bookmark(root_state: ShinySaveState) -> None: + return await self._scoped_on_bookmark(root_state) + + self._root_bookmark.on_bookmark(scoped_on_bookmark) + + # TODO: Barret - Implement restore scoped state! + + async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: + # Exit if no user-defined callbacks. + if self._on_bookmark_callbacks.count() == 0: + return + + from ..bookmark._bookmark import ShinySaveState + + scoped_state = ShinySaveState( + input=self._root_session.input, + exclude=self._root_bookmark.exclude, + on_save=None, + ) + + # Make subdir for scope + if root_state.dir is not None: + scope_subpath = self._ns("") + scoped_state.dir = Path(root_state.dir) / scope_subpath + if not os.path.exists(scoped_state.dir): + raise FileNotFoundError( + f"Scope directory could not be created for {scope_subpath}" + ) + + # Invoke the callback on the scopeState object + 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[self._ns(key)] = value + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._root_bookmark.on_bookmark(callback) + + def on_bookmarked( + self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + ) -> None: + # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` + raise NotImplementedError( + "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().bookmark.on_bookmark()`." + ) + + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError( + "Please call `._get_bookmark_exclude()` from the root session only." + ) + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + await self._root_bookmark.update_query_string(query_string, mode) + + async def do_bookmark(self) -> None: + await self._root_bookmark.do_bookmark() + + @property + def store(self) -> BookmarkStore: + return self._root_bookmark.store + + @store.setter + def store( # pyright: ignore[reportIncompatibleVariableOverride] + self, + value: BookmarkStore, + ) -> None: + self._root_bookmark.store = value + + # ====================================================================================== # Session abstract base class # ====================================================================================== @@ -181,9 +518,7 @@ class Session(ABC): # 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. - _get_proxy_bookmark_exclude_fns: list[Callable[[], list[str]]] - bookmark_exclude: list[str] - bookmark_store: BookmarkStore + bookmark: Bookmark user: str | None groups: list[str] | None @@ -490,94 +825,6 @@ def _increment_busy_count(self) -> None: ... @abstractmethod def _decrement_busy_count(self) -> None: ... - @abstractmethod - def _get_bookmark_exclude(self) -> list[str]: - """ - Retrieve the list of inputs excluded from being bookmarked. - """ - ... - - @abstractmethod - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - """ - 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. - """ - ... - - @abstractmethod - def on_bookmarked( - self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / - ) -> None: - """ - 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. - """ - ... - - @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 `bookmark_store` option. - - No actions will be performed if the `bookmark_store` option is set to `"disable"`. - """ - ... - - # @abstractmethod - # def set_bookmark_exclude(self, *names: str) -> None: - # """ - # Exclude inputs from being bookmarked. - # """ - # ... - - # @abstractmethod - # def get_bookmark_exclude(self) -> list[str]: - # """ - # Get the list of inputs excluded from being bookmarked. - # """ - # ... - # ====================================================================================== # AppSession @@ -623,11 +870,7 @@ def __init__( self.output: Outputs = Outputs(self, self.ns, outputs=dict()) self.clientdata: ClientData = ClientData(self) - self.bookmark_exclude: list[str] = [] - self.bookmark_store = "disable" - self._get_proxy_bookmark_exclude_fns: list[Callable[[], list[str]]] = [] - self._on_bookmark_callbacks = _utils.AsyncCallbacks() - self._on_bookmarked_callbacks = _utils.AsyncCallbacks() + self.bookmark: Bookmark = BookmarkApp(self) self.user: str | None = None self.groups: list[str] | None = None @@ -818,136 +1061,6 @@ def _is_hidden(self, name: str) -> bool: return hidden_value_obj() - # ========================================================================== - # Bookmarking - # ========================================================================== - - def _get_bookmark_exclude(self) -> list[str]: - """ - Get the list of inputs excluded from being bookmarked. - """ - - scoped_excludes: list[str] = [ - ".clientdata_pixelratio", - ".clientdata_url_protocol", - ".clientdata_url_hostname", - ".clientdata_url_port", - ".clientdata_url_pathname", - ".clientdata_url_search", - ".clientdata_url_hash_initial", - ".clientdata_url_hash", - ".clientdata_singletons", - ] - for proxy_exclude_fn in self._get_proxy_bookmark_exclude_fns: - scoped_excludes.extend(proxy_exclude_fn()) - # Remove duplicates - return list(set([*self.bookmark_exclude, *scoped_excludes])) - - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - self._on_bookmark_callbacks.register(wrap_async(callback)) - - def on_bookmarked( - self, - callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - /, - ) -> None: - self._on_bookmarked_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._send_message( - {"updateQueryString": {"queryString": query_string, "mode": mode}} - ) - - async def do_bookmark(self) -> None: - - if self.bookmark_store == "disable": - return - - try: - # ?withLogErrors - from ..bookmark._bookmark import ShinySaveState - - async def root_state_on_save(state: ShinySaveState) -> None: - await self._on_bookmark_callbacks.invoke(state) - - root_state = ShinySaveState( - input=self.input, - exclude=self._get_bookmark_exclude(), - on_save=root_state_on_save, - ) - - if self.bookmark_store == "server": - query_string = await root_state._save_state() - elif self.bookmark_store == "url": - query_string = await root_state._encode_state() - else: - raise ValueError("Unknown bookmark store: " + self.bookmark_store) - - port = str(self.clientdata.url_port()) - full_url = "".join( - [ - self.clientdata.url_protocol(), - "//", - self.clientdata.url_hostname(), - ":" if port else "", - port, - self.clientdata.url_pathname(), - "?", - query_string, - ] - ) - - # If onBookmarked callback was provided, invoke it; if not call - # the default. - if self._on_bookmarked_callbacks.count() > 0: - await self._on_bookmarked_callbacks.invoke(full_url) - else: - # showBookmarkUrlModal(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") - # TODO: Barret - Remove this! - raise RuntimeError("Error bookmarking state") from e - - # def set_bookmark_exclude(self, *names: str) -> None: - # """ - # Exclude inputs from being bookmarked. - # """ - # for name in names: - # if not isinstance(name, str): - # raise TypeError( - # "Bookmark exclude names must be strings. Received" + str(name) - # ) - # # Get unique values and store as list - # self._bookmark_exclude = list(set(names)) - - # def get_bookmark_exclude(self) -> list[str]: - # """ - # Get the list of inputs excluded from being bookmarked. - # """ - # scoped_excludes: list[str] = [] - # for exclude_fn in self._bookmark_exclude_fns: - # # Call the function and append the result to the list - # scoped_excludes.extend(exclude_fn()) - # # Remove duplicates - # return list(set([*self._bookmark_exclude, *scoped_excludes])) - # ========================================================================== # Message handlers # ========================================================================== @@ -1412,112 +1525,17 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._outbound_message_queues = parent._outbound_message_queues self._downloads = parent._downloads - self._init_bookmark() - - # init value last self._root = parent.root_scope() + self.bookmark = BookmarkProxy(parent.root_scope(), ns) def _is_hidden(self, name: str) -> bool: return self._parent._is_hidden(name) - def _get_bookmark_exclude(self) -> list[str]: - raise NotImplementedError( - "Please call `._get_bookmark_exclude()` from the root session only." - ) - - def _init_bookmark(self) -> None: - - self.bookmark_exclude: list[str] = [] - self._on_bookmark_callbacks = _utils.AsyncCallbacks() - - def ns_bookmark_exclude() -> list[str]: - # TODO: Barret - Double check that this works with nested modules! - return [self.ns(name) for name in self.bookmark_exclude] - - self._root._get_proxy_bookmark_exclude_fns.append(ns_bookmark_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`. - async def scoped_on_bookmark(root_state: ShinySaveState) -> None: - # Exit if no user-defined callbacks. - if self._on_bookmark_callbacks.count() == 0: - return - - from ..bookmark._bookmark import ShinySaveState - - scoped_state = ShinySaveState( - input=self.input, - exclude=self.bookmark_exclude, - on_save=None, - ) - - # Make subdir for scope - if root_state.dir is not None: - scope_subpath = self.ns("") - scoped_state.dir = Path(root_state.dir) / scope_subpath - if not os.path.exists(scoped_state.dir): - raise FileNotFoundError( - f"Scope directory could not be created for {scope_subpath}" - ) - - # Invoke the callback on the scopeState object - 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[self.ns(key)] = value - - self._root.on_bookmark(scoped_on_bookmark) - - # TODO: Barret - Implement restore scoped state! - - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - self._root.on_bookmark(callback) - - def on_bookmarked( - self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / - ) -> None: - # TODO: Barret - Q: Shouldn't we implement this? `session._root.on_bookmark()` - raise NotImplementedError( - "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().on_bookmark()`." - ) - - async def update_query_string( - self, query_string: str, mode: Literal["replace", "push"] = "replace" - ) -> None: - await self._root.update_query_string(query_string, mode) - - async def do_bookmark(self) -> None: - await self._root.do_bookmark() - - @property - def bookmark_store(self) -> BookmarkStore: - return self._parent.bookmark_store - - @bookmark_store.setter - def bookmark_store( # pyright: ignore[reportIncompatibleVariableOverride] - self, - value: BookmarkStore, - ) -> None: - self._parent.bookmark_store = value - def on_ended( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], ) -> Callable[[], None]: - return self._parent.on_ended(fn) + return self._root.on_ended(fn) def is_stub_session(self) -> bool: return self._parent.is_stub_session() @@ -1749,6 +1767,9 @@ async def _serialize( with reactive.isolate(): for key, value in self._map.items(): + # print(key, value) + if key.startswith(".clientdata_"): + continue if key in exclude_set: continue val = value() From cbc303ccd8211c55d43882a53144cfcb579c2869 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 17:19:22 -0500 Subject: [PATCH 13/62] Add notes on local storage; lints --- shiny/session/_session.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index fc2f8ccf6..290e79a62 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -230,7 +230,9 @@ def on_bookmark( @abstractmethod def on_bookmarked( - self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, ) -> None: """ Registers a function that will be called just after bookmarking state. @@ -353,6 +355,12 @@ async def root_state_on_save(state: ShinySaveState) -> None: query_string = await root_state._save_state() 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) @@ -467,7 +475,9 @@ def on_bookmark( self._root_bookmark.on_bookmark(callback) def on_bookmarked( - self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], / + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, ) -> None: # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` raise NotImplementedError( From 953b896d1ff519d5a530826d3f19e6bccdb4ead8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 17:28:41 -0500 Subject: [PATCH 14/62] Fix express module bookmarking --- shiny/express/_stub_session.py | 24 +++++++++++++++++++----- shiny/session/_session.py | 3 ++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index 10c0eda33..d56535370 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -149,8 +149,14 @@ def download( class BookmarkExpressStub(Bookmark): + def __init__(self, root_session: ExpressStubSession) -> None: + super().__init__(root_session) + self._proxy_exclude_fns = [] + def _get_bookmark_exclude(self) -> list[str]: - raise NotImplementedError("Please call this only from a real session object") + raise NotImplementedError( + "Please call `._get_bookmark_exclude()` only from a real session object" + ) def on_bookmark( self, @@ -159,18 +165,26 @@ def on_bookmark( | Callable[[ShinySaveState], Awaitable[None]] ), ) -> None: - raise NotImplementedError("Please call this only from a real session object") + raise NotImplementedError( + "Please call `.on_bookmark()` only from a real session object" + ) def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], ) -> None: - raise NotImplementedError("Please call this only from a real session object") + raise NotImplementedError( + "Please call `.on_bookmarked()` only from a real session object" + ) async def update_query_string( self, query_string: str, mode: Literal["replace", "push"] = "replace" ) -> None: - raise NotImplementedError("Please call this only from a real session object") + raise NotImplementedError( + "Please call `.update_query_string()` only from a real session object" + ) async def do_bookmark(self) -> None: - raise NotImplementedError("Please call this only from a real session object") + raise NotImplementedError( + "Please call `.do_bookmark()` only from a real session object" + ) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 290e79a62..90df659f7 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -428,7 +428,8 @@ def __init__(self, root_session: Session, ns: ResolvedId): async def scoped_on_bookmark(root_state: ShinySaveState) -> None: return await self._scoped_on_bookmark(root_state) - self._root_bookmark.on_bookmark(scoped_on_bookmark) + if isinstance(self._root_session, BookmarkApp): + self._root_bookmark.on_bookmark(scoped_on_bookmark) # TODO: Barret - Implement restore scoped state! From e2f838e25c92c531640373fba669b2e7447de6e2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 28 Feb 2025 17:28:46 -0500 Subject: [PATCH 15/62] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From da07a41b7da1e967a550a7a9d58def6b2915c0b4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 3 Mar 2025 11:59:18 -0500 Subject: [PATCH 16/62] Rearrange file content --- shiny/bookmark/__init__.py | 5 +- shiny/bookmark/_bookmark.py | 450 ++++++++++++++++++++-------- shiny/bookmark/_save_state.py | 163 ++++++++++ shiny/bookmark/_shiny_save_state.py | 0 shiny/session/_session.py | 354 +--------------------- 5 files changed, 492 insertions(+), 480 deletions(-) create mode 100644 shiny/bookmark/_shiny_save_state.py diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index 2c71dd17c..d29edd217 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -1,5 +1,4 @@ -from ._bookmark import ShinySaveState - +from ._bookmark import Bookmark, BookmarkApp, BookmarkProxy, ShinySaveState from ._save_state import SaveState -__all__ = ("SaveState", "ShinySaveState") +__all__ = ("SaveState", "ShinySaveState", "Bookmark", "BookmarkApp", "BookmarkProxy") diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index a74183719..4ec6807d9 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -44,166 +44,366 @@ # * May need to escape (all?) the parameters to avoid collisions with `h=` or `code=`. # Set query string to parent frame / tab -import pickle +from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, Awaitable, Callable -from urllib.parse import urlencode as urllib_urlencode - -from .. import Inputs -from .._utils import private_random_id -from ..reactive import isolate -from ._save_state import SaveState, SaveStateLocal -from ._utils import is_hosted, to_json - - -class ShinySaveState: - # session: ? - # * Would get us access to inputs, possibly app dir, registered on save / load classes (?), exclude - # - input: Inputs - values: dict[str, Any] +from typing import TYPE_CHECKING, Awaitable, Callable, Literal + +from .._utils import AsyncCallbacks, wrap_async +from ._save_state import ShinySaveState + +if TYPE_CHECKING: + from .._namespaces import ResolvedId + from ..session import Session + +BookmarkStore = Literal["url", "server", "disable"] + + +class Bookmark(ABC): + + _root_session: Session + + store: BookmarkStore + + _proxy_exclude_fns: list[Callable[[], list[str]]] exclude: list[str] - # _bookmark_: A special value that is always excluded from the bookmark. - on_save: ( - Callable[["ShinySaveState"], 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 + _on_bookmark_callbacks: AsyncCallbacks + _on_bookmarked_callbacks: AsyncCallbacks + + async def __call__(self) -> None: + await self._root_bookmark.do_bookmark() - def __init__( + @property + def _root_bookmark(self) -> "Bookmark": + return self._root_session.bookmark + + def __init__(self, root_session: Session): + super().__init__() + self._root_session = root_session + + # # TODO: Barret - Implement this?!? + # @abstractmethod + # async def get_url(self) -> str: + # ... + + # # `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)) + + @abstractmethod + def _get_bookmark_exclude(self) -> list[str]: + """ + Retrieve the list of inputs excluded from being bookmarked. + """ + ... + + @abstractmethod + def on_bookmark( self, - input: Inputs, - exclude: list[str], - on_save: Callable[["ShinySaveState"], 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] = ["._bookmark_"] - - 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) - - def _exclude_bookmark_value(self): - # If the bookmark value is not in the exclude list, add it. - if "._bookmark_" not in self.exclude: - self.exclude.append("._bookmark_") - - async def _save_state(self) -> str: + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: """ - Save a state to disk (pickle). + 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. - Returns - ------- - str - A query string which can be used to restore the session. + 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. """ - id = private_random_id(prefix="", bytes=8) + ... - # TODO: barret move code to single call location - # A function for saving the state object to disk, given a directory to save - # to. - async def save_state_to_dir(state_dir: Path) -> None: - self.dir = state_dir + @abstractmethod + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> None: + """ + Registers a function that will be called just after bookmarking state. - await self._call_on_save() + This callback will be executed **after** the bookmark state is saved serverside or in the URL. - self._exclude_bookmark_value() + 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. + """ + ... - input_values_json = await self.input._serialize( - exclude=self.exclude, - state_dir=self.dir, - ) - assert self.dir is not None - with open(self.dir / "input.pickle", "wb") as f: - pickle.dump(input_values_json, f) + @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 + # TODO: Barret - Q: Rename to `update()`? `session.bookmark.update()`? + 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"`. + """ + ... + + +class BookmarkApp(Bookmark): + def __init__(self, root_session: Session): + + super().__init__(root_session) + + self.store = "disable" + self.exclude = [] + self._proxy_exclude_fns = [] + self._on_bookmark_callbacks = AsyncCallbacks() + self._on_bookmarked_callbacks = AsyncCallbacks() + + 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._proxy_exclude_fns: + scoped_excludes.extend(proxy_exclude_fn()) + # Remove duplicates + return list(set([*self.exclude, *scoped_excludes])) + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._on_bookmark_callbacks.register(wrap_async(callback)) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> None: + self._on_bookmarked_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._root_session._send_message( + { + "updateQueryString": { + "queryString": query_string, + "mode": mode, + } + } + ) - if len(self.values) > 0: - with open(self.dir / "values.pickle", "wb") as f: - pickle.dump(self.values, f) + async def do_bookmark(self) -> None: + if self.store == "disable": return - # Pass the saveState function to the save interface function, which will - # invoke saveState after preparing the directory. + try: + # ?withLogErrors + from ..bookmark._bookmark import ShinySaveState - # TODO: FUTURE - Get the save interface from the session object? - # Look for a save.interface function. This will be defined by the hosting - # environment if it supports bookmarking. - save_interface_loaded: SaveState | None = None + async def root_state_on_save(state: ShinySaveState) -> None: + await self._on_bookmark_callbacks.invoke(state) - if save_interface_loaded is None: - if is_hosted(): - # TODO: Barret - raise NotImplementedError( - "The hosting environment does not support server-side bookmarking." - ) - else: - # We're running Shiny locally. - save_interface_loaded = SaveStateLocal() + root_state = ShinySaveState( + input=self._root_session.input, + exclude=self._get_bookmark_exclude(), + on_save=root_state_on_save, + ) - if not isinstance(save_interface_loaded, SaveState): - raise TypeError( - "The save interface retrieved must be an instance of `shiny.bookmark.SaveState`." + if self.store == "server": + query_string = await root_state._save_state() + 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._root_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, + ] ) - save_dir = Path(await save_interface_loaded.save_dir(id)) - await save_state_to_dir(save_dir) + # If onBookmarked callback was provided, invoke it; if not call + # the default. + if self._on_bookmarked_callbacks.count() > 0: + await self._on_bookmarked_callbacks.invoke(full_url) + else: + # `session.bookmark.show_modal(url)` - # No need to encode URI component as it is only ascii characters. - return f"_state_id_={id}" + # 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 - async def _encode_state(self) -> str: - """ - Encode the state to a URL. + notification_show(msg, duration=None, type="error") + # TODO: Barret - Remove this! + raise RuntimeError("Error bookmarking state") from e - 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() +class BookmarkProxy(Bookmark): + + _ns: ResolvedId + + def __init__(self, root_session: Session, ns: ResolvedId): + super().__init__(root_session) - self._exclude_bookmark_value() + self._ns = ns - input_values_serialized = await self.input._serialize( - exclude=self.exclude, - # Do not include directory as we are not saving to disk. - state_dir=None, + self.exclude = [] + self._proxy_exclude_fns = [] + self._on_bookmark_callbacks = AsyncCallbacks() + self._on_bookmarked_callbacks = AsyncCallbacks() + + # TODO: Barret - Double check that this works with nested modules! + self._root_session.bookmark._proxy_exclude_fns.append( + lambda: [self._ns(name) for name in self.exclude] ) - # Using an array to construct string to avoid multiple serial concatenations. - qs_str_parts: list[str] = [] + # 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`. + async def scoped_on_bookmark(root_state: ShinySaveState) -> None: + return await self._scoped_on_bookmark(root_state) - # If any input values are present, add them. - if len(input_values_serialized) > 0: - input_qs = urllib_urlencode(to_json(input_values_serialized)) + if isinstance(self._root_session, BookmarkApp): + self._root_bookmark.on_bookmark(scoped_on_bookmark) - qs_str_parts.append("_inputs_&") - qs_str_parts.append(input_qs) + # TODO: Barret - Implement restore scoped state! - if len(self.values) > 0: - if len(qs_str_parts) > 0: - qs_str_parts.append("&") + async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: + # Exit if no user-defined callbacks. + if self._on_bookmark_callbacks.count() == 0: + return - values_qs = urllib_urlencode(to_json(self.values)) + from ..bookmark._bookmark import ShinySaveState - qs_str_parts.append("_values_&") - qs_str_parts.append(values_qs) + scoped_state = ShinySaveState( + input=self._root_session.input, + exclude=self._root_bookmark.exclude, + on_save=None, + ) + + # Make subdir for scope + if root_state.dir is not None: + scope_subpath = self._ns("") + scoped_state.dir = Path(root_state.dir) / scope_subpath + if not scoped_state.dir.exists(): + raise FileNotFoundError( + f"Scope directory could not be created for {scope_subpath}" + ) - return "".join(qs_str_parts) + # Invoke the callback on the scopeState object + 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[self._ns(key)] = value + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + /, + ) -> None: + self._root_bookmark.on_bookmark(callback) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + /, + ) -> None: + # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` + raise NotImplementedError( + "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().bookmark.on_bookmark()`." + ) + + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError( + "Please call `._get_bookmark_exclude()` from the root session only." + ) + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + await self._root_bookmark.update_query_string(query_string, mode) + + async def do_bookmark(self) -> None: + await self._root_bookmark.do_bookmark() + + @property + def store(self) -> BookmarkStore: + return self._root_bookmark.store + + @store.setter + def store( # pyright: ignore[reportIncompatibleVariableOverride] + self, + value: BookmarkStore, + ) -> None: + self._root_bookmark.store = value # RestoreContext <- R6Class("RestoreContext", diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 6d26a58ee..65b79cafa 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -1,8 +1,18 @@ # TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 import os +import pickle from abc import ABC, abstractmethod 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 ._utils import is_hosted, to_json + +if TYPE_CHECKING: + from .. import Inputs class SaveState(ABC): @@ -93,3 +103,156 @@ async def load_dir(self, id: str) -> Path: # ) -> None: # await read_files(self._local_dir(id)) # await read_files(self._local_dir(id)) + + +# ############################################################################# + + +class ShinySaveState: + # 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[["ShinySaveState"], 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[["ShinySaveState"], 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] = ["._bookmark_"] + + 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) + + def _exclude_bookmark_value(self): + # If the bookmark value is not in the exclude list, add it. + if "._bookmark_" not in self.exclude: + self.exclude.append("._bookmark_") + + async def _save_state(self) -> str: + """ + Save a state to disk (pickle). + + Returns + ------- + str + A query string which can be used to restore the session. + """ + id = private_random_id(prefix="", bytes=8) + + # TODO: barret move code to single call location + # A function for saving the state object to disk, given a directory to save + # to. + async def save_state_to_dir(state_dir: Path) -> None: + self.dir = state_dir + + await self._call_on_save() + + self._exclude_bookmark_value() + + input_values_json = await self.input._serialize( + exclude=self.exclude, + state_dir=self.dir, + ) + assert self.dir is not None + with open(self.dir / "input.pickle", "wb") as f: + pickle.dump(input_values_json, f) + + if len(self.values) > 0: + with open(self.dir / "values.pickle", "wb") as f: + pickle.dump(self.values, f) + + return + + # Pass the saveState function to the save interface function, which will + # invoke saveState after preparing the directory. + + # TODO: FUTURE - Get the save interface from the session object? + # Look for a save.interface function. This will be defined by the hosting + # environment if it supports bookmarking. + save_interface_loaded: SaveState | None = None + + if save_interface_loaded is None: + if is_hosted(): + # TODO: Barret + raise NotImplementedError( + "The hosting environment does not support server-side bookmarking." + ) + else: + # We're running Shiny locally. + save_interface_loaded = SaveStateLocal() + + if not isinstance(save_interface_loaded, SaveState): + raise TypeError( + "The save interface retrieved must be an instance of `shiny.bookmark.SaveState`." + ) + + save_dir = Path(await save_interface_loaded.save_dir(id)) + await save_state_to_dir(save_dir) + + # 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() + + self._exclude_bookmark_value() + + 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(to_json(input_values_serialized)) + + 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("&") + + values_qs = urllib_urlencode(to_json(self.values)) + + qs_str_parts.append("_values_&") + qs_str_parts.append(values_qs) + + return "".join(qs_str_parts) diff --git a/shiny/bookmark/_shiny_save_state.py b/shiny/bookmark/_shiny_save_state.py new file mode 100644 index 000000000..e69de29bb diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 90df659f7..8bce57ad6 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -44,6 +44,7 @@ from .._namespaces import Id, ResolvedId, Root from .._typing_extensions import NotRequired, TypedDict from .._utils import wrap_async +from ..bookmark import BookmarkApp, BookmarkProxy from ..http_staticfiles import FileResponse from ..input_handler import input_handlers from ..reactive import Effect_, Value, effect, flush, isolate @@ -62,10 +63,7 @@ from shiny.bookmark._serializers import Unserializable from .._app import App - from ..bookmark._bookmark import ShinySaveState - - -BookmarkStore = Literal["url", "server", "disable"] + from ..bookmark import Bookmark class ConnectionState(enum.Enum): @@ -162,354 +160,6 @@ def add_input_message(self, id: str, message: dict[str, Any]) -> None: self.input_messages.append({"id": id, "message": message}) -class Bookmark(ABC): - - _root_session: Session - - store: BookmarkStore - - _proxy_exclude_fns: list[Callable[[], list[str]]] - exclude: list[str] - - _on_bookmark_callbacks: _utils.AsyncCallbacks - _on_bookmarked_callbacks: _utils.AsyncCallbacks - - async def __call__(self) -> None: - await self._root_bookmark.do_bookmark() - - @property - def _root_bookmark(self) -> Bookmark: - return self._root_session.bookmark - - def __init__(self, root_session: Session): - super().__init__() - self._root_session = root_session - - # # TODO: Barret - Implement this?!? - # @abstractmethod - # async def get_url(self) -> str: - # ... - - # # `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)) - - @abstractmethod - def _get_bookmark_exclude(self) -> list[str]: - """ - Retrieve the list of inputs excluded from being bookmarked. - """ - ... - - @abstractmethod - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - """ - 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. - """ - ... - - @abstractmethod - def on_bookmarked( - self, - callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - /, - ) -> None: - """ - 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. - """ - ... - - @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 - # TODO: Barret - Q: Rename to `update()`? `session.bookmark.update()`? - 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"`. - """ - ... - - -class BookmarkApp(Bookmark): - def __init__(self, root_session: Session): - - super().__init__(root_session) - - self.store = "disable" - self.exclude = [] - self._proxy_exclude_fns = [] - self._on_bookmark_callbacks = _utils.AsyncCallbacks() - self._on_bookmarked_callbacks = _utils.AsyncCallbacks() - - 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._proxy_exclude_fns: - scoped_excludes.extend(proxy_exclude_fn()) - # Remove duplicates - return list(set([*self.exclude, *scoped_excludes])) - - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - self._on_bookmark_callbacks.register(wrap_async(callback)) - - def on_bookmarked( - self, - callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - /, - ) -> None: - self._on_bookmarked_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._root_session._send_message( - { - "updateQueryString": { - "queryString": query_string, - "mode": mode, - } - } - ) - - async def do_bookmark(self) -> None: - - if self.store == "disable": - return - - try: - # ?withLogErrors - from ..bookmark._bookmark import ShinySaveState - - async def root_state_on_save(state: ShinySaveState) -> None: - await self._on_bookmark_callbacks.invoke(state) - - root_state = ShinySaveState( - input=self._root_session.input, - exclude=self._get_bookmark_exclude(), - on_save=root_state_on_save, - ) - - if self.store == "server": - query_string = await root_state._save_state() - 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._root_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: - 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") - # TODO: Barret - Remove this! - raise RuntimeError("Error bookmarking state") from e - - -class BookmarkProxy(Bookmark): - - _ns: ResolvedId - - def __init__(self, root_session: Session, ns: ResolvedId): - super().__init__(root_session) - - self._ns = ns - - self.exclude = [] - self._proxy_exclude_fns = [] - self._on_bookmark_callbacks = _utils.AsyncCallbacks() - self._on_bookmarked_callbacks = _utils.AsyncCallbacks() - - # TODO: Barret - Double check that this works with nested modules! - self._root_session.bookmark._proxy_exclude_fns.append( - lambda: [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`. - async def scoped_on_bookmark(root_state: ShinySaveState) -> None: - return await self._scoped_on_bookmark(root_state) - - if isinstance(self._root_session, BookmarkApp): - self._root_bookmark.on_bookmark(scoped_on_bookmark) - - # TODO: Barret - Implement restore scoped state! - - async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: - # Exit if no user-defined callbacks. - if self._on_bookmark_callbacks.count() == 0: - return - - from ..bookmark._bookmark import ShinySaveState - - scoped_state = ShinySaveState( - input=self._root_session.input, - exclude=self._root_bookmark.exclude, - on_save=None, - ) - - # Make subdir for scope - if root_state.dir is not None: - scope_subpath = self._ns("") - scoped_state.dir = Path(root_state.dir) / scope_subpath - if not os.path.exists(scoped_state.dir): - raise FileNotFoundError( - f"Scope directory could not be created for {scope_subpath}" - ) - - # Invoke the callback on the scopeState object - 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[self._ns(key)] = value - - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - /, - ) -> None: - self._root_bookmark.on_bookmark(callback) - - def on_bookmarked( - self, - callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - /, - ) -> None: - # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` - raise NotImplementedError( - "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().bookmark.on_bookmark()`." - ) - - def _get_bookmark_exclude(self) -> list[str]: - raise NotImplementedError( - "Please call `._get_bookmark_exclude()` from the root session only." - ) - - async def update_query_string( - self, query_string: str, mode: Literal["replace", "push"] = "replace" - ) -> None: - await self._root_bookmark.update_query_string(query_string, mode) - - async def do_bookmark(self) -> None: - await self._root_bookmark.do_bookmark() - - @property - def store(self) -> BookmarkStore: - return self._root_bookmark.store - - @store.setter - def store( # pyright: ignore[reportIncompatibleVariableOverride] - self, - value: BookmarkStore, - ) -> None: - self._root_bookmark.store = value - - # ====================================================================================== # Session abstract base class # ====================================================================================== From 24c134bd7323f4764d0611f2bf3833ac9fb70c30 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 3 Mar 2025 12:07:34 -0500 Subject: [PATCH 17/62] Move express stub bookmark object --- shiny/bookmark/__init__.py | 17 ++++++++++-- shiny/bookmark/_bookmark.py | 51 ++++++++++++++++++++++++++++++++++ shiny/bookmark/_save_state.py | 2 ++ shiny/express/_stub_session.py | 47 +------------------------------ 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index d29edd217..df8b75bc5 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -1,4 +1,17 @@ -from ._bookmark import Bookmark, BookmarkApp, BookmarkProxy, ShinySaveState +from ._bookmark import ( + Bookmark, + BookmarkApp, + BookmarkExpressStub, + BookmarkProxy, + ShinySaveState, +) from ._save_state import SaveState -__all__ = ("SaveState", "ShinySaveState", "Bookmark", "BookmarkApp", "BookmarkProxy") +__all__ = ( + "SaveState", + "ShinySaveState", + "Bookmark", + "BookmarkApp", + "BookmarkProxy", + "BookmarkExpressStub", +) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 4ec6807d9..f513f2b0e 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -53,7 +53,15 @@ if TYPE_CHECKING: from .._namespaces import ResolvedId + from ..express._stub_session import ExpressStubSession from ..session import Session +else: + from typing import Any + + Session = Any + ResolvedId = Any + ExpressStubSession = Any + BookmarkStore = Literal["url", "server", "disable"] @@ -406,6 +414,49 @@ def store( # pyright: ignore[reportIncompatibleVariableOverride] self._root_bookmark.store = value +class BookmarkExpressStub(Bookmark): + + def __init__(self, root_session: ExpressStubSession) -> None: + super().__init__(root_session) + self._proxy_exclude_fns = [] + + def _get_bookmark_exclude(self) -> list[str]: + raise NotImplementedError( + "Please call `._get_bookmark_exclude()` only from a real session object" + ) + + def on_bookmark( + self, + callback: ( + Callable[[ShinySaveState], None] + | Callable[[ShinySaveState], Awaitable[None]] + ), + ) -> None: + raise NotImplementedError( + "Please call `.on_bookmark()` only from a real session object" + ) + + def on_bookmarked( + self, + callback: Callable[[str], None] | Callable[[str], Awaitable[None]], + ) -> None: + raise NotImplementedError( + "Please call `.on_bookmarked()` only from a real session object" + ) + + async def update_query_string( + self, query_string: str, mode: Literal["replace", "push"] = "replace" + ) -> None: + raise NotImplementedError( + "Please call `.update_query_string()` only from a real session object" + ) + + async def do_bookmark(self) -> None: + raise NotImplementedError( + "Please call `.do_bookmark()` only from a real session object" + ) + + # RestoreContext <- R6Class("RestoreContext", # public = list( # # This will be set to TRUE if there's actually a state to restore diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 65b79cafa..838393737 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from .. import Inputs +else: + Inputs = Any class SaveState(ABC): diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index d56535370..62bc1f968 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -4,11 +4,9 @@ from htmltools import TagChild -from shiny.bookmark._bookmark import ShinySaveState - from .._namespaces import Id, ResolvedId, Root +from ..bookmark import BookmarkExpressStub from ..session import Inputs, Outputs, Session -from ..session._session import Bookmark, SessionProxy if TYPE_CHECKING: from ..session._session import DownloadHandler, DynamicRouteHandler, RenderedDeps @@ -145,46 +143,3 @@ def download( encoding: str = "utf-8", ) -> Callable[[DownloadHandler], None]: return lambda x: None - - -class BookmarkExpressStub(Bookmark): - - def __init__(self, root_session: ExpressStubSession) -> None: - super().__init__(root_session) - self._proxy_exclude_fns = [] - - def _get_bookmark_exclude(self) -> list[str]: - raise NotImplementedError( - "Please call `._get_bookmark_exclude()` only from a real session object" - ) - - def on_bookmark( - self, - callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] - ), - ) -> None: - raise NotImplementedError( - "Please call `.on_bookmark()` only from a real session object" - ) - - def on_bookmarked( - self, - callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - ) -> None: - raise NotImplementedError( - "Please call `.on_bookmarked()` only from a real session object" - ) - - async def update_query_string( - self, query_string: str, mode: Literal["replace", "push"] = "replace" - ) -> None: - raise NotImplementedError( - "Please call `.update_query_string()` only from a real session object" - ) - - async def do_bookmark(self) -> None: - raise NotImplementedError( - "Please call `.do_bookmark()` only from a real session object" - ) From 2d7e0c96fc79fdd86552f2cf0a3bc0ccd28362b6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 3 Mar 2025 12:10:05 -0500 Subject: [PATCH 18/62] Update _bookmark.py --- shiny/bookmark/_bookmark.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index f513f2b0e..cf5db3bc3 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -66,6 +66,12 @@ BookmarkStore = Literal["url", "server", "disable"] +# TODO: future - Local storage Bookmark class! +# * Needs a consistent id for storage. +# * Needs ways to clean up other storage +# * Needs ways to see available IDs + + class Bookmark(ABC): _root_session: Session @@ -94,6 +100,7 @@ def __init__(self, root_session: Session): # 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: From 99ecada8d621ffe458d0157664eb4e5d1d4193c8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 01:38:03 -0500 Subject: [PATCH 19/62] Update _utils.py --- shiny/session/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index fda6efd6b..1f59a4f6f 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. From b0bf3db7c34269fb5bb5f1bfd302bd213a739218 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 01:48:00 -0500 Subject: [PATCH 20/62] Move BookmarkState class to sep file --- shiny/bookmark/_bookmark_state.py | 93 ++++++++++++++++ shiny/bookmark/_save_state.py | 163 ++++++---------------------- shiny/bookmark/_shiny_save_state.py | 0 3 files changed, 129 insertions(+), 127 deletions(-) create mode 100644 shiny/bookmark/_bookmark_state.py delete mode 100644 shiny/bookmark/_shiny_save_state.py diff --git a/shiny/bookmark/_bookmark_state.py b/shiny/bookmark/_bookmark_state.py new file mode 100644 index 000000000..fb9ff5e79 --- /dev/null +++ b/shiny/bookmark/_bookmark_state.py @@ -0,0 +1,93 @@ +import os +from abc import ABC, abstractmethod +from pathlib import Path + + +class BookmarkState(ABC): + """ + Class for saving and restoring state to/from disk. + """ + + @abstractmethod + async def save_dir( + self, + id: str, + # write_files: Callable[[Path], Awaitable[None]], + ) -> Path: + """ + Construct directory for saving state. + + Parameters + ---------- + id + The unique identifier for the state. + + Returns + ------- + Path + Directory location for saving state. This directory must exist. + """ + # write_files + # A async function that writes the state to a serializable location. The method receives a path object and + ... + + @abstractmethod + async def load_dir( + self, + id: str, + # read_files: Callable[[Path], Awaitable[None]], + ) -> Path: + """ + Construct directory for loading state. + + Parameters + ---------- + id + The unique identifier for the state. + + Returns + ------- + Path | None + Directory location for loading state. If `None`, state loading will be ignored. If a `Path`, the directory must exist. + """ + ... + + +class BookmarkStateLocal(BookmarkState): + """ + Function wrappers for saving and restoring state to/from disk when running Shiny + locally. + """ + + def _local_dir(self, 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 save_dir(self, id: str) -> Path: + state_dir = self._local_dir(id) + if not state_dir.exists(): + state_dir.mkdir(parents=True) + return state_dir + + async def load_dir(self, id: str) -> Path: + return self._local_dir(id) + + # async def save( + # self, + # id: str, + # write_files: Callable[[Path], Awaitable[None]], + # ) -> None: + # state_dir = self._local_dir(id) + # if not state_dir.exists(): + # state_dir.mkdir(parents=True) + + # await write_files(state_dir) + + # async def load( + # self, + # id: str, + # read_files: Callable[[Path], Awaitable[None]], + # ) -> None: + # await read_files(self._local_dir(id)) + # await read_files(self._local_dir(id)) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 838393737..e90f18f81 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -1,15 +1,15 @@ # TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 +# Might need to have independent save/load functions to register to avoid a class constructor -import os import pickle -from abc import ABC, abstractmethod 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 ._utils import is_hosted, to_json +from ._bookmark_state import BookmarkState +from ._utils import is_hosted, to_json_str if TYPE_CHECKING: from .. import Inputs @@ -17,99 +17,6 @@ Inputs = Any -class SaveState(ABC): - """ - Class for saving and restoring state to/from disk. - """ - - @abstractmethod - async def save_dir( - self, - id: str, - # write_files: Callable[[Path], Awaitable[None]], - ) -> Path: - """ - Construct directory for saving state. - - Parameters - ---------- - id - The unique identifier for the state. - - Returns - ------- - Path - Directory location for saving state. This directory must exist. - """ - # write_files - # A async function that writes the state to a serializable location. The method receives a path object and - ... - - @abstractmethod - async def load_dir( - self, - id: str, - # read_files: Callable[[Path], Awaitable[None]], - ) -> Path: - """ - Construct directory for loading state. - - Parameters - ---------- - id - The unique identifier for the state. - - Returns - ------- - Path | None - Directory location for loading state. If `None`, state loading will be ignored. If a `Path`, the directory must exist. - """ - ... - - -class SaveStateLocal(SaveState): - """ - Function wrappers for saving and restoring state to/from disk when running Shiny - locally. - """ - - def _local_dir(self, 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 save_dir(self, id: str) -> Path: - state_dir = self._local_dir(id) - if not state_dir.exists(): - state_dir.mkdir(parents=True) - return state_dir - - async def load_dir(self, id: str) -> Path: - return self._local_dir(id) - - # async def save( - # self, - # id: str, - # write_files: Callable[[Path], Awaitable[None]], - # ) -> None: - # state_dir = self._local_dir(id) - # if not state_dir.exists(): - # state_dir.mkdir(parents=True) - - # await write_files(state_dir) - - # async def load( - # self, - # id: str, - # read_files: Callable[[Path], Awaitable[None]], - # ) -> None: - # await read_files(self._local_dir(id)) - # await read_files(self._local_dir(id)) - - -# ############################################################################# - - class ShinySaveState: # session: ? # * Would get us access to inputs, possibly app dir, registered on save / load classes (?), exclude @@ -163,37 +70,13 @@ async def _save_state(self) -> str: """ id = private_random_id(prefix="", bytes=8) - # TODO: barret move code to single call location - # A function for saving the state object to disk, given a directory to save - # to. - async def save_state_to_dir(state_dir: Path) -> None: - self.dir = state_dir - - await self._call_on_save() - - self._exclude_bookmark_value() - - input_values_json = await self.input._serialize( - exclude=self.exclude, - state_dir=self.dir, - ) - assert self.dir is not None - with open(self.dir / "input.pickle", "wb") as f: - pickle.dump(input_values_json, f) - - if len(self.values) > 0: - with open(self.dir / "values.pickle", "wb") as f: - pickle.dump(self.values, f) - - return - # Pass the saveState function to the save interface function, which will # invoke saveState after preparing the directory. # TODO: FUTURE - Get the save interface from the session object? # Look for a save.interface function. This will be defined by the hosting # environment if it supports bookmarking. - save_interface_loaded: SaveState | None = None + save_interface_loaded: BookmarkState | None = None if save_interface_loaded is None: if is_hosted(): @@ -203,15 +86,33 @@ async def save_state_to_dir(state_dir: Path) -> None: ) else: # We're running Shiny locally. - save_interface_loaded = SaveStateLocal() + save_interface_loaded = BookmarkStateLocal() - if not isinstance(save_interface_loaded, SaveState): + if not isinstance(save_interface_loaded, BookmarkState): raise TypeError( - "The save interface retrieved must be an instance of `shiny.bookmark.SaveState`." + "The save interface retrieved must be an instance of `shiny.bookmark.BookmarkStateLocal`." ) save_dir = Path(await save_interface_loaded.save_dir(id)) - await save_state_to_dir(save_dir) + + # Save the state to disk. + self.dir = save_dir + await self._call_on_save() + + self._exclude_bookmark_value() + + input_values_json = await self.input._serialize( + exclude=self.exclude, + state_dir=self.dir, + ) + assert self.dir is not None + with open(self.dir / "input.pickle", "wb") as f: + pickle.dump(input_values_json, f) + + if len(self.values) > 0: + with open(self.dir / "values.pickle", "wb") as f: + pickle.dump(self.values, f) + # End save to disk # No need to encode URI component as it is only ascii characters. return f"_state_id_={id}" @@ -243,7 +144,12 @@ async def _encode_state(self) -> str: # If any input values are present, add them. if len(input_values_serialized) > 0: - input_qs = urllib_urlencode(to_json(input_values_serialized)) + 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) @@ -252,7 +158,10 @@ async def _encode_state(self) -> str: if len(qs_str_parts) > 0: qs_str_parts.append("&") - values_qs = urllib_urlencode(to_json(self.values)) + # 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) diff --git a/shiny/bookmark/_shiny_save_state.py b/shiny/bookmark/_shiny_save_state.py deleted file mode 100644 index e69de29bb..000000000 From 1b484199c36957c6b1782ea1fb7f00343a1c86a0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 01:54:14 -0500 Subject: [PATCH 21/62] First pass at restore state. **many** debug statements. Modules are saving! --- shiny/_app.py | 46 +- shiny/_namespaces.py | 4 +- shiny/_utils.py | 9 +- shiny/bookmark/__init__.py | 10 +- shiny/bookmark/_bookmark.py | 703 +++++++++++++------------------ shiny/bookmark/_restore_state.py | 410 ++++++++++++++++++ shiny/bookmark/_utils.py | 8 +- shiny/reactive/_core.py | 6 + shiny/session/_session.py | 39 +- 9 files changed, 798 insertions(+), 437 deletions(-) create mode 100644 shiny/bookmark/_restore_state.py diff --git a/shiny/_app.py b/shiny/_app.py index 2929192cc..13acf97ab 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -3,6 +3,7 @@ import copy import os import secrets +import warnings from contextlib import AsyncExitStack, asynccontextmanager from inspect import signature from pathlib import Path @@ -30,6 +31,11 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length +from .bookmark._restore_state import ( + RestoreContext, + get_current_restore_context, + restore_context, +) 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 @@ -167,7 +173,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,11 +359,41 @@ async def _on_root_request_cb(self, request: Request) -> Response: request for / occurs. """ ui: RenderedHTML - if callable(self.ui): - ui = self._render_page(self.ui(request), self.lib_prefix) + # Create a restore context using query string + # TODO: Barret implement how to get bookmark_store value + # bookmarkStore <- getShinyOption("bookmarkStore", default = "disable") + print("TODO: Figure this out") + bookmark_store: str = "disable" + bookmark_store: str = "query" + + if bookmark_store == "disable": + restore_ctx = RestoreContext() else: - ui = self.ui - return HTMLResponse(content=ui["html"]) + restore_ctx = await RestoreContext.from_query_string(request.url.query) + + print( + { + "values": restore_ctx.as_state().values, + "input": restore_ctx.as_state().input, + } + ) + + with restore_context(restore_ctx): + if callable(self.ui): + ui = self._render_page(self.ui(request), self.lib_prefix) + else: + # TODO: Why is this here as there's a with restore_context above? + # TODO: Why not `if restore_ctx.active:`? + cur_restore_ctx = get_current_restore_context() + print("cur_restore_ctx", cur_restore_ctx) + if cur_restore_ctx is not None and cur_restore_ctx.active: + # TODO: See ?enableBookmarking + warnings.warn( + "Trying to restore saved app state, but UI code must be a function for this to work!" + ) + + ui = self.ui + return HTMLResponse(content=ui["html"]) async def _on_connect_cb(self, ws: starlette.websockets.WebSocket) -> None: """ 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 699d1fc0b..3bc548a21 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -550,6 +550,9 @@ 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]] = {} @@ -557,16 +560,16 @@ def __init__(self) -> None: def register( self, fn: Callable[..., Awaitable[None]], once: bool = False - ) -> Callable[[], None]: + ) -> 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, *args: Any, **kwargs: Any) -> None: # The list() wrapper is necessary to force collection of all the items before diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index df8b75bc5..a2aea19d6 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -5,13 +5,19 @@ BookmarkProxy, ShinySaveState, ) -from ._save_state import SaveState +from ._bookmark_state import BookmarkState +from ._restore_state import RestoreContext, RestoreContextState __all__ = ( - "SaveState", + # _bookmark "ShinySaveState", "Bookmark", "BookmarkApp", "BookmarkProxy", "BookmarkExpressStub", + # _bookmark_state + "BookmarkState", + # _restore_state + "RestoreContext", + "RestoreContextState", ) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index cf5db3bc3..dda28a070 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -1,3 +1,5 @@ +# TODO: bookmark button + # TODO: # bookmark -> save/load interface # * √ base class @@ -35,7 +37,7 @@ # * √ Update query string # bookmark -> restore state -# restore state -> {inputs, values, exclude} +# restore state -> {inputs, values} # restore {inputs} -> Update all inputs given restored value # Shinylive! @@ -46,19 +48,24 @@ from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Awaitable, Callable, Literal +from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn -from .._utils import AsyncCallbacks, wrap_async +from .._utils import AsyncCallbacks, CancelCallback, wrap_async +from ._restore_state import RestoreContextState from ._save_state import ShinySaveState if TYPE_CHECKING: from .._namespaces import ResolvedId from ..express._stub_session import ExpressStubSession from ..session import Session + from ..session._session import SessionProxy + from ._restore_state import RestoreContext else: from typing import Any + RestoreContext = Any Session = Any + SessionProxy = Any ResolvedId = Any ExpressStubSession = Any @@ -74,7 +81,8 @@ class Bookmark(ABC): - _root_session: Session + # TODO: Barret - This feels like it needs to be a weakref + _session_root: Session store: BookmarkStore @@ -83,17 +91,24 @@ class Bookmark(ABC): _on_bookmark_callbacks: AsyncCallbacks _on_bookmarked_callbacks: AsyncCallbacks + _on_restore_callbacks: AsyncCallbacks + _on_restored_callbacks: AsyncCallbacks + + _restore_context: RestoreContext | None async def __call__(self) -> None: await self._root_bookmark.do_bookmark() @property def _root_bookmark(self) -> "Bookmark": - return self._root_session.bookmark + return self._session_root.bookmark + + def __init__(self, session_root: Session): + # from ._restore_state import RestoreContext - def __init__(self, root_session: Session): super().__init__() - self._root_session = root_session + self._session_root = session_root + self._restore_context = None # # TODO: Barret - Implement this?!? # @abstractmethod @@ -109,6 +124,15 @@ def __init__(self, root_session: Session): # await session.insert_ui(modal_with_url(url)) + @abstractmethod + def _create_effects(self) -> None: + """ + Create the effects for the bookmarking system. + + This method should be called when the session is created after the initial inputs have been set. + """ + ... + @abstractmethod def _get_bookmark_exclude(self) -> list[str]: """ @@ -124,7 +148,7 @@ def on_bookmark( | Callable[[ShinySaveState], Awaitable[None]] ), /, - ) -> None: + ) -> CancelCallback: """ Registers a function that will be called just before bookmarking state. @@ -144,7 +168,7 @@ def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], /, - ) -> None: + ) -> CancelCallback: """ Registers a function that will be called just after bookmarking state. @@ -185,20 +209,136 @@ async def do_bookmark(self) -> None: 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. + """ + ... + + @abstractmethod + def on_restore( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], 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. + """ + ... + + @abstractmethod + def on_restored( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], 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. """ ... class BookmarkApp(Bookmark): - def __init__(self, root_session: Session): + def __init__(self, session_root: Session): - super().__init__(root_session) + super().__init__(session_root) self.store = "disable" + self.store = "url" self.exclude = [] self._proxy_exclude_fns = [] self._on_bookmark_callbacks = AsyncCallbacks() self._on_bookmarked_callbacks = AsyncCallbacks() + self._on_restore_callbacks = AsyncCallbacks() + self._on_restored_callbacks = AsyncCallbacks() + + def _create_effects(self) -> None: + # Get bookmarking config + if self.store == "disable": + return + + print("Creating effects") + + session = self._session_root + + 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_"]) + 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(): + print("Trying on restore") + 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: + raise e + print(f"Error calling on_restore callback: {e}") + 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(): + print("Trying on restored") + 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: + print(f"Error calling on_restored callback: {e}") + notification_show( + f"Error calling on_restored callback: {e}", + duration=None, + type="error", + ) + + return def _get_bookmark_exclude(self) -> list[str]: """ @@ -218,15 +358,33 @@ def on_bookmark( | Callable[[ShinySaveState], Awaitable[None]] ), /, - ) -> None: - self._on_bookmark_callbacks.register(wrap_async(callback)) + ) -> CancelCallback: + return self._on_bookmark_callbacks.register(wrap_async(callback)) def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], /, - ) -> None: - self._on_bookmarked_callbacks.register(wrap_async(callback)) + ) -> CancelCallback: + return self._on_bookmarked_callbacks.register(wrap_async(callback)) + + def on_restore( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restore_callbacks.register(wrap_async(callback)) + + def on_restored( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restored_callbacks.register(wrap_async(callback)) async def update_query_string( self, @@ -235,7 +393,7 @@ async def update_query_string( ) -> None: if mode not in {"replace", "push"}: raise ValueError(f"Invalid mode: {mode}") - await self._root_session._send_message( + await self._session_root._send_message( { "updateQueryString": { "queryString": query_string, @@ -252,12 +410,14 @@ async def do_bookmark(self) -> None: try: # ?withLogErrors from ..bookmark._bookmark import ShinySaveState + from ..session import session_context async def root_state_on_save(state: ShinySaveState) -> None: - await self._on_bookmark_callbacks.invoke(state) + with session_context(self._session_root): + await self._on_bookmark_callbacks.invoke(state) root_state = ShinySaveState( - input=self._root_session.input, + input=self._session_root.input, exclude=self._get_bookmark_exclude(), on_save=root_state_on_save, ) @@ -275,7 +435,7 @@ async def root_state_on_save(state: ShinySaveState) -> None: else: raise ValueError("Unknown bookmark store: " + self.store) - clientdata = self._root_session.clientdata + clientdata = self._session_root.clientdata port = str(clientdata.url_port()) full_url = "".join( @@ -294,7 +454,10 @@ async def root_state_on_save(state: ShinySaveState) -> None: # If onBookmarked callback was provided, invoke it; if not call # the default. if self._on_bookmarked_callbacks.count() > 0: - await self._on_bookmarked_callbacks.invoke(full_url) + from ..session import session_context + + with session_context(self._session_root): + await self._on_bookmarked_callbacks.invoke(full_url) else: # `session.bookmark.show_modal(url)` @@ -316,33 +479,57 @@ class BookmarkProxy(Bookmark): _ns: ResolvedId - def __init__(self, root_session: Session, ns: ResolvedId): - super().__init__(root_session) + def __init__(self, session_proxy: SessionProxy): + super().__init__(session_proxy.root_scope()) - self._ns = ns + self._ns = session_proxy.ns + # TODO: Barret - This feels like it needs to be a weakref + self._session_proxy = session_proxy self.exclude = [] self._proxy_exclude_fns = [] self._on_bookmark_callbacks = AsyncCallbacks() self._on_bookmarked_callbacks = AsyncCallbacks() + self._on_restore_callbacks = AsyncCallbacks() + self._on_restored_callbacks = AsyncCallbacks() # TODO: Barret - Double check that this works with nested modules! - self._root_session.bookmark._proxy_exclude_fns.append( - lambda: [self._ns(name) for name in self.exclude] + self._session_root.bookmark._proxy_exclude_fns.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._root_bookmark.on_bookmark async def scoped_on_bookmark(root_state: ShinySaveState) -> None: return await self._scoped_on_bookmark(root_state) - if isinstance(self._root_session, BookmarkApp): - self._root_bookmark.on_bookmark(scoped_on_bookmark) + from ..session import session_context + + ns_prefix = str(self._ns + self._ns._sep) + + @self._root_bookmark.on_restore + async def scoped_on_restore(restore_state: RestoreContextState) -> None: + if self._on_restore_callbacks.count() == 0: + return + + scoped_restore_state = restore_state._state_within_namespace(ns_prefix) + + with session_context(self._session_proxy): + await self._on_restore_callbacks.invoke(scoped_restore_state) + + @self._root_bookmark.on_restored + async def scoped_on_restored(restore_state: RestoreContextState) -> None: + if self._on_restored_callbacks.count() == 0: + return - # TODO: Barret - Implement restore scoped state! + scoped_restore_state = restore_state._state_within_namespace(ns_prefix) + with session_context(self._session_proxy): + await self._on_restored_callbacks.invoke(scoped_restore_state) async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: # Exit if no user-defined callbacks. @@ -352,14 +539,14 @@ async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: from ..bookmark._bookmark import ShinySaveState scoped_state = ShinySaveState( - input=self._root_session.input, + input=self._session_root.input, exclude=self._root_bookmark.exclude, on_save=None, ) # Make subdir for scope if root_state.dir is not None: - scope_subpath = self._ns("") + scope_subpath = self._ns scoped_state.dir = Path(root_state.dir) / scope_subpath if not scoped_state.dir.exists(): raise FileNotFoundError( @@ -367,14 +554,22 @@ async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: ) # Invoke the callback on the scopeState object - await self._on_bookmark_callbacks.invoke(scoped_state) + from ..session import session_context + + with session_context(self._session_proxy): + 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[self._ns(key)] = value + root_state.values[str(self._ns(key))] = value + + def _create_effects(self) -> NoReturn: + raise NotImplementedError( + "Please call `._create_effects()` from the root session only." + ) def on_bookmark( self, @@ -383,20 +578,20 @@ def on_bookmark( | Callable[[ShinySaveState], Awaitable[None]] ), /, - ) -> None: - self._root_bookmark.on_bookmark(callback) + ) -> CancelCallback: + return self._on_bookmark_callbacks.register(wrap_async(callback)) def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], /, - ) -> None: + ) -> NoReturn: # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` raise NotImplementedError( "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().bookmark.on_bookmark()`." ) - def _get_bookmark_exclude(self) -> list[str]: + def _get_bookmark_exclude(self) -> NoReturn: raise NotImplementedError( "Please call `._get_bookmark_exclude()` from the root session only." ) @@ -420,14 +615,41 @@ def store( # pyright: ignore[reportIncompatibleVariableOverride] ) -> None: self._root_bookmark.store = value + def on_restore( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restore_callbacks.register(wrap_async(callback)) + + def on_restored( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> CancelCallback: + return self._on_restored_callbacks.register(wrap_async(callback)) + class BookmarkExpressStub(Bookmark): - def __init__(self, root_session: ExpressStubSession) -> None: - super().__init__(root_session) + def __init__(self, session_root: ExpressStubSession) -> None: + super().__init__(session_root) self._proxy_exclude_fns = [] + self._on_bookmark_callbacks = AsyncCallbacks() + self._on_bookmarked_callbacks = AsyncCallbacks() + self._on_restore_callbacks = AsyncCallbacks() + self._on_restored_callbacks = AsyncCallbacks() - def _get_bookmark_exclude(self) -> list[str]: + def _create_effects(self) -> NoReturn: + raise NotImplementedError( + "Please call `._create_effects()` only from a real session object" + ) + + def _get_bookmark_exclude(self) -> NoReturn: raise NotImplementedError( "Please call `._get_bookmark_exclude()` only from a real session object" ) @@ -438,7 +660,7 @@ def on_bookmark( Callable[[ShinySaveState], None] | Callable[[ShinySaveState], Awaitable[None]] ), - ) -> None: + ) -> NoReturn: raise NotImplementedError( "Please call `.on_bookmark()` only from a real session object" ) @@ -446,400 +668,45 @@ def on_bookmark( def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - ) -> None: + ) -> NoReturn: raise NotImplementedError( "Please call `.on_bookmarked()` only from a real session object" ) async def update_query_string( self, query_string: str, mode: Literal["replace", "push"] = "replace" - ) -> None: + ) -> NoReturn: raise NotImplementedError( "Please call `.update_query_string()` only from a real session object" ) - async def do_bookmark(self) -> None: + async def do_bookmark(self) -> NoReturn: raise NotImplementedError( "Please call `.do_bookmark()` only from a real session object" ) + def on_restore( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> NoReturn: + raise NotImplementedError( + "Please call `.on_restore()` only from a real session object" + ) -# RestoreContext <- R6Class("RestoreContext", -# public = list( -# # This will be set to TRUE if there's actually a state to restore -# active = FALSE, - -# # 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. -# initErrorMessage = NULL, - -# # This is a RestoreInputSet for input values. This is a key-value store with -# # some special handling. -# input = NULL, - -# # Directory for extra files, if restoring from state that was saved to disk. -# dir = NULL, - -# # For values other than input values. These values don't need the special -# # phandling that's needed for input values, because they're only accessed -# # from the onRestore function. -# values = NULL, - -# initialize = function(queryString = NULL) { -# self$reset() # Need this to initialize self$input - -# if (!is.null(queryString) && nzchar(queryString)) { -# tryCatch( -# withLogErrors({ -# qsValues <- parseQueryString(queryString, nested = TRUE) - -# if (!is.null(qsValues[["__subapp__"]]) && qsValues[["__subapp__"]] == 1) { -# # Ignore subapps in shiny docs -# self$reset() - -# } else if (!is.null(qsValues[["_state_id_"]]) && nzchar(qsValues[["_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. -# self$active <- TRUE -# private$loadStateQueryString(queryString) - -# } else { -# # The query string contains the saved keys and values -# self$active <- TRUE -# private$decodeStateQueryString(queryString) -# } -# }), -# error = function(e) { -# # If there's an error in restoring problem, just reset these values -# self$reset() -# self$initErrorMessage <- e$message -# warning(e$message) -# } -# ) -# } -# }, - -# reset = function() { -# self$active <- FALSE -# self$initErrorMessage <- NULL -# self$input <- RestoreInputSet$new(list()) -# self$values <- new.env(parent = emptyenv()) -# self$dir <- NULL -# }, - -# # Completely replace the state -# set = function(active = FALSE, initErrorMessage = NULL, input = list(), values = list(), dir = NULL) { -# # Validate all inputs -# stopifnot(is.logical(active)) -# stopifnot(is.null(initErrorMessage) || is.character(initErrorMessage)) -# stopifnot(is.list(input)) -# stopifnot(is.list(values)) -# stopifnot(is.null(dir) || is.character(dir)) - -# self$active <- active -# self$initErrorMessage <- initErrorMessage -# self$input <- RestoreInputSet$new(input) -# self$values <- list2env2(values, parent = emptyenv()) -# self$dir <- dir -# }, - -# # This should be called before a restore context is popped off the stack. -# flushPending = function() { -# self$input$flushPending() -# }, - - -# # Returns a list representation of the RestoreContext object. This is passed -# # to the app author's onRestore function. An important difference between -# # the RestoreContext object and the list is that the former's `input` field -# # is a RestoreInputSet object, while the latter's `input` field is just a -# # list. -# asList = function() { -# list( -# input = self$input$asList(), -# dir = self$dir, -# values = self$values -# ) -# } -# ), - -# private = list( -# # Given a query string with a _state_id_, load saved state with that ID. -# loadStateQueryString = function(queryString) { -# values <- parseQueryString(queryString, nested = TRUE) -# id <- values[["_state_id_"]] - -# # Check that id has only alphanumeric chars -# if (grepl("[^a-zA-Z0-9]", id)) { -# stop("Invalid state id: ", id) -# } - -# # This function is passed to the loadInterface function; given a -# # directory, it will load state from that directory -# loadFun <- function(stateDir) { -# self$dir <- stateDir - -# if (!dirExists(stateDir)) { -# stop("Bookmarked state directory does not exist.") -# } - -# tryCatch({ -# inputValues <- readRDS(file.path(stateDir, "input.rds")) -# self$input <- RestoreInputSet$new(inputValues) -# }, -# error = function(e) { -# stop("Error reading input values file.") -# } -# ) - -# valuesFile <- file.path(stateDir, "values.rds") -# if (file.exists(valuesFile)) { -# tryCatch({ -# self$values <- readRDS(valuesFile) -# }, -# error = function(e) { -# stop("Error reading values file.") -# } -# ) -# } -# } - -# # Look for a load.interface function. This will be defined by the hosting -# # environment if it supports bookmarking. -# loadInterface <- getShinyOption("load.interface", default = NULL) - -# if (is.null(loadInterface)) { -# if (inShinyServer()) { -# # We're in a version of Shiny Server/Connect that doesn't have -# # bookmarking support. -# loadInterface <- function(id, callback) { -# stop("The hosting environment does not support saved-to-server bookmarking.") -# } - -# } else { -# # We're running Shiny locally. -# loadInterface <- loadInterfaceLocal -# } -# } - -# loadInterface(id, loadFun) - -# invisible() -# }, - -# # Given a query string with values encoded in it, restore saved state -# # from those values. -# decodeStateQueryString = function(queryString) { -# # Remove leading '?' -# if (substr(queryString, 1, 1) == '?') -# queryString <- substr(queryString, 2, nchar(queryString)) - -# # The "=" after "_inputs_" is optional. Shiny doesn't generate URLs with -# # "=", but httr always adds "=". -# inputs_reg <- "(^|&)_inputs_=?(&|$)" -# values_reg <- "(^|&)_values_=?(&|$)" - -# # Error if multiple '_inputs_' or '_values_'. This is needed because -# # strsplit won't add an entry if the search pattern is at the end of a -# # string. -# if (length(gregexpr(inputs_reg, queryString)[[1]]) > 1) -# stop("Invalid state string: more than one '_inputs_' found") -# if (length(gregexpr(values_reg, queryString)[[1]]) > 1) -# stop("Invalid state string: more than one '_values_' found") - -# # Look for _inputs_ and store following content in inputStr -# splitStr <- strsplit(queryString, inputs_reg)[[1]] -# if (length(splitStr) == 2) { -# inputStr <- splitStr[2] -# # Remove any _values_ (and content after _values_) that may come after -# # _inputs_ -# inputStr <- strsplit(inputStr, values_reg)[[1]][1] - -# } else { -# inputStr <- "" -# } - -# # Look for _values_ and store following content in valueStr -# splitStr <- strsplit(queryString, values_reg)[[1]] -# if (length(splitStr) == 2) { -# valueStr <- splitStr[2] -# # Remove any _inputs_ (and content after _inputs_) that may come after -# # _values_ -# valueStr <- strsplit(valueStr, inputs_reg)[[1]][1] - -# } else { -# valueStr <- "" -# } - - -# inputs <- parseQueryString(inputStr, nested = TRUE) -# values <- parseQueryString(valueStr, nested = TRUE) - -# valuesFromJSON <- function(vals) { -# varsUnparsed <- c() -# valsParsed <- mapply(names(vals), vals, SIMPLIFY = FALSE, -# FUN = function(name, value) { -# tryCatch( -# safeFromJSON(value), -# error = function(e) { -# varsUnparsed <<- c(varsUnparsed, name) -# warning("Failed to parse URL parameter \"", name, "\"") -# } -# ) -# } -# ) -# valsParsed[varsUnparsed] <- NULL -# valsParsed -# } - -# inputs <- valuesFromJSON(inputs) -# self$input <- RestoreInputSet$new(inputs) - -# values <- valuesFromJSON(values) -# self$values <- list2env2(values, self$values) -# } -# ) -# ) - - -# # 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 `flushPending()` 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 `withRestoreContext()`. Without this, if -# # a value is restored in a dynamic UI, it could completely prevent any other -# # (non- restored) kvalue from being used. -# RestoreInputSet <- R6Class("RestoreInputSet", -# private = list( -# values = NULL, -# pending = character(0), -# used = character(0) # Names of values which have been used -# ), - -# public = list( -# initialize = function(values) { -# private$values <- list2env2(values, parent = emptyenv()) -# }, - -# exists = function(name) { -# exists(name, envir = private$values) -# }, - -# # Return TRUE if the value exists and has not been marked as used. -# available = function(name) { -# self$exists(name) && !self$isUsed(name) -# }, - -# isPending = function(name) { -# name %in% private$pending -# }, - -# isUsed = function(name) { -# name %in% private$used -# }, - -# # Get a value. If `force` is TRUE, get the value without checking whether -# # has been used, and without marking it as pending. -# get = function(name, force = FALSE) { -# if (force) -# return(private$values[[name]]) - -# if (!self$available(name)) -# return(NULL) - -# # Mark this name as pending. Use unique so that it's not added twice. -# private$pending <- unique(c(private$pending, name)) -# private$values[[name]] -# }, - -# # Take pending names and mark them as used, then clear pending list. -# flushPending = function() { -# private$used <- unique(c(private$used, private$pending)) -# private$pending <- character(0) -# }, - -# asList = function() { -# as.list.environment(private$values, all.names = TRUE) -# } -# ) -# ) - -# restoreCtxStack <- NULL -# on_load({ -# restoreCtxStack <- fastmap::faststack() -# }) - -# withRestoreContext <- function(ctx, expr) { -# restoreCtxStack$push(ctx) - -# on.exit({ -# # Mark pending names as used -# restoreCtxStack$peek()$flushPending() -# restoreCtxStack$pop() -# }, add = TRUE) - -# force(expr) -# } - -# # Is there a current restore context? -# hasCurrentRestoreContext <- function() { -# if (restoreCtxStack$size() > 0) -# return(TRUE) -# domain <- getDefaultReactiveDomain() -# if (!is.null(domain) && !is.null(domain$restoreContext)) -# 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. -# getCurrentRestoreContext <- function() { -# ctx <- restoreCtxStack$peek() -# if (is.null(ctx)) { -# domain <- getDefaultReactiveDomain() - -# if (is.null(domain) || is.null(domain$restoreContext)) { -# stop("No restore context found") -# } - -# ctx <- domain$restoreContext -# } -# ctx -# } - -# #' 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 [textInput()]). -# #' -# #' @param id Name of the input value to restore. -# #' @param default A default value to use, if there's no value to restore. -# #' -# #' @export -# restoreInput <- function(id, default) { -# # Need to evaluate `default` in case it contains reactives like input$x. If we -# # don't, then the calling code won't take a reactive dependency on input$x -# # when restoring a value. -# force(default) - -# if (!hasCurrentRestoreContext()) { -# return(default) -# } + def on_restored( + self, + callback: ( + Callable[[RestoreContextState], None] + | Callable[[RestoreContextState], Awaitable[None]] + ), + ) -> NoReturn: + raise NotImplementedError( + "Please call `.on_restored()` only from a real session object" + ) -# oldInputs <- getCurrentRestoreContext()$input -# if (oldInputs$available(id)) { -# oldInputs$get(id) -# } else { -# default -# } -# } # #' Update URL in browser's location bar # #' diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py new file mode 100644 index 000000000..5228c87da --- /dev/null +++ b/shiny/bookmark/_restore_state.py @@ -0,0 +1,410 @@ +import pickle +import warnings +from contextlib import contextmanager +from contextvars import ContextVar, Token +from pathlib import Path +from typing import Any, Literal, Optional +from urllib.parse import parse_qs, parse_qsl + +from ._bookmark_state import BookmarkState, BookmarkStateLocal +from ._utils import from_json_str, is_hosted + + +class RestoreContextState: + 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) -> "RestoreContextState": + # 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) + } + + 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 RestoreContextState(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) -> "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) + + 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() + + # 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. + + def as_state(self) -> RestoreContextState: + return RestoreContextState( + # Shallow copy + input={**self.input.as_dict()}, + # Shallow copy + values={**self.values}, + dir=self.dir, + ) + + async def _load_state_qs(self, query_string: str) -> 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] + + # TODO: FUTURE - Get the load interface from the session object? + # Look for a load.interface function. This will be defined by the hosting + # environment if it supports bookmarking. + load_interface: BookmarkState | None = None + + if load_interface is None: + if is_hosted(): + # TODO: Barret + raise NotImplementedError( + "The hosting environment does not support server-side bookmarking." + ) + else: + # We're running Shiny locally. + load_interface = BookmarkStateLocal() + + load_dir = Path(await load_interface.load_dir(id)) + + # Load the state from disk. + self.dir = load_dir + + if not self.dir.exists(): + raise ValueError("Bookmarked state directory does not exist.") + + with open(self.dir / "input.pickle", "rb") as f: + input_values = pickle.load(f) + self.input = RestoreInputSet(input_values) + + values_file = self.dir / "values.rds" + if values_file.exists(): + with open(values_file, "rb") as f: + self.values = pickle.load(f) + # 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}"') + 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[str, Any] + _pending: set[str] + """Names of values which have been marked as pending""" + _used: set[str] + """Names of values which have been used""" + + def __init__(self, values: Optional[dict[str, Any]] = None): + + self._values = {} if values is None else values + self._pending = set() + self._used = set() + + def exists(self, name: str) -> bool: + return name in self._values + + def available(self, name: str) -> bool: + return self.exists(name) and not self.is_used(name) + + def is_pending(self, name: str) -> bool: + return name in self._pending + + def is_used(self, name: str) -> 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: str, 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 self._values + + +# ############################################################################# +# 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 + + +def restore_input(id: str, 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. + default + A default value to use, if there's no value to restore. + """ + # print("\n", "restore_input-1", id, default, "\n") + # 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(id): + return old_inputs.get(id) + + return default diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py index 32e774a3e..542c29f7e 100644 --- a/shiny/bookmark/_utils.py +++ b/shiny/bookmark/_utils.py @@ -17,5 +17,9 @@ def is_hosted() -> bool: return False -def to_json(x: Any) -> dict[str, Any]: - return orjson.loads(orjson.dumps(x)) +def to_json_str(x: Any) -> str: + return orjson.dumps(x).decode() + + +def from_json_str(x: str) -> Any: + return orjson.loads(x) diff --git a/shiny/reactive/_core.py b/shiny/reactive/_core.py index 6b4066456..8c55178b5 100644 --- a/shiny/reactive/_core.py +++ b/shiny/reactive/_core.py @@ -174,15 +174,21 @@ def on_flushed( async def flush(self) -> None: """Flush all pending operations""" + print("--Reactive flush--") await self._flush_sequential() + print("--Reactive flush callbacks--") await self._flushed_callbacks.invoke() + print("--Reactive flush done--") async def _flush_sequential(self) -> None: # Sequential flush: instead of storing the tasks in a list and calling gather() # on them later, just run each effect in sequence. + print("--Sequential flush--") while not self._pending_flush_queue.empty(): + print("--item") ctx = self._pending_flush_queue.get() await ctx.execute_flush_callbacks() + print("--Sequential flush done--") def add_pending_flush(self, ctx: Context, priority: int) -> None: self._pending_flush_queue.put(priority, ctx) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 8bce57ad6..f16641527 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,5 +1,7 @@ from __future__ import annotations +from shiny.bookmark._restore_state import RestoreContext + __all__ = ("Session", "Inputs", "Outputs", "ClientData") import asyncio @@ -47,8 +49,10 @@ from ..bookmark import BookmarkApp, BookmarkProxy from ..http_staticfiles import FileResponse from ..input_handler import input_handlers -from ..reactive import Effect_, Value, effect, flush, isolate -from ..reactive._core import lock, on_flushed +from ..reactive import Effect_, Value, effect, isolate +from ..reactive import flush as reactive_flush +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, @@ -630,17 +634,28 @@ def verify_state(expected_state: ConnectionState) -> None: return async with lock(): + print("with lock") if message_obj["method"] == "init": verify_state(ConnectionState.Start) + # BOOKMARKS! + self.bookmark._restore_context = ( + await RestoreContext.from_query_string( + message_obj["data"][".clientdata_url_search"] + ) + ) + # 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"]) @@ -648,6 +663,9 @@ def verify_state(expected_state: ConnectionState) -> None: with session_context(self): self.app.server(self.input, self.output, self) + if self.bookmark.store != "disable": + await reactive_flush() # TODO: Barret; Why isn't the reactive flush triggering itself? + elif message_obj["method"] == "update": verify_state(ConnectionState.Running) @@ -673,7 +691,7 @@ def verify_state(expected_state: ConnectionState) -> None: self._request_flush() - await flush() + await reactive_flush() except ConnectionClosed: ... @@ -942,6 +960,7 @@ async def wrap_content_sync() -> AsyncIterable[bytes]: return HTMLResponse("

Not Found

", 404) def send_input_message(self, id: str, message: dict[str, object]) -> None: + print("Send input message", id, message) self._outbound_message_queues.add_input_message(id, message) self._request_flush() @@ -1019,6 +1038,12 @@ 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: + print("Flushing restore context pending") + self.bookmark._restore_context.flush_pending() + print("Flushing restore context pending - done") + # Flush the callbacks await self._flush_callbacks.invoke() try: @@ -1031,11 +1056,13 @@ async def _flush(self) -> None: } try: + print("Sending message!", message) await self._send_message(message) finally: self._outbound_message_queues.reset() finally: with session_context(self): + print("Invoking flushed callbacks") await self._flushed_callbacks.invoke() def _increment_busy_count(self) -> None: @@ -1187,7 +1214,7 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._downloads = parent._downloads self._root = parent.root_scope() - self.bookmark = BookmarkProxy(parent.root_scope(), ns) + self.bookmark = BookmarkProxy(self) def _is_hidden(self, name: str) -> bool: return self._parent._is_hidden(name) @@ -1428,7 +1455,7 @@ async def _serialize( with reactive.isolate(): for key, value in self._map.items(): - # print(key, value) + # TODO: Barret - Q: Should this be anything that starts with a "."? if key.startswith(".clientdata_"): continue if key in exclude_set: From a79850737307f5b30e5501f783ba471d0ce8f02d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 01:54:35 -0500 Subject: [PATCH 22/62] Restore input_radio_buttons! --- shiny/ui/_input_check_radio.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index 5f52f7cd6..e7f15cf77 100644 --- a/shiny/ui/_input_check_radio.py +++ b/shiny/ui/_input_check_radio.py @@ -287,11 +287,14 @@ def input_radio_buttons( resolved_id = resolve_id(id) input_label = shiny_input_label(resolved_id, label) + + from ..bookmark._restore_state import restore_input + options = _generate_options( id=resolved_id, type="radio", choices=choices, - selected=selected, + selected=restore_input(resolved_id, selected), inline=inline, ) return div( From 2d6af57c014462580d0077c56b40743f47edb1c7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 10:08:05 -0500 Subject: [PATCH 23/62] lints --- shiny/_app.py | 7 ++++--- shiny/bookmark/_bookmark.py | 2 -- shiny/bookmark/_restore_state.py | 34 +++++++++++++++++--------------- shiny/bookmark/_save_state.py | 2 +- shiny/express/_stub_session.py | 1 + shiny/session/_session.py | 14 ++++++++----- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index 13acf97ab..303720dec 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -363,8 +363,8 @@ async def _on_root_request_cb(self, request: Request) -> Response: # TODO: Barret implement how to get bookmark_store value # bookmarkStore <- getShinyOption("bookmarkStore", default = "disable") print("TODO: Figure this out") - bookmark_store: str = "disable" - bookmark_store: str = "query" + bookmark_store: str = str("disable") + bookmark_store: str = str("query") if bookmark_store == "disable": restore_ctx = RestoreContext() @@ -389,7 +389,8 @@ async def _on_root_request_cb(self, request: Request) -> Response: if cur_restore_ctx is not None and cur_restore_ctx.active: # TODO: See ?enableBookmarking warnings.warn( - "Trying to restore saved app state, but UI code must be a function for this to work!" + "Trying to restore saved app state, but UI code must be a function for this to work!", + stacklevel=1, ) ui = self.ui diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index dda28a070..500d3c38e 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -454,8 +454,6 @@ async def root_state_on_save(state: ShinySaveState) -> None: # If onBookmarked callback was provided, invoke it; if not call # the default. if self._on_bookmarked_callbacks.count() > 0: - from ..session import session_context - with session_context(self._session_root): await self._on_bookmarked_callbacks.invoke(full_url) else: diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 5228c87da..811dfc669 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -135,21 +135,21 @@ async def from_query_string(query_string: str) -> "RestoreContext": 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_ + # 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: @@ -258,7 +258,9 @@ async def _decode_state_qs(self, query_string: str) -> None: 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}"') + 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) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index e90f18f81..16176ce76 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -8,7 +8,7 @@ from .._utils import private_random_id from ..reactive import isolate -from ._bookmark_state import BookmarkState +from ._bookmark_state import BookmarkState, BookmarkStateLocal from ._utils import is_hosted, to_json_str if TYPE_CHECKING: diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index 62bc1f968..7e092cffb 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -7,6 +7,7 @@ from .._namespaces import Id, ResolvedId, Root from ..bookmark import BookmarkExpressStub from ..session import Inputs, Outputs, Session +from ..session._session import SessionProxy if TYPE_CHECKING: from ..session._session import DownloadHandler, DynamicRouteHandler, RenderedDeps diff --git a/shiny/session/_session.py b/shiny/session/_session.py index f16641527..bd032515b 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -49,8 +49,9 @@ from ..bookmark import BookmarkApp, BookmarkProxy from ..http_staticfiles import FileResponse from ..input_handler import input_handlers -from ..reactive import Effect_, Value, effect, isolate +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 @@ -639,11 +640,14 @@ def verify_state(expected_state: ConnectionState) -> None: verify_state(ConnectionState.Start) # BOOKMARKS! - self.bookmark._restore_context = ( - await RestoreContext.from_query_string( - message_obj["data"][".clientdata_url_search"] + if ".clientdata_url_search" in message_obj["data"]: + self.bookmark._restore_context = ( + await RestoreContext.from_query_string( + message_obj["data"][".clientdata_url_search"] + ) ) - ) + else: + self.bookmark._restore_context = RestoreContext() # When a reactive flush occurs, flush the session's outputs, # errors, etc. to the client. Note that this is From 80f6091a1c70e173367390759efe8e23fc41d1cc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 13:55:35 -0500 Subject: [PATCH 24/62] Add a `shiny.bookmark.globals` module --- shiny/bookmark/__init__.py | 3 +++ shiny/bookmark/_globals.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 shiny/bookmark/_globals.py diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index a2aea19d6..d586932e0 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -1,3 +1,4 @@ +from . import _globals as globals from ._bookmark import ( Bookmark, BookmarkApp, @@ -9,6 +10,8 @@ from ._restore_state import RestoreContext, RestoreContextState __all__ = ( + # _globals + "globals", # _bookmark "ShinySaveState", "Bookmark", diff --git a/shiny/bookmark/_globals.py b/shiny/bookmark/_globals.py new file mode 100644 index 000000000..8deffe1e2 --- /dev/null +++ b/shiny/bookmark/_globals.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Awaitable, Callable, Literal + +from ..types import MISSING, MISSING_TYPE + +# TODO: Barret: Q: Can we merge how bookmark dirs are saved / loaded? +BookmarkStateSaveDir = Callable[[str], Awaitable[Path]] +BookmarkStateLoadDir = Callable[[str], Awaitable[Path]] +bookmark_state_save_dir: BookmarkStateSaveDir | MISSING_TYPE = MISSING +bookmark_state_load_dir: BookmarkStateLoadDir | MISSING_TYPE = MISSING + +BookmarkStore = Literal["url", "server", "disable"] +bookmark_store: BookmarkStore = "disable" + + +# def bookmark_store_get() -> BookmarkStore: +# return bookmark_store + + +# def bookmark_store_set(value: BookmarkStore) -> None: +# global bookmark_store +# bookmark_store = value From 30c8dd8f2a5419f93e2e1cb9857227f2edf9d43c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 14:01:59 -0500 Subject: [PATCH 25/62] Add `shiny.bookmark.input_bookmark_button()`; Use globals to set bookmark save and restore methods --- shiny/_app.py | 24 ++++---- shiny/bookmark/__init__.py | 6 +- shiny/bookmark/_bookmark.py | 77 ++++++++++++++---------- shiny/bookmark/_bookmark_state.py | 98 ++++--------------------------- shiny/bookmark/_button.py | 87 +++++++++++++++++++++++++++ shiny/bookmark/_restore_state.py | 23 ++++---- shiny/bookmark/_save_state.py | 49 +++++++--------- shiny/bookmark/_serializers.py | 2 + shiny/bookmark/_utils.py | 2 + shiny/session/_session.py | 12 ++-- shiny/ui/__init__.py | 15 +++-- 11 files changed, 213 insertions(+), 182 deletions(-) create mode 100644 shiny/bookmark/_button.py diff --git a/shiny/_app.py b/shiny/_app.py index 303720dec..029cc3575 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -7,7 +7,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 @@ -31,6 +31,7 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length +from .bookmark import _globals as bookmark_globals from .bookmark._restore_state import ( RestoreContext, get_current_restore_context, @@ -120,6 +121,7 @@ def __init__( ), *, static_assets: Optional[str | Path | Mapping[str, str | Path]] = None, + bookmarking: Optional[Literal["url", "query", "disable"]] = None, debug: bool = False, ) -> None: # Used to store callbacks to be called when the app is shutting down (according @@ -139,6 +141,8 @@ def __init__( "`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)" ) + if bookmarking is not None: + bookmark_globals.bookmark_store = bookmarking self._debug: bool = debug # Settings that the user can change after creating the App object. @@ -359,35 +363,29 @@ async def _on_root_request_cb(self, request: Request) -> Response: request for / occurs. """ ui: RenderedHTML - # Create a restore context using query string - # TODO: Barret implement how to get bookmark_store value - # bookmarkStore <- getShinyOption("bookmarkStore", default = "disable") - print("TODO: Figure this out") - bookmark_store: str = str("disable") - bookmark_store: str = str("query") - - if bookmark_store == "disable": + if bookmark_globals.bookmark_store == "disable": restore_ctx = RestoreContext() else: restore_ctx = await RestoreContext.from_query_string(request.url.query) print( + "Restored state", { "values": restore_ctx.as_state().values, "input": restore_ctx.as_state().input, - } + }, ) with restore_context(restore_ctx): if callable(self.ui): ui = self._render_page(self.ui(request), self.lib_prefix) else: - # TODO: Why is this here as there's a with restore_context above? - # TODO: Why not `if restore_ctx.active:`? + # TODO: Barret - Q: Why is this here as there's a with restore_context above? + # TODO: Barret - Q: Why not `if restore_ctx.active:`? cur_restore_ctx = get_current_restore_context() print("cur_restore_ctx", cur_restore_ctx) if cur_restore_ctx is not None and cur_restore_ctx.active: - # TODO: See ?enableBookmarking + # TODO: Barret - Docs: See ?enableBookmarking warnings.warn( "Trying to restore saved app state, but UI code must be a function for this to work!", stacklevel=1, diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index d586932e0..571e74baa 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -6,7 +6,7 @@ BookmarkProxy, ShinySaveState, ) -from ._bookmark_state import BookmarkState +from ._button import input_bookmark_button from ._restore_state import RestoreContext, RestoreContextState __all__ = ( @@ -18,8 +18,8 @@ "BookmarkApp", "BookmarkProxy", "BookmarkExpressStub", - # _bookmark_state - "BookmarkState", + # _button + "input_bookmark_button", # _restore_state "RestoreContext", "RestoreContextState", diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 500d3c38e..181224ac6 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -1,3 +1,16 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn + +from .._utils import AsyncCallbacks, CancelCallback, wrap_async +from ..types import MISSING, MISSING_TYPE +from . import _globals as bookmark_globals +from ._globals import BookmarkStore +from ._restore_state import RestoreContextState +from ._save_state import ShinySaveState + # TODO: bookmark button # TODO: @@ -46,13 +59,6 @@ # * May need to escape (all?) the parameters to avoid collisions with `h=` or `code=`. # Set query string to parent frame / tab -from abc import ABC, abstractmethod -from pathlib import Path -from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn - -from .._utils import AsyncCallbacks, CancelCallback, wrap_async -from ._restore_state import RestoreContextState -from ._save_state import ShinySaveState if TYPE_CHECKING: from .._namespaces import ResolvedId @@ -70,9 +76,6 @@ ExpressStubSession = Any -BookmarkStore = Literal["url", "server", "disable"] - - # TODO: future - Local storage Bookmark class! # * Needs a consistent id for storage. # * Needs ways to clean up other storage @@ -84,7 +87,20 @@ class Bookmark(ABC): # TODO: Barret - This feels like it needs to be a weakref _session_root: Session - store: BookmarkStore + _store: BookmarkStore | MISSING_TYPE + + @property + def store(self) -> BookmarkStore: + # Should we allow for this? + # Allows a per-session override of the global bookmark store + if isinstance(self._session_root.bookmark._store, MISSING_TYPE): + return bookmark_globals.bookmark_store + return self._session_root.bookmark._store + + @store.setter + def store(self, value: BookmarkStore) -> None: + self._session_root.bookmark._store = value + self._store = value _proxy_exclude_fns: list[Callable[[], list[str]]] exclude: list[str] @@ -107,8 +123,18 @@ def __init__(self, session_root: Session): # from ._restore_state import RestoreContext super().__init__() + # TODO: Barret - Q: Should this be a weakref; Session -> Bookmark -> Session self._session_root = session_root self._restore_context = None + self._store = MISSING + + self._proxy_exclude_fns = [] + self.exclude = [] + + self._on_bookmark_callbacks = AsyncCallbacks() + self._on_bookmarked_callbacks = AsyncCallbacks() + self._on_restore_callbacks = AsyncCallbacks() + self._on_restored_callbacks = AsyncCallbacks() # # TODO: Barret - Implement this?!? # @abstractmethod @@ -247,25 +273,14 @@ def on_restored( class BookmarkApp(Bookmark): def __init__(self, session_root: Session): - super().__init__(session_root) - self.store = "disable" - self.store = "url" - self.exclude = [] - self._proxy_exclude_fns = [] - self._on_bookmark_callbacks = AsyncCallbacks() - self._on_bookmarked_callbacks = AsyncCallbacks() - self._on_restore_callbacks = AsyncCallbacks() - self._on_restored_callbacks = AsyncCallbacks() - def _create_effects(self) -> None: # Get bookmarking config if self.store == "disable": return print("Creating effects") - session = self._session_root from .. import reactive @@ -481,17 +496,9 @@ def __init__(self, session_proxy: SessionProxy): super().__init__(session_proxy.root_scope()) self._ns = session_proxy.ns - # TODO: Barret - This feels like it needs to be a weakref + # TODO: Barret - Q: Should this be a weakref self._session_proxy = session_proxy - self.exclude = [] - self._proxy_exclude_fns = [] - self._on_bookmark_callbacks = AsyncCallbacks() - self._on_bookmarked_callbacks = AsyncCallbacks() - self._on_restore_callbacks = AsyncCallbacks() - self._on_restored_callbacks = AsyncCallbacks() - - # TODO: Barret - Double check that this works with nested modules! self._session_root.bookmark._proxy_exclude_fns.append( lambda: [str(self._ns(name)) for name in self.exclude] ) @@ -508,6 +515,14 @@ async def scoped_on_bookmark(root_state: ShinySaveState) -> None: from ..session import session_context + @self._root_bookmark.on_bookmarked + async def scoped_on_bookmarked(url: str) -> None: + if self._on_bookmarked_callbacks.count() == 0: + return + + with session_context(self._session_proxy): + await self._on_bookmarked_callbacks.invoke(url) + ns_prefix = str(self._ns + self._ns._sep) @self._root_bookmark.on_restore diff --git a/shiny/bookmark/_bookmark_state.py b/shiny/bookmark/_bookmark_state.py index fb9ff5e79..09bd3a2eb 100644 --- a/shiny/bookmark/_bookmark_state.py +++ b/shiny/bookmark/_bookmark_state.py @@ -1,93 +1,21 @@ +from __future__ import annotations + import os -from abc import ABC, abstractmethod from pathlib import Path -class BookmarkState(ABC): - """ - Class for saving and restoring state to/from disk. - """ - - @abstractmethod - async def save_dir( - self, - id: str, - # write_files: Callable[[Path], Awaitable[None]], - ) -> Path: - """ - Construct directory for saving state. - - Parameters - ---------- - id - The unique identifier for the state. - - Returns - ------- - Path - Directory location for saving state. This directory must exist. - """ - # write_files - # A async function that writes the state to a serializable location. The method receives a path object and - ... - - @abstractmethod - async def load_dir( - self, - id: str, - # read_files: Callable[[Path], Awaitable[None]], - ) -> Path: - """ - Construct directory for loading state. - - Parameters - ---------- - id - The unique identifier for the state. - - Returns - ------- - Path | None - Directory location for loading state. If `None`, state loading will be ignored. If a `Path`, the directory must exist. - """ - ... - - -class BookmarkStateLocal(BookmarkState): - """ - Function wrappers for saving and restoring state to/from disk when running Shiny - locally. - """ - - def _local_dir(self, 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 save_dir(self, id: str) -> Path: - state_dir = self._local_dir(id) - if not state_dir.exists(): - state_dir.mkdir(parents=True) - return state_dir +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 load_dir(self, id: str) -> Path: - return self._local_dir(id) - # async def save( - # self, - # id: str, - # write_files: Callable[[Path], Awaitable[None]], - # ) -> None: - # state_dir = self._local_dir(id) - # if not state_dir.exists(): - # state_dir.mkdir(parents=True) +async def local_save_dir(id: str) -> Path: + state_dir = _local_dir(id) + if not state_dir.exists(): + state_dir.mkdir(parents=True) + return state_dir - # await write_files(state_dir) - # async def load( - # self, - # id: str, - # read_files: Callable[[Path], Awaitable[None]], - # ) -> None: - # await read_files(self._local_dir(id)) - # await read_files(self._local_dir(id)) +async def local_load_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..8681f8d26 --- /dev/null +++ b/shiny/bookmark/_button.py @@ -0,0 +1,87 @@ +from typing import Optional + +from htmltools import HTML, Tag, TagAttrValue, TagChild + +from .._namespaces import resolve_id +from ..types import MISSING, MISSING_TYPE +from ..ui._input_action_button import input_action_button + +BOOKMARK_ID = "._bookmark_" + + +#' Create a button for bookmarking/sharing +#' +#' A `bookmarkButton` is a [actionButton()] with a default label +#' that consists of a link icon and the text "Bookmark...". It is meant to be +#' used for bookmarking state. +#' +#' @inheritParams actionButton +#' @param title A tooltip that is shown when the mouse cursor hovers over the +#' button. +#' @param 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 +#' [setBookmarkExclude()], and you must create an observer that +#' does the bookmarking when the button is pressed. See the examples below. +#' +#' @seealso [enableBookmarking()] for more examples. +#' +#' @examples +#' ## Only run these examples in interactive sessions +#' if (interactive()) { +#' +#' # This example shows how to use multiple bookmark buttons. If you only need +#' # a single bookmark button, see examples in ?enableBookmarking. +#' ui <- function(request) { +#' fluidPage( +#' tabsetPanel(id = "tabs", +#' tabPanel("One", +#' checkboxInput("chk1", "Checkbox 1"), +#' bookmarkButton(id = "bookmark1") +#' ), +#' tabPanel("Two", +#' checkboxInput("chk2", "Checkbox 2"), +#' bookmarkButton(id = "bookmark2") +#' ) +#' ) +#' ) +#' } +#' server <- function(input, output, session) { +#' # Need to exclude the buttons from themselves being bookmarked +#' setBookmarkExclude(c("bookmark1", "bookmark2")) +#' +#' # Trigger bookmarking with either button +#' observeEvent(input$bookmark1, { +#' session$doBookmark() +#' }) +#' observeEvent(input$bookmark2, { +#' session$doBookmark() +#' }) +#' } +#' enableBookmarking(store = "url") +#' shinyApp(ui, server) +#' } +#' @export +def input_bookmark_button( + label: TagChild = "Bookmark...", + *, + icon: TagChild | MISSING_TYPE = MISSING, + width: Optional[str] = None, + disabled: bool = False, + # id: str = "._bookmark_", + title: str = "Bookmark this application's state and get a URL for sharing.", + **kwargs: TagAttrValue, +) -> Tag: + resolved_id = resolve_id(BOOKMARK_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/_restore_state.py b/shiny/bookmark/_restore_state.py index 811dfc669..bef8208b5 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pickle import warnings from contextlib import contextmanager @@ -6,7 +8,11 @@ from typing import Any, Literal, Optional from urllib.parse import parse_qs, parse_qsl -from ._bookmark_state import BookmarkState, BookmarkStateLocal +from shiny.types import MISSING_TYPE + +from . import _globals as bookmark_globals +from ._bookmark_state import local_load_dir +from ._globals import BookmarkStateLoadDir from ._utils import from_json_str, is_hosted @@ -180,12 +186,11 @@ async def _load_state_qs(self, query_string: str) -> None: id = id[0] - # TODO: FUTURE - Get the load interface from the session object? - # Look for a load.interface function. This will be defined by the hosting - # environment if it supports bookmarking. - load_interface: BookmarkState | None = None + load_bookmark_fn: BookmarkStateLoadDir | None = None + if not isinstance(bookmark_globals.bookmark_state_load_dir, MISSING_TYPE): + load_bookmark_fn = bookmark_globals.bookmark_state_load_dir - if load_interface is None: + if load_bookmark_fn is None: if is_hosted(): # TODO: Barret raise NotImplementedError( @@ -193,12 +198,10 @@ async def _load_state_qs(self, query_string: str) -> None: ) else: # We're running Shiny locally. - load_interface = BookmarkStateLocal() - - load_dir = Path(await load_interface.load_dir(id)) + load_bookmark_fn = local_load_dir # Load the state from disk. - self.dir = load_dir + self.dir = Path(await load_bookmark_fn(id)) if not self.dir.exists(): raise ValueError("Bookmarked state directory does not exist.") diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 16176ce76..3ddc940d2 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -1,5 +1,4 @@ -# TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 -# Might need to have independent save/load functions to register to avoid a class constructor +from __future__ import annotations import pickle from pathlib import Path @@ -8,9 +7,16 @@ from .._utils import private_random_id from ..reactive import isolate -from ._bookmark_state import BookmarkState, BookmarkStateLocal +from ..types import MISSING_TYPE +from . import _globals as bookmark_globals +from ._bookmark_state import local_save_dir +from ._globals import BookmarkStateSaveDir from ._utils import is_hosted, to_json_str +# TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 +# Might need to have independent save/load functions to register to avoid a class constructor + + if TYPE_CHECKING: from .. import Inputs else: @@ -45,7 +51,7 @@ def __init__( self.dir = None # This will be set by external functions. self.values = {} - self._always_exclude: list[str] = ["._bookmark_"] + self._always_exclude: list[str] = [] async def _call_on_save(self): # Allow user-supplied save function to do things like add state$values, or @@ -54,11 +60,6 @@ async def _call_on_save(self): with isolate(): await self.on_save(self) - def _exclude_bookmark_value(self): - # If the bookmark value is not in the exclude list, add it. - if "._bookmark_" not in self.exclude: - self.exclude.append("._bookmark_") - async def _save_state(self) -> str: """ Save a state to disk (pickle). @@ -70,15 +71,16 @@ async def _save_state(self) -> str: """ id = private_random_id(prefix="", bytes=8) - # Pass the saveState function to the save interface function, which will - # invoke saveState after preparing the directory. + # 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`. - # TODO: FUTURE - Get the save interface from the session object? - # Look for a save.interface function. This will be defined by the hosting - # environment if it supports bookmarking. - save_interface_loaded: BookmarkState | None = None + # This will be defined by the hosting environment if it supports bookmarking. + save_bookmark_fn: BookmarkStateSaveDir | None = None + if not isinstance(bookmark_globals.bookmark_state_save_dir, MISSING_TYPE): + save_bookmark_fn = bookmark_globals.bookmark_state_save_dir - if save_interface_loaded is None: + if save_bookmark_fn is None: if is_hosted(): # TODO: Barret raise NotImplementedError( @@ -86,21 +88,12 @@ async def _save_state(self) -> str: ) else: # We're running Shiny locally. - save_interface_loaded = BookmarkStateLocal() - - if not isinstance(save_interface_loaded, BookmarkState): - raise TypeError( - "The save interface retrieved must be an instance of `shiny.bookmark.BookmarkStateLocal`." - ) - - save_dir = Path(await save_interface_loaded.save_dir(id)) + save_bookmark_fn = local_save_dir # Save the state to disk. - self.dir = save_dir + self.dir = Path(await save_bookmark_fn(id)) await self._call_on_save() - self._exclude_bookmark_value() - input_values_json = await self.input._serialize( exclude=self.exclude, state_dir=self.dir, @@ -131,8 +124,6 @@ async def _encode_state(self) -> str: # Allow user-supplied onSave function to do things like add state$values. await self._call_on_save() - self._exclude_bookmark_value() - input_values_serialized = await self.input._serialize( exclude=self.exclude, # Do not include directory as we are not saving to disk. diff --git a/shiny/bookmark/_serializers.py b/shiny/bookmark/_serializers.py index c24c258f0..85cfdf876 100644 --- a/shiny/bookmark/_serializers.py +++ b/shiny/bookmark/_serializers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from shutil import copyfile from typing import Any, TypeVar diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py index 542c29f7e..21b47779d 100644 --- a/shiny/bookmark/_utils.py +++ b/shiny/bookmark/_utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from typing import Any diff --git a/shiny/session/_session.py b/shiny/session/_session.py index bd032515b..49611ac3e 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,9 +1,6 @@ from __future__ import annotations -from shiny.bookmark._restore_state import RestoreContext - __all__ = ("Session", "Inputs", "Outputs", "ClientData") - import asyncio import contextlib import dataclasses @@ -47,11 +44,12 @@ 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 ..reactive import Effect_, Value, effect +from ..reactive import Effect_, Value, effect, isolate 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 @@ -1462,6 +1460,10 @@ async def _serialize( # TODO: Barret - Q: Should this be anything that starts with a "."? if key.startswith(".clientdata_"): continue + if key == BOOKMARK_ID or key.endswith( + f"{ResolvedId._sep}{BOOKMARK_ID}" + ): + continue if key in exclude_set: continue val = value() diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 9ca59cfa9..9011694fa 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 ( + busy_indicators, + css, # noqa: F401 # pyright: ignore[reportUnusedImport] + fill, +) from ._accordion import ( AccordionPanel, accordion, @@ -365,4 +366,6 @@ "fill", # utils "js_eval", + # bookmark + "input_bookmark_button", ) From b89bc9e9c4d3d3c20fbc3a09f4d7e29f32a3926b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 14:06:50 -0500 Subject: [PATCH 26/62] Reduce comments --- shiny/bookmark/_bookmark.py | 396 +----------------------------------- shiny/bookmark/_globals.py | 12 +- 2 files changed, 4 insertions(+), 404 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 181224ac6..641725d13 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -7,6 +7,7 @@ from .._utils import AsyncCallbacks, CancelCallback, wrap_async from ..types import MISSING, MISSING_TYPE from . import _globals as bookmark_globals +from ._button import BOOKMARK_ID from ._globals import BookmarkStore from ._restore_state import RestoreContextState from ._save_state import ShinySaveState @@ -291,7 +292,7 @@ def _create_effects(self) -> None: # Fires when the bookmark button is clicked. @reactive.effect - @reactive.event(session.input["._bookmark_"]) + @reactive.event(session.input[BOOKMARK_ID]) async def _(): await session.bookmark() @@ -721,161 +722,6 @@ def on_restored( ) -# #' Update URL in browser's location bar -# #' -# #' This function updates the client browser's query string in the location bar. -# #' It typically is called from an observer. Note that this will not work in -# #' Internet Explorer 9 and below. -# #' -# #' For `mode = "push"`, only three updates are currently allowed: -# #' \enumerate{ -# #' \item the query string (format: `?param1=val1¶m2=val2`) -# #' \item the hash (format: `#hash`) -# #' \item both the query string and the hash -# #' (format: `?param1=val1¶m2=val2#hash`) -# #' } -# #' -# #' In other words, if `mode = "push"`, the `queryString` must start -# #' with either `?` or with `#`. -# #' -# #' A technical curiosity: under the hood, this function is calling the HTML5 -# #' history API (which is where the names for the `mode` argument come from). -# #' When `mode = "replace"`, the function called is -# #' `window.history.replaceState(null, null, queryString)`. -# #' When `mode = "push"`, the function called is -# #' `window.history.pushState(null, null, queryString)`. -# #' -# #' @param queryString The new query string to show in the location bar. -# #' @param mode When the query string is updated, should the current history -# #' entry be replaced (default), or should a new history entry be pushed onto -# #' the history stack? The former should only be used in a live bookmarking -# #' context. The latter is useful if you want to navigate between states using -# #' the browser's back and forward buttons. See Examples. -# #' @param session A Shiny session object. -# #' @seealso [enableBookmarking()], [getQueryString()] -# #' @examples -# #' ## Only run these examples in interactive sessions -# #' if (interactive()) { -# #' -# #' ## App 1: Doing "live" bookmarking -# #' ## 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. -# #' enableBookmarking("url") -# #' shinyApp( -# #' 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) -# #' }) -# #' } -# #' ) -# #' -# #' ## App 2: Printing the value of the query string -# #' ## (Use the back and forward buttons to see how the browser -# #' ## keeps a record of each state) -# #' shinyApp( -# #' ui = fluidPage( -# #' textInput("txt", "Enter new query string"), -# #' helpText("Format: ?param1=val1¶m2=val2"), -# #' actionButton("go", "Update"), -# #' hr(), -# #' verbatimTextOutput("query") -# #' ), -# #' server = function(input, output, session) { -# #' observeEvent(input$go, { -# #' updateQueryString(input$txt, mode = "push") -# #' }) -# #' output$query <- renderText({ -# #' query <- getQueryString() -# #' queryText <- paste(names(query), query, -# #' sep = "=", collapse=", ") -# #' paste("Your query string is:\n", queryText) -# #' }) -# #' } -# #' ) -# #' } -# #' @export -# updateQueryString <- function(queryString, mode = c("replace", "push"), -# session = getDefaultReactiveDomain()) { -# mode <- match.arg(mode) -# session$updateQueryString(queryString, mode) -# } - -# #' Create a button for bookmarking/sharing -# #' -# #' A `bookmarkButton` is a [actionButton()] with a default label -# #' that consists of a link icon and the text "Bookmark...". It is meant to be -# #' used for bookmarking state. -# #' -# #' @inheritParams actionButton -# #' @param title A tooltip that is shown when the mouse cursor hovers over the -# #' button. -# #' @param 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 -# #' [setBookmarkExclude()], and you must create an observer that -# #' does the bookmarking when the button is pressed. See the examples below. -# #' -# #' @seealso [enableBookmarking()] for more examples. -# #' -# #' @examples -# #' ## Only run these examples in interactive sessions -# #' if (interactive()) { -# #' -# #' # This example shows how to use multiple bookmark buttons. If you only need -# #' # a single bookmark button, see examples in ?enableBookmarking. -# #' ui <- function(request) { -# #' fluidPage( -# #' tabsetPanel(id = "tabs", -# #' tabPanel("One", -# #' checkboxInput("chk1", "Checkbox 1"), -# #' bookmarkButton(id = "bookmark1") -# #' ), -# #' tabPanel("Two", -# #' checkboxInput("chk2", "Checkbox 2"), -# #' bookmarkButton(id = "bookmark2") -# #' ) -# #' ) -# #' ) -# #' } -# #' server <- function(input, output, session) { -# #' # Need to exclude the buttons from themselves being bookmarked -# #' setBookmarkExclude(c("bookmark1", "bookmark2")) -# #' -# #' # Trigger bookmarking with either button -# #' observeEvent(input$bookmark1, { -# #' session$doBookmark() -# #' }) -# #' observeEvent(input$bookmark2, { -# #' session$doBookmark() -# #' }) -# #' } -# #' enableBookmarking(store = "url") -# #' shinyApp(ui, server) -# #' } -# #' @export -# bookmarkButton <- function(label = "Bookmark...", -# icon = shiny::icon("link", lib = "glyphicon"), -# title = "Bookmark this application's state and get a URL for sharing.", -# ..., -# id = "._bookmark_") -# { -# actionButton(id, label, icon, title = title, ...) -# } - - # #' Generate a modal dialog that displays a URL # #' # #' The modal dialog generated by `urlModal` will display the URL in a @@ -1186,241 +1032,3 @@ def on_restored( # store <- match.arg(store) # shinyOptions(bookmarkStore = store) # } - - -# #' Exclude inputs from bookmarking -# #' -# #' This function tells Shiny which inputs should be excluded from bookmarking. -# #' It should be called from inside the application's server function. -# #' -# #' This function can also be called from a module's server function, in which -# #' case it will exclude inputs with the specified names, from that module. It -# #' will not affect inputs from other modules or from the top level of the Shiny -# #' application. -# #' -# #' @param names A character vector containing names of inputs to exclude from -# #' bookmarking. -# #' @param session A shiny session object. -# #' @seealso [enableBookmarking()] for examples. -# #' @export -# setBookmarkExclude <- function(names = character(0), session = getDefaultReactiveDomain()) { -# session$setBookmarkExclude(names) -# } - - -# #' Add callbacks for Shiny session bookmarking events -# #' -# #' @description -# #' -# #' These functions are for registering callbacks on Shiny session events. They -# #' should be called within an application's server function. -# #' -# #' \itemize{ -# #' \item `onBookmark` registers a function that will be called just -# #' before Shiny bookmarks state. -# #' \item `onBookmarked` registers a function that will be called just -# #' after Shiny bookmarks state. -# #' \item `onRestore` registers a function that will be called when a -# #' session is restored, after the server function executes, but before all -# #' other reactives, observers and render functions are run. -# #' \item `onRestored` registers a function that will be called after a -# #' session is restored. This is similar to `onRestore`, but it will be -# #' called after all reactives, observers, and render functions run, and -# #' after results are sent to the client browser. `onRestored` -# #' callbacks can be useful for sending update messages to the client -# #' browser. -# #' } -# #' -# #' @details -# #' -# #' All of these functions return a function which can be called with no -# #' arguments to cancel the registration. -# #' -# #' The callback function that is passed to these functions should take one -# #' argument, typically named "state" (for `onBookmark`, `onRestore`, -# #' and `onRestored`) or "url" (for `onBookmarked`). -# #' -# #' For `onBookmark`, the state object has three relevant fields. The -# #' `values` field is an environment which can be used to save arbitrary -# #' values (see examples). If the state is being saved to disk (as opposed to -# #' being encoded in a URL), the `dir` field contains the name of a -# #' directory which can be used to store extra files. Finally, the state object -# #' has an `input` field, which is simply the application's `input` -# #' object. It can be read, but not modified. -# #' -# #' For `onRestore` and `onRestored`, the state object is a list. This -# #' list contains `input`, which is a named list of input values to restore, -# #' `values`, which is an environment containing arbitrary values that were -# #' saved in `onBookmark`, and `dir`, the name of the directory that -# #' the state is being restored from, and which could have been used to save -# #' extra files. -# #' -# #' For `onBookmarked`, the callback function receives a string with the -# #' bookmark URL. This callback function should be used to display UI in the -# #' client browser with the bookmark URL. If no callback function is registered, -# #' then Shiny will by default display a modal dialog with the bookmark URL. -# #' -# #' @section Modules: -# #' -# #' These callbacks may also be used in Shiny modules. When used this way, the -# #' inputs and values will automatically be namespaced for the module, and the -# #' callback functions registered for the module will only be able to see the -# #' module's inputs and values. -# #' -# #' @param fun A callback function which takes one argument. -# #' @param session A shiny session object. -# #' @seealso enableBookmarking for general information on bookmarking. -# #' -# #' @examples -# #' ## Only run these examples in interactive sessions -# #' if (interactive()) { -# #' -# #' # Basic use of onBookmark and onRestore: This app saves the time in its -# #' # arbitrary values, and restores that time when the app is restored. -# #' ui <- function(req) { -# #' fluidPage( -# #' textInput("txt", "Input text"), -# #' bookmarkButton() -# #' ) -# #' } -# #' server <- function(input, output) { -# #' onBookmark(function(state) { -# #' savedTime <- as.character(Sys.time()) -# #' cat("Last saved at", savedTime, "\n") -# #' # state is a mutable reference object, and we can add arbitrary values to -# #' # it. -# #' state$values$time <- savedTime -# #' }) -# #' -# #' onRestore(function(state) { -# #' cat("Restoring from state bookmarked at", state$values$time, "\n") -# #' }) -# #' } -# #' enableBookmarking("url") -# #' shinyApp(ui, server) -# #' -# #' -# #' -# # This app illustrates two things: saving values in a file using state$dir, and -# # using an onRestored callback to call an input updater function. (In real use -# # cases, it probably makes sense to save content to a file only if it's much -# # larger.) -# #' ui <- function(req) { -# #' fluidPage( -# #' textInput("txt", "Input text"), -# #' bookmarkButton() -# #' ) -# #' } -# #' server <- function(input, output, session) { -# #' lastUpdateTime <- NULL -# #' -# #' observeEvent(input$txt, { -# #' updateTextInput(session, "txt", -# #' label = paste0("Input text (Changed ", as.character(Sys.time()), ")") -# #' ) -# #' }) -# #' -# #' onBookmark(function(state) { -# #' # Save content to a file -# #' messageFile <- file.path(state$dir, "message.txt") -# #' cat(as.character(Sys.time()), file = messageFile) -# #' }) -# #' -# #' onRestored(function(state) { -# #' # Read the file -# #' messageFile <- file.path(state$dir, "message.txt") -# #' timeText <- readChar(messageFile, 1000) -# #' -# #' # updateTextInput must be called in onRestored, as opposed to onRestore, -# #' # because onRestored happens after the client browser is ready. -# #' updateTextInput(session, "txt", -# #' label = paste0("Input text (Changed ", timeText, ")") -# #' ) -# #' }) -# #' } -# #' # "server" bookmarking is needed for writing to disk. -# #' enableBookmarking("server") -# #' shinyApp(ui, server) -# #' -# #' -# #' # This app has a module, and both the module and the main app code have -# #' # onBookmark and onRestore functions which write and read state$values$hash. The -# #' # module's version of state$values$hash does not conflict with the app's version -# #' # of state$values$hash. -# #' # -# #' # A basic module that captializes text. -# #' capitalizerUI <- function(id) { -# #' ns <- NS(id) -# #' wellPanel( -# #' h4("Text captializer module"), -# #' textInput(ns("text"), "Enter text:"), -# #' verbatimTextOutput(ns("out")) -# #' ) -# #' } -# #' capitalizerServer <- function(input, output, session) { -# #' output$out <- renderText({ -# #' toupper(input$text) -# #' }) -# #' onBookmark(function(state) { -# #' state$values$hash <- rlang::hash(input$text) -# #' }) -# #' onRestore(function(state) { -# #' if (identical(rlang::hash(input$text), state$values$hash)) { -# #' message("Module's input text matches hash ", state$values$hash) -# #' } else { -# #' message("Module's input text does not match hash ", state$values$hash) -# #' } -# #' }) -# #' } -# #' # Main app code -# #' ui <- function(request) { -# #' fluidPage( -# #' sidebarLayout( -# #' sidebarPanel( -# #' capitalizerUI("tc"), -# #' textInput("text", "Enter text (not in module):"), -# #' bookmarkButton() -# #' ), -# #' mainPanel() -# #' ) -# #' ) -# #' } -# #' server <- function(input, output, session) { -# #' callModule(capitalizerServer, "tc") -# #' onBookmark(function(state) { -# #' state$values$hash <- rlang::hash(input$text) -# #' }) -# #' onRestore(function(state) { -# #' if (identical(rlang::hash(input$text), state$values$hash)) { -# #' message("App's input text matches hash ", state$values$hash) -# #' } else { -# #' message("App's input text does not match hash ", state$values$hash) -# #' } -# #' }) -# #' } -# #' enableBookmarking(store = "url") -# #' shinyApp(ui, server) -# #' } -# #' @export -# onBookmark <- function(fun, session = getDefaultReactiveDomain()) { -# session$onBookmark(fun) -# } - -# #' @rdname onBookmark -# #' @export -# onBookmarked <- function(fun, session = getDefaultReactiveDomain()) { -# session$onBookmarked(fun) -# } - -# #' @rdname onBookmark -# #' @export -# onRestore <- function(fun, session = getDefaultReactiveDomain()) { -# session$onRestore(fun) -# } - -# #' @rdname onBookmark -# #' @export -# onRestored <- function(fun, session = getDefaultReactiveDomain()) { -# session$onRestored(fun) -# } -# } diff --git a/shiny/bookmark/_globals.py b/shiny/bookmark/_globals.py index 8deffe1e2..1f4f9763e 100644 --- a/shiny/bookmark/_globals.py +++ b/shiny/bookmark/_globals.py @@ -5,20 +5,12 @@ from ..types import MISSING, MISSING_TYPE -# TODO: Barret: Q: Can we merge how bookmark dirs are saved / loaded? +# TODO: Barret: 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. BookmarkStateSaveDir = Callable[[str], Awaitable[Path]] BookmarkStateLoadDir = Callable[[str], Awaitable[Path]] bookmark_state_save_dir: BookmarkStateSaveDir | MISSING_TYPE = MISSING bookmark_state_load_dir: BookmarkStateLoadDir | MISSING_TYPE = MISSING BookmarkStore = Literal["url", "server", "disable"] +# TODO: barret - Q: Should we have a `enable_bookmarking(store: BookmarkStore)` function? bookmark_store: BookmarkStore = "disable" - - -# def bookmark_store_get() -> BookmarkStore: -# return bookmark_store - - -# def bookmark_store_set(value: BookmarkStore) -> None: -# global bookmark_store -# bookmark_store = value From 308291243060165a9f1b158da155c16ed5194b1f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 14:23:33 -0500 Subject: [PATCH 27/62] Clean up comments --- shiny/_utils.py | 2 +- shiny/bookmark/_bookmark.py | 26 ++++++++----------- shiny/bookmark/_globals.py | 14 ++++++---- shiny/bookmark/_restore_state.py | 10 +++---- shiny/bookmark/_save_state.py | 12 ++++----- shiny/bookmark/_serializers.py | 4 +-- shiny/bookmark/_utils.py | 2 +- shiny/express/_stub_session.py | 3 --- shiny/input_handler.py | 2 ++ shiny/session/_session.py | 2 +- .../pytest/test_render_data_frame_tbl_data.py | 2 +- 11 files changed, 39 insertions(+), 40 deletions(-) diff --git a/shiny/_utils.py b/shiny/_utils.py index 3bc548a21..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. diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 641725d13..6a38b0715 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -12,26 +12,24 @@ from ._restore_state import RestoreContextState from ._save_state import ShinySaveState -# TODO: bookmark button - -# TODO: +# TODO: Barret - Bookmark state # bookmark -> save/load interface -# * √ base class -# * √ local -# save/load interface -> register interface -# * implement; Q on approach! +# * √ global hooks +# * √ default local functions +# save/load interface -> register functions +# * `shiny.bookmark.globals` # register interface -> Make interface for Connect -# * implement in Connect PR +# * 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 +# * √ 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! +# * √ 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` +# * √ `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` @@ -485,8 +483,6 @@ async def root_state_on_save(state: ShinySaveState) -> None: from ..ui._notification import notification_show notification_show(msg, duration=None, type="error") - # TODO: Barret - Remove this! - raise RuntimeError("Error bookmarking state") from e class BookmarkProxy(Bookmark): diff --git a/shiny/bookmark/_globals.py b/shiny/bookmark/_globals.py index 1f4f9763e..b833c2249 100644 --- a/shiny/bookmark/_globals.py +++ b/shiny/bookmark/_globals.py @@ -6,11 +6,15 @@ from ..types import MISSING, MISSING_TYPE # TODO: Barret: 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. -BookmarkStateSaveDir = Callable[[str], Awaitable[Path]] -BookmarkStateLoadDir = Callable[[str], Awaitable[Path]] -bookmark_state_save_dir: BookmarkStateSaveDir | MISSING_TYPE = MISSING -bookmark_state_load_dir: BookmarkStateLoadDir | MISSING_TYPE = MISSING +BookmarkSaveDir = Callable[[str], Awaitable[Path]] +BookmarkLoadDir = Callable[[str], Awaitable[Path]] + +bookmark_save_dir: BookmarkSaveDir | MISSING_TYPE = MISSING +bookmark_load_dir: BookmarkLoadDir | MISSING_TYPE = MISSING BookmarkStore = Literal["url", "server", "disable"] -# TODO: barret - Q: Should we have a `enable_bookmarking(store: BookmarkStore)` function? +# TODO: Barret - Q: Should we have a `enable_bookmarking(store: BookmarkStore)` function? bookmark_store: BookmarkStore = "disable" + + +# TODO: Barret; Q: I feel like there could be a `@shiny.globals.on_session_start` decorator that would allow us to set these values. diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index bef8208b5..0b0a943f3 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -12,7 +12,7 @@ from . import _globals as bookmark_globals from ._bookmark_state import local_load_dir -from ._globals import BookmarkStateLoadDir +from ._globals import BookmarkLoadDir from ._utils import from_json_str, is_hosted @@ -186,13 +186,13 @@ async def _load_state_qs(self, query_string: str) -> None: id = id[0] - load_bookmark_fn: BookmarkStateLoadDir | None = None - if not isinstance(bookmark_globals.bookmark_state_load_dir, MISSING_TYPE): - load_bookmark_fn = bookmark_globals.bookmark_state_load_dir + load_bookmark_fn: BookmarkLoadDir | None = None + if not isinstance(bookmark_globals.bookmark_load_dir, MISSING_TYPE): + load_bookmark_fn = bookmark_globals.bookmark_load_dir if load_bookmark_fn is None: if is_hosted(): - # TODO: Barret + # TODO: Barret; Implement Connect's `bookmark_load_dir` function raise NotImplementedError( "The hosting environment does not support server-side bookmarking." ) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 3ddc940d2..30615923d 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -10,10 +10,10 @@ from ..types import MISSING_TYPE from . import _globals as bookmark_globals from ._bookmark_state import local_save_dir -from ._globals import BookmarkStateSaveDir +from ._globals import BookmarkSaveDir from ._utils import is_hosted, to_json_str -# TODO: barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 +# TODO: Barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 # Might need to have independent save/load functions to register to avoid a class constructor @@ -76,13 +76,13 @@ async def _save_state(self) -> str: # to `self.dir`. # This will be defined by the hosting environment if it supports bookmarking. - save_bookmark_fn: BookmarkStateSaveDir | None = None - if not isinstance(bookmark_globals.bookmark_state_save_dir, MISSING_TYPE): - save_bookmark_fn = bookmark_globals.bookmark_state_save_dir + save_bookmark_fn: BookmarkSaveDir | None = None + if not isinstance(bookmark_globals.bookmark_save_dir, MISSING_TYPE): + save_bookmark_fn = bookmark_globals.bookmark_save_dir if save_bookmark_fn is None: if is_hosted(): - # TODO: Barret + # TODO: Barret; Implement `bookmark_save_dir` for Connect raise NotImplementedError( "The hosting environment does not support server-side bookmarking." ) diff --git a/shiny/bookmark/_serializers.py b/shiny/bookmark/_serializers.py index 85cfdf876..c30207f05 100644 --- a/shiny/bookmark/_serializers.py +++ b/shiny/bookmark/_serializers.py @@ -27,7 +27,7 @@ async def serializer_default(value: T, state_dir: Path | None) -> T: return value -# TODO-barret; Integrate +# TODO: Barret - Integrate def serializer_file_input( value: Any, state_dir: Path | None, @@ -35,7 +35,7 @@ def serializer_file_input( if state_dir is None: return Unserializable() - # TODO: barret; Double check this logic! + # 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. diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py index 21b47779d..ec531874a 100644 --- a/shiny/bookmark/_utils.py +++ b/shiny/bookmark/_utils.py @@ -9,7 +9,7 @@ def is_hosted() -> bool: # Can't look at SHINY_PORT, as we already set it in shiny/_main.py's `run_app()` - # TODO: Support shinyapps.io or use `SHINY_PORT` how R-shiny did + # TODO: Barret: Q: How to support shinyapps.io? Or use `SHINY_PORT` how R-shiny did # Instead, looking for the presence of the environment variable that Connect sets # (*_Connect) or Shiny Server sets (SHINY_APP) diff --git a/shiny/express/_stub_session.py b/shiny/express/_stub_session.py index 7e092cffb..adb1b6a93 100644 --- a/shiny/express/_stub_session.py +++ b/shiny/express/_stub_session.py @@ -47,9 +47,6 @@ def __init__(self, ns: ResolvedId = Root): self.bookmark = BookmarkExpressStub(self) - self.exclude = [] - self.store = "disable" # TODO: Is this correct? - def is_stub_session(self) -> Literal[True]: return True 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/session/_session.py b/shiny/session/_session.py index 49611ac3e..e890e2107 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1457,7 +1457,7 @@ async def _serialize( with reactive.isolate(): for key, value in self._map.items(): - # TODO: Barret - Q: Should this be anything that starts with a "."? + # TODO: Barret - Q: Should this be ignoring any Input key that starts with a "."? if key.startswith(".clientdata_"): continue if key == BOOKMARK_ID or key.endswith( diff --git a/tests/pytest/test_render_data_frame_tbl_data.py b/tests/pytest/test_render_data_frame_tbl_data.py index 123e987b6..2d5041679 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 From a889407be9f265551af47291020357b5b4dbd93e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 4 Mar 2025 15:36:22 -0500 Subject: [PATCH 28/62] Use on_flush (not on_flushed) when calling restored callbacks --- shiny/bookmark/_bookmark.py | 5 ++++- shiny/session/_session.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 6a38b0715..0197d0cef 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -332,7 +332,10 @@ async def invoke_on_restore_callbacks(): # Run the on_restored function after the flush cycle completes and # information is sent to the client. - @session.on_flushed + # Update: Using `on_flush` to have the callbacks populate the output message + # queue going to the browser. If `on_flushed` is used, the messages stall + # until the next `on_flushed` invocation. + @session.on_flush async def invoke_on_restored_callbacks(): print("Trying on restored") if self._on_restored_callbacks.count() == 0: diff --git a/shiny/session/_session.py b/shiny/session/_session.py index e890e2107..08416ff2f 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -665,9 +665,6 @@ def verify_state(expected_state: ConnectionState) -> None: with session_context(self): self.app.server(self.input, self.output, self) - if self.bookmark.store != "disable": - await reactive_flush() # TODO: Barret; Why isn't the reactive flush triggering itself? - elif message_obj["method"] == "update": verify_state(ConnectionState.Running) From 3319fb182c6c4f11e1b7301ad2bae9ba41c8c280 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 09:54:08 -0500 Subject: [PATCH 29/62] Export `shiny.bookmark.restore_input()` --- shiny/bookmark/__init__.py | 3 ++- shiny/ui/_input_check_radio.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index 571e74baa..b0b432c8c 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -7,7 +7,7 @@ ShinySaveState, ) from ._button import input_bookmark_button -from ._restore_state import RestoreContext, RestoreContextState +from ._restore_state import RestoreContext, RestoreContextState, restore_input __all__ = ( # _globals @@ -23,4 +23,5 @@ # _restore_state "RestoreContext", "RestoreContextState", + "restore_input", ) diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index e7f15cf77..32edff7e8 100644 --- a/shiny/ui/_input_check_radio.py +++ b/shiny/ui/_input_check_radio.py @@ -288,7 +288,7 @@ def input_radio_buttons( resolved_id = resolve_id(id) input_label = shiny_input_label(resolved_id, label) - from ..bookmark._restore_state import restore_input + from ..bookmark import restore_input options = _generate_options( id=resolved_id, From ab42bffa25d91fcae12516ed1a52e1df7203d17a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 09:55:54 -0500 Subject: [PATCH 30/62] Add warning statements; Remove many print statements --- shiny/bookmark/_bookmark.py | 15 +++++++++------ shiny/reactive/_core.py | 6 ------ shiny/session/_session.py | 6 ------ 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 0197d0cef..113857aaf 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from abc import ABC, abstractmethod from pathlib import Path from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn @@ -279,7 +280,6 @@ def _create_effects(self) -> None: if self.store == "disable": return - print("Creating effects") session = self._session_root from .. import reactive @@ -309,7 +309,6 @@ def init_error_message(): # the server function has been executed. @reactive.effect(priority=1000000) async def invoke_on_restore_callbacks(): - print("Trying on restore") if self._on_restore_callbacks.count() == 0: return @@ -322,8 +321,10 @@ async def invoke_on_restore_callbacks(): restore_state = self._restore_context.as_state() await self._on_restore_callbacks.invoke(restore_state) except Exception as e: - raise e - print(f"Error calling on_restore callback: {e}") + warnings.warn( + f"Error calling on_restore callback: {e}", + stacklevel=2, + ) notification_show( f"Error calling on_restore callback: {e}", duration=None, @@ -337,7 +338,6 @@ async def invoke_on_restore_callbacks(): # until the next `on_flushed` invocation. @session.on_flush async def invoke_on_restored_callbacks(): - print("Trying on restored") if self._on_restored_callbacks.count() == 0: return @@ -348,7 +348,10 @@ async def invoke_on_restored_callbacks(): restore_state = self._restore_context.as_state() await self._on_restored_callbacks.invoke(restore_state) except Exception as e: - print(f"Error calling on_restored callback: {e}") + warnings.warn( + f"Error calling on_restored callback: {e}", + stacklevel=2, + ) notification_show( f"Error calling on_restored callback: {e}", duration=None, diff --git a/shiny/reactive/_core.py b/shiny/reactive/_core.py index 8c55178b5..6b4066456 100644 --- a/shiny/reactive/_core.py +++ b/shiny/reactive/_core.py @@ -174,21 +174,15 @@ def on_flushed( async def flush(self) -> None: """Flush all pending operations""" - print("--Reactive flush--") await self._flush_sequential() - print("--Reactive flush callbacks--") await self._flushed_callbacks.invoke() - print("--Reactive flush done--") async def _flush_sequential(self) -> None: # Sequential flush: instead of storing the tasks in a list and calling gather() # on them later, just run each effect in sequence. - print("--Sequential flush--") while not self._pending_flush_queue.empty(): - print("--item") ctx = self._pending_flush_queue.get() await ctx.execute_flush_callbacks() - print("--Sequential flush done--") def add_pending_flush(self, ctx: Context, priority: int) -> None: self._pending_flush_queue.put(priority, ctx) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 08416ff2f..689ef576a 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -633,7 +633,6 @@ def verify_state(expected_state: ConnectionState) -> None: return async with lock(): - print("with lock") if message_obj["method"] == "init": verify_state(ConnectionState.Start) @@ -959,7 +958,6 @@ async def wrap_content_sync() -> AsyncIterable[bytes]: return HTMLResponse("

Not Found

", 404) def send_input_message(self, id: str, message: dict[str, object]) -> None: - print("Send input message", id, message) self._outbound_message_queues.add_input_message(id, message) self._request_flush() @@ -1039,9 +1037,7 @@ 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: - print("Flushing restore context pending") self.bookmark._restore_context.flush_pending() - print("Flushing restore context pending - done") # Flush the callbacks await self._flush_callbacks.invoke() @@ -1055,13 +1051,11 @@ async def _flush(self) -> None: } try: - print("Sending message!", message) await self._send_message(message) finally: self._outbound_message_queues.reset() finally: with session_context(self): - print("Invoking flushed callbacks") await self._flushed_callbacks.invoke() def _increment_busy_count(self) -> None: From 3de1378937cbd7d541fa2c9aa09dd984c41cb09c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 10:12:44 -0500 Subject: [PATCH 31/62] Docs --- shiny/bookmark/_bookmark.py | 34 ++++++++++++++++++++++++++++++-- shiny/bookmark/_restore_state.py | 13 ++++++------ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 113857aaf..98b5139d0 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -86,8 +86,16 @@ class Bookmark(ABC): # TODO: Barret - This feels like it needs to be a weakref _session_root: Session + """ + The root session object (most likely a `AppSession` object). + """ _store: BookmarkStore | MISSING_TYPE + """ + Session specific bookmark store value. + + This value could help determine how session state is saved. However, 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: @@ -103,20 +111,33 @@ def store(self, value: BookmarkStore) -> None: self._store = value _proxy_exclude_fns: 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 - _restore_context: RestoreContext | None + _restore_context_value: RestoreContext | None + """ + Placeholder value that should only be manually set within the session's `init` websocket message. + """ + + @property + def _restore_context(self) -> RestoreContext | None: + """ + A read-only value of the session's RestoreContext object. + """ + return self._root_bookmark._restore_context_value async def __call__(self) -> None: await self._root_bookmark.do_bookmark() @property def _root_bookmark(self) -> "Bookmark": + """The base session's bookmark object.""" return self._session_root.bookmark def __init__(self, session_root: Session): @@ -125,7 +146,7 @@ def __init__(self, session_root: Session): super().__init__() # TODO: Barret - Q: Should this be a weakref; Session -> Bookmark -> Session self._session_root = session_root - self._restore_context = None + self._restore_context_value = None self._store = MISSING self._proxy_exclude_fns = [] @@ -276,6 +297,15 @@ def __init__(self, session_root: Session): super().__init__(session_root) 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 diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 0b0a943f3..5902dc661 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -161,13 +161,14 @@ async def from_query_string(query_string: str) -> "RestoreContext": def flush_pending(self) -> None: self.input.flush_pending() - # 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. - def as_state(self) -> RestoreContextState: + """ + 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 RestoreContextState( # Shallow copy input={**self.input.as_dict()}, From f65f12033f4d622d9798dbda528abc83742800fa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 10:16:28 -0500 Subject: [PATCH 32/62] Safely create the dir using `exist_ok=True` --- shiny/_main_create.py | 3 +-- shiny/bookmark/_bookmark_state.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) 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/bookmark/_bookmark_state.py b/shiny/bookmark/_bookmark_state.py index 09bd3a2eb..7c7ce8cfe 100644 --- a/shiny/bookmark/_bookmark_state.py +++ b/shiny/bookmark/_bookmark_state.py @@ -12,8 +12,7 @@ def _local_dir(id: str) -> Path: async def local_save_dir(id: str) -> Path: state_dir = _local_dir(id) - if not state_dir.exists(): - state_dir.mkdir(parents=True) + state_dir.mkdir(parents=True, exist_ok=True) return state_dir From 27b88b5cea60945ba2c6d965d5d319dcda710ae4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 11:59:24 -0500 Subject: [PATCH 33/62] Fix bug where the proxy session didn't have a restore context --- shiny/session/_session.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 689ef576a..535a7c3b6 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -638,13 +638,13 @@ def verify_state(expected_state: ConnectionState) -> None: # BOOKMARKS! if ".clientdata_url_search" in message_obj["data"]: - self.bookmark._restore_context = ( + self.bookmark._restore_context_value = ( await RestoreContext.from_query_string( message_obj["data"][".clientdata_url_search"] ) ) else: - self.bookmark._restore_context = RestoreContext() + self.bookmark._restore_context_value = RestoreContext() # When a reactive flush occurs, flush the session's outputs, # errors, etc. to the client. Note that this is @@ -1035,7 +1035,7 @@ 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. + # 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 From 6c4f5967ea9746eda75f53067c08ad4666133a51 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 12:04:38 -0500 Subject: [PATCH 34/62] Add bookmark workaround for `on_restored()` callbacks not executing on load #1889 --- shiny/bookmark/_bookmark.py | 5 +---- shiny/session/_session.py | 5 +++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 98b5139d0..98224deb1 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -363,10 +363,7 @@ async def invoke_on_restore_callbacks(): # Run the on_restored function after the flush cycle completes and # information is sent to the client. - # Update: Using `on_flush` to have the callbacks populate the output message - # queue going to the browser. If `on_flushed` is used, the messages stall - # until the next `on_flushed` invocation. - @session.on_flush + @session.on_flushed async def invoke_on_restored_callbacks(): if self._on_restored_callbacks.count() == 0: return diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 535a7c3b6..b87bf5475 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -664,6 +664,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) From f6a38e77eda520cd50ae15d8cab6e82989b435f1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 6 Mar 2025 12:20:01 -0500 Subject: [PATCH 35/62] Use `App.bookmark_store` to be the source of truth for `session.bookmark.store`; Remove setter for per-session bookmark store value --- shiny/_app.py | 17 ++++++++++++----- shiny/bookmark/_bookmark.py | 20 +++++++++++--------- shiny/bookmark/_globals.py | 3 --- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index 029cc3575..dd172aa3c 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -31,7 +31,7 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length -from .bookmark import _globals as bookmark_globals +from .bookmark._globals import BookmarkStore from .bookmark._restore_state import ( RestoreContext, get_current_restore_context, @@ -113,6 +113,12 @@ def server(input: Inputs, output: Outputs, session: Session): ui: RenderedHTML | Callable[[Request], Tag | TagList] server: Callable[[Inputs, Outputs, Session], None] + _bookmark_store: BookmarkStore + + @property + def bookmark_store(self) -> BookmarkStore: + return self._bookmark_store + def __init__( self, ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path, @@ -121,7 +127,8 @@ def __init__( ), *, static_assets: Optional[str | Path | Mapping[str, str | Path]] = None, - bookmarking: Optional[Literal["url", "query", "disable"]] = 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 @@ -141,8 +148,8 @@ def __init__( "`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)" ) - if bookmarking is not None: - bookmark_globals.bookmark_store = bookmarking + self._bookmark_store = bookmark_store + self._debug: bool = debug # Settings that the user can change after creating the App object. @@ -363,7 +370,7 @@ async def _on_root_request_cb(self, request: Request) -> Response: request for / occurs. """ ui: RenderedHTML - if bookmark_globals.bookmark_store == "disable": + if self.bookmark_store == "disable": restore_ctx = RestoreContext() else: restore_ctx = await RestoreContext.from_query_string(request.url.query) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 98224deb1..f81754a3c 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -97,18 +97,20 @@ class Bookmark(ABC): This value could help determine how session state is saved. However, 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. """ + # 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: - # Should we allow for this? - # Allows a per-session override of the global bookmark store - if isinstance(self._session_root.bookmark._store, MISSING_TYPE): - return bookmark_globals.bookmark_store - return self._session_root.bookmark._store + """ + App's bookmark store value - @store.setter - def store(self, value: BookmarkStore) -> None: - self._session_root.bookmark._store = value - self._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. + """ + + # Read from the App's bookmark store value. + return self._session_root.app.bookmark_store _proxy_exclude_fns: list[Callable[[], list[str]]] """Callbacks that BookmarkProxy classes utilize to help determine the list of inputs to exclude from bookmarking.""" diff --git a/shiny/bookmark/_globals.py b/shiny/bookmark/_globals.py index b833c2249..44764cc52 100644 --- a/shiny/bookmark/_globals.py +++ b/shiny/bookmark/_globals.py @@ -13,8 +13,5 @@ bookmark_load_dir: BookmarkLoadDir | MISSING_TYPE = MISSING BookmarkStore = Literal["url", "server", "disable"] -# TODO: Barret - Q: Should we have a `enable_bookmarking(store: BookmarkStore)` function? -bookmark_store: BookmarkStore = "disable" - # TODO: Barret; Q: I feel like there could be a `@shiny.globals.on_session_start` decorator that would allow us to set these values. From 9f15505c7ef351cf3feb4463b8adffb348219075 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 7 Mar 2025 10:53:15 -0500 Subject: [PATCH 36/62] Fix server-side values not being restored --- shiny/bookmark/_restore_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 5902dc661..3efe32d45 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -211,7 +211,7 @@ async def _load_state_qs(self, query_string: str) -> None: input_values = pickle.load(f) self.input = RestoreInputSet(input_values) - values_file = self.dir / "values.rds" + values_file = self.dir / "values.pickle" if values_file.exists(): with open(values_file, "rb") as f: self.values = pickle.load(f) From 3df0026ea710ecba75b73ac2313478d3a670e8da Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 7 Mar 2025 16:12:23 -0500 Subject: [PATCH 37/62] Move files around. Add `shiny.bookmark. set_save_dir()` and `set_restore_dir()` --- shiny/_app.py | 2 +- shiny/bookmark/__init__.py | 7 +- shiny/bookmark/_bookmark.py | 13 ++-- shiny/bookmark/_bookmark_state.py | 2 +- shiny/bookmark/_button.py | 106 +++++++++++++++--------------- shiny/bookmark/_external.py | 54 +++++++++++++++ shiny/bookmark/_globals.py | 17 ----- shiny/bookmark/_restore_state.py | 16 ++--- shiny/bookmark/_save_state.py | 11 ++-- shiny/bookmark/_types.py | 87 ++++++++++++++++++++++++ shiny/session/_session.py | 3 +- shiny/ui/__init__.py | 4 +- 12 files changed, 222 insertions(+), 100 deletions(-) create mode 100644 shiny/bookmark/_external.py delete mode 100644 shiny/bookmark/_globals.py create mode 100644 shiny/bookmark/_types.py diff --git a/shiny/_app.py b/shiny/_app.py index dd172aa3c..f73e04356 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -31,12 +31,12 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length -from .bookmark._globals import BookmarkStore from .bookmark._restore_state import ( RestoreContext, get_current_restore_context, restore_context, ) +from .bookmark._types import 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 diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index b0b432c8c..e483314fe 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -1,4 +1,3 @@ -from . import _globals as globals from ._bookmark import ( Bookmark, BookmarkApp, @@ -7,11 +6,10 @@ ShinySaveState, ) from ._button import input_bookmark_button +from ._external import set_load_dir, set_save_dir from ._restore_state import RestoreContext, RestoreContextState, restore_input __all__ = ( - # _globals - "globals", # _bookmark "ShinySaveState", "Bookmark", @@ -20,6 +18,9 @@ "BookmarkExpressStub", # _button "input_bookmark_button", + # _external + "set_save_dir", + "set_load_dir", # _restore_state "RestoreContext", "RestoreContextState", diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index f81754a3c..1d5ec1438 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -7,11 +7,10 @@ from .._utils import AsyncCallbacks, CancelCallback, wrap_async from ..types import MISSING, MISSING_TYPE -from . import _globals as bookmark_globals from ._button import BOOKMARK_ID -from ._globals import BookmarkStore from ._restore_state import RestoreContextState from ._save_state import ShinySaveState +from ._types import BookmarkStore # TODO: Barret - Bookmark state # bookmark -> save/load interface @@ -590,9 +589,12 @@ async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> 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}" @@ -653,13 +655,6 @@ async def do_bookmark(self) -> None: def store(self) -> BookmarkStore: return self._root_bookmark.store - @store.setter - def store( # pyright: ignore[reportIncompatibleVariableOverride] - self, - value: BookmarkStore, - ) -> None: - self._root_bookmark.store = value - def on_restore( self, callback: ( diff --git a/shiny/bookmark/_bookmark_state.py b/shiny/bookmark/_bookmark_state.py index 7c7ce8cfe..206d1745e 100644 --- a/shiny/bookmark/_bookmark_state.py +++ b/shiny/bookmark/_bookmark_state.py @@ -16,5 +16,5 @@ async def local_save_dir(id: str) -> Path: return state_dir -async def local_load_dir(id: str) -> Path: +async def local_restore_dir(id: str) -> Path: return _local_dir(id) diff --git a/shiny/bookmark/_button.py b/shiny/bookmark/_button.py index 8681f8d26..49c31ad20 100644 --- a/shiny/bookmark/_button.py +++ b/shiny/bookmark/_button.py @@ -9,59 +9,59 @@ BOOKMARK_ID = "._bookmark_" -#' Create a button for bookmarking/sharing -#' -#' A `bookmarkButton` is a [actionButton()] with a default label -#' that consists of a link icon and the text "Bookmark...". It is meant to be -#' used for bookmarking state. -#' -#' @inheritParams actionButton -#' @param title A tooltip that is shown when the mouse cursor hovers over the -#' button. -#' @param 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 -#' [setBookmarkExclude()], and you must create an observer that -#' does the bookmarking when the button is pressed. See the examples below. -#' -#' @seealso [enableBookmarking()] for more examples. -#' -#' @examples -#' ## Only run these examples in interactive sessions -#' if (interactive()) { -#' -#' # This example shows how to use multiple bookmark buttons. If you only need -#' # a single bookmark button, see examples in ?enableBookmarking. -#' ui <- function(request) { -#' fluidPage( -#' tabsetPanel(id = "tabs", -#' tabPanel("One", -#' checkboxInput("chk1", "Checkbox 1"), -#' bookmarkButton(id = "bookmark1") -#' ), -#' tabPanel("Two", -#' checkboxInput("chk2", "Checkbox 2"), -#' bookmarkButton(id = "bookmark2") -#' ) -#' ) -#' ) -#' } -#' server <- function(input, output, session) { -#' # Need to exclude the buttons from themselves being bookmarked -#' setBookmarkExclude(c("bookmark1", "bookmark2")) -#' -#' # Trigger bookmarking with either button -#' observeEvent(input$bookmark1, { -#' session$doBookmark() -#' }) -#' observeEvent(input$bookmark2, { -#' session$doBookmark() -#' }) -#' } -#' enableBookmarking(store = "url") -#' shinyApp(ui, server) -#' } -#' @export +# ' Create a button for bookmarking/sharing +# ' +# ' A `bookmarkButton` is a [actionButton()] with a default label +# ' that consists of a link icon and the text "Bookmark...". It is meant to be +# ' used for bookmarking state. +# ' +# ' @inheritParams actionButton +# ' @param title A tooltip that is shown when the mouse cursor hovers over the +# ' button. +# ' @param 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 +# ' [setBookmarkExclude()], and you must create an observer that +# ' does the bookmarking when the button is pressed. See the examples below. +# ' +# ' @seealso [enableBookmarking()] for more examples. +# ' +# ' @examples +# ' ## Only run these examples in interactive sessions +# ' if (interactive()) { +# ' +# ' # This example shows how to use multiple bookmark buttons. If you only need +# ' # a single bookmark button, see examples in ?enableBookmarking. +# ' ui <- function(request) { +# ' fluidPage( +# ' tabsetPanel(id = "tabs", +# ' tabPanel("One", +# ' checkboxInput("chk1", "Checkbox 1"), +# ' bookmarkButton(id = "bookmark1") +# ' ), +# ' tabPanel("Two", +# ' checkboxInput("chk2", "Checkbox 2"), +# ' bookmarkButton(id = "bookmark2") +# ' ) +# ' ) +# ' ) +# ' } +# ' server <- function(input, output, session) { +# ' # Need to exclude the buttons from themselves being bookmarked +# ' setBookmarkExclude(c("bookmark1", "bookmark2")) +# ' +# ' # Trigger bookmarking with either button +# ' observeEvent(input$bookmark1, { +# ' session$doBookmark() +# ' }) +# ' observeEvent(input$bookmark2, { +# ' session$doBookmark() +# ' }) +# ' } +# ' enableBookmarking(store = "url") +# ' shinyApp(ui, server) +# ' } +# ' @export def input_bookmark_button( label: TagChild = "Bookmark...", *, diff --git a/shiny/bookmark/_external.py b/shiny/bookmark/_external.py new file mode 100644 index 000000000..6df5b4fe7 --- /dev/null +++ b/shiny/bookmark/_external.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Awaitable, Callable, Literal, TypeVar + +from .._utils import wrap_async +from ..types import MISSING, MISSING_TYPE +from ._types import GetBookmarkRestoreDir, GetBookmarkSaveDir + +BookmarkStore = Literal["url", "server", "disable"] + + +# 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. + +# The set methods below are used to set the save_dir and restore_dir locations for locations like Connect or SSP. +# Ex: +# ```python +# @shiny.bookmark.set_save_dir +# def connect_save_shiny_bookmark(id: str) -> Path: +# path = Path("connect") / id +# path.mkdir(parents=True, exist_ok=True) +# return path +# @shiny.bookmark.set_restore_dir +# def connect_restore_shiny_bookmark(id: str) -> Path: +# return Path("connect") / id +# ``` + + +_bookmark_save_dir: GetBookmarkSaveDir | MISSING_TYPE = MISSING +_bookmark_restore_dir: GetBookmarkRestoreDir | MISSING_TYPE = MISSING + + +GetBookmarkDirT = TypeVar( + "GetBookmarkDirT", + bound=Callable[[str], Awaitable[Path]] | Callable[[str], Awaitable[Path]], +) + + +def set_save_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT: + """TODO: Barret document""" + global _bookmark_save_dir + + _bookmark_save_dir = wrap_async(fn) + return fn + + +def set_restore_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT: + """TODO: Barret document""" + global _bookmark_restore_dir + + _bookmark_restore_dir = wrap_async(fn) + return fn diff --git a/shiny/bookmark/_globals.py b/shiny/bookmark/_globals.py deleted file mode 100644 index 44764cc52..000000000 --- a/shiny/bookmark/_globals.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Awaitable, Callable, Literal - -from ..types import MISSING, MISSING_TYPE - -# TODO: Barret: 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. -BookmarkSaveDir = Callable[[str], Awaitable[Path]] -BookmarkLoadDir = Callable[[str], Awaitable[Path]] - -bookmark_save_dir: BookmarkSaveDir | MISSING_TYPE = MISSING -bookmark_load_dir: BookmarkLoadDir | MISSING_TYPE = MISSING - -BookmarkStore = Literal["url", "server", "disable"] - -# TODO: Barret; Q: I feel like there could be a `@shiny.globals.on_session_start` decorator that would allow us to set these values. diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 3efe32d45..cc090f767 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -10,9 +10,9 @@ from shiny.types import MISSING_TYPE -from . import _globals as bookmark_globals -from ._bookmark_state import local_load_dir -from ._globals import BookmarkLoadDir +from . import _external as bookmark_external +from ._bookmark_state import local_restore_dir +from ._types import GetBookmarkRestoreDir from ._utils import from_json_str, is_hosted @@ -59,6 +59,7 @@ def _state_within_namespace(self, prefix: str) -> "RestoreContextState": if self._name_has_namespace(name, prefix) } + # TODO-barret; Is this for bookmarking?!? dir = self.dir if dir is not None: dir = dir / prefix @@ -187,9 +188,9 @@ async def _load_state_qs(self, query_string: str) -> None: id = id[0] - load_bookmark_fn: BookmarkLoadDir | None = None - if not isinstance(bookmark_globals.bookmark_load_dir, MISSING_TYPE): - load_bookmark_fn = bookmark_globals.bookmark_load_dir + load_bookmark_fn: GetBookmarkRestoreDir | None = None + if not isinstance(bookmark_external._bookmark_restore_dir, MISSING_TYPE): + load_bookmark_fn = bookmark_external._bookmark_restore_dir if load_bookmark_fn is None: if is_hosted(): @@ -199,7 +200,7 @@ async def _load_state_qs(self, query_string: str) -> None: ) else: # We're running Shiny locally. - load_bookmark_fn = local_load_dir + load_bookmark_fn = local_restore_dir # Load the state from disk. self.dir = Path(await load_bookmark_fn(id)) @@ -401,7 +402,6 @@ def restore_input(id: str, default: Any) -> Any: default A default value to use, if there's no value to restore. """ - # print("\n", "restore_input-1", id, default, "\n") # Will run even if the domain is missing if not has_current_restore_context(): return default diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 30615923d..85ed2c9ef 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -8,9 +8,9 @@ from .._utils import private_random_id from ..reactive import isolate from ..types import MISSING_TYPE -from . import _globals as bookmark_globals +from . import _external as bookmark_external from ._bookmark_state import local_save_dir -from ._globals import BookmarkSaveDir +from ._types import GetBookmarkSaveDir from ._utils import is_hosted, to_json_str # TODO: Barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 @@ -76,9 +76,9 @@ async def _save_state(self) -> str: # to `self.dir`. # This will be defined by the hosting environment if it supports bookmarking. - save_bookmark_fn: BookmarkSaveDir | None = None - if not isinstance(bookmark_globals.bookmark_save_dir, MISSING_TYPE): - save_bookmark_fn = bookmark_globals.bookmark_save_dir + save_bookmark_fn: GetBookmarkSaveDir | None = None + if not isinstance(bookmark_external._bookmark_save_dir, MISSING_TYPE): + save_bookmark_fn = bookmark_external._bookmark_save_dir if save_bookmark_fn is None: if is_hosted(): @@ -99,6 +99,7 @@ async def _save_state(self) -> str: state_dir=self.dir, ) assert self.dir is not None + with open(self.dir / "input.pickle", "wb") as f: pickle.dump(input_values_json, f) diff --git a/shiny/bookmark/_types.py b/shiny/bookmark/_types.py new file mode 100644 index 000000000..b155248b3 --- /dev/null +++ b/shiny/bookmark/_types.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Awaitable, Callable, Literal + +# TODO: Barret: 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. + +# TODO: Barret Rename load -> restore. Keep the names consistent! +GetBookmarkSaveDir = Callable[[str], Awaitable[Path]] +GetBookmarkRestoreDir = Callable[[str], Awaitable[Path]] + + +BookmarkStore = Literal["url", "server", "disable"] + + +# TODO: Barret; Q: I feel like there could be a `@shiny.globals.on_session_start` decorator that would allow us to set these values. + + +# @shiny.session.on_session_start +# def _(session): ... + + +# @shiny.session.on_session_started +# def _(session): ... + + +# shiny.bookmark.globals.bookmark_save_dir = connect_custom_method +# shiny.bookmark.globals.bookmark_load_dir = connect_custom_method + + +# shiny.bookmark.set_bookmark_save_dir(connect_custom_method) +# shiny.bookmark.set_bookmark_load_dir(connect_custom_method) + +# ------------ + +# import shiny + +# # Garrick like's this one... fix the name! +# @shiny.bookmark.bookmark_save_dir +# def connect_custom_method(id: str) -> Path: +# return Path("connect") / id + + +# shiny.run_app("some file") + +# Using a decorator + + +# from shiny.bookmark import _globals as bookmark_globals +# bookmark_globals.bookmark_store = "url" + +# bookmark.globals.bookmark_store = "url" + +# import shiny + +# TODO: Barret - Implement + +# # Make placeholders start with underscore +# shiny.bookmark.globals.bookmark_save_dir = connect_save_shiny_bookmark +# shiny.bookmark.globals.bookmark_load_dir = connect_restore_shiny_bookmark + +# # Implement this for now +# # Hold off on + +# # Global level +# @shiny.bookmark.set_save_dir +# def connect_save_shiny_bookmark(): ... + +# Don't implement this +# # App level +# @app.bookmark.set_save_dir +# def save_shiny_bookmark(): ... + + +# @app.bookmark.set_save_dir +# def save_shiny_bookmark(): ... + + +# @shiny.bookmark.set_restore_dir +# def connect_save_shiny_bookmark(): ... + + +# VV Don't use the next line style. It must be in the App() object! +# shiny.bookmark.globals.bookmark_store = "url" + +# shiny.run_app("foo") diff --git a/shiny/session/_session.py b/shiny/session/_session.py index b87bf5475..106657352 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -48,8 +48,9 @@ from ..bookmark._restore_state import RestoreContext from ..http_staticfiles import FileResponse from ..input_handler import input_handlers -from ..reactive import Effect_, Value, effect, isolate +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 diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index 9011694fa..9cbf0d74c 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -36,9 +36,9 @@ # 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). # Export busy_indicators module -from . import ( +from . import ( # noqa: F401 busy_indicators, - css, # noqa: F401 # pyright: ignore[reportUnusedImport] + css, # pyright: ignore[reportUnusedImport] fill, ) from ._accordion import ( From 79dd52ba38d88b83da27d7fcad4d3d04f72c8ea2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 7 Mar 2025 16:19:04 -0500 Subject: [PATCH 38/62] Remove comments and fix lints --- shiny/_app.py | 6 +-- shiny/bookmark/__init__.py | 4 +- shiny/bookmark/_bookmark.py | 2 +- shiny/bookmark/_restore_state.py | 2 +- shiny/bookmark/_types.py | 77 +------------------------------- 5 files changed, 6 insertions(+), 85 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index f73e04356..bd9098252 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -387,11 +387,7 @@ async def _on_root_request_cb(self, request: Request) -> Response: if callable(self.ui): ui = self._render_page(self.ui(request), self.lib_prefix) else: - # TODO: Barret - Q: Why is this here as there's a with restore_context above? - # TODO: Barret - Q: Why not `if restore_ctx.active:`? - cur_restore_ctx = get_current_restore_context() - print("cur_restore_ctx", cur_restore_ctx) - if cur_restore_ctx is not None and cur_restore_ctx.active: + if restore_ctx.active: # TODO: Barret - Docs: See ?enableBookmarking warnings.warn( "Trying to restore saved app state, but UI code must be a function for this to work!", diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index e483314fe..0db760fec 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -6,7 +6,7 @@ ShinySaveState, ) from ._button import input_bookmark_button -from ._external import set_load_dir, set_save_dir +from ._external import set_restore_dir, set_save_dir from ._restore_state import RestoreContext, RestoreContextState, restore_input __all__ = ( @@ -20,7 +20,7 @@ "input_bookmark_button", # _external "set_save_dir", - "set_load_dir", + "set_restore_dir", # _restore_state "RestoreContext", "RestoreContextState", diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 1d5ec1438..6968a4b17 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -589,7 +589,7 @@ async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: ) # Make subdir for scope - # TODO-barret; Is this for uploaded files?!? + # 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 diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index cc090f767..026933382 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -59,7 +59,7 @@ def _state_within_namespace(self, prefix: str) -> "RestoreContextState": if self._name_has_namespace(name, prefix) } - # TODO-barret; Is this for bookmarking?!? + # TODO: Barret; Is this for bookmarking?!? dir = self.dir if dir is not None: dir = dir / prefix diff --git a/shiny/bookmark/_types.py b/shiny/bookmark/_types.py index b155248b3..aa46b8a73 100644 --- a/shiny/bookmark/_types.py +++ b/shiny/bookmark/_types.py @@ -3,85 +3,10 @@ from pathlib import Path from typing import Awaitable, Callable, Literal -# TODO: Barret: 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. +# 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. - -# TODO: Barret Rename load -> restore. Keep the names consistent! GetBookmarkSaveDir = Callable[[str], Awaitable[Path]] GetBookmarkRestoreDir = Callable[[str], Awaitable[Path]] BookmarkStore = Literal["url", "server", "disable"] - - -# TODO: Barret; Q: I feel like there could be a `@shiny.globals.on_session_start` decorator that would allow us to set these values. - - -# @shiny.session.on_session_start -# def _(session): ... - - -# @shiny.session.on_session_started -# def _(session): ... - - -# shiny.bookmark.globals.bookmark_save_dir = connect_custom_method -# shiny.bookmark.globals.bookmark_load_dir = connect_custom_method - - -# shiny.bookmark.set_bookmark_save_dir(connect_custom_method) -# shiny.bookmark.set_bookmark_load_dir(connect_custom_method) - -# ------------ - -# import shiny - -# # Garrick like's this one... fix the name! -# @shiny.bookmark.bookmark_save_dir -# def connect_custom_method(id: str) -> Path: -# return Path("connect") / id - - -# shiny.run_app("some file") - -# Using a decorator - - -# from shiny.bookmark import _globals as bookmark_globals -# bookmark_globals.bookmark_store = "url" - -# bookmark.globals.bookmark_store = "url" - -# import shiny - -# TODO: Barret - Implement - -# # Make placeholders start with underscore -# shiny.bookmark.globals.bookmark_save_dir = connect_save_shiny_bookmark -# shiny.bookmark.globals.bookmark_load_dir = connect_restore_shiny_bookmark - -# # Implement this for now -# # Hold off on - -# # Global level -# @shiny.bookmark.set_save_dir -# def connect_save_shiny_bookmark(): ... - -# Don't implement this -# # App level -# @app.bookmark.set_save_dir -# def save_shiny_bookmark(): ... - - -# @app.bookmark.set_save_dir -# def save_shiny_bookmark(): ... - - -# @shiny.bookmark.set_restore_dir -# def connect_save_shiny_bookmark(): ... - - -# VV Don't use the next line style. It must be in the App() object! -# shiny.bookmark.globals.bookmark_store = "url" - -# shiny.run_app("foo") From 8be292d7191033268215f6e6d36214f3ccf6a955 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 7 Mar 2025 16:49:00 -0500 Subject: [PATCH 39/62] Add docs; Rename `ShinySaveState` -> `BookmarkState` --- docs/_quartodoc-core.yml | 18 +++++++ shiny/_app.py | 6 +-- shiny/bookmark/__init__.py | 9 ++-- shiny/bookmark/_bookmark.py | 58 ++++++++------------ shiny/bookmark/_button.py | 90 +++++++++++++------------------- shiny/bookmark/_restore_state.py | 10 ++-- shiny/bookmark/_save_state.py | 6 +-- shiny/express/ui/__init__.py | 2 + shiny/ui/_input_action_button.py | 1 + 9 files changed, 93 insertions(+), 107 deletions(-) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index ed225f878..5a9bee160 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -98,6 +98,24 @@ 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 + - session.Session.bookmark.exclude + - 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_save_dir + - bookmark.set_restore_dir - title: Chat interface desc: Build a chatbot interface contents: diff --git a/shiny/_app.py b/shiny/_app.py index bd9098252..9b40cc2f4 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -31,11 +31,7 @@ from ._error import ErrorMiddleware from ._shinyenv import is_pyodide from ._utils import guess_mime_type, is_async_callable, sort_keys_length -from .bookmark._restore_state import ( - RestoreContext, - get_current_restore_context, - restore_context, -) +from .bookmark._restore_state import RestoreContext, restore_context from .bookmark._types import BookmarkStore from .html_dependencies import jquery_deps, require_deps, shiny_deps from .http_staticfiles import FileResponse, StaticFiles diff --git a/shiny/bookmark/__init__.py b/shiny/bookmark/__init__.py index 0db760fec..733872f4d 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -3,15 +3,14 @@ BookmarkApp, BookmarkExpressStub, BookmarkProxy, - ShinySaveState, ) from ._button import input_bookmark_button from ._external import set_restore_dir, set_save_dir -from ._restore_state import RestoreContext, RestoreContextState, restore_input +from ._restore_state import RestoreContext, RestoreState, restore_input +from ._save_state import BookmarkState __all__ = ( # _bookmark - "ShinySaveState", "Bookmark", "BookmarkApp", "BookmarkProxy", @@ -23,6 +22,8 @@ "set_restore_dir", # _restore_state "RestoreContext", - "RestoreContextState", + "RestoreState", "restore_input", + # _save_state + "BookmarkState", ) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 6968a4b17..f781455d7 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -8,8 +8,8 @@ from .._utils import AsyncCallbacks, CancelCallback, wrap_async from ..types import MISSING, MISSING_TYPE from ._button import BOOKMARK_ID -from ._restore_state import RestoreContextState -from ._save_state import ShinySaveState +from ._restore_state import RestoreState +from ._save_state import BookmarkState from ._types import BookmarkStore # TODO: Barret - Bookmark state @@ -192,8 +192,7 @@ def _get_bookmark_exclude(self) -> list[str]: def on_bookmark( self, callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] ), /, ) -> CancelCallback: @@ -266,8 +265,7 @@ async def do_bookmark(self) -> None: def on_restore( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: """ @@ -281,8 +279,7 @@ def on_restore( def on_restored( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: """ @@ -402,8 +399,7 @@ def _get_bookmark_exclude(self) -> list[str]: def on_bookmark( self, callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] ), /, ) -> CancelCallback: @@ -419,8 +415,7 @@ def on_bookmarked( def on_restore( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: return self._on_restore_callbacks.register(wrap_async(callback)) @@ -428,8 +423,7 @@ def on_restore( def on_restored( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: return self._on_restored_callbacks.register(wrap_async(callback)) @@ -457,14 +451,14 @@ async def do_bookmark(self) -> None: try: # ?withLogErrors - from ..bookmark._bookmark import ShinySaveState + from ..bookmark._bookmark import BookmarkState from ..session import session_context - async def root_state_on_save(state: ShinySaveState) -> None: + async def root_state_on_save(state: BookmarkState) -> None: with session_context(self._session_root): await self._on_bookmark_callbacks.invoke(state) - root_state = ShinySaveState( + root_state = BookmarkState( input=self._session_root.input, exclude=self._get_bookmark_exclude(), on_save=root_state_on_save, @@ -541,7 +535,7 @@ def __init__(self, session_proxy: SessionProxy): # The goal of this method is to save the scope's values. All namespaced inputs # will already exist within the `root_state`. @self._root_bookmark.on_bookmark - async def scoped_on_bookmark(root_state: ShinySaveState) -> None: + async def scoped_on_bookmark(root_state: BookmarkState) -> None: return await self._scoped_on_bookmark(root_state) from ..session import session_context @@ -557,7 +551,7 @@ async def scoped_on_bookmarked(url: str) -> None: ns_prefix = str(self._ns + self._ns._sep) @self._root_bookmark.on_restore - async def scoped_on_restore(restore_state: RestoreContextState) -> None: + async def scoped_on_restore(restore_state: RestoreState) -> None: if self._on_restore_callbacks.count() == 0: return @@ -567,7 +561,7 @@ async def scoped_on_restore(restore_state: RestoreContextState) -> None: await self._on_restore_callbacks.invoke(scoped_restore_state) @self._root_bookmark.on_restored - async def scoped_on_restored(restore_state: RestoreContextState) -> None: + async def scoped_on_restored(restore_state: RestoreState) -> None: if self._on_restored_callbacks.count() == 0: return @@ -575,14 +569,14 @@ async def scoped_on_restored(restore_state: RestoreContextState) -> None: with session_context(self._session_proxy): await self._on_restored_callbacks.invoke(scoped_restore_state) - async def _scoped_on_bookmark(self, root_state: ShinySaveState) -> None: + 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 ShinySaveState + from ..bookmark._bookmark import BookmarkState - scoped_state = ShinySaveState( + scoped_state = BookmarkState( input=self._session_root.input, exclude=self._root_bookmark.exclude, on_save=None, @@ -621,8 +615,7 @@ def _create_effects(self) -> NoReturn: def on_bookmark( self, callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] ), /, ) -> CancelCallback: @@ -658,8 +651,7 @@ def store(self) -> BookmarkStore: def on_restore( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: return self._on_restore_callbacks.register(wrap_async(callback)) @@ -667,8 +659,7 @@ def on_restore( def on_restored( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> CancelCallback: return self._on_restored_callbacks.register(wrap_async(callback)) @@ -697,8 +688,7 @@ def _get_bookmark_exclude(self) -> NoReturn: def on_bookmark( self, callback: ( - Callable[[ShinySaveState], None] - | Callable[[ShinySaveState], Awaitable[None]] + Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] ), ) -> NoReturn: raise NotImplementedError( @@ -728,8 +718,7 @@ async def do_bookmark(self) -> NoReturn: def on_restore( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> NoReturn: raise NotImplementedError( @@ -739,8 +728,7 @@ def on_restore( def on_restored( self, callback: ( - Callable[[RestoreContextState], None] - | Callable[[RestoreContextState], Awaitable[None]] + Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), ) -> NoReturn: raise NotImplementedError( diff --git a/shiny/bookmark/_button.py b/shiny/bookmark/_button.py index 49c31ad20..4b5aa02cf 100644 --- a/shiny/bookmark/_button.py +++ b/shiny/bookmark/_button.py @@ -9,70 +9,50 @@ BOOKMARK_ID = "._bookmark_" -# ' Create a button for bookmarking/sharing -# ' -# ' A `bookmarkButton` is a [actionButton()] with a default label -# ' that consists of a link icon and the text "Bookmark...". It is meant to be -# ' used for bookmarking state. -# ' -# ' @inheritParams actionButton -# ' @param title A tooltip that is shown when the mouse cursor hovers over the -# ' button. -# ' @param 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 -# ' [setBookmarkExclude()], and you must create an observer that -# ' does the bookmarking when the button is pressed. See the examples below. -# ' -# ' @seealso [enableBookmarking()] for more examples. -# ' -# ' @examples -# ' ## Only run these examples in interactive sessions -# ' if (interactive()) { -# ' -# ' # This example shows how to use multiple bookmark buttons. If you only need -# ' # a single bookmark button, see examples in ?enableBookmarking. -# ' ui <- function(request) { -# ' fluidPage( -# ' tabsetPanel(id = "tabs", -# ' tabPanel("One", -# ' checkboxInput("chk1", "Checkbox 1"), -# ' bookmarkButton(id = "bookmark1") -# ' ), -# ' tabPanel("Two", -# ' checkboxInput("chk2", "Checkbox 2"), -# ' bookmarkButton(id = "bookmark2") -# ' ) -# ' ) -# ' ) -# ' } -# ' server <- function(input, output, session) { -# ' # Need to exclude the buttons from themselves being bookmarked -# ' setBookmarkExclude(c("bookmark1", "bookmark2")) -# ' -# ' # Trigger bookmarking with either button -# ' observeEvent(input$bookmark1, { -# ' session$doBookmark() -# ' }) -# ' observeEvent(input$bookmark2, { -# ' session$doBookmark() -# ' }) -# ' } -# ' enableBookmarking(store = "url") -# ' shinyApp(ui, server) -# ' } -# ' @export def input_bookmark_button( label: TagChild = "Bookmark...", *, icon: TagChild | MISSING_TYPE = MISSING, width: Optional[str] = None, disabled: bool = False, - # id: str = "._bookmark_", + id: str = BOOKMARK_ID, title: str = "Bookmark this application's state and get a URL for sharing.", **kwargs: TagAttrValue, ) -> Tag: - resolved_id = resolve_id(BOOKMARK_ID) + """ + 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("🔗") diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 026933382..7311d276a 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -16,7 +16,7 @@ from ._utils import from_json_str, is_hosted -class RestoreContextState: +class RestoreState: input: dict[str, Any] values: dict[str, Any] dir: Path | None @@ -41,7 +41,7 @@ def _un_namespace(self, name: str, prefix: str) -> str: return name.removeprefix(prefix) - def _state_within_namespace(self, prefix: str) -> "RestoreContextState": + def _state_within_namespace(self, prefix: str) -> "RestoreState": # Given a restore state object, return a modified version that's scoped to this # namespace. @@ -69,7 +69,7 @@ def _state_within_namespace(self, prefix: str) -> "RestoreContextState": # if not dir.exists(): # dir = None - return RestoreContextState(input=input, values=values, dir=dir) + return RestoreState(input=input, values=values, dir=dir) class RestoreContext: @@ -162,7 +162,7 @@ async def from_query_string(query_string: str) -> "RestoreContext": def flush_pending(self) -> None: self.input.flush_pending() - def as_state(self) -> RestoreContextState: + 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 @@ -170,7 +170,7 @@ def as_state(self) -> RestoreContextState: is a RestoreInputSet object, while the latter's `input` field is just a list. """ - return RestoreContextState( + return RestoreState( # Shallow copy input={**self.input.as_dict()}, # Shallow copy diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 85ed2c9ef..832c75a3c 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -23,7 +23,7 @@ Inputs = Any -class ShinySaveState: +class BookmarkState: # session: ? # * Would get us access to inputs, possibly app dir, registered on save / load classes (?), exclude # @@ -32,7 +32,7 @@ class ShinySaveState: exclude: list[str] # _bookmark_: A special value that is always excluded from the bookmark. on_save: ( - Callable[["ShinySaveState"], Awaitable[None]] | None + 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 @@ -43,7 +43,7 @@ def __init__( self, input: Inputs, exclude: list[str], - on_save: Callable[["ShinySaveState"], Awaitable[None]] | None, + on_save: Callable[["BookmarkState"], Awaitable[None]] | None, ): self.input = input self.exclude = exclude 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/ui/_input_action_button.py b/shiny/ui/_input_action_button.py index c3bc53746..22eb16584 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` """ From a6d314912c57cc7f81c04f50e0f2d772bdc58667 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Fri, 7 Mar 2025 16:55:41 -0500 Subject: [PATCH 40/62] Clean up TODOs --- shiny/bookmark/_bookmark.py | 11 ++--------- shiny/bookmark/_external.py | 2 ++ shiny/bookmark/_restore_state.py | 3 +-- shiny/bookmark/_save_state.py | 4 ---- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index f781455d7..e6d15f05b 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -83,7 +83,6 @@ class Bookmark(ABC): - # TODO: Barret - This feels like it needs to be a weakref _session_root: Session """ The root session object (most likely a `AppSession` object). @@ -145,7 +144,6 @@ def __init__(self, session_root: Session): # from ._restore_state import RestoreContext super().__init__() - # TODO: Barret - Q: Should this be a weakref; Session -> Bookmark -> Session self._session_root = session_root self._restore_context_value = None self._store = MISSING @@ -248,7 +246,6 @@ async def update_query_string( ... @abstractmethod - # TODO: Barret - Q: Rename to `update()`? `session.bookmark.update()`? async def do_bookmark(self) -> None: """ Perform bookmarking. @@ -521,7 +518,6 @@ def __init__(self, session_proxy: SessionProxy): super().__init__(session_proxy.root_scope()) self._ns = session_proxy.ns - # TODO: Barret - Q: Should this be a weakref self._session_proxy = session_proxy self._session_root.bookmark._proxy_exclude_fns.append( @@ -625,11 +621,8 @@ def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], /, - ) -> NoReturn: - # TODO: Barret - Q: Shouldn't we implement this? `self._root_bookmark.on_bookmark()` - raise NotImplementedError( - "Please call `.on_bookmarked()` from the root session only, e.g. `session.root_scope().bookmark.on_bookmark()`." - ) + ) -> CancelCallback: + return self._on_bookmarked_callbacks.register(wrap_async(callback)) def _get_bookmark_exclude(self) -> NoReturn: raise NotImplementedError( diff --git a/shiny/bookmark/_external.py b/shiny/bookmark/_external.py index 6df5b4fe7..3cd8e2546 100644 --- a/shiny/bookmark/_external.py +++ b/shiny/bookmark/_external.py @@ -37,6 +37,8 @@ bound=Callable[[str], Awaitable[Path]] | Callable[[str], Awaitable[Path]], ) +# TODO: Barret - Integrate Set / Restore for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 + def set_save_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT: """TODO: Barret document""" diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 7311d276a..bdc763b89 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -59,7 +59,7 @@ def _state_within_namespace(self, prefix: str) -> "RestoreState": if self._name_has_namespace(name, prefix) } - # TODO: Barret; Is this for bookmarking?!? + # TODO: Barret; Is this for file inputs?!? dir = self.dir if dir is not None: dir = dir / prefix @@ -194,7 +194,6 @@ async def _load_state_qs(self, query_string: str) -> None: if load_bookmark_fn is None: if is_hosted(): - # TODO: Barret; Implement Connect's `bookmark_load_dir` function raise NotImplementedError( "The hosting environment does not support server-side bookmarking." ) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 832c75a3c..d1b04890e 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -13,10 +13,6 @@ from ._types import GetBookmarkSaveDir from ._utils import is_hosted, to_json_str -# TODO: Barret - Set / Load SaveState for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 -# Might need to have independent save/load functions to register to avoid a class constructor - - if TYPE_CHECKING: from .. import Inputs else: From c7a3a3b8db1dfb94a142462336021eebf37556a4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 10 Mar 2025 11:30:56 -0400 Subject: [PATCH 41/62] =?UTF-8?q?Get=20express=20mode=20to=20work!=20?= =?UTF-8?q?=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired #1895 --- shiny/_app.py | 8 ------- shiny/bookmark/_bookmark.py | 45 +++++++++++++------------------------ shiny/express/_run.py | 32 ++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index 9b40cc2f4..fedd0dbc8 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -371,14 +371,6 @@ async def _on_root_request_cb(self, request: Request) -> Response: else: restore_ctx = await RestoreContext.from_query_string(request.url.query) - print( - "Restored state", - { - "values": restore_ctx.as_state().values, - "input": restore_ctx.as_state().input, - }, - ) - with restore_context(restore_ctx): if callable(self.ui): ui = self._render_page(self.ui(request), self.lib_prefix) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index e6d15f05b..735d98c75 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -662,11 +662,6 @@ class BookmarkExpressStub(Bookmark): def __init__(self, session_root: ExpressStubSession) -> None: super().__init__(session_root) - self._proxy_exclude_fns = [] - self._on_bookmark_callbacks = AsyncCallbacks() - self._on_bookmarked_callbacks = AsyncCallbacks() - self._on_restore_callbacks = AsyncCallbacks() - self._on_restored_callbacks = AsyncCallbacks() def _create_effects(self) -> NoReturn: raise NotImplementedError( @@ -683,50 +678,42 @@ def on_bookmark( callback: ( Callable[[BookmarkState], None] | Callable[[BookmarkState], Awaitable[None]] ), - ) -> NoReturn: - raise NotImplementedError( - "Please call `.on_bookmark()` only from a real session object" - ) + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], - ) -> NoReturn: - raise NotImplementedError( - "Please call `.on_bookmarked()` only from a real session object" - ) + ) -> 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" - ) -> NoReturn: - raise NotImplementedError( - "Please call `.update_query_string()` only from a real session object" - ) + ) -> None: + return None - async def do_bookmark(self) -> NoReturn: - raise NotImplementedError( - "Please call `.do_bookmark()` only from a real session object" - ) + async def do_bookmark(self) -> None: + return None def on_restore( self, callback: ( Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), - ) -> NoReturn: - raise NotImplementedError( - "Please call `.on_restore()` only from a real session object" - ) + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None def on_restored( self, callback: ( Callable[[RestoreState], None] | Callable[[RestoreState], Awaitable[None]] ), - ) -> NoReturn: - raise NotImplementedError( - "Please call `.on_restored()` only from a real session object" - ) + ) -> CancelCallback: + # Provide a no-op function within ExpressStub + return lambda: None # #' Generate a modal dialog that displays a URL diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 83f4b91c9..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,12 +303,15 @@ def __getattr__(self, name: str): class AppOpts(TypedDict): static_assets: NotRequired[dict[str, Path]] + bookmark_store: NotRequired[BookmarkStore] debug: NotRequired[bool] @no_example() 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, ): """ @@ -313,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. """ @@ -339,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 @@ -357,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"] From 59e2caea62cb4143ee5bfd15e236af1eedf9580c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 10 Mar 2025 13:58:41 -0400 Subject: [PATCH 42/62] Add tests for bookmarking Coverage: * core / express * modules only (global session will be tested later) * store: url / server --- .../shiny/bookmark/modules/app-express.py | 121 ++++++++++++++++ .../playwright/shiny/bookmark/modules/app.py | 137 ++++++++++++++++++ .../bookmark/modules/test_bookmark_modules.py | 65 +++++++++ .../components/card-input/test_card-input.py | 51 ++++--- 4 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 tests/playwright/shiny/bookmark/modules/app-express.py create mode 100644 tests/playwright/shiny/bookmark/modules/app.py create mode 100644 tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py 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..30b09e6ef --- /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, input, 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/app.py b/tests/playwright/shiny/bookmark/modules/app.py new file mode 100644 index 000000000..b685104e7 --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/app.py @@ -0,0 +1,137 @@ +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/test_bookmark_modules.py b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py new file mode 100644 index 000000000..422a13323 --- /dev/null +++ b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py @@ -0,0 +1,65 @@ +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", ["app-express.py", "app.py"]) +@pytest.mark.parametrize("bookmark_store", ["url", "server"]) +def test_bookmark_modules(page: Page, bookmark_store: str, app_name: str): + + # 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", ["a", "a", "a", "a"]) + expect_mod("mod1", ["a", "a", "a", "a"]) + + set_mod("mod0", ["b", "b", "c", "c"]) + + expect_mod("mod0", ["b", "b", "c", "c"]) + expect_mod("mod1", ["a", "a", "a", "a"]) + + page.reload() + + expect_mod("mod0", ["b", "b", "c", "c"]) + expect_mod("mod1", ["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() From 0fd3921019a89c92857ea348c95cfc113db3a40b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 10 Mar 2025 15:52:33 -0400 Subject: [PATCH 43/62] Add some examples (not finished) --- docs/_quartodoc-core.yml | 1 - .../input_bookmark_button/app-core.py | 33 ++++++++++++ .../input_bookmark_button/app-express.py | 23 +++++++++ shiny/api-examples/restore_input/app.py | 51 +++++++++++++++++++ shiny/bookmark/_button.py | 2 + shiny/playwright/controller/_navs.py | 3 +- shiny/playwright/controller/_output.py | 3 +- shiny/session/_session.py | 4 +- shiny/ui/_input_task_button.py | 3 +- 9 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 shiny/api-examples/input_bookmark_button/app-core.py create mode 100644 shiny/api-examples/input_bookmark_button/app-express.py create mode 100644 shiny/api-examples/restore_input/app.py diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 5a9bee160..39346c21e 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -104,7 +104,6 @@ quartodoc: - ui.input_bookmark_button - bookmark.restore_input - bookmark.Bookmark - - session.Session.bookmark.exclude - bookmark.BookmarkState - bookmark.RestoreState - kind: page 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..4c539b6a7 --- /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, reactive, render, 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..e1f4495bb --- /dev/null +++ b/shiny/api-examples/input_bookmark_button/app-express.py @@ -0,0 +1,23 @@ +from starlette.requests import Request + +from shiny import reactive, render +from shiny.express import app_opts, input, 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..6adc03bc2 --- /dev/null +++ b/shiny/api-examples/restore_input/app.py @@ -0,0 +1,51 @@ +from htmltools import tags +from starlette.requests import Request + +from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny._namespaces import resolve_id + + +def custom_input_text( + id: str, + value: str = "", +) -> Tag: + + resolved_id = resolve_id(id) + return tags.div( + "Custom input text:", + tags.input( + id=resolve_id(id), + type="text", + value=value, + placeholder="Type here...", + ), + class_="shiny-input-container", + style=css(width=width), + ) + + +# 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="Default value - Hello, world!"), + ui.input_bookmark_button(), + # 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): + + @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/bookmark/_button.py b/shiny/bookmark/_button.py index 4b5aa02cf..2a4deccea 100644 --- a/shiny/bookmark/_button.py +++ b/shiny/bookmark/_button.py @@ -2,6 +2,7 @@ from htmltools import HTML, Tag, TagAttrValue, TagChild +from .._docstring import add_example from .._namespaces import resolve_id from ..types import MISSING, MISSING_TYPE from ..ui._input_action_button import input_action_button @@ -9,6 +10,7 @@ BOOKMARK_ID = "._bookmark_" +@add_example() def input_bookmark_button( label: TagChild = "Bookmark...", *, 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 106657352..e32d99641 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -64,10 +64,9 @@ from ._utils import RenderedDeps, read_thunk_opt, session_context if TYPE_CHECKING: - from shiny.bookmark._serializers import Unserializable - from .._app import App from ..bookmark import Bookmark + from ..bookmark._serializers import Unserializable class ConnectionState(enum.Enum): @@ -1457,6 +1456,7 @@ async def _serialize( # 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}" ): diff --git a/shiny/ui/_input_task_button.py b/shiny/ui/_input_task_button.py index 76229e915..e82b4ed74 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 .._namespaces import resolve_id from .._typing_extensions import ParamSpec 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 From 4199f29922ca3e226c9abe882c4b04db451e2936 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 12:46:31 -0400 Subject: [PATCH 44/62] Followup from #1898 --- shiny/bookmark/_bookmark.py | 4 ++-- shiny/bookmark/_button.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 735d98c75..82dcfca4b 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -60,11 +60,11 @@ if TYPE_CHECKING: - from .._namespaces import ResolvedId from ..express._stub_session import ExpressStubSession + from ..module import ResolvedId from ..session import Session from ..session._session import SessionProxy - from ._restore_state import RestoreContext + from . import RestoreContext else: from typing import Any diff --git a/shiny/bookmark/_button.py b/shiny/bookmark/_button.py index 2a4deccea..0ccfcc575 100644 --- a/shiny/bookmark/_button.py +++ b/shiny/bookmark/_button.py @@ -3,7 +3,7 @@ from htmltools import HTML, Tag, TagAttrValue, TagChild from .._docstring import add_example -from .._namespaces import resolve_id +from ..module import resolve_id from ..types import MISSING, MISSING_TYPE from ..ui._input_action_button import input_action_button From a50860e21c5a86af723e757f04829fa4457e2549 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 16:24:51 -0400 Subject: [PATCH 45/62] Allow for App to set bookmark save/restore dir functions --- shiny/_app.py | 40 +++++++++-- shiny/bookmark/__init__.py | 6 +- shiny/bookmark/_bookmark.py | 2 +- shiny/bookmark/{_external.py => _global.py} | 53 ++++++++------ shiny/bookmark/_restore_state.py | 21 +++--- shiny/bookmark/_save_state.py | 12 ++-- shiny/bookmark/_types.py | 8 ++- shiny/session/_session.py | 3 +- .../playwright/shiny/bookmark/dir/.gitignore | 1 + .../playwright/shiny/bookmark/dir/app-attr.py | 70 ++++++++++++++++++ .../shiny/bookmark/dir/app-global.py | 72 +++++++++++++++++++ .../bookmark/dir/test_bookmark_global.py | 42 +++++++++++ 12 files changed, 277 insertions(+), 53 deletions(-) rename shiny/bookmark/{_external.py => _global.py} (50%) create mode 100644 tests/playwright/shiny/bookmark/dir/.gitignore create mode 100644 tests/playwright/shiny/bookmark/dir/app-attr.py create mode 100644 tests/playwright/shiny/bookmark/dir/app-global.py create mode 100644 tests/playwright/shiny/bookmark/dir/test_bookmark_global.py diff --git a/shiny/_app.py b/shiny/_app.py index fedd0dbc8..340f1ef52 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -31,8 +31,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 BookmarkStore +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 @@ -109,12 +116,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 - @property - def bookmark_store(self) -> BookmarkStore: - return self._bookmark_store - def __init__( self, ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path, @@ -144,7 +149,7 @@ def __init__( "`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)" ) - self._bookmark_store = bookmark_store + self._init_bookmarking(bookmark_store=bookmark_store) self._debug: bool = debug @@ -369,7 +374,9 @@ async def _on_root_request_cb(self, request: Request) -> Response: if self.bookmark_store == "disable": restore_ctx = RestoreContext() else: - restore_ctx = await RestoreContext.from_query_string(request.url.query) + restore_ctx = await RestoreContext.from_query_string( + request.url.query, app=self + ) with restore_context(restore_ctx): if callable(self.ui): @@ -492,6 +499,25 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML: return rendered + # ========================================================================== + # Bookmarking + # ========================================================================== + + def _init_bookmarking(self, *, bookmark_store: BookmarkStore) -> 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 + + @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/bookmark/__init__.py b/shiny/bookmark/__init__.py index 733872f4d..c74ffd6e0 100644 --- a/shiny/bookmark/__init__.py +++ b/shiny/bookmark/__init__.py @@ -5,7 +5,7 @@ BookmarkProxy, ) from ._button import input_bookmark_button -from ._external import set_restore_dir, set_save_dir +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 @@ -18,8 +18,8 @@ # _button "input_bookmark_button", # _external - "set_save_dir", - "set_restore_dir", + "set_global_save_dir_fn", + "set_global_restore_dir_fn", # _restore_state "RestoreContext", "RestoreState", diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 82dcfca4b..cb3422822 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -462,7 +462,7 @@ async def root_state_on_save(state: BookmarkState) -> None: ) if self.store == "server": - query_string = await root_state._save_state() + query_string = await root_state._save_state(app=self._session_root.app) elif self.store == "url": query_string = await root_state._encode_state() # # Can we have browser storage? diff --git a/shiny/bookmark/_external.py b/shiny/bookmark/_global.py similarity index 50% rename from shiny/bookmark/_external.py rename to shiny/bookmark/_global.py index 3cd8e2546..41be0b737 100644 --- a/shiny/bookmark/_external.py +++ b/shiny/bookmark/_global.py @@ -1,14 +1,14 @@ from __future__ import annotations -from pathlib import Path -from typing import Awaitable, Callable, Literal, TypeVar +from typing import overload from .._utils import wrap_async -from ..types import MISSING, MISSING_TYPE -from ._types import GetBookmarkRestoreDir, GetBookmarkSaveDir - -BookmarkStore = Literal["url", "server", "disable"] - +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 @@ -17,40 +17,51 @@ # The set methods below are used to set the save_dir and restore_dir locations for locations like Connect or SSP. # Ex: # ```python -# @shiny.bookmark.set_save_dir +# @shiny.bookmark.set_global_save_dir_fn # def connect_save_shiny_bookmark(id: str) -> Path: # path = Path("connect") / id # path.mkdir(parents=True, exist_ok=True) # return path -# @shiny.bookmark.set_restore_dir +# @shiny.bookmark.set_global_restore_dir_fn # def connect_restore_shiny_bookmark(id: str) -> Path: # return Path("connect") / id # ``` -_bookmark_save_dir: GetBookmarkSaveDir | MISSING_TYPE = MISSING -_bookmark_restore_dir: GetBookmarkRestoreDir | MISSING_TYPE = MISSING +bookmark_save_dir: BookmarkSaveDirFn | None = None +bookmark_restore_dir: BookmarkRestoreDirFn | None = None -GetBookmarkDirT = TypeVar( - "GetBookmarkDirT", - bound=Callable[[str], Awaitable[Path]] | Callable[[str], Awaitable[Path]], -) +@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) + # TODO: Barret - Integrate Set / Restore for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 -def set_save_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT: +def set_global_save_dir_fn(fn: BookmarkDirFn): """TODO: Barret document""" - global _bookmark_save_dir + global bookmark_save_dir - _bookmark_save_dir = wrap_async(fn) + bookmark_save_dir = as_bookmark_dir_fn(fn) return fn -def set_restore_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT: +def set_global_restore_dir_fn(fn: BookmarkDirFn): """TODO: Barret document""" - global _bookmark_restore_dir + global bookmark_restore_dir - _bookmark_restore_dir = wrap_async(fn) + bookmark_restore_dir = as_bookmark_dir_fn(fn) return fn diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index bdc763b89..930837463 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -5,15 +5,15 @@ from contextlib import contextmanager from contextvars import ContextVar, Token from pathlib import Path -from typing import Any, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional from urllib.parse import parse_qs, parse_qsl -from shiny.types import MISSING_TYPE - -from . import _external as bookmark_external from ._bookmark_state import local_restore_dir -from ._types import GetBookmarkRestoreDir from ._utils import from_json_str, is_hosted +from ._types import BookmarkRestoreDirFn + +if TYPE_CHECKING: + from .._app import App class RestoreState: @@ -105,7 +105,7 @@ def reset(self) -> None: self.dir = None @staticmethod - async def from_query_string(query_string: str) -> "RestoreContext": + async def from_query_string(query_string: str, *, app: App) -> "RestoreContext": res_ctx = RestoreContext() if query_string.startswith("?"): @@ -128,7 +128,7 @@ async def from_query_string(query_string: str) -> "RestoreContext": # 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) + await res_ctx._load_state_qs(query_string, app=app) else: # The query string contains the saved keys and values @@ -178,7 +178,7 @@ def as_state(self) -> RestoreState: dir=self.dir, ) - async def _load_state_qs(self, query_string: str) -> None: + 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) @@ -188,9 +188,7 @@ async def _load_state_qs(self, query_string: str) -> None: id = id[0] - load_bookmark_fn: GetBookmarkRestoreDir | None = None - if not isinstance(bookmark_external._bookmark_restore_dir, MISSING_TYPE): - load_bookmark_fn = bookmark_external._bookmark_restore_dir + load_bookmark_fn: BookmarkRestoreDirFn | None = app._bookmark_restore_dir_fn if load_bookmark_fn is None: if is_hosted(): @@ -207,6 +205,7 @@ async def _load_state_qs(self, query_string: str) -> None: if not self.dir.exists(): raise ValueError("Bookmarked state directory does not exist.") + # TODO: Barret; Store/restore as JSON with open(self.dir / "input.pickle", "rb") as f: input_values = pickle.load(f) self.input = RestoreInputSet(input_values) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index d1b04890e..dc147bf2b 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -7,13 +7,13 @@ from .._utils import private_random_id from ..reactive import isolate -from ..types import MISSING_TYPE -from . import _external as bookmark_external from ._bookmark_state import local_save_dir -from ._types import GetBookmarkSaveDir from ._utils import is_hosted, to_json_str +from ._types import BookmarkSaveDirFn if TYPE_CHECKING: + from shiny._app import App + from .. import Inputs else: Inputs = Any @@ -56,7 +56,7 @@ async def _call_on_save(self): with isolate(): await self.on_save(self) - async def _save_state(self) -> str: + async def _save_state(self, *, app: App) -> str: """ Save a state to disk (pickle). @@ -72,9 +72,7 @@ async def _save_state(self) -> str: # to `self.dir`. # This will be defined by the hosting environment if it supports bookmarking. - save_bookmark_fn: GetBookmarkSaveDir | None = None - if not isinstance(bookmark_external._bookmark_save_dir, MISSING_TYPE): - save_bookmark_fn = bookmark_external._bookmark_save_dir + save_bookmark_fn: BookmarkSaveDirFn | None = app._bookmark_save_dir_fn if save_bookmark_fn is None: if is_hosted(): diff --git a/shiny/bookmark/_types.py b/shiny/bookmark/_types.py index aa46b8a73..1c4360708 100644 --- a/shiny/bookmark/_types.py +++ b/shiny/bookmark/_types.py @@ -5,8 +5,12 @@ # 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. -GetBookmarkSaveDir = Callable[[str], Awaitable[Path]] -GetBookmarkRestoreDir = Callable[[str], Awaitable[Path]] + +BookmarkDirFn = 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/session/_session.py b/shiny/session/_session.py index d5e3642f8..98011888e 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -641,7 +641,8 @@ def verify_state(expected_state: ConnectionState) -> None: if ".clientdata_url_search" in message_obj["data"]: self.bookmark._restore_context_value = ( await RestoreContext.from_query_string( - message_obj["data"][".clientdata_url_search"] + message_obj["data"][".clientdata_url_search"], + app=self.app, ) ) else: 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..53b8d13d1 --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/app-attr.py @@ -0,0 +1,70 @@ +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 + +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..c5ce2af67 --- /dev/null +++ b/tests/playwright/shiny/bookmark/dir/app-global.py @@ -0,0 +1,72 @@ +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 + +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() From 5302f900aca7c64cef81422894f9103c4df462e6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 16:25:20 -0400 Subject: [PATCH 46/62] lints --- .../api-examples/input_bookmark_button/app-core.py | 2 +- .../input_bookmark_button/app-express.py | 5 +---- shiny/api-examples/restore_input/app.py | 13 +++++++------ .../shiny/bookmark/modules/app-express.py | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/shiny/api-examples/input_bookmark_button/app-core.py b/shiny/api-examples/input_bookmark_button/app-core.py index 4c539b6a7..5c1eaef15 100644 --- a/shiny/api-examples/input_bookmark_button/app-core.py +++ b/shiny/api-examples/input_bookmark_button/app-core.py @@ -1,6 +1,6 @@ from starlette.requests import Request -from shiny import App, Inputs, Outputs, Session, reactive, render, ui +from shiny import App, Inputs, Outputs, Session, ui # App UI **must** be a function to ensure that each user restores their own UI values. diff --git a/shiny/api-examples/input_bookmark_button/app-express.py b/shiny/api-examples/input_bookmark_button/app-express.py index e1f4495bb..1221fc997 100644 --- a/shiny/api-examples/input_bookmark_button/app-express.py +++ b/shiny/api-examples/input_bookmark_button/app-express.py @@ -1,7 +1,4 @@ -from starlette.requests import Request - -from shiny import reactive, render -from shiny.express import app_opts, input, session, ui +from shiny.express import app_opts, session, ui app_opts(bookmark_store="url") diff --git a/shiny/api-examples/restore_input/app.py b/shiny/api-examples/restore_input/app.py index 6adc03bc2..d43dd8683 100644 --- a/shiny/api-examples/restore_input/app.py +++ b/shiny/api-examples/restore_input/app.py @@ -1,26 +1,27 @@ from htmltools import tags from starlette.requests import Request -from shiny import App, Inputs, Outputs, Session, reactive, render, ui -from shiny._namespaces import resolve_id +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 = "", -) -> Tag: +) -> ui.Tag: resolved_id = resolve_id(id) return tags.div( "Custom input text:", tags.input( - id=resolve_id(id), + id=resolved_id, type="text", - value=value, + value=restore_input(resolved_id, value), placeholder="Type here...", ), class_="shiny-input-container", - style=css(width=width), + style=ui.css(width="400px"), ) diff --git a/tests/playwright/shiny/bookmark/modules/app-express.py b/tests/playwright/shiny/bookmark/modules/app-express.py index 30b09e6ef..8733cce22 100644 --- a/tests/playwright/shiny/bookmark/modules/app-express.py +++ b/tests/playwright/shiny/bookmark/modules/app-express.py @@ -4,7 +4,7 @@ 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, input, module, session, ui +from shiny.express import app_opts, module, session, ui SHINY_BOOKMARK_STORE: Literal["url", "server"] = os.getenv( "SHINY_BOOKMARK_STORE", "url" From 5b2db2affc55bea3cd8430ba5e42e7ea679f08f7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 16:25:46 -0400 Subject: [PATCH 47/62] Revamp is_hosted() -> in_shiny_server() --- shiny/bookmark/_restore_state.py | 4 ++-- shiny/bookmark/_save_state.py | 4 ++-- shiny/bookmark/_utils.py | 16 ++++++---------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 930837463..f2da506d1 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -9,8 +9,8 @@ from urllib.parse import parse_qs, parse_qsl from ._bookmark_state import local_restore_dir -from ._utils import from_json_str, is_hosted from ._types import BookmarkRestoreDirFn +from ._utils import from_json_str, in_shiny_server if TYPE_CHECKING: from .._app import App @@ -191,7 +191,7 @@ async def _load_state_qs(self, query_string: str, *, app: App) -> None: load_bookmark_fn: BookmarkRestoreDirFn | None = app._bookmark_restore_dir_fn if load_bookmark_fn is None: - if is_hosted(): + if in_shiny_server(): raise NotImplementedError( "The hosting environment does not support server-side bookmarking." ) diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index dc147bf2b..80ded4492 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -8,8 +8,8 @@ from .._utils import private_random_id from ..reactive import isolate from ._bookmark_state import local_save_dir -from ._utils import is_hosted, to_json_str from ._types import BookmarkSaveDirFn +from ._utils import in_shiny_server, to_json_str if TYPE_CHECKING: from shiny._app import App @@ -75,7 +75,7 @@ async def _save_state(self, *, app: App) -> str: save_bookmark_fn: BookmarkSaveDirFn | None = app._bookmark_save_dir_fn if save_bookmark_fn is None: - if is_hosted(): + if in_shiny_server(): # TODO: Barret; Implement `bookmark_save_dir` for Connect raise NotImplementedError( "The hosting environment does not support server-side bookmarking." diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py index ec531874a..472e5d5d9 100644 --- a/shiny/bookmark/_utils.py +++ b/shiny/bookmark/_utils.py @@ -6,17 +6,13 @@ import orjson -def is_hosted() -> bool: - # Can't look at SHINY_PORT, as we already set it in shiny/_main.py's `run_app()` +# 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 - # TODO: Barret: Q: How to support shinyapps.io? Or use `SHINY_PORT` how R-shiny did - - # Instead, looking for the presence of the environment variable that Connect sets - # (*_Connect) or Shiny Server sets (SHINY_APP) - for env_var in ("POSIT_CONNECT", "RSTUDIO_CONNECT", "SHINY_APP"): - if env_var in os.environ: - return True - return False + return True def to_json_str(x: Any) -> str: From 1daae23e5a575e20ca7f1c7a53829616a3f753d9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 16:26:02 -0400 Subject: [PATCH 48/62] Add warning for when bookmark is requested but it is disabled --- shiny/bookmark/_bookmark.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index cb3422822..7ae46eab9 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -444,6 +444,16 @@ async def update_query_string( 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: From 5192884947cfc6090579022da4ad0333513847d6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 11 Mar 2025 17:50:10 -0400 Subject: [PATCH 49/62] First pass of using `._session._parent.bookmark` instead of `._root_bookmark`; "Exclude" todo remaining --- shiny/bookmark/_bookmark.py | 206 +++++++++++++++++++------------ shiny/bookmark/_restore_state.py | 2 +- shiny/bookmark/_save_state.py | 5 +- shiny/session/_session.py | 28 +++-- 4 files changed, 144 insertions(+), 97 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 7ae46eab9..5578a47e8 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn from .._utils import AsyncCallbacks, CancelCallback, wrap_async -from ..types import MISSING, MISSING_TYPE from ._button import BOOKMARK_ID from ._restore_state import RestoreState from ._save_state import BookmarkState @@ -62,41 +61,33 @@ if TYPE_CHECKING: from ..express._stub_session import ExpressStubSession from ..module import ResolvedId - from ..session import Session - from ..session._session import SessionProxy + from ..session._session import AppSession, SessionProxy from . import RestoreContext else: from typing import Any RestoreContext = Any - Session = Any SessionProxy = Any + AppSession = Any ResolvedId = Any ExpressStubSession = Any -# TODO: future - Local storage Bookmark class! -# * Needs a consistent id for storage. -# * Needs ways to clean up other storage -# * Needs ways to see available IDs - - class Bookmark(ABC): - _session_root: Session - """ - The root session object (most likely a `AppSession` object). - """ - - _store: BookmarkStore | MISSING_TYPE - """ - Session specific bookmark store value. + _proxy_exclude_fns: 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.""" - This value could help determine how session state is saved. However, 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. - """ + _on_bookmark_callbacks: AsyncCallbacks + _on_bookmarked_callbacks: AsyncCallbacks + _on_restore_callbacks: AsyncCallbacks + _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 @@ -106,47 +97,31 @@ def store(self) -> BookmarkStore: * `"server"`: Save / reload the bookmark state on the server. * `"disable"` (default): Bookmarking is diabled. """ - - # Read from the App's bookmark store value. - return self._session_root.app.bookmark_store - - _proxy_exclude_fns: 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 - - _restore_context_value: RestoreContext | None - """ - Placeholder value that should only be manually set within the session's `init` websocket message. - """ + ... @property + @abstractmethod def _restore_context(self) -> RestoreContext | None: """ A read-only value of the session's RestoreContext object. """ - return self._root_bookmark._restore_context_value + ... - async def __call__(self) -> None: - await self._root_bookmark.do_bookmark() + @abstractmethod + def _set_restore_context(self, restore_context: RestoreContext): + """ + Set the session's RestoreContext object. - @property - def _root_bookmark(self) -> "Bookmark": - """The base session's bookmark object.""" - return self._session_root.bookmark + This should only be done within the `init` websocket message. + """ + ... - def __init__(self, session_root: Session): - # from ._restore_state import RestoreContext + async def __call__(self) -> None: + await self.do_bookmark() + + def __init__(self): super().__init__() - self._session_root = session_root - self._restore_context_value = None - self._store = MISSING self._proxy_exclude_fns = [] self.exclude = [] @@ -288,8 +263,47 @@ def on_restored( class BookmarkApp(Bookmark): - def __init__(self, session_root: Session): - super().__init__(session_root) + _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: + """ + 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. + """ + + return self._session.app.bookmark_store + + @property + def _restore_context(self) -> RestoreContext | None: + """ + A read-only value of the session's RestoreContext object. + """ + return self._restore_context_value + + def _set_restore_context(self, restore_context: RestoreContext): + self._restore_context_value = restore_context def _create_effects(self) -> None: """ @@ -305,7 +319,7 @@ def _create_effects(self) -> None: if self.store == "disable": return - session = self._session_root + session = self._session from .. import reactive from ..session import session_context @@ -432,7 +446,7 @@ async def update_query_string( ) -> None: if mode not in {"replace", "push"}: raise ValueError(f"Invalid mode: {mode}") - await self._session_root._send_message( + await self._session._send_message( { "updateQueryString": { "queryString": query_string, @@ -462,17 +476,17 @@ async def do_bookmark(self) -> None: from ..session import session_context async def root_state_on_save(state: BookmarkState) -> None: - with session_context(self._session_root): + with session_context(self._session): await self._on_bookmark_callbacks.invoke(state) root_state = BookmarkState( - input=self._session_root.input, + 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_root.app) + 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? @@ -484,7 +498,7 @@ async def root_state_on_save(state: BookmarkState) -> None: else: raise ValueError("Unknown bookmark store: " + self.store) - clientdata = self._session_root.clientdata + clientdata = self._session.clientdata port = str(clientdata.url_port()) full_url = "".join( @@ -503,7 +517,7 @@ async def root_state_on_save(state: BookmarkState) -> None: # If onBookmarked callback was provided, invoke it; if not call # the default. if self._on_bookmarked_callbacks.count() > 0: - with session_context(self._session_root): + with session_context(self._session): await self._on_bookmarked_callbacks.invoke(full_url) else: # `session.bookmark.show_modal(url)` @@ -523,14 +537,20 @@ async def root_state_on_save(state: BookmarkState) -> None: class BookmarkProxy(Bookmark): _ns: ResolvedId + _session: SessionProxy def __init__(self, session_proxy: SessionProxy): - super().__init__(session_proxy.root_scope()) + from ..session._session import SessionProxy + + assert isinstance(session_proxy, SessionProxy) + super().__init__() self._ns = session_proxy.ns - self._session_proxy = session_proxy + self._session = session_proxy - self._session_root.bookmark._proxy_exclude_fns.append( + # TODO: Barret - This isn't getting to the root + # Maybe `._get_bookmark_exclude()` should be used instead of`proxy_exclude_fns`? + self._session._parent.bookmark._proxy_exclude_fns.append( lambda: [str(self._ns(name)) for name in self.exclude] ) @@ -540,39 +560,39 @@ def __init__(self, session_proxy: SessionProxy): # The goal of this method is to save the scope's values. All namespaced inputs # will already exist within the `root_state`. - @self._root_bookmark.on_bookmark + @self._session._parent.bookmark.on_bookmark async def scoped_on_bookmark(root_state: BookmarkState) -> None: return await self._scoped_on_bookmark(root_state) from ..session import session_context - @self._root_bookmark.on_bookmarked + @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_proxy): + with session_context(self._session): await self._on_bookmarked_callbacks.invoke(url) ns_prefix = str(self._ns + self._ns._sep) - @self._root_bookmark.on_restore + @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_proxy): + with session_context(self._session): await self._on_restore_callbacks.invoke(scoped_restore_state) - @self._root_bookmark.on_restored + @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_proxy): + with session_context(self._session): await self._on_restored_callbacks.invoke(scoped_restore_state) async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None: @@ -583,8 +603,8 @@ async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None: from ..bookmark._bookmark import BookmarkState scoped_state = BookmarkState( - input=self._session_root.input, - exclude=self._root_bookmark.exclude, + input=self._session.input, + exclude=self.exclude, on_save=None, ) @@ -603,7 +623,7 @@ async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None: # Invoke the callback on the scopeState object from ..session import session_context - with session_context(self._session_proxy): + with session_context(self._session): await self._on_bookmark_callbacks.invoke(scoped_state) # Copy `values` from scoped_state to root_state (adding namespace) @@ -613,6 +633,19 @@ async def _scoped_on_bookmark(self, root_state: BookmarkState) -> None: 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 + + def _set_restore_context(self, restore_context: RestoreContext) -> NoReturn: + raise NotImplementedError( + "The `RestoreContext` should only be set on the root session object." + ) + def _create_effects(self) -> NoReturn: raise NotImplementedError( "Please call `._create_effects()` from the root session only." @@ -635,6 +668,7 @@ def on_bookmarked( return self._on_bookmarked_callbacks.register(wrap_async(callback)) def _get_bookmark_exclude(self) -> NoReturn: + raise NotImplementedError( "Please call `._get_bookmark_exclude()` from the root session only." ) @@ -642,14 +676,10 @@ def _get_bookmark_exclude(self) -> NoReturn: async def update_query_string( self, query_string: str, mode: Literal["replace", "push"] = "replace" ) -> None: - await self._root_bookmark.update_query_string(query_string, mode) + await self._session._parent.bookmark.update_query_string(query_string, mode) async def do_bookmark(self) -> None: - await self._root_bookmark.do_bookmark() - - @property - def store(self) -> BookmarkStore: - return self._root_bookmark.store + await self._session._parent.bookmark.do_bookmark() def on_restore( self, @@ -670,8 +700,24 @@ def on_restored( class BookmarkExpressStub(Bookmark): - def __init__(self, session_root: ExpressStubSession) -> None: - super().__init__(session_root) + def __init__(self, session: ExpressStubSession) -> None: + super().__init__() + + from ..express._stub_session import ExpressStubSession + + assert isinstance(session, ExpressStubSession) + self._session = session + + @property + def store(self) -> BookmarkStore: + return "disable" + + @property + def _restore_context(self) -> RestoreContext | None: + return None + + def _set_restore_context(self, restore_context: RestoreContext) -> None: + return None def _create_effects(self) -> NoReturn: raise NotImplementedError( diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index f2da506d1..7ec7b9c72 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -396,7 +396,7 @@ def restore_input(id: str, default: Any) -> Any: Parameters ---------- id - Name of the input value to restore. + 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. """ diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 80ded4492..fdcfc3e69 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -12,11 +12,8 @@ from ._utils import in_shiny_server, to_json_str if TYPE_CHECKING: - from shiny._app import App - from .. import Inputs -else: - Inputs = Any + from .._app import App class BookmarkState: diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 98011888e..0c0758b8f 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -639,14 +639,14 @@ def verify_state(expected_state: ConnectionState) -> None: # BOOKMARKS! if ".clientdata_url_search" in message_obj["data"]: - self.bookmark._restore_context_value = ( + self.bookmark._set_restore_context( await RestoreContext.from_query_string( message_obj["data"][".clientdata_url_search"], app=self.app, ) ) else: - self.bookmark._restore_context_value = RestoreContext() + 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 @@ -1213,7 +1213,6 @@ def __init__(self, parent: Session, ns: ResolvedId) -> None: self._outbound_message_queues = parent._outbound_message_queues self._downloads = parent._downloads - self._root = parent.root_scope() self.bookmark = BookmarkProxy(self) def _is_hidden(self, name: str) -> bool: @@ -1223,7 +1222,7 @@ def on_ended( self, fn: Callable[[], None] | Callable[[], Awaitable[None]], ) -> Callable[[], None]: - return self._root.on_ended(fn) + return self._parent.on_ended(fn) def is_stub_session(self) -> bool: return self._parent.is_stub_session() @@ -1349,6 +1348,19 @@ 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: @@ -1401,14 +1413,6 @@ def __contains__(self, key: str) -> bool: def __dir__(self): return list(self._map.keys()) - _serializers: dict[ - str, - Callable[ - [Any, Path | None], - Awaitable[Any | Unserializable], - ], - ] - # 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 From 63333bbe4b1bb61883a11bddf74b7f28cfef5640 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 10:20:47 -0400 Subject: [PATCH 50/62] Test for recursive module bookmarking --- shiny/bookmark/_bookmark.py | 73 ++++------ .../bookmark/modules/app-core-recursive.py | 129 ++++++++++++++++++ .../bookmark/modules/{app.py => app-core.py} | 0 .../bookmark/modules/test_bookmark_modules.py | 34 +++-- 4 files changed, 181 insertions(+), 55 deletions(-) create mode 100644 tests/playwright/shiny/bookmark/modules/app-core-recursive.py rename tests/playwright/shiny/bookmark/modules/{app.py => app-core.py} (100%) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 5578a47e8..ef40b9200 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -75,7 +75,7 @@ class Bookmark(ABC): - _proxy_exclude_fns: list[Callable[[], list[str]]] + _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.""" @@ -85,6 +85,21 @@ class Bookmark(ABC): _on_restore_callbacks: AsyncCallbacks _on_restored_callbacks: AsyncCallbacks + 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 @@ -116,20 +131,16 @@ def _set_restore_context(self, restore_context: RestoreContext): """ ... - async def __call__(self) -> None: - await self.do_bookmark() - - def __init__(self): - - super().__init__() - - self._proxy_exclude_fns = [] - self.exclude = [] + def _get_bookmark_exclude(self) -> list[str]: + """ + Get the list of inputs excluded from being bookmarked. + """ - self._on_bookmark_callbacks = AsyncCallbacks() - self._on_bookmarked_callbacks = AsyncCallbacks() - self._on_restore_callbacks = AsyncCallbacks() - self._on_restored_callbacks = AsyncCallbacks() + 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])) # # TODO: Barret - Implement this?!? # @abstractmethod @@ -154,13 +165,6 @@ def _create_effects(self) -> None: """ ... - @abstractmethod - def _get_bookmark_exclude(self) -> list[str]: - """ - Retrieve the list of inputs excluded from being bookmarked. - """ - ... - @abstractmethod def on_bookmark( self, @@ -396,17 +400,6 @@ async def invoke_on_restored_callbacks(): return - 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._proxy_exclude_fns: - scoped_excludes.extend(proxy_exclude_fn()) - # Remove duplicates - return list(set([*self.exclude, *scoped_excludes])) - def on_bookmark( self, callback: ( @@ -548,9 +541,8 @@ def __init__(self, session_proxy: SessionProxy): self._ns = session_proxy.ns self._session = session_proxy - # TODO: Barret - This isn't getting to the root # Maybe `._get_bookmark_exclude()` should be used instead of`proxy_exclude_fns`? - self._session._parent.bookmark._proxy_exclude_fns.append( + self._session._parent.bookmark._on_get_exclude.append( lambda: [str(self._ns(name)) for name in self.exclude] ) @@ -667,12 +659,6 @@ def on_bookmarked( ) -> CancelCallback: return self._on_bookmarked_callbacks.register(wrap_async(callback)) - def _get_bookmark_exclude(self) -> NoReturn: - - raise NotImplementedError( - "Please call `._get_bookmark_exclude()` from the root session only." - ) - async def update_query_string( self, query_string: str, mode: Literal["replace", "push"] = "replace" ) -> None: @@ -706,7 +692,7 @@ def __init__(self, session: ExpressStubSession) -> None: from ..express._stub_session import ExpressStubSession assert isinstance(session, ExpressStubSession) - self._session = session + # self._session = session @property def store(self) -> BookmarkStore: @@ -724,11 +710,6 @@ def _create_effects(self) -> NoReturn: "Please call `._create_effects()` only from a real session object" ) - def _get_bookmark_exclude(self) -> NoReturn: - raise NotImplementedError( - "Please call `._get_bookmark_exclude()` only from a real session object" - ) - def on_bookmark( self, callback: ( 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..91bf0077a --- /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.py b/tests/playwright/shiny/bookmark/modules/app-core.py similarity index 100% rename from tests/playwright/shiny/bookmark/modules/app.py rename to tests/playwright/shiny/bookmark/modules/app-core.py diff --git a/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py index 422a13323..31306d9cc 100644 --- a/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py +++ b/tests/playwright/shiny/bookmark/modules/test_bookmark_modules.py @@ -8,9 +8,25 @@ from shiny.run import ShinyAppProc, run_shiny_app -@pytest.mark.parametrize("app_name", ["app-express.py", "app.py"]) +@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): +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 @@ -41,18 +57,18 @@ def set_mod(mod_key: str, values: list[str]): InputRadioButtons(page, f"{mod_key}-dyn1").set(values[2]) InputRadioButtons(page, f"{mod_key}-dyn2").set(values[3]) - expect_mod("mod0", ["a", "a", "a", "a"]) - expect_mod("mod1", ["a", "a", "a", "a"]) + expect_mod(mod0_key, ["a", "a", "a", "a"]) + expect_mod(mod1_key, ["a", "a", "a", "a"]) - set_mod("mod0", ["b", "b", "c", "c"]) + set_mod(mod0_key, ["b", "b", "c", "c"]) - expect_mod("mod0", ["b", "b", "c", "c"]) - expect_mod("mod1", ["a", "a", "a", "a"]) + expect_mod(mod0_key, ["b", "b", "c", "c"]) + expect_mod(mod1_key, ["a", "a", "a", "a"]) page.reload() - expect_mod("mod0", ["b", "b", "c", "c"]) - expect_mod("mod1", ["a", "a", "a", "a"]) + 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 From 8b0a85a4d6686b6b7d6f25aab909e9897fa8002d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 10:31:05 -0400 Subject: [PATCH 51/62] Update _quartodoc-core.yml --- docs/_quartodoc-core.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 39346c21e..9d0af101e 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -113,8 +113,8 @@ quartodoc: desc: "Decorators to set save and restore directories." flatten: true contents: - - bookmark.set_save_dir - - bookmark.set_restore_dir + - bookmark.set_global_save_dir + - bookmark.set_global_restore_dir - title: Chat interface desc: Build a chatbot interface contents: From b00d2a8ec55ce953548c96b6714f5ad747864bc5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 10:53:41 -0400 Subject: [PATCH 52/62] Require that a `ResolvedId` is supplied to `restore_input()` --- shiny/api-examples/restore_input/app.py | 25 ++++++++---------- shiny/bookmark/_restore_state.py | 34 +++++++++++++++---------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/shiny/api-examples/restore_input/app.py b/shiny/api-examples/restore_input/app.py index d43dd8683..d1c3c2ec1 100644 --- a/shiny/api-examples/restore_input/app.py +++ b/shiny/api-examples/restore_input/app.py @@ -1,4 +1,4 @@ -from htmltools import tags +from htmltools import css, tags from starlette.requests import Request from shiny import App, Inputs, Outputs, Session, ui @@ -13,32 +13,26 @@ def custom_input_text( resolved_id = resolve_id(id) return tags.div( - "Custom input text:", - tags.input( + tags.label(tags.strong("Custom input text:")), + tags.textarea( + restore_input(resolved_id, value), id=resolved_id, type="text", - value=restore_input(resolved_id, value), placeholder="Type here...", + style=css(width="400px", height="3hr"), ), class_="shiny-input-container", - style=ui.css(width="400px"), ) # 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="Default value - Hello, world!"), + custom_input_text( + "myid", + value="Change this value, then click bookmark and refresh the page.", + ), ui.input_bookmark_button(), - # 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!"), ) @@ -49,4 +43,5 @@ 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/_restore_state.py b/shiny/bookmark/_restore_state.py index 7ec7b9c72..30a9fde92 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Literal, Optional from urllib.parse import parse_qs, parse_qsl +from ..module import ResolvedId from ._bookmark_state import local_restore_dir from ._types import BookmarkRestoreDirFn from ._utils import from_json_str, in_shiny_server @@ -283,33 +284,36 @@ class RestoreInputSet: could completely prevent any other (non- restored) kvalue from being used. """ - _values: dict[str, Any] - _pending: set[str] + _values: dict[ResolvedId, Any] + _pending: set[ResolvedId] """Names of values which have been marked as pending""" - _used: set[str] + _used: set[ResolvedId] """Names of values which have been used""" def __init__(self, values: Optional[dict[str, Any]] = None): - self._values = {} if values is None else values + 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: str) -> bool: + def exists(self, name: ResolvedId) -> bool: return name in self._values - def available(self, name: str) -> bool: + def available(self, name: ResolvedId) -> bool: return self.exists(name) and not self.is_used(name) - def is_pending(self, name: str) -> bool: + def is_pending(self, name: ResolvedId) -> bool: return name in self._pending - def is_used(self, name: str) -> bool: + 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: str, force: bool = False) -> Any: + def get(self, name: ResolvedId, force: bool = False) -> Any: if force: return self._values[name] @@ -325,7 +329,7 @@ def flush_pending(self) -> None: self._pending.clear() def as_dict(self) -> dict[str, Any]: - return self._values + return {str(key): value for key, value in self._values.items()} # ############################################################################# @@ -386,7 +390,7 @@ def get_current_restore_context() -> RestoreContext | None: return ctx -def restore_input(id: str, default: Any) -> Any: +def restore_input(resolved_id: ResolvedId, default: Any) -> Any: """ Restore an input value @@ -400,6 +404,10 @@ def restore_input(id: str, default: Any) -> Any: 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 @@ -408,7 +416,7 @@ def restore_input(id: str, default: Any) -> Any: ctx = get_current_restore_context() if isinstance(ctx, RestoreContext): old_inputs = ctx.input - if old_inputs.available(id): - return old_inputs.get(id) + if old_inputs.available(resolved_id): + return old_inputs.get(resolved_id) return default From 69272f9a8669d753ab1f4a0f1fa5f8971edeb725 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 11:28:39 -0400 Subject: [PATCH 53/62] Use JSON instead of pickle files for storage of bookmarks --- shiny/bookmark/_restore_state.py | 13 +++++-------- shiny/bookmark/_save_state.py | 11 ++++------- shiny/bookmark/_utils.py | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index 30a9fde92..f3ccc90c8 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pickle import warnings from contextlib import contextmanager from contextvars import ContextVar, Token @@ -11,7 +10,7 @@ from ..module import ResolvedId from ._bookmark_state import local_restore_dir from ._types import BookmarkRestoreDirFn -from ._utils import from_json_str, in_shiny_server +from ._utils import from_json_file, from_json_str, in_shiny_server if TYPE_CHECKING: from .._app import App @@ -204,17 +203,15 @@ async def _load_state_qs(self, query_string: str, *, app: App) -> None: self.dir = Path(await load_bookmark_fn(id)) if not self.dir.exists(): - raise ValueError("Bookmarked state directory does not exist.") + raise RuntimeError("Bookmarked state directory does not exist.") # TODO: Barret; Store/restore as JSON - with open(self.dir / "input.pickle", "rb") as f: - input_values = pickle.load(f) + input_values = from_json_file(self.dir / "input.json") self.input = RestoreInputSet(input_values) - values_file = self.dir / "values.pickle" + values_file = self.dir / "values.json" if values_file.exists(): - with open(values_file, "rb") as f: - self.values = pickle.load(f) + self.values = from_json_file(values_file) # End load state from disk return diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index fdcfc3e69..6af5336a1 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -1,6 +1,5 @@ from __future__ import annotations -import pickle from pathlib import Path from typing import TYPE_CHECKING, Any, Awaitable, Callable from urllib.parse import urlencode as urllib_urlencode @@ -9,7 +8,7 @@ from ..reactive import isolate from ._bookmark_state import local_save_dir from ._types import BookmarkSaveDirFn -from ._utils import in_shiny_server, to_json_str +from ._utils import in_shiny_server, to_json_file, to_json_str if TYPE_CHECKING: from .. import Inputs @@ -55,7 +54,7 @@ async def _call_on_save(self): async def _save_state(self, *, app: App) -> str: """ - Save a state to disk (pickle). + Save a bookmark state to disk (JSON). Returns ------- @@ -91,12 +90,10 @@ async def _save_state(self, *, app: App) -> str: ) assert self.dir is not None - with open(self.dir / "input.pickle", "wb") as f: - pickle.dump(input_values_json, f) + to_json_file(input_values_json, self.dir / "input.json") if len(self.values) > 0: - with open(self.dir / "values.pickle", "wb") as f: - pickle.dump(self.values, f) + 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. diff --git a/shiny/bookmark/_utils.py b/shiny/bookmark/_utils.py index 472e5d5d9..4ed5d1ac5 100644 --- a/shiny/bookmark/_utils.py +++ b/shiny/bookmark/_utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from pathlib import Path from typing import Any import orjson @@ -21,3 +22,17 @@ def to_json_str(x: Any) -> str: 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")) From 663f028f4893e4ed1f3f22abe4bbdbd529c3b653 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 11:30:12 -0400 Subject: [PATCH 54/62] lint --- tests/playwright/shiny/bookmark/modules/app-core-recursive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/playwright/shiny/bookmark/modules/app-core-recursive.py b/tests/playwright/shiny/bookmark/modules/app-core-recursive.py index 91bf0077a..a3dfaf70d 100644 --- a/tests/playwright/shiny/bookmark/modules/app-core-recursive.py +++ b/tests/playwright/shiny/bookmark/modules/app-core-recursive.py @@ -90,14 +90,14 @@ def app_ui(request: Request) -> ui.Tag: return ui.page_fixed( ui.output_code("bookmark_store"), "Click Buttons to update bookmark", - mod_btn(f"mod{k-1}", k - 1), + 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) + btn_server(f"mod{k - 1}", k - 1) @render.code def bookmark_store(): From 8b982af8b6ae41680b184358648d4a10f2d533a2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 12:59:37 -0400 Subject: [PATCH 55/62] Add notes on why a temp dir is used --- tests/playwright/shiny/bookmark/dir/app-attr.py | 4 ++++ tests/playwright/shiny/bookmark/dir/app-global.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/playwright/shiny/bookmark/dir/app-attr.py b/tests/playwright/shiny/bookmark/dir/app-attr.py index 53b8d13d1..c81261517 100644 --- a/tests/playwright/shiny/bookmark/dir/app-attr.py +++ b/tests/playwright/shiny/bookmark/dir/app-attr.py @@ -42,6 +42,10 @@ def called_restored(): 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) diff --git a/tests/playwright/shiny/bookmark/dir/app-global.py b/tests/playwright/shiny/bookmark/dir/app-global.py index c5ce2af67..5d2bbd9ea 100644 --- a/tests/playwright/shiny/bookmark/dir/app-global.py +++ b/tests/playwright/shiny/bookmark/dir/app-global.py @@ -43,6 +43,10 @@ def called_restored(): 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) From df9f9bdad19d832d02d16c8f2f166dd76f54a240 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 13:00:16 -0400 Subject: [PATCH 56/62] Clean up bookmarking classes to reduce abstract methods only required for a single class --- shiny/bookmark/_bookmark.py | 176 +++++++++------------------------ shiny/bookmark/_global.py | 13 --- shiny/session/_session.py | 3 + shiny/ui/_input_check_radio.py | 3 +- 4 files changed, 53 insertions(+), 142 deletions(-) diff --git a/shiny/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index ef40b9200..3d3caf709 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -3,7 +3,7 @@ import warnings from abc import ABC, abstractmethod from pathlib import Path -from typing import TYPE_CHECKING, Awaitable, Callable, Literal, NoReturn +from typing import TYPE_CHECKING, Awaitable, Callable, Literal from .._utils import AsyncCallbacks, CancelCallback, wrap_async from ._button import BOOKMARK_ID @@ -122,26 +122,6 @@ def _restore_context(self) -> RestoreContext | None: """ ... - @abstractmethod - def _set_restore_context(self, restore_context: RestoreContext): - """ - Set the session's RestoreContext object. - - This should only be done within the `init` websocket message. - """ - ... - - 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])) - # # TODO: Barret - Implement this?!? # @abstractmethod # async def get_url(self) -> str: @@ -156,16 +136,6 @@ def _get_bookmark_exclude(self) -> list[str]: # await session.insert_ui(modal_with_url(url)) - @abstractmethod - def _create_effects(self) -> None: - """ - Create the effects for the bookmarking system. - - This method should be called when the session is created after the initial inputs have been set. - """ - ... - - @abstractmethod def on_bookmark( self, callback: ( @@ -185,9 +155,8 @@ def on_bookmark( 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)) - @abstractmethod def on_bookmarked( self, callback: Callable[[str], None] | Callable[[str], Awaitable[None]], @@ -204,7 +173,33 @@ def on_bookmarked( 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( @@ -237,34 +232,6 @@ async def do_bookmark(self) -> None: """ ... - @abstractmethod - 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. - """ - ... - - @abstractmethod - 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. - """ - ... - class BookmarkApp(Bookmark): _session: AppSession @@ -288,25 +255,19 @@ def __init__(self, session: AppSession): # 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: - """ - 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. - """ return self._session.app.bookmark_store @property def _restore_context(self) -> RestoreContext | None: - """ - A read-only value of the session's RestoreContext object. - """ 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: @@ -448,6 +409,17 @@ async def update_query_string( } ) + 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": @@ -552,9 +524,7 @@ def __init__(self, session_proxy: SessionProxy): # 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 - async def scoped_on_bookmark(root_state: BookmarkState) -> None: - return await self._scoped_on_bookmark(root_state) + self._session._parent.bookmark.on_bookmark(self._scoped_on_bookmark) from ..session import session_context @@ -633,32 +603,6 @@ def store(self) -> BookmarkStore: def _restore_context(self) -> RestoreContext | None: return self._session._parent.bookmark._restore_context - def _set_restore_context(self, restore_context: RestoreContext) -> NoReturn: - raise NotImplementedError( - "The `RestoreContext` should only be set on the root session object." - ) - - def _create_effects(self) -> NoReturn: - raise NotImplementedError( - "Please call `._create_effects()` from the root session only." - ) - - 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)) - async def update_query_string( self, query_string: str, mode: Literal["replace", "push"] = "replace" ) -> None: @@ -667,22 +611,6 @@ async def update_query_string( async def do_bookmark(self) -> None: await self._session._parent.bookmark.do_bookmark() - 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)) - class BookmarkExpressStub(Bookmark): @@ -692,7 +620,6 @@ def __init__(self, session: ExpressStubSession) -> None: from ..express._stub_session import ExpressStubSession assert isinstance(session, ExpressStubSession) - # self._session = session @property def store(self) -> BookmarkStore: @@ -700,16 +627,9 @@ def store(self) -> BookmarkStore: @property def _restore_context(self) -> RestoreContext | None: + # no-op within ExpressStub return None - def _set_restore_context(self, restore_context: RestoreContext) -> None: - return None - - def _create_effects(self) -> NoReturn: - raise NotImplementedError( - "Please call `._create_effects()` only from a real session object" - ) - def on_bookmark( self, callback: ( @@ -729,9 +649,11 @@ def on_bookmarked( 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( diff --git a/shiny/bookmark/_global.py b/shiny/bookmark/_global.py index 41be0b737..23d5dedf1 100644 --- a/shiny/bookmark/_global.py +++ b/shiny/bookmark/_global.py @@ -14,19 +14,6 @@ # During App initialization, the save_dir and restore_dir functions are conventionally set # to read-only on the App. -# The set methods below are used to set the save_dir and restore_dir locations for locations like Connect or SSP. -# Ex: -# ```python -# @shiny.bookmark.set_global_save_dir_fn -# def connect_save_shiny_bookmark(id: str) -> Path: -# path = Path("connect") / id -# path.mkdir(parents=True, exist_ok=True) -# return path -# @shiny.bookmark.set_global_restore_dir_fn -# def connect_restore_shiny_bookmark(id: str) -> Path: -# return Path("connect") / id -# ``` - bookmark_save_dir: BookmarkSaveDirFn | None = None bookmark_restore_dir: BookmarkRestoreDirFn | None = None diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 0c0758b8f..5d3dc533d 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -638,6 +638,9 @@ def verify_state(expected_state: ConnectionState) -> None: 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( diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index f79db6095..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 @@ -288,8 +289,6 @@ def input_radio_buttons( resolved_id = resolve_id(id) input_label = shiny_input_label(resolved_id, label) - from ..bookmark import restore_input - options = _generate_options( id=resolved_id, type="radio", From 8fbd8f27aa0be3e878c7747944c0aefdeb480c16 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 17:05:41 -0400 Subject: [PATCH 57/62] Update warning message for when a user doesn't supply a function and wants bookmarking --- shiny/_app.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index 340f1ef52..284de42bd 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -149,7 +149,7 @@ def __init__( "`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)" ) - self._init_bookmarking(bookmark_store=bookmark_store) + self._init_bookmarking(bookmark_store=bookmark_store, ui=ui) self._debug: bool = debug @@ -378,19 +378,14 @@ async def _on_root_request_cb(self, request: Request) -> Response: request.url.query, app=self ) - with restore_context(restore_ctx): - if callable(self.ui): + if callable(self.ui): + # 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: - if restore_ctx.active: - # TODO: Barret - Docs: See ?enableBookmarking - warnings.warn( - "Trying to restore saved app state, but UI code must be a function for this to work!", - stacklevel=1, - ) - - ui = self.ui - return HTMLResponse(content=ui["html"]) + else: + ui = self.ui + return HTMLResponse(content=ui["html"]) async def _on_connect_cb(self, ws: starlette.websockets.WebSocket) -> None: """ @@ -503,11 +498,16 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML: # Bookmarking # ========================================================================== - def _init_bookmarking(self, *, bookmark_store: BookmarkStore) -> None: + 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 From fe4c0e80cb00674b85ae7acbdce2a9a7bb7bd3fc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 17:23:31 -0400 Subject: [PATCH 58/62] docs --- docs/_quartodoc-core.yml | 4 +- shiny/api-examples/bookmark_callbacks/app.py | 92 +++++++++++++++++++ shiny/bookmark/_bookmark.py | 4 + shiny/bookmark/_global.py | 78 +++++++++++++++- shiny/bookmark/_restore_state.py | 3 +- shiny/bookmark/_save_state.py | 1 - shiny/session/_session.py | 4 +- .../shiny/bookmark/modules/app-core.py | 5 +- 8 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 shiny/api-examples/bookmark_callbacks/app.py diff --git a/docs/_quartodoc-core.yml b/docs/_quartodoc-core.yml index 9d0af101e..57b6cf86f 100644 --- a/docs/_quartodoc-core.yml +++ b/docs/_quartodoc-core.yml @@ -113,8 +113,8 @@ quartodoc: desc: "Decorators to set save and restore directories." flatten: true contents: - - bookmark.set_global_save_dir - - bookmark.set_global_restore_dir + - 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/api-examples/bookmark_callbacks/app.py b/shiny/api-examples/bookmark_callbacks/app.py new file mode 100644 index 000000000..7382cc975 --- /dev/null +++ b/shiny/api-examples/bookmark_callbacks/app.py @@ -0,0 +1,92 @@ +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. Only the first radio button will be restored." + "\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/bookmark/_bookmark.py b/shiny/bookmark/_bookmark.py index 3d3caf709..31f78c065 100644 --- a/shiny/bookmark/_bookmark.py +++ b/shiny/bookmark/_bookmark.py @@ -5,6 +5,7 @@ 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 @@ -85,6 +86,7 @@ class Bookmark(ABC): _on_restore_callbacks: AsyncCallbacks _on_restored_callbacks: AsyncCallbacks + @add_example("input_bookmark_button") async def __call__(self) -> None: await self.do_bookmark() @@ -136,6 +138,7 @@ def _restore_context(self) -> RestoreContext | None: # await session.insert_ui(modal_with_url(url)) + @add_example("bookmark_callbacks") def on_bookmark( self, callback: ( @@ -157,6 +160,7 @@ def on_bookmark( """ 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]], diff --git a/shiny/bookmark/_global.py b/shiny/bookmark/_global.py index 23d5dedf1..ed9741c23 100644 --- a/shiny/bookmark/_global.py +++ b/shiny/bookmark/_global.py @@ -35,11 +35,45 @@ def as_bookmark_dir_fn(fn: BookmarkDirFn | None) -> BookmarkDirFnAsync | None: return wrap_async(fn) -# TODO: Barret - Integrate Set / Restore for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915 +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. -def set_global_save_dir_fn(fn: BookmarkDirFn): - """TODO: Barret document""" + 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) @@ -47,7 +81,43 @@ def set_global_save_dir_fn(fn: BookmarkDirFn): def set_global_restore_dir_fn(fn: BookmarkDirFn): - """TODO: Barret document""" + """ + 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) diff --git a/shiny/bookmark/_restore_state.py b/shiny/bookmark/_restore_state.py index f3ccc90c8..0cdc2f85e 100644 --- a/shiny/bookmark/_restore_state.py +++ b/shiny/bookmark/_restore_state.py @@ -7,6 +7,7 @@ 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 @@ -205,7 +206,6 @@ async def _load_state_qs(self, query_string: str, *, app: App) -> None: if not self.dir.exists(): raise RuntimeError("Bookmarked state directory does not exist.") - # TODO: Barret; Store/restore as JSON input_values = from_json_file(self.dir / "input.json") self.input = RestoreInputSet(input_values) @@ -387,6 +387,7 @@ def get_current_restore_context() -> RestoreContext | None: return ctx +@add_example() def restore_input(resolved_id: ResolvedId, default: Any) -> Any: """ Restore an input value diff --git a/shiny/bookmark/_save_state.py b/shiny/bookmark/_save_state.py index 6af5336a1..8d3cabe4e 100644 --- a/shiny/bookmark/_save_state.py +++ b/shiny/bookmark/_save_state.py @@ -72,7 +72,6 @@ async def _save_state(self, *, app: App) -> str: if save_bookmark_fn is None: if in_shiny_server(): - # TODO: Barret; Implement `bookmark_save_dir` for Connect raise NotImplementedError( "The hosting environment does not support server-side bookmarking." ) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index 5d3dc533d..8f9b270a1 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1201,8 +1201,8 @@ class UpdateProgressMessage(TypedDict): class SessionProxy(Session): def __init__(self, parent: Session, ns: ResolvedId) -> None: - # TODO: Barret - Q: Why are we storing `parent`? It really feels like all `._parent` should be replaced with `.root_scope()` or `._root`, really - # TODO: Barret - Q: Why is there no super().__init__()? Why don't we proxy to the root on get/set? + super().__init__() + self._parent = parent self.app = parent.app self.id = parent.id diff --git a/tests/playwright/shiny/bookmark/modules/app-core.py b/tests/playwright/shiny/bookmark/modules/app-core.py index b685104e7..d39b1ada5 100644 --- a/tests/playwright/shiny/bookmark/modules/app-core.py +++ b/tests/playwright/shiny/bookmark/modules/app-core.py @@ -15,7 +15,10 @@ def mod_btn(idx: int): ui.layout_column_wrap( ui.TagList( ui.input_radio_buttons( - "btn1", "Button Input", choices=["a", "b", "c"], selected="a" + "btn1", + "Button Input", + choices=["a", "b", "c"], + selected="a", ), ui.input_radio_buttons( "btn2", From 6ad1345f922707973d078d34a5898bd818c5e055 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 17:32:06 -0400 Subject: [PATCH 59/62] Update app.py --- shiny/api-examples/bookmark_callbacks/app.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/shiny/api-examples/bookmark_callbacks/app.py b/shiny/api-examples/bookmark_callbacks/app.py index 7382cc975..62744cd24 100644 --- a/shiny/api-examples/bookmark_callbacks/app.py +++ b/shiny/api-examples/bookmark_callbacks/app.py @@ -11,7 +11,7 @@ def app_ui(request: Request): "Directions: " "\n1. Change the radio buttons below" "\n2. Refresh your browser." - "\n3. Only the first radio button will be restored." + "\n3. The radio buttons should be restored to their previous state." "\n4. Check the console messages for bookmarking events." ), ui.hr(), @@ -45,12 +45,7 @@ async def _(): @render.code def letters(): - return str( - [ - input.letter(), - lowercase_letter(), - ] - ) + return str([input.letter(), lowercase_letter()]) # When the user interacts with the input, we will bookmark the state. @reactive.effect From cd069b9d13a98b425f4ae4f3205d7d37b632e7ac Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 12 Mar 2025 17:33:18 -0400 Subject: [PATCH 60/62] lint --- shiny/_app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shiny/_app.py b/shiny/_app.py index 284de42bd..6eeba7b68 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -3,7 +3,6 @@ import copy import os import secrets -import warnings from contextlib import AsyncExitStack, asynccontextmanager from inspect import signature from pathlib import Path From 7ffe2a60261e3c9c9c2b427e94f0e7a0c641db6b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Mar 2025 10:10:05 -0400 Subject: [PATCH 61/62] lints --- shiny/bookmark/_button.py | 2 ++ shiny/bookmark/_types.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shiny/bookmark/_button.py b/shiny/bookmark/_button.py index 0ccfcc575..cf2097976 100644 --- a/shiny/bookmark/_button.py +++ b/shiny/bookmark/_button.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Optional from htmltools import HTML, Tag, TagAttrValue, TagChild diff --git a/shiny/bookmark/_types.py b/shiny/bookmark/_types.py index 1c4360708..e676e5776 100644 --- a/shiny/bookmark/_types.py +++ b/shiny/bookmark/_types.py @@ -1,12 +1,12 @@ from __future__ import annotations from pathlib import Path -from typing import Awaitable, Callable, Literal +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 = Callable[[str], Awaitable[Path]] | Callable[[str], Path] +BookmarkDirFn = Union[Callable[[str], Awaitable[Path]], Callable[[str], Path]] BookmarkDirFnAsync = Callable[[str], Awaitable[Path]] BookmarkSaveDirFn = BookmarkDirFnAsync From 1dee75470fa5f93ef60e48dcc85af2ed6bb8aaf5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 13 Mar 2025 11:06:28 -0400 Subject: [PATCH 62/62] Increase timeout for flakey test --- tests/playwright/shiny/components/chat/icon/test_chat_icon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}.")