Skip to content

Commit 84f872b

Browse files
authored
Allow serving static assets in Shiny Express apps (#1170)
1 parent af891a6 commit 84f872b

File tree

9 files changed

+157
-18
lines changed

9 files changed

+157
-18
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
* `ui.sidebar(open=)` now accepts a dictionary with keys `desktop` and `mobile`, allowing you to independently control the initial state of the sidebar at desktop and mobile screen sizes. (#1129)
2222

23+
* Closed #984: In Shiny Express apps, if there is a `"www"` subdirectory in the app's directory, Shiny will serve the files in that directory as static assets, mounted at `/`. (#1170)
24+
25+
* For Shiny Express apps, added `express.app_opts()`, which allows setting application-level options, like `static_assets` and `debug`. (#1170)
26+
2327
### Other changes
2428

2529
* `@render.data_frame` now properly fills its container by default. (#1126)

docs/_quartodoc-express.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ quartodoc:
177177
# - express.ui.fill.remove_all_fill
178178
# - express.ui.css.as_css_unit
179179
# - express.ui.css.as_css_padding
180+
- title: Application-level settings
181+
desc: Set application-level settings.
182+
contents:
183+
- express.app_opts
180184
- title: Express developer tooling
181185
desc:
182186
contents:

shiny/_app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from ._connection import Connection, StarletteConnection
3030
from ._error import ErrorMiddleware
3131
from ._shinyenv import is_pyodide
32-
from ._utils import guess_mime_type, is_async_callable
32+
from ._utils import guess_mime_type, is_async_callable, sort_keys_length
3333
from .html_dependencies import jquery_deps, require_deps, shiny_deps
3434
from .http_staticfiles import FileResponse, StaticFiles
3535
from .session._session import Inputs, Outputs, Session, session_context
@@ -149,6 +149,12 @@ def __init__(
149149
)
150150
static_assets = {"/": Path(static_assets)}
151151

152+
# Sort the static assets keys by descending length, to ensure that the most
153+
# specific paths are mounted first. Suppose there are mounts "/foo" and "/". If
154+
# "/" is first in the dict, then requests to "/foo/file.html" will never reach
155+
# the second mount. We need to put "/foo" first and "/" second so that it will
156+
# actually look in the "/foo" mount.
157+
static_assets = sort_keys_length(static_assets, descending=True)
152158
self._static_assets: dict[str, Path] = static_assets
153159

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

shiny/_utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
CancelledError = asyncio.CancelledError
1919

20+
T = TypeVar("T")
21+
2022

2123
# ==============================================================================
2224
# Misc utility functions
@@ -49,6 +51,12 @@ def lists_to_tuples(x: object) -> object:
4951
return x
5052

5153

54+
# Given a dictionary, return a new dictionary with the keys sorted by length.
55+
def sort_keys_length(x: dict[str, T], descending: bool = False) -> dict[str, T]:
56+
sorted_keys = sorted(x.keys(), key=len, reverse=descending)
57+
return {key: x[key] for key in sorted_keys}
58+
59+
5260
def guess_mime_type(
5361
url: "str | os.PathLike[str]",
5462
default: str = "application/octet-stream",

shiny/express/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
output_args, # pyright: ignore[reportUnusedImport]
1616
suspend_display, # pyright: ignore[reportUnusedImport] - Deprecated
1717
)
18-
from ._run import wrap_express_app
18+
from ._run import app_opts, wrap_express_app
1919
from .expressify_decorator import expressify
2020

2121

@@ -25,6 +25,7 @@
2525
"output",
2626
"session",
2727
"is_express_app",
28+
"app_opts",
2829
"wrap_express_app",
2930
"ui",
3031
"expressify",

shiny/express/_mock_session.py

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

33
import textwrap
4-
from typing import Awaitable, Callable, cast
4+
from typing import TYPE_CHECKING, Awaitable, Callable, cast
55

66
from .._namespaces import Root
77
from ..session import Inputs, Outputs, Session
88

9-
all = ("MockSession",)
9+
if TYPE_CHECKING:
10+
from ._run import AppOpts
1011

12+
all = ("ExpressMockSession",)
13+
14+
15+
class ExpressMockSession:
16+
"""
17+
A very bare-bones mock session class that is used only in shiny.express's UI
18+
rendering phase.
19+
20+
Note that this class is also used to hold application-level options that are set via
21+
the `app_opts()` function.
22+
"""
1123

12-
# A very bare-bones mock session class that is used only in shiny.express's UI rendering
13-
# phase.
14-
class MockSession:
1524
def __init__(self):
1625
self.ns = Root
1726
self.input = Inputs({})
1827
self.output = Outputs(cast(Session, self), self.ns, {}, {})
1928

29+
# Application-level (not session-level) options that may be set via app_opts().
30+
self.app_opts: AppOpts = {}
31+
2032
# This is needed so that Outputs don't throw an error.
2133
def _is_hidden(self, name: str) -> bool:
2234
return False

shiny/express/_run.py

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import ast
4+
import os
45
import sys
56
from pathlib import Path
67
from typing import cast
@@ -9,16 +10,21 @@
910

1011
from .._app import App
1112
from .._docstring import no_example
12-
from ..session import Inputs, Outputs, Session, session_context
13-
from ._mock_session import MockSession
13+
from .._typing_extensions import NotRequired, TypedDict
14+
from ..session import Inputs, Outputs, Session, get_current_session, session_context
15+
from ..types import MISSING, MISSING_TYPE
16+
from ._mock_session import ExpressMockSession
1417
from ._recall_context import RecallContextManager
1518
from .expressify_decorator._func_displayhook import _expressify_decorator_function_def
1619
from .expressify_decorator._node_transformers import (
1720
DisplayFuncsTransformer,
1821
expressify_decorator_func_name,
1922
)
2023

21-
__all__ = ("wrap_express_app",)
24+
__all__ = (
25+
"app_opts",
26+
"wrap_express_app",
27+
)
2228

2329

2430
@no_example()
@@ -35,8 +41,10 @@ def wrap_express_app(file: Path) -> App:
3541
:
3642
A :class:`shiny.App` object.
3743
"""
44+
3845
try:
39-
with session_context(cast(Session, MockSession())):
46+
mock_session = ExpressMockSession()
47+
with session_context(cast(Session, mock_session)):
4048
# We tagify here, instead of waiting for the App object to do it when it wraps
4149
# the UI in a HTMLDocument and calls render() on it. This is because
4250
# AttributeErrors can be thrown during the tagification process, and we need to
@@ -59,7 +67,20 @@ def express_server(input: Inputs, output: Outputs, session: Session):
5967
traceback.print_exception(*sys.exc_info())
6068
raise
6169

62-
app = App(app_ui, express_server)
70+
app_opts: AppOpts = {}
71+
72+
www_dir = file.parent / "www"
73+
if www_dir.is_dir():
74+
app_opts["static_assets"] = {"/": www_dir}
75+
76+
app_opts = _merge_app_opts(app_opts, mock_session.app_opts)
77+
app_opts = _normalize_app_opts(app_opts, file.parent)
78+
79+
app = App(
80+
app_ui,
81+
express_server,
82+
**app_opts,
83+
)
6384

6485
return app
6586

@@ -164,3 +185,86 @@ def __getattr__(self, name: str):
164185
"Tried to access `input`, but it was not imported. "
165186
"Perhaps you need `from shiny.express import input`?"
166187
)
188+
189+
190+
class AppOpts(TypedDict):
191+
static_assets: NotRequired[dict[str, Path]]
192+
debug: NotRequired[bool]
193+
194+
195+
@no_example()
196+
def app_opts(
197+
static_assets: (
198+
str | os.PathLike[str] | dict[str, str | Path] | MISSING_TYPE
199+
) = MISSING,
200+
debug: bool | MISSING_TYPE = MISSING,
201+
):
202+
"""
203+
Set App-level options in Shiny Express
204+
205+
This function sets application-level options for Shiny Express. These options are
206+
the same as those from the :class:`shiny.App` constructor.
207+
208+
Parameters
209+
----------
210+
static_assets
211+
Static files to be served by the app. If this is a string or Path object, it
212+
must be a directory, and it will be mounted at `/`. If this is a dictionary,
213+
each key is a mount point and each value is a file or directory to be served at
214+
that mount point. In Shiny Express, if there is a `www` subdirectory of the
215+
directory containing the app file, it will automatically be mounted at `/`, even
216+
without needing to set the option here.
217+
debug
218+
Whether to enable debug mode.
219+
"""
220+
221+
# Store these options only if we're in the UI-rendering phase of Shiny Express.
222+
mock_session = get_current_session()
223+
if not isinstance(mock_session, ExpressMockSession):
224+
return
225+
226+
if not isinstance(static_assets, MISSING_TYPE):
227+
if isinstance(static_assets, (str, os.PathLike)):
228+
static_assets = {"/": Path(static_assets)}
229+
230+
# Convert string values to Paths. (Need new var name to help type checker.)
231+
static_assets_paths = {k: Path(v) for k, v in static_assets.items()}
232+
233+
mock_session.app_opts["static_assets"] = static_assets_paths
234+
235+
if not isinstance(debug, MISSING_TYPE):
236+
mock_session.app_opts["debug"] = debug
237+
238+
239+
def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts:
240+
"""
241+
Merge a set of app options into an existing set of app options. The values from
242+
`app_opts_new` take precedence. This will alter the original app_opts and return it.
243+
"""
244+
245+
# We can't just do a `app_opts.update(app_opts_new)` because we need to handle the
246+
# case where app_opts["static_assets"] and app_opts_new["static_assets"] are
247+
# dictionaries, and we need to merge those dictionaries.
248+
if "static_assets" in app_opts and "static_assets" in app_opts_new:
249+
app_opts["static_assets"].update(app_opts_new["static_assets"])
250+
elif "static_assets" in app_opts_new:
251+
app_opts["static_assets"] = app_opts_new["static_assets"].copy()
252+
253+
if "debug" in app_opts_new:
254+
app_opts["debug"] = app_opts_new["debug"]
255+
256+
return app_opts
257+
258+
259+
def _normalize_app_opts(app_opts: AppOpts, parent_dir: Path) -> AppOpts:
260+
"""
261+
Normalize the app options, ensuring that all paths in static_assets are absolute.
262+
Modifies the original in place.
263+
"""
264+
if "static_assets" in app_opts:
265+
for mount_point, path in app_opts["static_assets"].items():
266+
if not path.is_absolute():
267+
path = parent_dir / path
268+
app_opts["static_assets"][mount_point] = path
269+
270+
return app_opts

shiny/reactive/_reactives.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ def __init__(
477477
self.__name__ = fn.__name__
478478
self.__doc__ = fn.__doc__
479479

480-
from ..express._mock_session import MockSession
480+
from ..express._mock_session import ExpressMockSession
481481
from ..render.renderer import Renderer
482482

483483
if isinstance(fn, Renderer):
@@ -513,9 +513,9 @@ def __init__(
513513
# could be None if outside of a session).
514514
session = get_current_session()
515515

516-
if isinstance(session, MockSession):
517-
# If we're in a MockSession, then don't actually set up this Effect -- we
518-
# don't want it to try to run later.
516+
if isinstance(session, ExpressMockSession):
517+
# If we're in an ExpressMockSession, then don't actually set up this effect
518+
# -- we don't want it to try to run later.
519519
return
520520

521521
self._session = session

shiny/render/_render.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .._docstring import add_example, no_example
3131
from .._namespaces import ResolvedId
3232
from .._typing_extensions import Self
33-
from ..express._mock_session import MockSession
33+
from ..express._mock_session import ExpressMockSession
3434
from ..session import get_current_session, require_active_session
3535
from ..session._session import DownloadHandler, DownloadInfo
3636
from ..types import MISSING, MISSING_TYPE, ImgData
@@ -690,7 +690,7 @@ def url() -> str:
690690
# not being None is because in Express, when the UI is rendered, this function
691691
# `render.download()()` called once before any sessions have been started.
692692
session = get_current_session()
693-
if session is not None and not isinstance(session, MockSession):
693+
if session is not None and not isinstance(session, ExpressMockSession):
694694
session._downloads[self.output_id] = DownloadInfo(
695695
filename=self.filename,
696696
content_type=self.media_type,

0 commit comments

Comments
 (0)