diff --git a/examples/moduleapp/app.py b/examples/moduleapp/app.py index 918cb2c35..08a608662 100644 --- a/examples/moduleapp/app.py +++ b/examples/moduleapp/app.py @@ -1,23 +1,20 @@ -from typing import Callable from shiny import * -from shiny.modules import * - # ============================================================ # Counter module # ============================================================ -def counter_ui( - ns: Callable[[str], str], label: str = "Increment counter" -) -> ui.TagChildArg: +def counter_ui(label: str = "Increment counter") -> ui.TagChildArg: return ui.div( {"style": "border: 1px solid #ccc; border-radius: 5px; margin: 5px 0;"}, ui.h2("This is " + label), - ui.input_action_button(id=ns("button"), label=label), - ui.output_text_verbatim(id=ns("out")), + ui.input_action_button(id="button", label=label), + ui.output_text_verbatim(id="out"), ) -def counter_server(input: ModuleInputs, output: ModuleOutputs, session: ModuleSession): +def counter_server( + input: Inputs, output: Outputs, session: Session, counter0: reactive.Calc_[int] +) -> int: count: reactive.Value[int] = reactive.Value(0) @reactive.Effect() @@ -30,6 +27,13 @@ def _(): def out() -> str: return f"Click count is {count()}" + @reactive.Effect() + def _(): + print("Clicked global counter", counter0()) + # print("Clicked global counter", inputs.counter0()) + + return 1 + counter_module = Module(counter_ui, counter_server) @@ -38,14 +42,19 @@ def out() -> str: # App that uses module # ============================================================================= app_ui = ui.page_fluid( - counter_module.ui("counter1", "Counter 1"), + counter_module.ui("counter1", label="Counter 1"), counter_module.ui("counter2", "Counter 2"), + ui.input_action_button("counter0", "Print global counter"), ) -def server(input: Inputs, output: Outputs, session: Session): - counter_module.server("counter1") - counter_module.server("counter2") +def server(inputs: Inputs, output: Outputs, sess: Session): + @reactive.Calc() + def counter0(): + return inputs.counter0() + + counter_module.server("counter1", counter0=counter0) + counter_module.server("counter2", counter0=counter0) app = App(app_ui, server) diff --git a/shiny/__init__.py b/shiny/__init__.py index f09297a24..5580f7010 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -12,6 +12,7 @@ from ._app import App from ._decorators import event from ._validation import req +from .modules import Module if _is_pyodide: # In pyodide, avoid importing _main because it imports packages that aren't @@ -21,7 +22,6 @@ from ._main import run_app # User-facing submodules that should *not* be available on `from shiny import *` -from . import modules from . import types # N.B.: we intentionally don't import 'developer-facing' submodules (e.g., @@ -41,6 +41,8 @@ "event", # _main.py "run_app", + # _modules.py + "Module", # _render.py "render_text", "render_plot", diff --git a/shiny/_namespaces.py b/shiny/_namespaces.py new file mode 100644 index 000000000..58a3cb8df --- /dev/null +++ b/shiny/_namespaces.py @@ -0,0 +1,89 @@ +__all__ = ("namespace_context", "namespaced_id") + +from contextlib import contextmanager +from contextvars import ContextVar, Token +import functools + +from typing import Awaitable, Union, Optional, TypeVar, Callable, cast + +from shiny.types import MISSING, MISSING_TYPE + +from ._utils import is_async_callable + +_current_namespace: ContextVar[Optional[str]] = ContextVar( + "current_namespace", default=None +) + + +def get_current_namespace() -> Optional[str]: + """ + Get the current namespace. + """ + return _current_namespace.get() + + +@contextmanager +def namespace_context(ns: str): + """ + Set a namespace for the duration of the context. + """ + token: Token[Union[str, None]] = _current_namespace.set(ns) + try: + yield + finally: + _current_namespace.reset(token) + + +def namespaced_id(id: str, ns: Union[str, MISSING_TYPE, None] = MISSING) -> str: + """ + Namespace an ID based on the current ``Module()``'s namespace. + + Parameters + ---------- + id + The ID to namespace. + + Warning + ------- + This is only provided so that htmltools can use it to namespace ids within a + ``Module()`` UI function. + """ + if isinstance(ns, MISSING_TYPE): + ns = get_current_namespace() + + if ns is None: + return id + else: + return ns + "_" + id + + +T = TypeVar("T") +Fn = Union[Callable[..., T], Callable[..., Awaitable[T]]] + + +def add_namespace(fn: Fn[T], ns: str) -> Fn[T]: + """ + Modify a function to use the **current** namespace when it executes. + + Note + ---- + This is useful for marking user supplied functions to use the **current** namespace + when they execute **later on**. + """ + + if is_async_callable(fn): + + @functools.wraps(fn) + async def wrapper(*args: object, **kwargs: object) -> T: + with namespace_context(ns): + return await fn(*args, **kwargs) + + else: + fn = cast(Callable[..., T], fn) + + @functools.wraps(fn) + def wrapper(*args: object, **kwargs: object) -> T: + with namespace_context(ns): + return fn(*args, **kwargs) + + return wrapper diff --git a/shiny/examples/Module/app.py b/shiny/examples/Module/app.py index 918cb2c35..81c031e90 100644 --- a/shiny/examples/Module/app.py +++ b/shiny/examples/Module/app.py @@ -1,23 +1,18 @@ -from typing import Callable from shiny import * -from shiny.modules import * - # ============================================================ # Counter module # ============================================================ -def counter_ui( - ns: Callable[[str], str], label: str = "Increment counter" -) -> ui.TagChildArg: +def counter_ui(label: str = "Increment counter") -> ui.TagChildArg: return ui.div( {"style": "border: 1px solid #ccc; border-radius: 5px; margin: 5px 0;"}, ui.h2("This is " + label), - ui.input_action_button(id=ns("button"), label=label), - ui.output_text_verbatim(id=ns("out")), + ui.input_action_button(id="button", label=label), + ui.output_text_verbatim(id="out"), ) -def counter_server(input: ModuleInputs, output: ModuleOutputs, session: ModuleSession): +def counter_server(input: Inputs, output: Outputs, session: Session): count: reactive.Value[int] = reactive.Value(0) @reactive.Effect() diff --git a/shiny/modules.py b/shiny/modules.py index 5af963d48..940606623 100644 --- a/shiny/modules.py +++ b/shiny/modules.py @@ -1,130 +1,77 @@ -__all__ = ( - "ModuleInputs", - "ModuleOutputs", - "ModuleSession", - "Module", -) +__all__ = ("Module",) -from typing import Any, Callable, Optional, Dict +from typing import Any, Callable, Optional from htmltools import TagChildArg from ._docstring import add_example -from .reactive import Value -from .render import RenderFunction +from ._namespaces import namespace_context, namespaced_id, get_current_namespace from .session import Inputs, Outputs, Session, require_active_session, session_context class ModuleInputs(Inputs): """ - A class representing the inputs of a module. + A class representing a module's outputs. Warning ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.modules.Module`'s ``server`` function. For this reason, you - shouldn't need to create instances of this class yourself. + shouldn't need to create instances of this class yourself. Furthermore, you + probably shouldn't need this class for type checking either since it has the same + signature as :class:`shiny.session.Session`. """ - def __init__(self, ns: str, values: Inputs): - self._ns: str = ns - self._values: Inputs = values - - def _ns_key(self, key: str) -> str: - return self._ns + "-" + key - - def __setitem__(self, key: str, value: Value[Any]) -> None: - self._values[self._ns_key(key)].set(value) - - def __getitem__(self, key: str) -> Value[Any]: - return self._values[self._ns_key(key)] - - def __delitem__(self, key: str) -> None: - del self._values[self._ns_key(key)] - - # Allow access of values as attributes. - def __setattr__(self, attr: str, value: Value[Any]) -> None: - if attr in ("_values", "_ns", "_ns_key"): - object.__setattr__(self, attr, value) - return - else: - self.__setitem__(attr, value) - - def __getattr__(self, attr: str) -> Value[Any]: - if attr in ("_values", "_ns", "_ns_key"): - return object.__getattribute__(self, attr) - else: - return self.__getitem__(attr) - - def __delattr__(self, key: str) -> None: - self.__delitem__(key) + def __init__(self, ns: str, parent_inputs: Inputs): + self._ns = namespaced_id(ns, parent_inputs._ns) # Support nested modules + # Don't set _parent attribute like the other classes since Inputs redefines + # __setattr__ + self._map = parent_inputs._map class ModuleOutputs(Outputs): """ - A class representing the outputs of a module. + A class representing a module's outputs. Warning ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.modules.Module`'s ``server`` function. For this reason, you - shouldn't need to create instances of this class yourself. + shouldn't need to create instances of this class yourself. Furthermore, you + probably shouldn't need this class for type checking either since it has the same + signature as :class:`shiny.session.Session`. """ - def __init__(self, ns: str, outputs: Outputs): - self._ns: str = ns - self._outputs: Outputs = outputs - - def _ns_key(self, key: str) -> str: - return self._ns + "-" + key - - def __call__( - self, - *, - name: Optional[str] = None, - suspend_when_hidden: bool = True, - priority: int = 0 - ) -> Callable[[RenderFunction], None]: - def set_fn(fn: RenderFunction) -> None: - fn_name = name or fn.__name__ - fn_name = self._ns_key(fn_name) - out_fn = self._outputs( - name=fn_name, suspend_when_hidden=suspend_when_hidden, priority=priority - ) - return out_fn(fn) - - return set_fn - + def __init__(self, ns: str, parent_outputs: Outputs): + self._parent = parent_outputs + self._ns = namespaced_id(ns, parent_outputs._ns) # Support nested modules -NSFunc = Callable[[Optional[str]], str] + def __getattr__(self, attr: str) -> Any: + return getattr(self._parent, attr) class ModuleSession(Session): """ - A class representing the session of a module. + A class representing a module's outputs. Warning ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.modules.Module`'s ``server`` function. For this reason, you - shouldn't need to create instances of this class yourself. + shouldn't need to create instances of this class yourself. Furthermore, you + probably shouldn't need this class for type checking either since it has the same + signature as :class:`shiny.session.Session`. """ - def __init__(self, ns_func: NSFunc, parent_session: Session) -> None: - self._ns_func: NSFunc = ns_func - self._parent: Session = parent_session - self.input: ModuleInputs = ModuleInputs(ns_func(None), parent_session.input) - self.output: ModuleOutputs = ModuleOutputs(ns_func(None), parent_session.output) + def __init__(self, ns: str, parent_session: Session): + self._parent = parent_session + self._ns = namespaced_id(ns, parent_session._ns) # Support nested modules + self.input = ModuleInputs(ns, parent_session.input) + self.output = ModuleOutputs(ns, parent_session.output) def __getattr__(self, attr: str) -> Any: return getattr(self._parent, attr) - def send_input_message(self, id: str, message: Dict[str, object]) -> None: - return super().send_input_message(self.ns(id), message) - - def ns(self, id: str) -> str: - return self._ns_func(id) - @add_example() class Module: @@ -142,12 +89,10 @@ class Module: def __init__( self, ui: Callable[..., TagChildArg], - server: Callable[[ModuleInputs, ModuleOutputs, ModuleSession], None], + server: Callable[..., object], ) -> None: self._ui: Callable[..., TagChildArg] = ui - self._server: Callable[ - [ModuleInputs, ModuleOutputs, ModuleSession], None - ] = server + self._server: Callable[..., object] = server def ui(self, ns: str, *args: Any, **kwargs: Any) -> TagChildArg: """ @@ -157,14 +102,20 @@ def ui(self, ns: str, *args: Any, **kwargs: Any) -> TagChildArg: ---------- namespace A namespace for the module. - args + *args Additional arguments to pass to the module's UI definition. + **kwargs + Additional keyword arguments to pass to the module's UI definition. """ - return self._ui(Module._make_ns_fn(ns), *args, **kwargs) + + # Support nested modules by adding this namespace to any current namespace. + ns_full = namespaced_id(ns, get_current_namespace()) + with namespace_context(ns_full): + return self._ui(*args, **kwargs) def server( self, ns: str, *args: Any, session: Optional[Session] = None, **kwargs: Any - ) -> Any: + ) -> object: """ Invoke the module's server-side logic. @@ -172,20 +123,22 @@ def server( ---------- ns A namespace for the module. + *args + Additional arguments to pass to the module's server-side logic. session A :class:`~shiny.Session` instance. If not provided, it is inferred via :func:`~shiny.session.get_current_session`. + **kwargs + Additional keyword arguments to pass to the module's server-side logic. """ - ms = ModuleSession(Module._make_ns_fn(ns), require_active_session(session)) - with session_context(ms): - return self._server(ms.input, ms.output, ms, *args, **kwargs) - - @staticmethod - def _make_ns_fn(ns: str) -> NSFunc: - def ns_fn(id: Optional[str] = None) -> str: - if id is None: - return ns - else: - return ns + "-" + id - - return ns_fn + + mod_sess = ModuleSession(ns, require_active_session(session)) + # N.B. we don't need a `with namespace_context()` here, because when + # reactive primitive like Calc(), Effect(), and RenderFunction() are created, + # they use the session._ns field to add a `with namespace_context()` to the + # the user's function (it has to be done this way since the user functions + # are executed after the server function is executed). + with session_context(mod_sess): + return self._server( + mod_sess.input, mod_sess.output, mod_sess, *args, **kwargs + ) diff --git a/shiny/reactive/_reactives.py b/shiny/reactive/_reactives.py index ee2ae0cb9..4fab61aad 100644 --- a/shiny/reactive/_reactives.py +++ b/shiny/reactive/_reactives.py @@ -18,6 +18,7 @@ from ._core import Context, Dependents, ReactiveWarning from .._docstring import add_example +from .._namespaces import add_namespace from .. import _utils from ..types import MISSING, MISSING_TYPE, SilentException @@ -212,12 +213,6 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ - # The CalcAsync subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: CalcFunctionAsync[T] = _utils.wrap_async(fn) - self._is_async: bool = _utils.is_async_callable(fn) - self._dependents: Dependents = Dependents() self._invalidated: bool = True self._running: bool = False @@ -237,6 +232,17 @@ def __init__( session = get_current_session() self._session = session + # If created within a ModuleSession(), add the namespace_context to the user's + # func so that any UI generated by it will have namespaced ids. + if session is not None and session._ns is not None: + fn = add_namespace(fn, session._ns) + + # The CalcAsync subclass will pass in an async function, but it tells the + # static type checker that it's synchronous. wrap_async() is smart -- if is + # passed an async function, it will not change it. + self._fn: CalcFunctionAsync[T] = _utils.wrap_async(fn) + self._is_async: bool = _utils.is_async_callable(fn) + # Use lists to hold (optional) value and error, instead of Optional[T], # because it makes typing more straightforward. For example if # .get_value() simply returned self._value, self._value had type @@ -420,13 +426,6 @@ def __init__( self.__name__ = fn.__name__ self.__doc__ = fn.__doc__ - # The EffectAsync subclass will pass in an async function, but it tells the - # static type checker that it's synchronous. wrap_async() is smart -- if is - # passed an async function, it will not change it. - self._fn: EffectFunctionAsync = _utils.wrap_async(fn) - # This indicates whether the user's effect function (before wrapping) is async. - self._is_async: bool = _utils.is_async_callable(fn) - self._priority: int = priority self._suspended = suspended self._on_resume: Callable[[], None] = lambda: None @@ -450,6 +449,17 @@ def __init__( if self._session is not None: self._session.on_ended(self._on_session_ended_cb) + # If created within a ModuleSession(), add the namespace_context to the + # user's func so that any UI generated by it will have namespaced ids. + if self._session._ns is not None: + fn = add_namespace(fn, self._session._ns) + + # The EffectAsync subclass will pass in an async function, but it tells the + # static type checker that it's synchronous. wrap_async() is smart -- if is + # passed an async function, it will not change it. + self._fn: EffectFunctionAsync = _utils.wrap_async(fn) + # This indicates whether the user's effect function (before wrapping) is async. + self._is_async: bool = _utils.is_async_callable(fn) # Defer the first running of this until flushReact is called self._create_context().invalidate() diff --git a/shiny/session/_session.py b/shiny/session/_session.py index cf44307fb..759dc10e4 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -50,6 +50,7 @@ from .._fileupload import FileInfo, FileUploadManager from ..http_staticfiles import FileResponse from ..input_handler import input_handlers +from .._namespaces import namespaced_id, add_namespace from ..reactive import Value, Effect, Effect_, isolate, flush from ..reactive._core import lock from ..types import SafeException, SilentCancelOutputException, SilentException @@ -118,7 +119,8 @@ class Session: ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.App`'s ``server`` function. For this reason, you shouldn't - need to create instances of this class yourself. + need to create instances of this class yourself (it's only part of the public API + for type checking reasons). """ # ========================================================================== @@ -135,6 +137,8 @@ def __init__( self.input: Inputs = Inputs() self.output: Outputs = Outputs(self) + self._ns: Optional[str] = None # Only relevant for ModuleSession + self._outbound_message_queues = empty_outbound_message_queues() self._message_handlers: Dict[ @@ -315,6 +319,7 @@ async def uploadEnd(job_id: str, input_id: str) -> None: # ========================================================================== # Handling /session/{session_id}/{action}/{subpath} requests # ========================================================================== + # TODO: anything to be done here for module support? async def _handle_request( self, request: Request, action: str, subpath: Optional[str] ) -> ASGIApp: @@ -437,7 +442,7 @@ def send_input_message(self, id: str, message: Dict[str, object]) -> None: message The message to send. """ - msg: Dict[str, object] = {"id": id, "message": message} + msg: Dict[str, object] = {"id": self.ns(id), "message": message} self._outbound_message_queues["input_messages"].append(msg) self._request_flush() @@ -588,6 +593,7 @@ async def _unhandled_error(self, e: Exception) -> None: await self.close() # TODO: probably name should be id + # TODO: anything to be done here for module support? @add_example() def download( self, @@ -649,7 +655,7 @@ def _process_ui(self, ui: TagChildArg) -> RenderedDeps: return {"deps": deps, "html": res["html"]} def ns(self, id: str) -> str: - return id + return namespaced_id(id, self._ns) # ====================================================================================== @@ -666,7 +672,8 @@ class Inputs: ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.App`'s ``server`` function. For this reason, you shouldn't - need to create instances of this class yourself. + need to create instances of this class yourself (it's only part of the public API + for type checking reasons). """ def __init__(self, **kwargs: object) -> None: @@ -674,13 +681,16 @@ def __init__(self, **kwargs: object) -> None: for key, value in kwargs.items(): self._map[key] = Value(value, read_only=True) + self._ns: Optional[str] = None # Only relevant for ModuleInputs() + def __setitem__(self, key: str, value: Value[Any]) -> None: if not isinstance(value, Value): raise TypeError("`value` must be a shiny.Value object.") - self._map[key] = value + self._map[namespaced_id(key, self._ns)] = value def __getitem__(self, key: str) -> Value[Any]: + key = namespaced_id(key, self._ns) # Auto-populate key if accessed but not yet set. Needed to take reactive # dependencies on input values that haven't been received from client # yet. @@ -690,19 +700,18 @@ def __getitem__(self, key: str) -> Value[Any]: return self._map[key] def __delitem__(self, key: str) -> None: - del self._map[key] + del self._map[namespaced_id(key, self._ns)] # Allow access of values as attributes. def __setattr__(self, attr: str, value: Value[Any]) -> None: - # Need special handling of "_map". - if attr == "_map": + if attr in ("_map", "_ns"): super().__setattr__(attr, value) return self.__setitem__(attr, value) def __getattr__(self, attr: str) -> Value[Any]: - if attr == "_map": + if attr in ("_map", "_ns"): return object.__getattribute__(self, attr) return self.__getitem__(attr) @@ -721,13 +730,15 @@ class Outputs: ------- An instance of this class is created for each request and passed as an argument to the :class:`shiny.App`'s ``server`` function. For this reason, you shouldn't - need to create instances of this class yourself. + need to create instances of this class yourself (it's only part of the public API + for type checking reasons). """ def __init__(self, session: Session) -> None: self._effects: Dict[str, Effect_] = {} self._suspend_when_hidden: Dict[str, bool] = {} self._session: Session = session + self._ns: Optional[str] = None # Only relevant for ModuleOutputs() def __call__( self, @@ -737,7 +748,14 @@ def __call__( priority: int = 0, ) -> Callable[[render.RenderFunction], None]: def set_fn(fn: render.RenderFunction) -> None: - fn_name = name or fn.__name__ + # Get the (possibly namespaced) output id + fn_name = namespaced_id(name or fn.__name__, self._ns) + + # If created within a ModuleSession(), add the namespace_context to the + # user's func so that any UI generated by it will have namespaced ids. + if self._ns is not None: + fn.__call__ = add_namespace(fn.__call__, self._ns) + # fn is either a regular function or a RenderFunction object. If # it's the latter, we can give it a bit of metadata, which can be # used by the diff --git a/tests/test_modules.py b/tests/test_modules.py index 10bd47f03..796e351ab 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -3,25 +3,24 @@ import pytest from shiny import * -from shiny.modules import * -from shiny._utils import Callable +from shiny.modules import Module, ModuleInputs from shiny.reactive import * from htmltools import TagChildArg -def mod_ui(ns: Callable[[str], str]) -> TagChildArg: +def mod_ui() -> TagChildArg: return ui.TagList( - ui.input_action_button(id=ns("button"), label="module1"), - ui.output_text_verbatim(id=ns("out")), + ui.input_action_button(id="button", label="module1"), + ui.output_text_verbatim(id="out"), ) # Note: We currently can't test Session; this is just here for future use. -def mod_server(input: ModuleInputs, output: ModuleOutputs, session: ModuleSession): +def mod_server(input: Inputs, output: Outputs, session: Session): count: Value[int] = Value(0) @Effect() - @event(session.input.button) + @event(input.button) def _(): count.set(count() + 1) @@ -36,8 +35,9 @@ def out() -> str: def test_module_ui(): x = mod.ui("mod1") - assert x[0].attrs["id"] == "mod1-button" - assert x[1].attrs["id"] == "mod1-out" + assert x[0].attrs["id"] == "mod1_button" + assert x[1].attrs["id"] == "mod1_out" + # TODO: add a test for nested modules @pytest.mark.asyncio @@ -50,7 +50,7 @@ async def test_inputs_proxy(): # Different ways of accessing "a" from the input proxy. assert input_proxy.a.is_set() is False assert input_proxy["a"].is_set() is False - assert input["mod1-a"].is_set() is False + assert input["mod1_a"].is_set() is False input_proxy.a._set(2) @@ -58,7 +58,7 @@ async def test_inputs_proxy(): assert input.a() == 1 assert input_proxy.a() == 2 assert input_proxy["a"]() == 2 - assert input["mod1-a"]() == 2 + assert input["mod1_a"]() == 2 # Nested input proxies input_proxy_proxy = ModuleInputs("mod2", input_proxy) @@ -68,7 +68,7 @@ async def test_inputs_proxy(): # Different ways of accessing "a" from the input proxy. assert input_proxy_proxy.a.is_set() is False assert input_proxy_proxy["a"].is_set() is False - assert input_proxy["mod1-a"].is_set() is False + assert input_proxy["mod1_a"].is_set() is False input_proxy_proxy.a._set(3) @@ -77,4 +77,4 @@ async def test_inputs_proxy(): assert input_proxy.a() == 2 assert input_proxy_proxy.a() == 3 assert input_proxy_proxy["a"]() == 3 - assert input["mod1-mod2-a"]() == 3 + assert input["mod1_mod2_a"]() == 3