Skip to content

Commit a50860e

Browse files
committed
Allow for App to set bookmark save/restore dir functions
1 parent aadf050 commit a50860e

File tree

12 files changed

+277
-53
lines changed

12 files changed

+277
-53
lines changed

shiny/_app.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@
3131
from ._error import ErrorMiddleware
3232
from ._shinyenv import is_pyodide
3333
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
34+
from .bookmark import _global as bookmark_global_state
35+
from .bookmark._global import as_bookmark_dir_fn
3436
from .bookmark._restore_state import RestoreContext, restore_context
35-
from .bookmark._types import BookmarkStore
37+
from .bookmark._types import (
38+
BookmarkDirFn,
39+
BookmarkRestoreDirFn,
40+
BookmarkSaveDirFn,
41+
BookmarkStore,
42+
)
3643
from .html_dependencies import jquery_deps, require_deps, shiny_deps
3744
from .http_staticfiles import FileResponse, StaticFiles
3845
from .session._session import AppSession, Inputs, Outputs, Session, session_context
@@ -109,12 +116,10 @@ def server(input: Inputs, output: Outputs, session: Session):
109116
ui: RenderedHTML | Callable[[Request], Tag | TagList]
110117
server: Callable[[Inputs, Outputs, Session], None]
111118

119+
_bookmark_save_dir_fn: BookmarkSaveDirFn | None
120+
_bookmark_restore_dir_fn: BookmarkRestoreDirFn | None
112121
_bookmark_store: BookmarkStore
113122

114-
@property
115-
def bookmark_store(self) -> BookmarkStore:
116-
return self._bookmark_store
117-
118123
def __init__(
119124
self,
120125
ui: Tag | TagList | Callable[[Request], Tag | TagList] | Path,
@@ -144,7 +149,7 @@ def __init__(
144149
"`server` must have 1 (Inputs) or 3 parameters (Inputs, Outputs, Session)"
145150
)
146151

147-
self._bookmark_store = bookmark_store
152+
self._init_bookmarking(bookmark_store=bookmark_store)
148153

149154
self._debug: bool = debug
150155

@@ -369,7 +374,9 @@ async def _on_root_request_cb(self, request: Request) -> Response:
369374
if self.bookmark_store == "disable":
370375
restore_ctx = RestoreContext()
371376
else:
372-
restore_ctx = await RestoreContext.from_query_string(request.url.query)
377+
restore_ctx = await RestoreContext.from_query_string(
378+
request.url.query, app=self
379+
)
373380

374381
with restore_context(restore_ctx):
375382
if callable(self.ui):
@@ -492,6 +499,25 @@ def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:
492499

493500
return rendered
494501

502+
# ==========================================================================
503+
# Bookmarking
504+
# ==========================================================================
505+
506+
def _init_bookmarking(self, *, bookmark_store: BookmarkStore) -> None:
507+
self._bookmark_save_dir_fn = bookmark_global_state.bookmark_save_dir
508+
self._bookmark_restore_dir_fn = bookmark_global_state.bookmark_restore_dir
509+
self._bookmark_store = bookmark_store
510+
511+
@property
512+
def bookmark_store(self) -> BookmarkStore:
513+
return self._bookmark_store
514+
515+
def set_bookmark_save_dir_fn(self, bookmark_save_dir_fn: BookmarkDirFn):
516+
self._bookmark_save_dir_fn = as_bookmark_dir_fn(bookmark_save_dir_fn)
517+
518+
def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn):
519+
self._bookmark_restore_dir_fn = as_bookmark_dir_fn(bookmark_restore_dir_fn)
520+
495521

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

shiny/bookmark/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
BookmarkProxy,
66
)
77
from ._button import input_bookmark_button
8-
from ._external import set_restore_dir, set_save_dir
8+
from ._global import set_global_restore_dir_fn, set_global_save_dir_fn
99
from ._restore_state import RestoreContext, RestoreState, restore_input
1010
from ._save_state import BookmarkState
1111

@@ -18,8 +18,8 @@
1818
# _button
1919
"input_bookmark_button",
2020
# _external
21-
"set_save_dir",
22-
"set_restore_dir",
21+
"set_global_save_dir_fn",
22+
"set_global_restore_dir_fn",
2323
# _restore_state
2424
"RestoreContext",
2525
"RestoreState",

shiny/bookmark/_bookmark.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ async def root_state_on_save(state: BookmarkState) -> None:
462462
)
463463

464464
if self.store == "server":
465-
query_string = await root_state._save_state()
465+
query_string = await root_state._save_state(app=self._session_root.app)
466466
elif self.store == "url":
467467
query_string = await root_state._encode_state()
468468
# # Can we have browser storage?
Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

3-
from pathlib import Path
4-
from typing import Awaitable, Callable, Literal, TypeVar
3+
from typing import overload
54

65
from .._utils import wrap_async
7-
from ..types import MISSING, MISSING_TYPE
8-
from ._types import GetBookmarkRestoreDir, GetBookmarkSaveDir
9-
10-
BookmarkStore = Literal["url", "server", "disable"]
11-
6+
from ._types import (
7+
BookmarkDirFn,
8+
BookmarkDirFnAsync,
9+
BookmarkRestoreDirFn,
10+
BookmarkSaveDirFn,
11+
)
1212

1313
# WARNING! This file contains global state!
1414
# During App initialization, the save_dir and restore_dir functions are conventionally set
@@ -17,40 +17,51 @@
1717
# The set methods below are used to set the save_dir and restore_dir locations for locations like Connect or SSP.
1818
# Ex:
1919
# ```python
20-
# @shiny.bookmark.set_save_dir
20+
# @shiny.bookmark.set_global_save_dir_fn
2121
# def connect_save_shiny_bookmark(id: str) -> Path:
2222
# path = Path("connect") / id
2323
# path.mkdir(parents=True, exist_ok=True)
2424
# return path
25-
# @shiny.bookmark.set_restore_dir
25+
# @shiny.bookmark.set_global_restore_dir_fn
2626
# def connect_restore_shiny_bookmark(id: str) -> Path:
2727
# return Path("connect") / id
2828
# ```
2929

3030

31-
_bookmark_save_dir: GetBookmarkSaveDir | MISSING_TYPE = MISSING
32-
_bookmark_restore_dir: GetBookmarkRestoreDir | MISSING_TYPE = MISSING
31+
bookmark_save_dir: BookmarkSaveDirFn | None = None
32+
bookmark_restore_dir: BookmarkRestoreDirFn | None = None
3333

3434

35-
GetBookmarkDirT = TypeVar(
36-
"GetBookmarkDirT",
37-
bound=Callable[[str], Awaitable[Path]] | Callable[[str], Awaitable[Path]],
38-
)
35+
@overload
36+
def as_bookmark_dir_fn(fn: BookmarkDirFn) -> BookmarkDirFnAsync:
37+
pass
38+
39+
40+
@overload
41+
def as_bookmark_dir_fn(fn: None) -> None:
42+
pass
43+
44+
45+
def as_bookmark_dir_fn(fn: BookmarkDirFn | None) -> BookmarkDirFnAsync | None:
46+
if fn is None:
47+
return None
48+
return wrap_async(fn)
49+
3950

4051
# TODO: Barret - Integrate Set / Restore for Connect. Ex: Connect https://github.com/posit-dev/connect/blob/8de330aec6a61cf21e160b5081d08a1d3d7e8129/R/connect.R#L915
4152

4253

43-
def set_save_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT:
54+
def set_global_save_dir_fn(fn: BookmarkDirFn):
4455
"""TODO: Barret document"""
45-
global _bookmark_save_dir
56+
global bookmark_save_dir
4657

47-
_bookmark_save_dir = wrap_async(fn)
58+
bookmark_save_dir = as_bookmark_dir_fn(fn)
4859
return fn
4960

5061

51-
def set_restore_dir(fn: GetBookmarkDirT) -> GetBookmarkDirT:
62+
def set_global_restore_dir_fn(fn: BookmarkDirFn):
5263
"""TODO: Barret document"""
53-
global _bookmark_restore_dir
64+
global bookmark_restore_dir
5465

55-
_bookmark_restore_dir = wrap_async(fn)
66+
bookmark_restore_dir = as_bookmark_dir_fn(fn)
5667
return fn

shiny/bookmark/_restore_state.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
from contextlib import contextmanager
66
from contextvars import ContextVar, Token
77
from pathlib import Path
8-
from typing import Any, Literal, Optional
8+
from typing import TYPE_CHECKING, Any, Literal, Optional
99
from urllib.parse import parse_qs, parse_qsl
1010

11-
from shiny.types import MISSING_TYPE
12-
13-
from . import _external as bookmark_external
1411
from ._bookmark_state import local_restore_dir
15-
from ._types import GetBookmarkRestoreDir
1612
from ._utils import from_json_str, is_hosted
13+
from ._types import BookmarkRestoreDirFn
14+
15+
if TYPE_CHECKING:
16+
from .._app import App
1717

1818

1919
class RestoreState:
@@ -105,7 +105,7 @@ def reset(self) -> None:
105105
self.dir = None
106106

107107
@staticmethod
108-
async def from_query_string(query_string: str) -> "RestoreContext":
108+
async def from_query_string(query_string: str, *, app: App) -> "RestoreContext":
109109
res_ctx = RestoreContext()
110110

111111
if query_string.startswith("?"):
@@ -128,7 +128,7 @@ async def from_query_string(query_string: str) -> "RestoreContext":
128128
# ignore other key/value pairs. If not, restore from key/value
129129
# pairs in the query string.
130130
res_ctx.active = True
131-
await res_ctx._load_state_qs(query_string)
131+
await res_ctx._load_state_qs(query_string, app=app)
132132

133133
else:
134134
# The query string contains the saved keys and values
@@ -178,7 +178,7 @@ def as_state(self) -> RestoreState:
178178
dir=self.dir,
179179
)
180180

181-
async def _load_state_qs(self, query_string: str) -> None:
181+
async def _load_state_qs(self, query_string: str, *, app: App) -> None:
182182
"""Given a query string with a _state_id_, load saved state with that ID."""
183183
values = parse_qs(query_string)
184184
id = values.get("_state_id_", None)
@@ -188,9 +188,7 @@ async def _load_state_qs(self, query_string: str) -> None:
188188

189189
id = id[0]
190190

191-
load_bookmark_fn: GetBookmarkRestoreDir | None = None
192-
if not isinstance(bookmark_external._bookmark_restore_dir, MISSING_TYPE):
193-
load_bookmark_fn = bookmark_external._bookmark_restore_dir
191+
load_bookmark_fn: BookmarkRestoreDirFn | None = app._bookmark_restore_dir_fn
194192

195193
if load_bookmark_fn is None:
196194
if is_hosted():
@@ -207,6 +205,7 @@ async def _load_state_qs(self, query_string: str) -> None:
207205
if not self.dir.exists():
208206
raise ValueError("Bookmarked state directory does not exist.")
209207

208+
# TODO: Barret; Store/restore as JSON
210209
with open(self.dir / "input.pickle", "rb") as f:
211210
input_values = pickle.load(f)
212211
self.input = RestoreInputSet(input_values)

shiny/bookmark/_save_state.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77

88
from .._utils import private_random_id
99
from ..reactive import isolate
10-
from ..types import MISSING_TYPE
11-
from . import _external as bookmark_external
1210
from ._bookmark_state import local_save_dir
13-
from ._types import GetBookmarkSaveDir
1411
from ._utils import is_hosted, to_json_str
12+
from ._types import BookmarkSaveDirFn
1513

1614
if TYPE_CHECKING:
15+
from shiny._app import App
16+
1717
from .. import Inputs
1818
else:
1919
Inputs = Any
@@ -56,7 +56,7 @@ async def _call_on_save(self):
5656
with isolate():
5757
await self.on_save(self)
5858

59-
async def _save_state(self) -> str:
59+
async def _save_state(self, *, app: App) -> str:
6060
"""
6161
Save a state to disk (pickle).
6262
@@ -72,9 +72,7 @@ async def _save_state(self) -> str:
7272
# to `self.dir`.
7373

7474
# This will be defined by the hosting environment if it supports bookmarking.
75-
save_bookmark_fn: GetBookmarkSaveDir | None = None
76-
if not isinstance(bookmark_external._bookmark_save_dir, MISSING_TYPE):
77-
save_bookmark_fn = bookmark_external._bookmark_save_dir
75+
save_bookmark_fn: BookmarkSaveDirFn | None = app._bookmark_save_dir_fn
7876

7977
if save_bookmark_fn is None:
8078
if is_hosted():

shiny/bookmark/_types.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55

66
# 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.
77
# 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.
8-
GetBookmarkSaveDir = Callable[[str], Awaitable[Path]]
9-
GetBookmarkRestoreDir = Callable[[str], Awaitable[Path]]
8+
9+
BookmarkDirFn = Callable[[str], Awaitable[Path]] | Callable[[str], Path]
10+
BookmarkDirFnAsync = Callable[[str], Awaitable[Path]]
11+
12+
BookmarkSaveDirFn = BookmarkDirFnAsync
13+
BookmarkRestoreDirFn = BookmarkDirFnAsync
1014

1115

1216
BookmarkStore = Literal["url", "server", "disable"]

shiny/session/_session.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,8 @@ def verify_state(expected_state: ConnectionState) -> None:
641641
if ".clientdata_url_search" in message_obj["data"]:
642642
self.bookmark._restore_context_value = (
643643
await RestoreContext.from_query_string(
644-
message_obj["data"][".clientdata_url_search"]
644+
message_obj["data"][".clientdata_url_search"],
645+
app=self.app,
645646
)
646647
)
647648
else:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bookmarks-*/
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
from starlette.requests import Request
5+
6+
from shiny import App, Inputs, Outputs, Session, reactive, render, ui
7+
from shiny._utils import rand_hex
8+
9+
10+
def app_ui(request: Request):
11+
return ui.page_fluid(
12+
ui.input_radio_buttons("letter", "Choose a letter", choices=["A", "B", "C"]),
13+
ui.h3("Has saved:"),
14+
ui.output_code("called_saved"),
15+
ui.h3("Has restored:"),
16+
ui.output_code("called_restored"),
17+
)
18+
19+
20+
def server(input: Inputs, ouput: Outputs, session: Session):
21+
22+
@reactive.effect
23+
@reactive.event(input.letter, ignore_init=True)
24+
async def _():
25+
await session.bookmark()
26+
27+
@session.bookmark.on_bookmarked
28+
async def _(url: str):
29+
await session.bookmark.update_query_string(url)
30+
31+
@render.code
32+
def called_saved():
33+
reactive.invalidate_later(1)
34+
return str(did_save)
35+
36+
@render.code
37+
def called_restored():
38+
reactive.invalidate_later(1)
39+
return str(did_restore)
40+
41+
42+
did_save = False
43+
did_restore = False
44+
45+
bookmark_dir = Path(__file__).parent / f"bookmarks-{rand_hex(8)}"
46+
bookmark_dir.mkdir(exist_ok=True)
47+
48+
app = App(app_ui, server, bookmark_store="server")
49+
50+
51+
app.on_shutdown(lambda: shutil.rmtree(bookmark_dir))
52+
53+
54+
def restore_bookmark_dir(id: str) -> Path:
55+
global did_restore
56+
did_restore = True
57+
return bookmark_dir / id
58+
59+
60+
def save_bookmark_dir(id: str) -> Path:
61+
global did_save
62+
did_save = True
63+
save_dir = bookmark_dir / id
64+
save_dir.mkdir(parents=True, exist_ok=True)
65+
return save_dir
66+
67+
68+
# Same exact app as `app-global.py`, except we're using `App` functions to set the save and restore directories.
69+
app.set_bookmark_save_dir_fn(save_bookmark_dir)
70+
app.set_bookmark_restore_dir_fn(restore_bookmark_dir)

0 commit comments

Comments
 (0)