From 56449c361cd792b59c8a078ecc30ba4c7dca4283 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 4 Mar 2022 18:18:05 -0600 Subject: [PATCH] wip attempt at simplifying Module() API and implementation --- examples/moduleapp/app.py | 26 ++++--- shiny/__init__.py | 4 +- shiny/_namespaces.py | 48 ++++++++++++ shiny/modules.py | 155 ++++++++------------------------------ shiny/session/_session.py | 53 ++++++++----- 5 files changed, 130 insertions(+), 156 deletions(-) create mode 100644 shiny/_namespaces.py diff --git a/examples/moduleapp/app.py b/examples/moduleapp/app.py index 918cb2c35..a3a92f61f 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, foo: int = 1, bar: int = 2 +): count: reactive.Value[int] = reactive.Value(0) @reactive.Effect() @@ -28,8 +25,11 @@ def _(): @output() @render_text() def out() -> str: + print(session) return f"Click count is {count()}" + return dict(foo=foo, bar=bar) + counter_module = Module(counter_ui, counter_server) @@ -38,14 +38,16 @@ 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"), ) def server(input: Inputs, output: Outputs, session: Session): - counter_module.server("counter1") - counter_module.server("counter2") + a = counter_module.server("counter1") + b = counter_module.server("counter2", foo=3, bar=4) + print(a) + print(b) 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..9d3638121 --- /dev/null +++ b/shiny/_namespaces.py @@ -0,0 +1,48 @@ +__all__ = ("namespace_context",) + +from contextlib import contextmanager +from contextvars import ContextVar, Token + +from typing import Union, Optional + +_current_namespace: ContextVar[Optional[str]] = ContextVar( + "current_namespace", default=None +) + + +@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) + + +# Protect id's from being namespaced twice. +class Namespaced(str): + pass + + +def namespaced(id: str) -> Union[str, Namespaced]: + """ + 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. + """ + ns = _current_namespace.get() + if ns is None or isinstance(id, Namespaced): + return id + else: + return Namespaced(ns + "-" + id) diff --git a/shiny/modules.py b/shiny/modules.py index 5af963d48..773a9a142 100644 --- a/shiny/modules.py +++ b/shiny/modules.py @@ -1,102 +1,12 @@ -__all__ = ( - "ModuleInputs", - "ModuleOutputs", - "ModuleSession", - "Module", -) +__all__ = ("Module",) -from typing import Any, Callable, Optional, Dict +from typing import Any, Callable, Optional, TypeVar from htmltools import TagChildArg from ._docstring import add_example -from .reactive import Value -from .render import RenderFunction -from .session import Inputs, Outputs, Session, require_active_session, session_context - - -class ModuleInputs(Inputs): - """ - A class representing the inputs of a module. - - 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. - """ - - 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) - - -class ModuleOutputs(Outputs): - """ - A class representing the outputs of a module. - - 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. - """ - - 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 - - -NSFunc = Callable[[Optional[str]], str] +from ._namespaces import namespace_context +from .session import Session, require_active_session, session_context class ModuleSession(Session): @@ -106,24 +16,24 @@ class ModuleSession(Session): 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. + the :class:`Module`'s ``server`` function. For this reason, you shouldn't need to + create instances of this class yourself. """ - 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) -> None: + self._ns = ns + self._parent = parent_session 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) + if self._ns is None: + return id + return self._ns + "-" + id + + +T = TypeVar("T", bound=Any) @add_example() @@ -142,12 +52,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 +65,17 @@ 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) + with namespace_context(ns): + 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 +83,14 @@ 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 + session = ModuleSession(ns, require_active_session(session)) + with session_context(session): + return self._server(session.input, session.output, session, *args, **kwargs) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index cf44307fb..a0e34f703 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -53,7 +53,7 @@ from ..reactive import Value, Effect, Effect_, isolate, flush from ..reactive._core import lock from ..types import SafeException, SilentCancelOutputException, SilentException -from ._utils import RenderedDeps, read_thunk_opt, session_context +from ._utils import RenderedDeps, read_thunk_opt, session_context, get_current_session from .. import render from .. import _utils @@ -118,7 +118,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). """ # ========================================================================== @@ -133,7 +134,7 @@ def __init__( self._debug: bool = debug self.input: Inputs = Inputs() - self.output: Outputs = Outputs(self) + self.output: Outputs = Outputs() self._outbound_message_queues = empty_outbound_message_queues() @@ -315,6 +316,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 +439,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 +590,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, @@ -666,21 +669,28 @@ 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: + def __init__(self) -> None: self._map: dict[str, Value[Any]] = {} - for key, value in kwargs.items(): - self._map[key] = Value(value, read_only=True) + + @staticmethod + def _ns_key(key: str) -> str: + session = get_current_session() + if session is None: + return key + return session.ns(key) 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[self._ns_key(key)] = value def __getitem__(self, key: str) -> Value[Any]: + key = self._ns_key(key) # 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,24 +700,23 @@ def __getitem__(self, key: str) -> Value[Any]: return self._map[key] def __delitem__(self, key: str) -> None: - del self._map[key] + del self._map[self._ns_key(key)] # 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", "_session"]: super().__setattr__(attr, value) return self.__setitem__(attr, value) def __getattr__(self, attr: str) -> Value[Any]: - if attr == "_map": + if attr in ["_map", "_session"]: return object.__getattribute__(self, attr) return self.__getitem__(attr) def __delattr__(self, key: str) -> None: - self.__delitem__(key) + self.__delitem__(self._ns_key(key)) # ====================================================================================== @@ -721,13 +730,14 @@ 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: + def __init__(self) -> None: self._effects: Dict[str, Effect_] = {} self._suspend_when_hidden: Dict[str, bool] = {} - self._session: Session = session + self._session: Optional[Session] = None def __call__( self, @@ -737,7 +747,12 @@ def __call__( priority: int = 0, ) -> Callable[[render.RenderFunction], None]: def set_fn(fn: render.RenderFunction) -> None: - fn_name = name or fn.__name__ + self._session = get_current_session() + if self._session is None: + raise RuntimeError("Can't register Output outside of a Session.") + + fn_name = self._session.ns(name or fn.__name__) + # 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 @@ -754,6 +769,8 @@ def set_fn(fn: render.RenderFunction) -> None: priority=priority, ) async def output_obs(): + self._session = cast(Session, self._session) + await self._session.send_custom_message( "recalculating", {"name": fn_name, "status": "recalculating"} )