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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* `@render.data_frame`'s `<ID>.cell_selection()` no longer returns a `None` value and now always returns a dictionary containing both the `rows` and `cols` keys. This is done to achieve more consistent author code when working with cell selection. When the value's `type="none"`, both `rows` and `cols` are empty tuples. When `type="row"`, `cols` represents all column numbers of the data. In the future, when `type="col"`, `rows` will represent all row numbers of the data. These extra values are not available in `input.<ID>_cell_selection()` as they are independent of cells being selected and are removed to reduce information being sent to and from the browser. (#1376)

* Relative imports, like `from . import utils`, now can be used in Shiny Express apps. (#1464)

### Bug fixes

Expand Down
94 changes: 90 additions & 4 deletions shiny/express/_run.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import ast
import importlib.abc
import importlib.util
import sys
import types
from importlib.machinery import ModuleSpec
from pathlib import Path
from typing import Mapping, cast
from typing import Mapping, Sequence, cast

from htmltools import Tag, TagList

Expand All @@ -27,11 +31,19 @@
"wrap_express_app",
)

# Mapping from package name to file path of app. When running multiple concurrent apps
# (as is possible with shinylive), we need to give each one a unique package name.
package_filepath_map: dict[str, Path] = {}


@no_example()
def wrap_express_app(file: Path) -> App:
"""Wrap a Shiny Express mode app into a Shiny `App` object.

This also creates a Python package for the app named something like
`shiny_express_app_0`. This package is required for relative imports to work, as in
`from . import utils`.

Parameters
----------
file
Expand All @@ -42,6 +54,64 @@ def wrap_express_app(file: Path) -> App:
:
A :class:`shiny.App` object.
"""
package_name = f"shiny_express_app_{len(package_filepath_map)}"
package_filepath_map[package_name] = file

# Importing the module triggers the ShinyExpressAppImportFinder and
# ShinyExpressAppLoader.
app_module = importlib.import_module(package_name)
return app_module.app


# ======================================================================================
# Import hook to load Shiny Express app as a package
# ======================================================================================
class ShinyExpressAppImportFinder(importlib.abc.MetaPathFinder):
def __init__(self):
self.loaded_modules: dict[str, ModuleSpec] = {}

def find_spec(
self,
fullname: str,
path: Sequence[str] | None,
target: types.ModuleType | None = None,
) -> ModuleSpec | None:
if fullname in self.loaded_modules:
return self.loaded_modules[fullname]

if fullname in package_filepath_map:
app_path = package_filepath_map[fullname]
app_dir = str(app_path.parent)
spec = importlib.util.spec_from_loader(
fullname, ShinyExpressAppLoader(), origin=app_dir
)
if spec is None:
return None

spec.submodule_search_locations = [app_dir]
self.loaded_modules[fullname] = spec
return spec


class ShinyExpressAppLoader(importlib.abc.Loader):
def create_module(self, spec: ModuleSpec):
my_module = types.ModuleType(spec.name)
return my_module

def exec_module(self, module: types.ModuleType) -> None:
module.app = create_express_app( # pyright: ignore[reportAttributeAccessIssue]
package_filepath_map[module.__name__], module.__name__
)


sys.meta_path.insert(0, ShinyExpressAppImportFinder())
# ======================================================================================


# This is invoked from the ShinyExpressAppLoader.exec_module() method above. It creates
# the App object from the Shiny Express app file.
@no_example()
def create_express_app(file: Path, package_name: str) -> App:

file = file.resolve()

Expand All @@ -59,14 +129,14 @@ def wrap_express_app(file: Path) -> App:
# catch them here and convert them to a different type of error, because uvicorn
# specifically catches AttributeErrors and prints an error message that is
# misleading for Shiny Express. https://github.com/posit-dev/py-shiny/issues/937
app_ui = run_express(file).tagify()
app_ui = run_express(file, package_name).tagify()

except AttributeError as e:
raise RuntimeError(e) from e

def express_server(input: Inputs, output: Outputs, session: Session):
try:
run_express(file)
run_express(file, package_name)

except Exception:
import traceback
Expand All @@ -93,7 +163,21 @@ def express_server(input: Inputs, output: Outputs, session: Session):
return app


def run_express(file: Path) -> Tag | TagList:
def run_express(file: Path, package_name: str | None = None) -> Tag | TagList:
"""
Run the code in a Shiny Express app file and return the UI. This is to be run in
both the UI-rendering phase and the server-rendering phase of a Shiny Express app.
When used in the server-rendering phase, the returned UI should simply be ignored.

Parameters
----------
file
The path to the file containing the Shiny Express application.
package_name
The name of the package for the app. This is generated by `wrap_express_app()`
and should be something like "shiny_express_app_0". The purpose of this is to
allow relative imports in the app code.
"""
with open(file, encoding="utf-8") as f:
content = f.read()

Expand All @@ -118,6 +202,8 @@ def set_result(x: object):

var_context: dict[str, object] = {
"__file__": file_path,
"__name__": "app",
"__package__": package_name,
expressify_decorator_func_name: _expressify_decorator_function_def,
"input": InputNotImportedShim(),
}
Expand Down
3 changes: 1 addition & 2 deletions shiny/express/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

from pathlib import Path

from .._app import App
from ._run import wrap_express_app
from ._utils import unescape_from_var_name


# If someone requests shiny.express.app:_2f_path_2f_to_2f_app_2e_py, then we will call
# wrap_express_app(Path("/path/to/app.py")) and return the result.
def __getattr__(name: str) -> App:
def __getattr__(name: str) -> object:
name = unescape_from_var_name(name)
return wrap_express_app(Path(name))