Skip to content

Commit c5f8a48

Browse files
authored
feat: Add support for bookmarking via shiny.bookmark (#1870)
1 parent 9b61375 commit c5f8a48

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3068
-67
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ docs/source/reference/
115115
_dev/
116116
tests/playwright/deploys/**/requirements.txt
117117
test-results/
118+
shiny_bookmarks/
118119

119120
# setuptools_scm
120121
shiny/_version.py

docs/_quartodoc-core.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,23 @@ quartodoc:
9898
- ui.input_file
9999
- ui.download_button
100100
- ui.download_link
101+
- title: Bookmarking
102+
desc: Saving and restoring app state
103+
contents:
104+
- ui.input_bookmark_button
105+
- bookmark.restore_input
106+
- bookmark.Bookmark
107+
- bookmark.BookmarkState
108+
- bookmark.RestoreState
109+
- kind: page
110+
path: bookmark_integration
111+
summary:
112+
name: "Integration"
113+
desc: "Decorators to set save and restore directories."
114+
flatten: true
115+
contents:
116+
- bookmark.set_global_save_dir_fn
117+
- bookmark.set_global_restore_dir_fn
101118
- title: Chat interface
102119
desc: Build a chatbot interface
103120
contents:

shiny/_app.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from contextlib import AsyncExitStack, asynccontextmanager
77
from inspect import signature
88
from pathlib import Path
9-
from typing import Any, Callable, Mapping, Optional, TypeVar, cast
9+
from typing import Any, Callable, Literal, Mapping, Optional, TypeVar, cast
1010

1111
import starlette.applications
1212
import starlette.exceptions
@@ -30,6 +30,15 @@
3030
from ._error import ErrorMiddleware
3131
from ._shinyenv import is_pyodide
3232
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
33+
from .bookmark import _global as bookmark_global_state
34+
from .bookmark._global import as_bookmark_dir_fn
35+
from .bookmark._restore_state import RestoreContext, restore_context
36+
from .bookmark._types import (
37+
BookmarkDirFn,
38+
BookmarkRestoreDirFn,
39+
BookmarkSaveDirFn,
40+
BookmarkStore,
41+
)
3342
from .html_dependencies import jquery_deps, require_deps, shiny_deps
3443
from .http_staticfiles import FileResponse, StaticFiles
3544
from .session._session import AppSession, Inputs, Outputs, Session, session_context
@@ -106,6 +115,10 @@ def server(input: Inputs, output: Outputs, session: Session):
106115
ui: RenderedHTML | Callable[[Request], Tag | TagList]
107116
server: Callable[[Inputs, Outputs, Session], None]
108117

118+
_bookmark_save_dir_fn: BookmarkSaveDirFn | None
119+
_bookmark_restore_dir_fn: BookmarkRestoreDirFn | None
120+
_bookmark_store: BookmarkStore
121+
109122
def __init__(
110123
self,
111124
ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path,
@@ -114,6 +127,8 @@ def __init__(
114127
),
115128
*,
116129
static_assets: Optional[str | Path | Mapping[str, str | Path]] = None,
130+
# Document type as Literal to have clearer type hints to App author
131+
bookmark_store: Literal["url", "server", "disable"] = "disable",
117132
debug: bool = False,
118133
) -> None:
119134
# Used to store callbacks to be called when the app is shutting down (according
@@ -133,6 +148,8 @@ def __init__(
133148
"`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)"
134149
)
135150

151+
self._init_bookmarking(bookmark_store=bookmark_store, ui=ui)
152+
136153
self._debug: bool = debug
137154

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

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

170-
self._sessions_needing_flush: dict[int, AppSession] = {}
187+
# self._sessions_needing_flush: dict[int, AppSession] = {}
171188

172189
self._registered_dependencies: dict[str, HTMLDependency] = {}
173190
self._dependency_handler = starlette.routing.Router()
@@ -353,8 +370,18 @@ async def _on_root_request_cb(self, request: Request) -> Response:
353370
request for / occurs.
354371
"""
355372
ui: RenderedHTML
373+
if self.bookmark_store == "disable":
374+
restore_ctx = RestoreContext()
375+
else:
376+
restore_ctx = await RestoreContext.from_query_string(
377+
request.url.query, app=self
378+
)
379+
356380
if callable(self.ui):
357-
ui = self._render_page(self.ui(request), self.lib_prefix)
381+
# At this point, if `app.bookmark_store != "disable"`, then we've already
382+
# checked that `ui` is a function (in `App._init_bookmarking()`). No need to throw warning if `ui` is _not_ a function.
383+
with restore_context(restore_ctx):
384+
ui = self._render_page(self.ui(request), self.lib_prefix)
358385
else:
359386
ui = self.ui
360387
return HTMLResponse(content=ui["html"])
@@ -466,6 +493,30 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:
466493

467494
return rendered
468495

496+
# ==========================================================================
497+
# Bookmarking
498+
# ==========================================================================
499+
500+
def _init_bookmarking(self, *, bookmark_store: BookmarkStore, ui: Any) -> None:
501+
self._bookmark_save_dir_fn = bookmark_global_state.bookmark_save_dir
502+
self._bookmark_restore_dir_fn = bookmark_global_state.bookmark_restore_dir
503+
self._bookmark_store = bookmark_store
504+
505+
if bookmark_store != "disable" and not callable(ui):
506+
raise TypeError(
507+
"App(ui=) must be a function that accepts a request object to allow the UI to be properly reconstructed from a bookmarked state."
508+
)
509+
510+
@property
511+
def bookmark_store(self) -> BookmarkStore:
512+
return self._bookmark_store
513+
514+
def set_bookmark_save_dir_fn(self, bookmark_save_dir_fn: BookmarkDirFn):
515+
self._bookmark_save_dir_fn = as_bookmark_dir_fn(bookmark_save_dir_fn)
516+
517+
def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn):
518+
self._bookmark_restore_dir_fn = as_bookmark_dir_fn(bookmark_restore_dir_fn)
519+
469520

470521
def is_uifunc(x: Path | Tag | TagList | Callable[[Request], Tag | TagList]) -> bool:
471522
if (

shiny/_main_create.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -775,8 +775,7 @@ def copy_template_files(
775775
)
776776
sys.exit(1)
777777

778-
if not dest_dir.exists():
779-
dest_dir.mkdir()
778+
dest_dir.mkdir(parents=True, exist_ok=True)
780779

781780
for item in template.path.iterdir():
782781
if item.is_file():

shiny/_namespaces.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88

99
class ResolvedId(str):
10+
_sep: str = "-" # Shared object for all instances
11+
1012
def __call__(self, id: Id) -> ResolvedId:
1113
if isinstance(id, ResolvedId):
1214
return id
@@ -16,7 +18,7 @@ def __call__(self, id: Id) -> ResolvedId:
1618
if self == "":
1719
return ResolvedId(id)
1820
else:
19-
return ResolvedId(self + "-" + id)
21+
return ResolvedId(str(self) + self._sep + id)
2022

2123

2224
Root: ResolvedId = ResolvedId("")

shiny/_utils.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ async def fn_async(*args: P.args, **kwargs: P.kwargs) -> R:
281281
return fn_async
282282

283283

284-
# # TODO-barret-future; Q: Keep code?
284+
# # TODO: Barret - Future: Q: Keep code?
285285
# class WrapAsync(Generic[P, R]):
286286
# """
287287
# Make a function asynchronous.
@@ -517,11 +517,11 @@ async def __anext__(self):
517517
# ==============================================================================
518518
class Callbacks:
519519
def __init__(self) -> None:
520-
self._callbacks: dict[int, tuple[Callable[[], None], bool]] = {}
520+
self._callbacks: dict[int, tuple[Callable[..., None], bool]] = {}
521521
self._id: int = 0
522522

523523
def register(
524-
self, fn: Callable[[], None], once: bool = False
524+
self, fn: Callable[..., None], once: bool = False
525525
) -> Callable[[], None]:
526526
self._id += 1
527527
id = self._id
@@ -533,14 +533,14 @@ def _():
533533

534534
return _
535535

536-
def invoke(self) -> None:
536+
def invoke(self, *args: Any, **kwargs: Any) -> None:
537537
# The list() wrapper is necessary to force collection of all the items before
538538
# iteration begins. This is necessary because self._callbacks may be mutated
539539
# by callbacks.
540540
for id, value in list(self._callbacks.items()):
541541
fn, once = value
542542
try:
543-
fn()
543+
fn(*args, **kwargs)
544544
finally:
545545
if once:
546546
if id in self._callbacks:
@@ -550,32 +550,35 @@ def count(self) -> int:
550550
return len(self._callbacks)
551551

552552

553+
CancelCallback = Callable[[], None]
554+
555+
553556
class AsyncCallbacks:
554557
def __init__(self) -> None:
555-
self._callbacks: dict[int, tuple[Callable[[], Awaitable[None]], bool]] = {}
558+
self._callbacks: dict[int, tuple[Callable[..., Awaitable[None]], bool]] = {}
556559
self._id: int = 0
557560

558561
def register(
559-
self, fn: Callable[[], Awaitable[None]], once: bool = False
560-
) -> Callable[[], None]:
562+
self, fn: Callable[..., Awaitable[None]], once: bool = False
563+
) -> CancelCallback:
561564
self._id += 1
562565
id = self._id
563566
self._callbacks[id] = (fn, once)
564567

565-
def _():
568+
def cancel_callback():
566569
if id in self._callbacks:
567570
del self._callbacks[id]
568571

569-
return _
572+
return cancel_callback
570573

571-
async def invoke(self) -> None:
574+
async def invoke(self, *args: Any, **kwargs: Any) -> None:
572575
# The list() wrapper is necessary to force collection of all the items before
573576
# iteration begins. This is necessary because self._callbacks may be mutated
574577
# by callbacks.
575578
for id, value in list(self._callbacks.items()):
576579
fn, once = value
577580
try:
578-
await fn()
581+
await fn(*args, **kwargs)
579582
finally:
580583
if once:
581584
if id in self._callbacks:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from starlette.requests import Request
2+
3+
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
4+
from shiny.bookmark import BookmarkState
5+
6+
7+
# App UI **must** be a function to ensure that each user restores their own UI values.
8+
def app_ui(request: Request):
9+
return ui.page_fluid(
10+
ui.markdown(
11+
"Directions: "
12+
"\n1. Change the radio buttons below"
13+
"\n2. Refresh your browser."
14+
"\n3. The radio buttons should be restored to their previous state."
15+
"\n4. Check the console messages for bookmarking events."
16+
),
17+
ui.hr(),
18+
ui.input_radio_buttons(
19+
"letter",
20+
"Choose a letter (Store in Bookmark 'input')",
21+
choices=["A", "B", "C"],
22+
),
23+
ui.input_radio_buttons(
24+
"letter_values",
25+
"Choose a letter (Stored in Bookmark 'values' as lowercase)",
26+
choices=["A", "B", "C"],
27+
),
28+
"Selection:",
29+
ui.output_code("letters"),
30+
)
31+
32+
33+
def server(input: Inputs, output: Outputs, session: Session):
34+
35+
# Exclude `"letter_values"` from being saved in the bookmark as we'll store it manually for example's sake
36+
# Append or adjust this list as needed.
37+
session.bookmark.exclude.append("letter_values")
38+
39+
lowercase_letter = reactive.value()
40+
41+
@reactive.effect
42+
@reactive.event(input.letter_values)
43+
async def _():
44+
lowercase_letter.set(input.letter_values().lower())
45+
46+
@render.code
47+
def letters():
48+
return str([input.letter(), lowercase_letter()])
49+
50+
# When the user interacts with the input, we will bookmark the state.
51+
@reactive.effect
52+
@reactive.event(input.letter, lowercase_letter, ignore_init=True)
53+
async def _():
54+
await session.bookmark()
55+
56+
# Before saving state, we can adjust the bookmark state values object
57+
@session.bookmark.on_bookmark
58+
async def _(state: BookmarkState):
59+
print("Bookmark state:", state.input, state.values, state.dir)
60+
with reactive.isolate():
61+
state.values["lowercase"] = lowercase_letter()
62+
63+
# After saving state, we will update the query string with the bookmark URL.
64+
@session.bookmark.on_bookmarked
65+
async def _(url: str):
66+
print("Bookmarked url:", url)
67+
await session.bookmark.update_query_string(url)
68+
69+
@session.bookmark.on_restore
70+
def _(state: BookmarkState):
71+
print("Restore state:", state.input, state.values, state.dir)
72+
73+
# Update the radio button selection based on the restored state.
74+
if "lowercase" in state.values:
75+
uppercase = state.values["lowercase"].upper()
76+
# 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.
77+
ui.update_radio_buttons("letter_values", selected=uppercase)
78+
79+
@session.bookmark.on_restored
80+
def _(state: BookmarkState):
81+
# For rare cases, you can update the UI after the session has been fully restored.
82+
print("Restored state:", state.input, state.values, state.dir)
83+
84+
85+
# Make sure to set the bookmark_store to `"url"` (or `"server"`)
86+
# to store the bookmark information/key in the URL query string.
87+
app = App(app_ui, server, bookmark_store="url")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from starlette.requests import Request
2+
3+
from shiny import App, Inputs, Outputs, Session, ui
4+
5+
6+
# App UI **must** be a function to ensure that each user restores their own UI values.
7+
def app_ui(request: Request):
8+
return ui.page_fluid(
9+
ui.markdown(
10+
"Directions: "
11+
"\n1. Change the radio button selection below"
12+
"\n2. Save the bookmark."
13+
"\n3. Then, refresh your browser page to see the radio button selection has been restored."
14+
),
15+
ui.hr(),
16+
ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]),
17+
ui.input_bookmark_button(label="Save bookmark!"),
18+
)
19+
20+
21+
def server(input: Inputs, output: Outputs, session: Session):
22+
23+
# @reactive.effect
24+
# @reactive.event(input.letter, ignore_init=True)
25+
# async def _():
26+
# await session.bookmark()
27+
28+
@session.bookmark.on_bookmarked
29+
async def _(url: str):
30+
await session.bookmark.update_query_string(url)
31+
32+
33+
app = App(app_ui, server, bookmark_store="url")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from shiny.express import app_opts, session, ui
2+
3+
app_opts(bookmark_store="url")
4+
5+
6+
ui.markdown(
7+
"Directions: "
8+
"\n1. Change the radio button selection below"
9+
"\n2. Save the bookmark."
10+
"\n3. Then, refresh your browser page to see the radio button selection has been restored."
11+
)
12+
13+
14+
ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"])
15+
ui.input_bookmark_button()
16+
17+
18+
@session.bookmark.on_bookmarked
19+
async def _(url: str):
20+
await session.bookmark.update_query_string(url)

0 commit comments

Comments
 (0)