From 6bfd5f0245e330d58665a4e4698f3186669f6f9c Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Thu, 29 Feb 2024 17:22:52 -0600 Subject: [PATCH 1/6] For Express, automatically serve www/ dir --- shiny/express/_run.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 17cdbbf21..ca4a1c94c 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -35,6 +35,13 @@ def wrap_express_app(file: Path) -> App: : A :class:`shiny.App` object. """ + + extra_kwargs: dict[str, object] = {} + + www_dir = file.parent / "www" + if www_dir.is_dir(): + extra_kwargs["static_assets"] = str(www_dir) + try: with session_context(cast(Session, MockSession())): # We tagify here, instead of waiting for the App object to do it when it wraps @@ -59,7 +66,11 @@ def express_server(input: Inputs, output: Outputs, session: Session): traceback.print_exception(*sys.exc_info()) raise - app = App(app_ui, express_server) + app = App( + app_ui, + express_server, + **extra_kwargs, # pyright: ignore[reportArgumentType] + ) return app From 5b22794f935b7d1aa6ecfee4c07a2c41ac9d395a Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 1 Mar 2024 13:04:05 -0600 Subject: [PATCH 2/6] Add express.app_opts() function --- shiny/_app.py | 8 ++- shiny/_utils.py | 8 +++ shiny/express/__init__.py | 3 +- shiny/express/_mock_session.py | 22 ++++++-- shiny/express/_run.py | 98 ++++++++++++++++++++++++++++++---- shiny/reactive/_reactives.py | 8 +-- shiny/render/_render.py | 4 +- 7 files changed, 127 insertions(+), 24 deletions(-) diff --git a/shiny/_app.py b/shiny/_app.py index 5432a66ed..8a23c5fca 100644 --- a/shiny/_app.py +++ b/shiny/_app.py @@ -29,7 +29,7 @@ from ._connection import Connection, StarletteConnection from ._error import ErrorMiddleware from ._shinyenv import is_pyodide -from ._utils import guess_mime_type, is_async_callable +from ._utils import guess_mime_type, is_async_callable, sort_keys_length from .html_dependencies import jquery_deps, require_deps, shiny_deps from .http_staticfiles import FileResponse, StaticFiles from .session._session import Inputs, Outputs, Session, session_context @@ -149,6 +149,12 @@ def __init__( ) static_assets = {"/": Path(static_assets)} + # Sort the static assets keys by descending length, to ensure that the most + # specific paths are mounted first. Suppose there are mounts "/foo" and "/". If + # "/" is first in the dict, then requests to "/foo/file.html" will never reach + # the second mount. We need to put "/foo" first and "/" second so that it will + # actually look in the "/foo" mount. + static_assets = sort_keys_length(static_assets, descending=True) self._static_assets: dict[str, Path] = static_assets self._sessions: dict[str, Session] = {} diff --git a/shiny/_utils.py b/shiny/_utils.py index 99bf761d9..5357c91a5 100644 --- a/shiny/_utils.py +++ b/shiny/_utils.py @@ -17,6 +17,8 @@ CancelledError = asyncio.CancelledError +T = TypeVar("T") + # ============================================================================== # Misc utility functions @@ -49,6 +51,12 @@ def lists_to_tuples(x: object) -> object: return x +# Given a dictionary, return a new dictionary with the keys sorted by length. +def sort_keys_length(x: dict[str, T], descending: bool = False) -> dict[str, T]: + sorted_keys = sorted(x.keys(), key=len, reverse=descending) + return {key: x[key] for key in sorted_keys} + + def guess_mime_type( url: "str | os.PathLike[str]", default: str = "application/octet-stream", diff --git a/shiny/express/__init__.py b/shiny/express/__init__.py index ca72a1858..aa9cd1fa0 100644 --- a/shiny/express/__init__.py +++ b/shiny/express/__init__.py @@ -15,7 +15,7 @@ output_args, # pyright: ignore[reportUnusedImport] suspend_display, # pyright: ignore[reportUnusedImport] - Deprecated ) -from ._run import wrap_express_app +from ._run import app_opts, wrap_express_app from .expressify_decorator import expressify @@ -25,6 +25,7 @@ "output", "session", "is_express_app", + "app_opts", "wrap_express_app", "ui", "expressify", diff --git a/shiny/express/_mock_session.py b/shiny/express/_mock_session.py index 6eab21e9e..9478dc078 100644 --- a/shiny/express/_mock_session.py +++ b/shiny/express/_mock_session.py @@ -1,22 +1,34 @@ from __future__ import annotations import textwrap -from typing import Awaitable, Callable, cast +from typing import TYPE_CHECKING, Awaitable, Callable, cast from .._namespaces import Root from ..session import Inputs, Outputs, Session -all = ("MockSession",) +if TYPE_CHECKING: + from ._run import AppOpts +all = ("ExpressMockSession",) + + +class ExpressMockSession: + """ + A very bare-bones mock session class that is used only in shiny.express's UI + rendering phase. + + Note that this class is also used to hold application-level options that are set via + the `app_opts()` function. + """ -# A very bare-bones mock session class that is used only in shiny.express's UI rendering -# phase. -class MockSession: def __init__(self): self.ns = Root self.input = Inputs({}) self.output = Outputs(cast(Session, self), self.ns, {}, {}) + # Application-level (not session-level) options that may be set via app_opts(). + self.app_opts: AppOpts = {} + # This is needed so that Outputs don't throw an error. def _is_hidden(self, name: str) -> bool: return False diff --git a/shiny/express/_run.py b/shiny/express/_run.py index ca4a1c94c..a846ba3d7 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import os import sys from pathlib import Path from typing import cast @@ -9,8 +10,10 @@ from .._app import App from .._docstring import no_example -from ..session import Inputs, Outputs, Session, session_context -from ._mock_session import MockSession +from .._typing_extensions import NotRequired, TypedDict +from ..session import Inputs, Outputs, Session, get_current_session, session_context +from ..types import MISSING, MISSING_TYPE +from ._mock_session import ExpressMockSession from ._recall_context import RecallContextManager from .expressify_decorator._func_displayhook import _expressify_decorator_function_def from .expressify_decorator._node_transformers import ( @@ -18,7 +21,10 @@ expressify_decorator_func_name, ) -__all__ = ("wrap_express_app",) +__all__ = ( + "app_opts", + "wrap_express_app", +) @no_example() @@ -36,14 +42,9 @@ def wrap_express_app(file: Path) -> App: A :class:`shiny.App` object. """ - extra_kwargs: dict[str, object] = {} - - www_dir = file.parent / "www" - if www_dir.is_dir(): - extra_kwargs["static_assets"] = str(www_dir) - try: - with session_context(cast(Session, MockSession())): + mock_session = ExpressMockSession() + with session_context(cast(Session, mock_session)): # We tagify here, instead of waiting for the App object to do it when it wraps # the UI in a HTMLDocument and calls render() on it. This is because # AttributeErrors can be thrown during the tagification process, and we need to @@ -66,10 +67,18 @@ def express_server(input: Inputs, output: Outputs, session: Session): traceback.print_exception(*sys.exc_info()) raise + app_opts: AppOpts = {} + + www_dir = file.parent / "www" + if www_dir.is_dir(): + app_opts["static_assets"] = {"/": www_dir} + + app_opts = _merge_app_opts(app_opts, mock_session.app_opts) + app = App( app_ui, express_server, - **extra_kwargs, # pyright: ignore[reportArgumentType] + **app_opts, ) return app @@ -175,3 +184,70 @@ def __getattr__(self, name: str): "Tried to access `input`, but it was not imported. " "Perhaps you need `from shiny.express import input`?" ) + + +class AppOpts(TypedDict): + static_assets: NotRequired[dict[str, Path]] + debug: NotRequired[bool] + + +def app_opts( + static_assets: str | os.PathLike[str] | dict[str, Path] | MISSING_TYPE = MISSING, + debug: bool | MISSING_TYPE = MISSING, +): + """ + Set App-level options in Shiny Express + + This function sets application-level options for Shiny Express. These options are + the same as those from the :class:`shiny.App` constructor. + + Parameters + ---------- + static_assets + Static files to be served by the app. If this is a string or Path object, it + must be a directory, and it will be mounted at `/`. If this is a dictionary, + each key is a mount point and each value is a file or directory to be served at + that mount point. In Shiny Express, if there is a `www` subdirectory of the + directory containing the app file, it will automatically be mounted at `/`, even + without needing to set the option here. + debug + Whether to enable debug mode. + """ + + # Store these options only if we're in the UI-rendering phase of Shiny Express. + mock_session = get_current_session() + if not isinstance(mock_session, ExpressMockSession): + return + + if not isinstance(static_assets, MISSING_TYPE): + if isinstance(static_assets, (str, os.PathLike)): + if not os.path.isabs(static_assets): + raise ValueError( + f"static_assets must be an absolute path: {static_assets}" + ) + static_assets = {"/": Path(static_assets)} + + mock_session.app_opts["static_assets"] = static_assets + + if not isinstance(debug, MISSING_TYPE): + mock_session.app_opts["debug"] = debug + + +def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: + """ + Merge a set of app options into an existing set of app options. The values from + `app_opts_new` take precedence. This will alter the original app_opts and return it. + """ + + # We can't just do a `app_opts.update(app_opts_new)` because we need to handle the + # case where app_opts["static_assets"] and app_opts_new["static_assets"] are + # dictionaries, and we need to merge those dictionaries. + if "static_assets" in app_opts and "static_assets" in app_opts_new: + app_opts["static_assets"].update(app_opts_new["static_assets"]) + elif "static_assets" in app_opts_new: + app_opts["static_assets"] = app_opts_new["static_assets"].copy() + + if "debug" in app_opts_new: + app_opts["debug"] = app_opts_new["debug"] + + return app_opts diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index cc75a419d..f82658d09 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -477,7 +477,7 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ - from ..express._mock_session import MockSession + from ..express._mock_session import ExpressMockSession from ..render.renderer import Renderer if isinstance(fn, Renderer): @@ -513,9 +513,9 @@ def __init__( # could be None if outside of a session). session = get_current_session() - if isinstance(session, MockSession): - # If we're in a MockSession, then don't actually set up this Effect -- we - # don't want it to try to run later. + if isinstance(session, ExpressMockSession): + # If we're in an ExpressMockSession, then don't actually set up this effect + # -- we don't want it to try to run later. return self._session = session diff --git a/shiny/render/_render.py b/shiny/render/_render.py index 20f793a54..cdf2a1cfd 100644 --- a/shiny/render/_render.py +++ b/shiny/render/_render.py @@ -30,7 +30,7 @@ from .._docstring import add_example, no_example from .._namespaces import ResolvedId from .._typing_extensions import Self -from ..express._mock_session import MockSession +from ..express._mock_session import ExpressMockSession from ..session import get_current_session, require_active_session from ..session._session import DownloadHandler, DownloadInfo from ..types import MISSING, MISSING_TYPE, ImgData @@ -690,7 +690,7 @@ def url() -> str: # not being None is because in Express, when the UI is rendered, this function # `render.download()()` called once before any sessions have been started. session = get_current_session() - if session is not None and not isinstance(session, MockSession): + if session is not None and not isinstance(session, ExpressMockSession): session._downloads[self.output_id] = DownloadInfo( filename=self.filename, content_type=self.media_type, From 91a72ccb64fb6f23ffe0ea9df46dcc20cbbe6703 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 1 Mar 2024 13:35:44 -0600 Subject: [PATCH 3/6] Allow strings and relative paths for app_opts(static_assets=...) --- shiny/express/_run.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index a846ba3d7..441fa75e6 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -74,6 +74,7 @@ def express_server(input: Inputs, output: Outputs, session: Session): app_opts["static_assets"] = {"/": www_dir} app_opts = _merge_app_opts(app_opts, mock_session.app_opts) + app_opts = _normalize_app_opts(app_opts, file.parent) app = App( app_ui, @@ -192,7 +193,9 @@ class AppOpts(TypedDict): def app_opts( - static_assets: str | os.PathLike[str] | dict[str, Path] | MISSING_TYPE = MISSING, + static_assets: ( + str | os.PathLike[str] | dict[str, str | Path] | MISSING_TYPE + ) = MISSING, debug: bool | MISSING_TYPE = MISSING, ): """ @@ -221,13 +224,12 @@ def app_opts( if not isinstance(static_assets, MISSING_TYPE): if isinstance(static_assets, (str, os.PathLike)): - if not os.path.isabs(static_assets): - raise ValueError( - f"static_assets must be an absolute path: {static_assets}" - ) static_assets = {"/": Path(static_assets)} - mock_session.app_opts["static_assets"] = static_assets + # Convert string values to Paths. (Need new var name to help type checker.) + static_assets_paths = {k: Path(v) for k, v in static_assets.items()} + + mock_session.app_opts["static_assets"] = static_assets_paths if not isinstance(debug, MISSING_TYPE): mock_session.app_opts["debug"] = debug @@ -251,3 +253,17 @@ def _merge_app_opts(app_opts: AppOpts, app_opts_new: AppOpts) -> AppOpts: app_opts["debug"] = app_opts_new["debug"] return app_opts + + +def _normalize_app_opts(app_opts: AppOpts, parent_dir: Path) -> AppOpts: + """ + Normalize the app options, ensuring that all paths in static_assets are absolute. + Modifies the original in place. + """ + if "static_assets" in app_opts: + for mount_point, path in app_opts["static_assets"].items(): + if not path.is_absolute(): + path = parent_dir / path + app_opts["static_assets"][mount_point] = path + + return app_opts From bd13039364025267933fee45155181cc32cbf984 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 1 Mar 2024 14:21:24 -0600 Subject: [PATCH 4/6] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de2bd730d..420a31c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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) +* 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) + +* For Shiny Express apps, added `express.app_opts()`, which allows setting application-level options, like `static_assets` and `debug`. (#1170) + ### Other changes * `@render.data_frame` now properly fills its container by default. (#1126) From 30deb93fe5f26e8e53bc5ac04c6c4158ff04917d Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 1 Mar 2024 15:28:18 -0600 Subject: [PATCH 5/6] Add API docs for express.app_opts() --- docs/_quartodoc-express.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/_quartodoc-express.yml b/docs/_quartodoc-express.yml index e27a6d754..410a609cf 100644 --- a/docs/_quartodoc-express.yml +++ b/docs/_quartodoc-express.yml @@ -177,6 +177,10 @@ quartodoc: # - express.ui.fill.remove_all_fill # - express.ui.css.as_css_unit # - express.ui.css.as_css_padding + - title: Application-level settings + desc: Set application-level settings. + contents: + - express.app_opts - title: Express developer tooling desc: contents: From fde7e35001a24ffaf9cabc63b040ab93b23b6c52 Mon Sep 17 00:00:00 2001 From: Winston Chang Date: Fri, 1 Mar 2024 15:30:40 -0600 Subject: [PATCH 6/6] Add @no_example --- shiny/express/_run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shiny/express/_run.py b/shiny/express/_run.py index 441fa75e6..272b18537 100644 --- a/shiny/express/_run.py +++ b/shiny/express/_run.py @@ -192,6 +192,7 @@ class AppOpts(TypedDict): debug: NotRequired[bool] +@no_example() def app_opts( static_assets: ( str | os.PathLike[str] | dict[str, str | Path] | MISSING_TYPE