Skip to content

Commit ca671cd

Browse files
authored
feat(bookmark): Support bookmarking input files (#1945)
1 parent 1e4df73 commit ca671cd

File tree

10 files changed

+184
-44
lines changed

10 files changed

+184
-44
lines changed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ include LICENSE
22

33
recursive-include tests *
44
recursive-exclude * __pycache__
5+
recursive-exclude * shiny_bookmarks
56
recursive-exclude * *.py[co]
67

78
recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ doc = [
134134
"griffe>=1.3.2",
135135
]
136136

137+
[tool.uv.sources]
138+
# https://github.com/encode/uvicorn/pull/2602
139+
uvicorn = { git = "https://github.com/schloerke/uvicorn", branch = "reload-exclude-abs-path" }
140+
137141
[project.urls]
138142
Homepage = "https://github.com/posit-dev/py-shiny"
139143
Documentation = "https://shiny.posit.co/py/"

shiny/_main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from . import __version__, _autoreload, _hostenv, _static, _utils
2222
from ._docstring import no_example
2323
from ._typing_extensions import NotRequired, TypedDict
24+
from .bookmark._bookmark_state import shiny_bookmarks_folder_name
2425
from .express import is_express_app
2526
from .express._utils import escape_to_var_name
2627

@@ -44,7 +45,15 @@ def main() -> None:
4445
"*.yml",
4546
"*.yaml",
4647
)
47-
RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv")
48+
RELOAD_EXCLUDES_DEFAULT = (
49+
".*",
50+
"*.py[cod]",
51+
"__pycache__",
52+
"env",
53+
"venv",
54+
".venv",
55+
shiny_bookmarks_folder_name,
56+
)
4857

4958

5059
@main.command(

shiny/bookmark/_bookmark_state.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import os
44
from pathlib import Path
55

6+
shiny_bookmarks_folder_name = "shiny_bookmarks"
7+
68

79
def _local_dir(id: str) -> Path:
810
# Try to save/load from current working directory as we do not know where the
911
# app file is located
10-
return Path(os.getcwd()) / "shiny_bookmarks" / id
12+
return Path(os.getcwd()) / shiny_bookmarks_folder_name / id
1113

1214

1315
async def local_save_dir(id: str) -> Path:

shiny/bookmark/_serializers.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
from __future__ import annotations
22

3+
import warnings
34
from pathlib import Path
45
from shutil import copyfile
5-
from typing import Any, TypeVar
6+
from typing import TYPE_CHECKING, Any, TypeVar
67

78
from typing_extensions import TypeIs
89

10+
if TYPE_CHECKING:
11+
from ..session import Session
12+
913

1014
class Unserializable: ...
1115

@@ -28,22 +32,64 @@ async def serializer_default(value: T, state_dir: Path | None) -> T:
2832
return value
2933

3034

31-
# TODO: Barret - Integrate
32-
def serializer_file_input(value: Any, state_dir: Path | None) -> Any | Unserializable:
35+
def serializer_file_input(
36+
value: list[dict[str, str | int]], state_dir: Path | None
37+
) -> Any | Unserializable:
3338
if state_dir is None:
39+
warnings.warn(
40+
"`shiny.ui.input_file()` is attempting to save bookmark state. "
41+
'However the App\'s `bookmark_store=` is not set to `"server"`. '
42+
"Either exclude the input value (`session.bookmark.exclude.append(NAME)`) "
43+
'or set `bookmark_store="server"`.',
44+
UserWarning,
45+
stacklevel=1,
46+
)
3447
return Unserializable()
3548

36-
# TODO: Barret - Double check this logic!
37-
38-
# `value` is a data frame. When persisting files, we need to copy the file to
49+
# `value` is a "data frame" (list of arrays). When persisting files, we need to copy the file to
3950
# the persistent dir and then strip the original path before saving.
40-
datapath = Path(value["datapath"])
41-
new_paths = state_dir / datapath.name
42-
43-
if new_paths.exists():
44-
new_paths.unlink()
45-
copyfile(datapath, new_paths)
4651

47-
value["datapath"] = new_paths.name
48-
49-
return value
52+
if not isinstance(value, list):
53+
raise ValueError(
54+
f"Invalid value type for file input. Expected list, received: {type(value)}"
55+
)
56+
57+
ret_file_infos = value.copy()
58+
59+
for i, file_info in enumerate(ret_file_infos):
60+
if not isinstance(file_info, dict):
61+
raise ValueError(
62+
f"Invalid file info type for file input ({i}). "
63+
f"Expected dict, received: {type(file_info)}"
64+
)
65+
if "datapath" not in file_info:
66+
raise ValueError(f"Missing 'datapath' key in file info ({i}).")
67+
if not isinstance(file_info["datapath"], str):
68+
raise TypeError(
69+
f"Invalid type for 'datapath' in file info ({i}). "
70+
f"Expected str, received: {type(file_info['datapath'])}"
71+
)
72+
73+
datapath = Path(file_info["datapath"])
74+
new_path = state_dir / datapath.name
75+
if new_path.exists():
76+
new_path.unlink()
77+
copyfile(datapath, new_path)
78+
79+
# Store back into the file_info dict to update `ret_file_infos`
80+
file_info["datapath"] = new_path.name
81+
82+
return ret_file_infos
83+
84+
85+
def can_serialize_input_file(session: Session) -> bool:
86+
"""
87+
Check if the session can serialize file input.
88+
89+
Args:
90+
session (Session): The current session.
91+
92+
Returns:
93+
bool: True if the session can serialize file input, False otherwise.
94+
"""
95+
return session.bookmark.store == "server"

shiny/input_handler.py

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

33
from datetime import date, datetime, timezone
4+
from pathlib import Path
45
from typing import TYPE_CHECKING, Any, Callable, Dict
56

67
from .bookmark import serializer_unserializable
8+
from .bookmark._serializers import can_serialize_input_file, serializer_file_input
79

810
if TYPE_CHECKING:
911
from .session import Session
@@ -173,33 +175,80 @@ def _(value: Any, name: ResolvedId, session: Session) -> Any:
173175
if value is None:
174176
return None
175177

176-
# TODO: Barret: Input handler for file inputs
178+
if not can_serialize_input_file(session):
179+
raise ValueError(
180+
"`shiny.ui.input_file()` is attempting to restore bookmark state. "
181+
'However the App\'s `bookmark_store=` is not set to `"server"`. '
182+
"Either exclude the input value (`session.bookmark.exclude.append(NAME)`) "
183+
'or set `bookmark_store="server"`.'
184+
)
177185

178-
# # The data will be a named list of lists; convert to a data frame.
179-
# val <- as.data.frame(lapply(val, unlist), stringsAsFactors = FALSE)
186+
value_obj = value
187+
188+
# Convert from:
189+
# `{name: (n1, n2, n3), size: (s1, s2, s3), type: (t1, t2, t3), datapath: (d1, d2, d3)}`
190+
# to:
191+
# `[{name: n1, size: s1, type: t1, datapath: d1}, ...]`
192+
value_list: list[dict[str, str | int | None]] = []
193+
for i in range(len(value_obj["name"])):
194+
value_list.append(
195+
{
196+
"name": value_obj["name"][i],
197+
"size": value_obj["size"][i],
198+
"type": value_obj["type"][i],
199+
"datapath": value_obj["datapath"][i],
200+
}
201+
)
180202

181-
# # `val$datapath` should be a filename without a path, for security reasons.
182-
# if (basename(val$datapath) != val$datapath) {
183-
# stop("Invalid '/' found in file input path.")
184-
# }
203+
# Validate the input value
204+
for value_item in value_list:
205+
if value_item["datapath"] is not None:
206+
if not isinstance(value_item["datapath"], str):
207+
raise ValueError(
208+
"Invalid type for file input path: ", type(value_item["datapath"])
209+
)
210+
if Path(value_item["datapath"]).name != value_item["datapath"]:
211+
raise ValueError("Invalid '/' found in file input path.")
185212

186-
# # Prepend the persistent dir
187-
# oldfile <- file.path(getCurrentRestoreContext()$dir, val$datapath)
213+
import shutil
214+
import tempfile
188215

189-
# # Copy the original file to a new temp dir, so that a restored session can't
190-
# # modify the original.
191-
# newdir <- file.path(tempdir(), createUniqueId(12))
192-
# dir.create(newdir)
193-
# val$datapath <- file.path(newdir, val$datapath)
194-
# file.copy(oldfile, val$datapath)
216+
from shiny._utils import rand_hex
195217

196-
# # Need to mark this input value with the correct serializer. When a file is
197-
# # uploaded the usual way (instead of being restored), this occurs in
198-
# # session$`@uploadEnd`.
199-
# setSerializer(name, serializerFileInput)
218+
from .bookmark._restore_state import get_current_restore_context
219+
from .session import session_context
200220

201-
# snapshotPreprocessInput(name, snapshotPreprocessorFileInput)
221+
with session_context(session):
222+
restore_ctx = get_current_restore_context()
202223

203-
# val
224+
# These should not fail as we know
225+
if restore_ctx is None or restore_ctx.dir is None:
226+
raise RuntimeError("No restore context found. Cannot restore file input.")
204227

205-
return value
228+
restore_ctx_dir = Path(restore_ctx.dir)
229+
230+
if len(value_list) > 0:
231+
tempdir_root = tempfile.TemporaryDirectory()
232+
session.on_ended(lambda: tempdir_root.cleanup())
233+
234+
for f in value_list:
235+
assert f["datapath"] is not None and isinstance(f["datapath"], str)
236+
237+
data_path = f["datapath"]
238+
239+
# Prepend the persistent dir
240+
old_file = restore_ctx_dir / data_path
241+
242+
# Copy the original file to a new temp dir, so that a restored session can't
243+
# modify the original.
244+
tempdir = Path(tempdir_root.name) / rand_hex(12)
245+
tempdir.mkdir(parents=True, exist_ok=True)
246+
f["datapath"] = str(tempdir / Path(data_path).name)
247+
shutil.copy2(old_file, f["datapath"])
248+
249+
# Need to mark this input value with the correct serializer. When a file is
250+
# uploaded the usual way (instead of being restored), this occurs in
251+
# session$`@uploadEnd`.
252+
session.input.set_serializer(name, serializer_file_input)
253+
254+
return value_list

shiny/session/_session.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from ..bookmark import BookmarkApp, BookmarkProxy
4747
from ..bookmark._button import BOOKMARK_ID
4848
from ..bookmark._restore_state import RestoreContext
49+
from ..bookmark._serializers import serializer_file_input
4950
from ..http_staticfiles import FileResponse
5051
from ..input_handler import input_handlers
5152
from ..module import ResolvedId
@@ -824,6 +825,10 @@ async def uploadEnd(job_id: str, input_id: str) -> None:
824825
# by wrapping it in ResolvedId, otherwise self.input will throw an id
825826
# validation error.
826827
self.input[ResolvedId(input_id)]._set(file_data)
828+
829+
# This also occurs during input handler: shiny.file
830+
self.input.set_serializer(input_id, serializer_file_input)
831+
827832
# Explicitly return None to signal that the message was handled.
828833
return None
829834

shiny/ui/_input_file.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
from __future__ import annotations
22

3-
__all__ = ("input_file",)
4-
3+
import warnings
54
from typing import Literal, Optional
65

76
from htmltools import Tag, TagChild, css, div, span, tags
87

98
from .._docstring import add_example
9+
from ..bookmark import restore_input
10+
from ..bookmark._utils import to_json_str
1011
from ..module import resolve_id
1112
from ._utils import shiny_input_label
1213

14+
__all__ = ("input_file",)
15+
1316

1417
@add_example()
1518
def input_file(
@@ -83,6 +86,28 @@ def input_file(
8386
accept = [accept]
8487

8588
resolved_id = resolve_id(id)
89+
restored_value = restore_input(resolved_id, default=None)
90+
91+
if restored_value is not None:
92+
restored_obj: dict[str, list[str | int | None]] = {
93+
"name": [],
94+
"size": [],
95+
"type": [],
96+
"datapath": [],
97+
}
98+
try:
99+
for file in restored_value:
100+
restored_obj["name"].append(file.get("name", None))
101+
restored_obj["size"].append(file.get("size", None))
102+
restored_obj["type"].append(file.get("type", None))
103+
restored_obj["datapath"].append(file.get("datapath", None))
104+
restored_value = to_json_str(restored_obj)
105+
except Exception:
106+
warnings.warn(
107+
f"Error while restoring file input value for `{resolved_id}`. Resetting to `None`.",
108+
stacklevel=1,
109+
)
110+
86111
btn_file = span(
87112
button_label,
88113
tags.input(
@@ -95,6 +120,7 @@ def input_file(
95120
# Don't use "display: none;" style, which causes keyboard accessibility issue; instead use the following workaround: https://css-tricks.com/places-its-tempting-to-use-display-none-but-dont/
96121
style="position: absolute !important; top: -99999px !important; left: -99999px !important;",
97122
class_="shiny-input-file",
123+
**({"data-restore": restored_value} if restored_value else {}),
98124
),
99125
class_="btn btn-default btn-file",
100126
)

tests/playwright/ai_generated_apps/bookmark/input_file/app-express.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from shiny.express import app_opts, input, module, render, session, ui
22

3-
app_opts(bookmark_store="url")
3+
app_opts(bookmark_store="server")
44

55
with ui.card():
66
ui.card_header("Bookmarking File Input Demo")

tests/playwright/ai_generated_apps/bookmark/input_file/test_input_file_express_bookmarking.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import pytest
21
from playwright.sync_api import FilePayload, Page
32

43
from shiny.playwright import controller
@@ -8,7 +7,6 @@
87
app = create_app_fixture(["app-express.py"])
98

109

11-
@pytest.mark.skip("Broken test! TODO: Barret")
1210
def test_file_input_bookmarking(page: Page, app: ShinyAppProc) -> None:
1311
page.goto(app.url)
1412

0 commit comments

Comments
 (0)