Skip to content

feat: Add support for bookmarking via shiny.bookmark #1870

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 68 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
d3b5c45
Input serializers and serializer method
schloerke Feb 25, 2025
e43d33c
Add utils; `is_hosted()` needs to be inspected
schloerke Feb 25, 2025
3136d3d
Create _serializers.py
schloerke Feb 25, 2025
1ef9e82
Create _save_state.py
schloerke Feb 25, 2025
e3d6a7f
First pass at `ShinySaveState` class; Implemented `_save_state()` and…
schloerke Feb 25, 2025
8600686
typo
schloerke Feb 26, 2025
505e027
Fix circular dep
schloerke Feb 26, 2025
25ba8b4
Set up bookmark exclude and bookmark store
schloerke Feb 26, 2025
dddafe6
Update _bookmark.py
schloerke Feb 26, 2025
5681da8
`Callbacks` and `AsyncCallbacks` added param support
schloerke Feb 28, 2025
2f80153
`session.do_bookmark()` is functional in `"url"` and `"server"`
schloerke Feb 28, 2025
a40f268
Use a Bookmark class
schloerke Feb 28, 2025
cbc303c
Add notes on local storage; lints
schloerke Feb 28, 2025
953b896
Fix express module bookmarking
schloerke Feb 28, 2025
e2f838e
Update .gitignore
schloerke Feb 28, 2025
da07a41
Rearrange file content
schloerke Mar 3, 2025
24c134b
Move express stub bookmark object
schloerke Mar 3, 2025
2d7e0c9
Update _bookmark.py
schloerke Mar 3, 2025
99ecada
Update _utils.py
schloerke Mar 4, 2025
b0bf3db
Move BookmarkState class to sep file
schloerke Mar 4, 2025
1b48419
First pass at restore state. **many** debug statements. Modules are s…
schloerke Mar 4, 2025
a798507
Restore input_radio_buttons!
schloerke Mar 4, 2025
2d6af57
lints
schloerke Mar 4, 2025
a63f389
Merge branch 'main' into bookmarking
schloerke Mar 4, 2025
80f6091
Add a `shiny.bookmark.globals` module
schloerke Mar 4, 2025
30c8dd8
Add `shiny.bookmark.input_bookmark_button()`; Use globals to set book…
schloerke Mar 4, 2025
b89bc9e
Reduce comments
schloerke Mar 4, 2025
3082912
Clean up comments
schloerke Mar 4, 2025
a889407
Use on_flush (not on_flushed) when calling restored callbacks
schloerke Mar 4, 2025
3319fb1
Export `shiny.bookmark.restore_input()`
schloerke Mar 6, 2025
ab42bff
Add warning statements; Remove many print statements
schloerke Mar 6, 2025
3de1378
Docs
schloerke Mar 6, 2025
f65f120
Safely create the dir using `exist_ok=True`
schloerke Mar 6, 2025
27b88b5
Fix bug where the proxy session didn't have a restore context
schloerke Mar 6, 2025
6c4f596
Add bookmark workaround for `on_restored()` callbacks not executing o…
schloerke Mar 6, 2025
f6a38e7
Use `App.bookmark_store` to be the source of truth for `session.bookm…
schloerke Mar 6, 2025
9f15505
Fix server-side values not being restored
schloerke Mar 7, 2025
3df0026
Move files around. Add `shiny.bookmark. set_save_dir()` and `set_rest…
schloerke Mar 7, 2025
79dd52b
Remove comments and fix lints
schloerke Mar 7, 2025
8be292d
Add docs; Rename `ShinySaveState` -> `BookmarkState`
schloerke Mar 7, 2025
a6d3149
Clean up TODOs
schloerke Mar 7, 2025
edaf393
Merge branch 'main' into bookmarking
schloerke Mar 10, 2025
c7a3a3b
Get express mode to work! 🎉
schloerke Mar 10, 2025
892cbfd
Merge branch 'main' into bookmarking
schloerke Mar 10, 2025
cfd3bba
Merge branch 'main' into bookmarking
schloerke Mar 10, 2025
59e2cae
Add tests for bookmarking
schloerke Mar 10, 2025
221dc0d
Merge branch 'bookmarking' of https://github.com/posit-dev/py-shiny i…
schloerke Mar 10, 2025
0fd3921
Add some examples (not finished)
schloerke Mar 10, 2025
4199f29
Followup from #1898
schloerke Mar 11, 2025
aadf050
Merge branch 'main' into bookmarking
schloerke Mar 11, 2025
a50860e
Allow for App to set bookmark save/restore dir functions
schloerke Mar 11, 2025
5302f90
lints
schloerke Mar 11, 2025
5b2db2a
Revamp is_hosted() -> in_shiny_server()
schloerke Mar 11, 2025
1daae23
Add warning for when bookmark is requested but it is disabled
schloerke Mar 11, 2025
5192884
First pass of using `._session._parent.bookmark` instead of `._root_b…
schloerke Mar 11, 2025
63333bb
Test for recursive module bookmarking
schloerke Mar 12, 2025
8b0a85a
Update _quartodoc-core.yml
schloerke Mar 12, 2025
b00d2a8
Require that a `ResolvedId` is supplied to `restore_input()`
schloerke Mar 12, 2025
69272f9
Use JSON instead of pickle files for storage of bookmarks
schloerke Mar 12, 2025
663f028
lint
schloerke Mar 12, 2025
8b982af
Add notes on why a temp dir is used
schloerke Mar 12, 2025
df9f9bd
Clean up bookmarking classes to reduce abstract methods only required…
schloerke Mar 12, 2025
8fbd8f2
Update warning message for when a user doesn't supply a function and …
schloerke Mar 12, 2025
fe4c0e8
docs
schloerke Mar 12, 2025
6ad1345
Update app.py
schloerke Mar 12, 2025
cd069b9
lint
schloerke Mar 12, 2025
7ffe2a6
lints
schloerke Mar 13, 2025
1dee754
Increase timeout for flakey test
schloerke Mar 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ docs/source/reference/
_dev/
tests/playwright/deploys/**/requirements.txt
test-results/
shiny_bookmarks/

# setuptools_scm
shiny/_version.py
17 changes: 17 additions & 0 deletions docs/_quartodoc-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ quartodoc:
- ui.input_file
- ui.download_button
- ui.download_link
- title: Bookmarking
desc: Saving and restoring app state
contents:
- ui.input_bookmark_button
- bookmark.restore_input
- bookmark.Bookmark
- bookmark.BookmarkState
- bookmark.RestoreState
- kind: page
path: bookmark_integration
summary:
name: "Integration"
desc: "Decorators to set save and restore directories."
flatten: true
contents:
- bookmark.set_global_save_dir_fn
- bookmark.set_global_restore_dir_fn
- title: Chat interface
desc: Build a chatbot interface
contents:
Expand Down
57 changes: 54 additions & 3 deletions shiny/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from contextlib import AsyncExitStack, asynccontextmanager
from inspect import signature
from pathlib import Path
from typing import Any, Callable, Mapping, Optional, TypeVar, cast
from typing import Any, Callable, Literal, Mapping, Optional, TypeVar, cast

import starlette.applications
import starlette.exceptions
Expand All @@ -30,6 +30,15 @@
from ._error import ErrorMiddleware
from ._shinyenv import is_pyodide
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
from .bookmark import _global as bookmark_global_state
from .bookmark._global import as_bookmark_dir_fn
from .bookmark._restore_state import RestoreContext, restore_context
from .bookmark._types import (
BookmarkDirFn,
BookmarkRestoreDirFn,
BookmarkSaveDirFn,
BookmarkStore,
)
from .html_dependencies import jquery_deps, require_deps, shiny_deps
from .http_staticfiles import FileResponse, StaticFiles
from .session._session import AppSession, Inputs, Outputs, Session, session_context
Expand Down Expand Up @@ -106,6 +115,10 @@ def server(input: Inputs, output: Outputs, session: Session):
ui: RenderedHTML | Callable[[Request], Tag | TagList]
server: Callable[[Inputs, Outputs, Session], None]

_bookmark_save_dir_fn: BookmarkSaveDirFn | None
_bookmark_restore_dir_fn: BookmarkRestoreDirFn | None
_bookmark_store: BookmarkStore

def __init__(
self,
ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path,
Expand All @@ -114,6 +127,8 @@ def __init__(
),
*,
static_assets: Optional[str | Path | Mapping[str, str | Path]] = None,
# Document type as Literal to have clearer type hints to App author
bookmark_store: Literal["url", "server", "disable"] = "disable",
debug: bool = False,
) -> None:
# Used to store callbacks to be called when the app is shutting down (according
Expand All @@ -133,6 +148,8 @@ def __init__(
"`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)"
)

self._init_bookmarking(bookmark_store=bookmark_store, ui=ui)

self._debug: bool = debug

# Settings that the user can change after creating the App object.
Expand Down Expand Up @@ -167,7 +184,7 @@ def __init__(

self._sessions: dict[str, AppSession] = {}

self._sessions_needing_flush: dict[int, AppSession] = {}
# self._sessions_needing_flush: dict[int, AppSession] = {}

self._registered_dependencies: dict[str, HTMLDependency] = {}
self._dependency_handler = starlette.routing.Router()
Expand Down Expand Up @@ -353,8 +370,18 @@ async def _on_root_request_cb(self, request: Request) -> Response:
request for / occurs.
"""
ui: RenderedHTML
if self.bookmark_store == "disable":
restore_ctx = RestoreContext()
else:
restore_ctx = await RestoreContext.from_query_string(
request.url.query, app=self
)

if callable(self.ui):
ui = self._render_page(self.ui(request), self.lib_prefix)
# At this point, if `app.bookmark_store != "disable"`, then we've already
# checked that `ui` is a function (in `App._init_bookmarking()`). No need to throw warning if `ui` is _not_ a function.
with restore_context(restore_ctx):
ui = self._render_page(self.ui(request), self.lib_prefix)
else:
ui = self.ui
return HTMLResponse(content=ui["html"])
Expand Down Expand Up @@ -466,6 +493,30 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:

return rendered

# ==========================================================================
# Bookmarking
# ==========================================================================

def _init_bookmarking(self, *, bookmark_store: BookmarkStore, ui: Any) -> None:
self._bookmark_save_dir_fn = bookmark_global_state.bookmark_save_dir
self._bookmark_restore_dir_fn = bookmark_global_state.bookmark_restore_dir
self._bookmark_store = bookmark_store

if bookmark_store != "disable" and not callable(ui):
raise TypeError(
"App(ui=) must be a function that accepts a request object to allow the UI to be properly reconstructed from a bookmarked state."
)

@property
def bookmark_store(self) -> BookmarkStore:
return self._bookmark_store

def set_bookmark_save_dir_fn(self, bookmark_save_dir_fn: BookmarkDirFn):
self._bookmark_save_dir_fn = as_bookmark_dir_fn(bookmark_save_dir_fn)

def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn):
self._bookmark_restore_dir_fn = as_bookmark_dir_fn(bookmark_restore_dir_fn)


def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]) -> bool:
if (
Expand Down
3 changes: 1 addition & 2 deletions shiny/_main_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
4 changes: 3 additions & 1 deletion shiny/_namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("")
Expand Down
27 changes: 15 additions & 12 deletions shiny/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -550,32 +550,35 @@ def count(self) -> int:
return len(self._callbacks)


CancelCallback = Callable[[], None]


class AsyncCallbacks:
def __init__(self) -> None:
self._callbacks: dict[int, tuple[Callable[[], Awaitable[None]], bool]] = {}
self._callbacks: dict[int, tuple[Callable[..., Awaitable[None]], bool]] = {}
self._id: int = 0

def register(
self, fn: Callable[[], Awaitable[None]], once: bool = False
) -> Callable[[], None]:
self, fn: Callable[..., Awaitable[None]], once: bool = False
) -> CancelCallback:
self._id += 1
id = self._id
self._callbacks[id] = (fn, once)

def _():
def cancel_callback():
if id in self._callbacks:
del self._callbacks[id]

return _
return cancel_callback

async def invoke(self) -> None:
async def invoke(self, *args: Any, **kwargs: Any) -> None:
# The list() wrapper is necessary to force collection of all the items before
# iteration begins. This is necessary because self._callbacks may be mutated
# by callbacks.
for id, value in list(self._callbacks.items()):
fn, once = value
try:
await fn()
await fn(*args, **kwargs)
finally:
if once:
if id in self._callbacks:
Expand Down
87 changes: 87 additions & 0 deletions shiny/api-examples/bookmark_callbacks/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from starlette.requests import Request

from shiny import App, Inputs, Outputs, Session, reactive, render, ui
from shiny.bookmark import BookmarkState


# App UI **must** be a function to ensure that each user restores their own UI values.
def app_ui(request: Request):
return ui.page_fluid(
ui.markdown(
"Directions: "
"\n1. Change the radio buttons below"
"\n2. Refresh your browser."
"\n3. The radio buttons should be restored to their previous state."
"\n4. Check the console messages for bookmarking events."
),
ui.hr(),
ui.input_radio_buttons(
"letter",
"Choose a letter (Store in Bookmark 'input')",
choices=["A", "B", "C"],
),
ui.input_radio_buttons(
"letter_values",
"Choose a letter (Stored in Bookmark 'values' as lowercase)",
choices=["A", "B", "C"],
),
"Selection:",
ui.output_code("letters"),
)


def server(input: Inputs, output: Outputs, session: Session):

# Exclude `"letter_values"` from being saved in the bookmark as we'll store it manually for example's sake
# Append or adjust this list as needed.
session.bookmark.exclude.append("letter_values")

lowercase_letter = reactive.value()

@reactive.effect
@reactive.event(input.letter_values)
async def _():
lowercase_letter.set(input.letter_values().lower())

@render.code
def letters():
return str([input.letter(), lowercase_letter()])

# When the user interacts with the input, we will bookmark the state.
@reactive.effect
@reactive.event(input.letter, lowercase_letter, ignore_init=True)
async def _():
await session.bookmark()

# Before saving state, we can adjust the bookmark state values object
@session.bookmark.on_bookmark
async def _(state: BookmarkState):
print("Bookmark state:", state.input, state.values, state.dir)
with reactive.isolate():
state.values["lowercase"] = lowercase_letter()

# After saving state, we will update the query string with the bookmark URL.
@session.bookmark.on_bookmarked
async def _(url: str):
print("Bookmarked url:", url)
await session.bookmark.update_query_string(url)

@session.bookmark.on_restore
def _(state: BookmarkState):
print("Restore state:", state.input, state.values, state.dir)

# Update the radio button selection based on the restored state.
if "lowercase" in state.values:
uppercase = state.values["lowercase"].upper()
# This may produce a small blip in the UI as the original value was restored on the client's HTML request, _then_ a message is received by the client to update the value.
ui.update_radio_buttons("letter_values", selected=uppercase)

@session.bookmark.on_restored
def _(state: BookmarkState):
# For rare cases, you can update the UI after the session has been fully restored.
print("Restored state:", state.input, state.values, state.dir)


# Make sure to set the bookmark_store to `"url"` (or `"server"`)
# to store the bookmark information/key in the URL query string.
app = App(app_ui, server, bookmark_store="url")
33 changes: 33 additions & 0 deletions shiny/api-examples/input_bookmark_button/app-core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from starlette.requests import Request

from shiny import App, Inputs, Outputs, Session, ui


# App UI **must** be a function to ensure that each user restores their own UI values.
def app_ui(request: Request):
return ui.page_fluid(
ui.markdown(
"Directions: "
"\n1. Change the radio button selection below"
"\n2. Save the bookmark."
"\n3. Then, refresh your browser page to see the radio button selection has been restored."
),
ui.hr(),
ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]),
ui.input_bookmark_button(label="Save bookmark!"),
)


def server(input: Inputs, output: Outputs, session: Session):

# @reactive.effect
# @reactive.event(input.letter, ignore_init=True)
# async def _():
# await session.bookmark()

@session.bookmark.on_bookmarked
async def _(url: str):
await session.bookmark.update_query_string(url)


app = App(app_ui, server, bookmark_store="url")
20 changes: 20 additions & 0 deletions shiny/api-examples/input_bookmark_button/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from shiny.express import app_opts, session, ui

app_opts(bookmark_store="url")


ui.markdown(
"Directions: "
"\n1. Change the radio button selection below"
"\n2. Save the bookmark."
"\n3. Then, refresh your browser page to see the radio button selection has been restored."
)


ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"])
ui.input_bookmark_button()


@session.bookmark.on_bookmarked
async def _(url: str):
await session.bookmark.update_query_string(url)
Loading
Loading