Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/_quartodoc-express.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion shiny/_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = {}
Expand Down
8 changes: 8 additions & 0 deletions shiny/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

CancelledError = asyncio.CancelledError

T = TypeVar("T")


# ==============================================================================
# Misc utility functions
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion shiny/express/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -25,6 +25,7 @@
"output",
"session",
"is_express_app",
"app_opts",
"wrap_express_app",
"ui",
"expressify",
Expand Down
22 changes: 17 additions & 5 deletions shiny/express/_mock_session.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
114 changes: 109 additions & 5 deletions shiny/express/_run.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import ast
import os
import sys
from pathlib import Path
from typing import cast
Expand All @@ -9,16 +10,21 @@

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 (
DisplayFuncsTransformer,
expressify_decorator_func_name,
)

__all__ = ("wrap_express_app",)
__all__ = (
"app_opts",
"wrap_express_app",
)


@no_example()
Expand All @@ -35,8 +41,10 @@ def wrap_express_app(file: Path) -> App:
:
A :class:`shiny.App` object.
"""

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
Expand All @@ -59,7 +67,20 @@ def express_server(input: Inputs, output: Outputs, session: Session):
traceback.print_exception(*sys.exc_info())
raise

app = App(app_ui, express_server)
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_opts = _normalize_app_opts(app_opts, file.parent)

app = App(
app_ui,
express_server,
**app_opts,
)

return app

Expand Down Expand Up @@ -164,3 +185,86 @@ 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]


@no_example()
def app_opts(
static_assets: (
str | os.PathLike[str] | dict[str, 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)):
static_assets = {"/": Path(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


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


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
8 changes: 4 additions & 4 deletions shiny/reactive/_reactives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions shiny/render/_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down