diff --git a/docs/source/index.rst b/docs/source/index.rst index 412530f44..f5ca5a27a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -303,7 +303,8 @@ Control application complexity by namespacing UI and server code. :toctree: reference/ :template: class.rst - Module + module_ui + module_server Type hints diff --git a/examples/moduleapp/app.py b/examples/moduleapp/app.py index e63150b77..66519889c 100644 --- a/examples/moduleapp/app.py +++ b/examples/moduleapp/app.py @@ -1,23 +1,23 @@ -from typing import Callable from shiny import * - # ============================================================ # Counter module # ============================================================ -def counter_ui( - ns: Callable[[str], str], label: str = "Increment counter" -) -> ui.TagChildArg: +@module.ui +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: Inputs, output: Outputs, session: Session): - count: reactive.Value[int] = reactive.Value(0) +@module.server +def counter_server( + input: Inputs, output: Outputs, session: Session, starting_value: int = 0 +): + count: reactive.Value[int] = reactive.Value(starting_value) @reactive.Effect @event(input.button) @@ -30,21 +30,48 @@ def out() -> str: return f"Click count is {count()}" -counter_module = Module(counter_ui, counter_server) +# ============================================================ +# Counter Wrapper module -- shows that counter still works +# the same way when wrapped in a dynamic UI +# ============================================================ +@module.ui +def counter_wrapper_ui() -> ui.TagChildArg: + return ui.output_ui("dynamic_counter") + + +@module.server +def counter_wrapper_server( + input: Inputs, output: Outputs, session: Session, label: str = "Increment counter" +): + @output() + @render.ui() + def dynamic_counter(): + return counter_ui("counter", label) + + counter_server("counter") # ============================================================================= # App that uses module # ============================================================================= app_ui = ui.page_fluid( - counter_module.ui("counter1", "Counter 1"), - counter_module.ui("counter2", "Counter 2"), + counter_ui("counter1", "Counter 1"), + counter_wrapper_ui("counter2_wrapper"), + ui.output_ui("counter3_ui"), ) def server(input: Inputs, output: Outputs, session: Session): - counter_module.server("counter1") - counter_module.server("counter2") + counter_server("counter1") + counter_wrapper_server("counter2_wrapper", "Counter 2") + + @output() + @render.ui() + def counter3_ui(): + counter_server("counter3") + return counter_ui("counter3", "Counter 3") + + counter_server("counter") app = App(app_ui, server) diff --git a/pyrightconfig.json b/pyrightconfig.json index 8d5ddb884..75e2b64bd 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,5 +1,5 @@ { - "ignore": ["shiny/examples", "examples", "build", "dist", "typings"], + "ignore": ["shiny/examples", "examples", "build", "dist", "typings", "sandbox"], "typeCheckingMode": "strict", "reportImportCycles": "none", "reportUnusedFunction": "none", diff --git a/shiny/__init__.py b/shiny/__init__.py index c846f5bdb..40a339e9e 100644 --- a/shiny/__init__.py +++ b/shiny/__init__.py @@ -13,10 +13,11 @@ # Private submodules that have some user-facing functionality from ._app import App from ._decorators import event -from ._modules import Module from ._validation import req from ._deprecated import * +from . import module + if _is_pyodide: # In pyodide, avoid importing _main because it imports packages that aren't # available. @@ -43,7 +44,7 @@ # _main.py "run_app", # _modules.py - "Module", + "module", # _session.py "Session", "Inputs", diff --git a/shiny/_connection.py b/shiny/_connection.py index e73d563aa..906ab9a1e 100644 --- a/shiny/_connection.py +++ b/shiny/_connection.py @@ -34,18 +34,16 @@ def __init__(self): # make those more configurable if we need to customize the HTTPConnection (like # "scheme", "path", and "query_string"). self._http_conn = HTTPConnection(scope={"type": "websocket", "headers": {}}) + self._queue: asyncio.Queue[str] = asyncio.Queue() async def send(self, message: str) -> None: pass - # I should say I’m not 100% that the receive method can be a no-op for our testing - # purposes. It might need to be asyncio.sleep(0), and/or it might need an external - # way to yield until we tell the connection to continue, so that the run loop can - # continue. async def receive(self) -> str: - # Sleep forever - await asyncio.Event().wait() - raise RuntimeError("make the type checker happy") + msg = await self._queue.get() + if msg == "": + raise ConnectionClosed() + return msg async def close(self, code: int, reason: Optional[str]) -> None: pass @@ -53,6 +51,14 @@ async def close(self, code: int, reason: Optional[str]) -> None: def get_http_conn(self) -> HTTPConnection: return self._http_conn + def cause_receive(self, message: str) -> None: + """Call from tests to simulate the other side sending a message""" + self._queue.put_nowait(message) + + def cause_disconnect(self) -> None: + """Call from tests to simulate the other side disconnecting""" + self.cause_receive("") + class StarletteConnection(Connection): def __init__(self, conn: starlette.websockets.WebSocket): diff --git a/shiny/_modules.py b/shiny/_modules.py deleted file mode 100644 index 77c6424f6..000000000 --- a/shiny/_modules.py +++ /dev/null @@ -1,195 +0,0 @@ -__all__ = ("Module",) - -from typing import Any, Callable, Optional, Union - -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. 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) - - -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. 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, - fn: Optional[RenderFunction] = None, - *, - id: Optional[str] = None, - suspend_when_hidden: bool = True, - priority: int = 0, - name: Optional[str] = None, - ) -> Union[None, Callable[[RenderFunction], None]]: - if name is not None: - from . import _deprecated - - _deprecated.warn_deprecated( - "`@output(name=...)` is deprecated. Use `@output(id=...)` instead." - ) - id = name - - def set_fn(fn: RenderFunction) -> None: - output_id = id or fn.__name__ - output_id = self._ns_key(output_id) - out_fn = self._outputs( - id=output_id, - suspend_when_hidden=suspend_when_hidden, - priority=priority, - ) - return out_fn(fn) - - if fn is None: - return set_fn - else: - return set_fn(fn) - - -class ModuleSession(Session): - """ - A class representing the session 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. 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, parent_session: Session) -> None: - self._ns: str = ns - self._parent: Session = parent_session - self.input: ModuleInputs = ModuleInputs(ns, parent_session.input) - self.output: ModuleOutputs = ModuleOutputs(ns, parent_session.output) - - def __getattr__(self, attr: str) -> Any: - return getattr(self._parent, attr) - - -@add_example() -class Module: - """ - Modularize UI and server-side logic. - - Parameters - ---------- - ui - The module's UI definition. - server - The module's server-side logic. - """ - - def __init__( - self, - ui: Callable[..., TagChildArg], - server: Callable[[ModuleInputs, ModuleOutputs, ModuleSession], None], - ) -> None: - self._ui: Callable[..., TagChildArg] = ui - self._server: Callable[ - [ModuleInputs, ModuleOutputs, ModuleSession], None - ] = server - - def ui(self, ns: str, *args: Any) -> TagChildArg: - """ - Render the module's UI. - - Parameters - ---------- - namespace - A namespace for the module. - args - Additional arguments to pass to the module's UI definition. - """ - return self._ui(Module._make_ns_fn(ns), *args) - - def server(self, ns: str, *, session: Optional[Session] = None) -> None: - """ - Invoke the module's server-side logic. - - Parameters - ---------- - ns - A namespace for the module. - session - A :class:`~shiny.Session` instance. If not provided, it is inferred via - :func:`~shiny.session.get_current_session`. - """ - self.ns: str = ns - session = require_active_session(session) - session_proxy = ModuleSession(ns, session) - with session_context(session_proxy): - self._server(session_proxy.input, session_proxy.output, session_proxy) - - @staticmethod - def _make_ns_fn(namespace: str) -> Callable[[str], str]: - def ns_fn(id: str) -> str: - return namespace + "-" + id - - return ns_fn diff --git a/shiny/_namespaces.py b/shiny/_namespaces.py new file mode 100644 index 000000000..bf7a81459 --- /dev/null +++ b/shiny/_namespaces.py @@ -0,0 +1,56 @@ +from contextlib import contextmanager +from contextvars import ContextVar, Token +import re +from typing import Union + + +class ResolvedId(str): + def __call__(self, id: "Id") -> "ResolvedId": + if isinstance(id, ResolvedId): + return id + + validate_id(id) + + if self == "": + return ResolvedId(id) + else: + return ResolvedId(self + "-" + id) + + +Root = ResolvedId("") + + +Id = Union[str, ResolvedId] + + +def resolve_id(id: Id) -> ResolvedId: + curr_ns = _current_namespace.get() + return curr_ns(id) + + +# \w is a large set for unicode patterns, that's fine; we mostly want to avoid some +# special characters like space, comma, period, and especially dash +re_valid_id = re.compile("^\\.?\\w+$") + + +def validate_id(id: str): + if not re_valid_id.match(id): + raise ValueError( + f"The string '{id}' is not a valid id; only letters, numbers, and " + "underscore are permitted" + ) + + +_current_namespace: ContextVar[ResolvedId] = ContextVar( + "current_namespace", default=Root +) + + +@contextmanager +def namespace_context(id: Union[Id, None]): + namespace = resolve_id(id) if id else Root + token: Token[ResolvedId] = _current_namespace.set(namespace) + try: + yield + finally: + _current_namespace.reset(token) diff --git a/shiny/examples/Module/app.py b/shiny/examples/Module/app.py index e63150b77..f6e83b995 100644 --- a/shiny/examples/Module/app.py +++ b/shiny/examples/Module/app.py @@ -1,23 +1,23 @@ -from typing import Callable from shiny import * - # ============================================================ # Counter module # ============================================================ -def counter_ui( - ns: Callable[[str], str], label: str = "Increment counter" -) -> ui.TagChildArg: +@module.ui +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: Inputs, output: Outputs, session: Session): - count: reactive.Value[int] = reactive.Value(0) +@module.server +def counter_server( + input: Inputs, output: Outputs, session: Session, starting_value: int = 0 +): + count: reactive.Value[int] = reactive.Value(starting_value) @reactive.Effect @event(input.button) @@ -30,21 +30,18 @@ def out() -> str: return f"Click count is {count()}" -counter_module = Module(counter_ui, counter_server) - - # ============================================================================= # App that uses module # ============================================================================= app_ui = ui.page_fluid( - counter_module.ui("counter1", "Counter 1"), - counter_module.ui("counter2", "Counter 2"), + counter_ui("counter1", "Counter 1"), + counter_ui("counter2", "Counter 2"), ) def server(input: Inputs, output: Outputs, session: Session): - counter_module.server("counter1") - counter_module.server("counter2") + counter_server("counter1") + counter_server("counter2") app = App(app_ui, server) diff --git a/shiny/module.py b/shiny/module.py new file mode 100644 index 000000000..d1ddc482a --- /dev/null +++ b/shiny/module.py @@ -0,0 +1,35 @@ +__all__ = ("resolve_id", "ui", "server") + +import sys +from typing import Callable, TypeVar + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec, Concatenate +else: + from typing import ParamSpec, Concatenate + +from ._namespaces import resolve_id, namespace_context, Id +from .session import Inputs, Outputs, Session, require_active_session, session_context + +P = ParamSpec("P") +R = TypeVar("R") + + +def ui(fn: Callable[P, R]) -> Callable[Concatenate[str, P], R]: + def wrapper(id: Id, *args: P.args, **kwargs: P.kwargs) -> R: + with namespace_context(id): + return fn(*args, **kwargs) + + return wrapper + + +def server( + fn: Callable[Concatenate[Inputs, Outputs, Session, P], R] +) -> Callable[Concatenate[str, P], R]: + def wrapper(id: Id, *args: P.args, **kwargs: P.kwargs) -> R: + sess = require_active_session(None) + child_sess = sess.make_scope(id) + with session_context(child_sess): + return fn(child_sess.input, child_sess.output, child_sess, *args, **kwargs) + + return wrapper diff --git a/shiny/session/_session.py b/shiny/session/_session.py index ea426199b..cde2c4f27 100644 --- a/shiny/session/_session.py +++ b/shiny/session/_session.py @@ -1,5 +1,6 @@ __all__ = ("Session", "Inputs", "Outputs") +import enum import functools import os from pathlib import Path @@ -49,6 +50,7 @@ from .._fileupload import FileInfo, FileUploadManager from ..http_staticfiles import FileResponse from ..input_handler import input_handlers +from .._namespaces import ResolvedId, Id, Root from ..reactive import Value, Effect, Effect_, isolate, flush from ..reactive._core import lock from ..types import SafeException, SilentCancelOutputException, SilentException @@ -57,6 +59,27 @@ from .. import render from .. import _utils + +class ConnectionState(enum.Enum): + Start = 0 + Running = 1 + Closed = 2 + + +class ProtocolError(Exception): + def __init__(self, message: str = ""): + super(ProtocolError, self).__init__(message) + self.message = message + + +class SessionWarning(RuntimeWarning): + pass + + +# By default warnings are shown once; we want to always show them. +warnings.simplefilter("always", SessionWarning) + + # This cast is necessary because if the type checker thinks that if # "tag" isn't in `message`, then it's not a ClientMessage object. # This will be fixable when TypedDict items can be marked as @@ -111,7 +134,14 @@ def empty_outbound_message_queues() -> OutBoundMessageQueues: return {"values": [], "input_messages": [], "errors": []} -class Session: +# Makes isinstance(x, Session) also return True when x is a SessionProxy (i.e., a module +# session) +class SessionMeta(type): + def __instancecheck__(self, __instance: Any) -> bool: + return isinstance(__instance, SessionProxy) + + +class Session(object, metaclass=SessionMeta): """ A class representing a user session. @@ -123,6 +153,8 @@ class Session: for type checking reasons). """ + ns = Root + # ========================================================================== # Initialization # ========================================================================== @@ -138,8 +170,8 @@ def __init__( # query information about the request, like headers, cookies, etc. self.http_conn: HTTPConnection = conn.get_http_conn() - self.input: Inputs = Inputs() - self.output: Outputs = Outputs(self) + self.input: Inputs = Inputs(dict()) + self.output: Outputs = Outputs(self, self.ns, dict(), dict()) self.user: Union[str, None] = None self.groups: Union[List[str], None] = None @@ -176,9 +208,6 @@ def __init__( self._flush_callbacks = _utils.Callbacks() self._flushed_callbacks = _utils.Callbacks() - with session_context(self): - self.app.server(self.input, self.output, self) - def _register_session_end_callbacks(self) -> None: # This is to be called from the initialization. It registers functions # that are called when a session ends. @@ -203,6 +232,12 @@ async def close(self, code: int = 1001) -> None: self._run_session_end_tasks() async def _run(self) -> None: + conn_state: ConnectionState = ConnectionState.Start + + def verify_state(expected_state: ConnectionState) -> None: + if conn_state != expected_state: + raise ProtocolError("Invalid method for the current session state") + await self._send_message( {"config": {"workerId": "", "sessionId": str(self.id), "user": None}} ) @@ -218,8 +253,8 @@ async def _run(self) -> None: message, object_hook=_utils.lists_to_tuples ) except json.JSONDecodeError: - print("ERROR: Invalid JSON message") - continue + warnings.warn("ERROR: Invalid JSON message", SessionWarning) + return if "method" not in message_obj: self._send_error_response("Message does not contain 'method'.") @@ -228,36 +263,40 @@ async def _run(self) -> None: async with lock(): if message_obj["method"] == "init": + verify_state(ConnectionState.Start) + + conn_state = ConnectionState.Running message_obj = typing.cast(ClientMessageInit, message_obj) self._manage_inputs(message_obj["data"]) + with session_context(self): + self.app.server(self.input, self.output, self) + elif message_obj["method"] == "update": + verify_state(ConnectionState.Running) + message_obj = typing.cast(ClientMessageUpdate, message_obj) self._manage_inputs(message_obj["data"]) - else: - if "tag" not in message_obj: - warnings.warn( - "Cannot dispatch message with missing 'tag'; method: " - + message_obj["method"] - ) - return - if "args" not in message_obj: - warnings.warn( - "Cannot dispatch message with missing 'args'; method: " - + message_obj["method"] - ) - return + elif "tag" in message_obj and "args" in message_obj: + verify_state(ConnectionState.Running) message_obj = typing.cast(ClientMessageOther, message_obj) await self._dispatch(message_obj) + else: + raise ProtocolError( + f"Unrecognized method {message_obj['method']}" + ) + self._request_flush() await flush() except ConnectionClosed: self._run_session_end_tasks() + except ProtocolError as pe: + self._send_error_response(pe.message) def _manage_inputs(self, data: Dict[str, object]) -> None: for (key, val) in data.items(): @@ -270,9 +309,25 @@ def _manage_inputs(self, data: Dict[str, object]) -> None: if len(keys) == 2: val = input_handlers._process_value(keys[1], val, keys[0], self) - self.input[keys[0]]._set(val) + # The keys[0] value is already a fully namespaced id; make that explicit by + # wrapping it in ResolvedId, otherwise self.input will throw an id + # validation error. + self.input[ResolvedId(keys[0])]._set(val) + + self.output._manage_hidden() + + def _is_hidden(self, name: str) -> bool: + with isolate(): + # The .clientdata_output_{name}_hidden string is already a fully namespaced + # id; make that explicit by wrapping it in ResolvedId, otherwise self.input + # will throw an id validation error. + hidden_value_obj = cast( + Value[bool], self.input[ResolvedId(f".clientdata_output_{name}_hidden")] + ) + if not hidden_value_obj.is_set(): + return True - self.output._manage_hidden() + return hidden_value_obj() # ========================================================================== # Message handlers @@ -320,11 +375,15 @@ async def uploadEnd(job_id: str, input_id: str) -> None: upload_op = self._file_upload_manager.get_upload_operation(job_id) if upload_op is None: warnings.warn( - "Received uploadEnd message for non-existent upload operation." + "Received uploadEnd message for non-existent upload operation.", + SessionWarning, ) return None file_data = upload_op.finish() - self.input[input_id]._set(file_data) + # The input_id string is already a fully namespaced id; make that explicit + # by wrapping it in ResolvedId, otherwise self.input will throw an id + # validation error. + self.input[ResolvedId(input_id)]._set(file_data) # Explicitly return None to signal that the message was handled. return None @@ -375,7 +434,8 @@ async def _handle_request( "Unable to infer a filename for the " f"'{download_id}' download handler; please use " "@session.download(filename=) to specify one " - "manually" + "manually", + SessionWarning, ) filename = download_id @@ -638,7 +698,6 @@ async def _unhandled_error(self, e: Exception) -> None: print("Unhandled error: " + str(e)) await self.close() - # TODO: probably name should be id @add_example() def download( self, @@ -724,6 +783,44 @@ def _process_ui(self, ui: TagChildArg) -> RenderedDeps: return {"deps": deps, "html": res["html"]} + def make_scope(self, id: Id) -> "Session": + ns = self.ns(id) + return SessionProxy(parent=self, ns=ns) # type: ignore + + +class SessionProxy: + def __init__(self, parent: Session, ns: ResolvedId) -> None: + self._parent = parent + self.ns = ns + self.input = Inputs(values=parent.input._map, ns=ns) + self.output = Outputs( + session=cast(Session, self), + effects=self.output._effects, + suspend_when_hidden=self.output._suspend_when_hidden, + ns=ns, + ) + + def __getattr__(self, attr: str) -> Any: + return getattr(self._parent, attr) + + def make_scope(self, id: str) -> Session: + return self._parent.make_scope(self.ns(id)) + + def send_input_message(self, id: str, message: Dict[str, object]) -> None: + return self._parent.send_input_message(self.ns(id), message) + + def dynamic_route(self, name: str, handler: DynamicRouteHandler) -> str: + return self._parent.dynamic_route(self.ns(name), handler) + + def download( + self, id: Optional[str] = None, **kwargs: object + ) -> Callable[[DownloadHandler], None]: + def wrapper(fn: DownloadHandler): + id_ = self.ns(id or fn.__name__) + return self._parent.download(id=id_, **kwargs)(fn) + + return wrapper + # ====================================================================================== # Inputs @@ -743,40 +840,41 @@ class Inputs: for type checking reasons). """ - def __init__(self, **kwargs: object) -> None: - self._map: dict[str, Value[Any]] = {} - for key, value in kwargs.items(): - self._map[key] = Value(value, read_only=True) + def __init__( + self, values: Dict[str, Value[Any]], ns: Callable[[str], str] = Root + ) -> None: + self._map = values + self._ns = ns def __setitem__(self, key: str, value: Value[Any]) -> None: if not isinstance(value, Value): raise TypeError("`value` must be a reactive.Value object.") - self._map[key] = value + self._map[self._ns(key)] = value def __getitem__(self, key: str) -> Value[Any]: + key = self._ns(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. if key not in self._map: - self._map[key] = Value(read_only=True) + self._map[key] = Value[Any](read_only=True) return self._map[key] def __delitem__(self, key: str) -> None: - del self._map[key] + del self._map[self._ns(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", "_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) @@ -799,10 +897,17 @@ class Outputs: 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 + def __init__( + self, + session: Session, + ns: Callable[[str], str], + effects: Dict[str, Effect_], + suspend_when_hidden: Dict[str, bool], + ) -> None: + self._session = session + self._ns = ns + self._effects = effects + self._suspend_when_hidden = suspend_when_hidden @overload def __call__(self, fn: render.RenderFunction) -> None: @@ -837,7 +942,9 @@ def __call__( id = name def set_fn(fn: render.RenderFunction) -> None: - output_name = id or fn.__name__ + # Get the (possibly namespaced) output id + output_name = self._ns(id or fn.__name__) + # fn is either a regular function or a RenderFunction object. If # it's the latter, give it a bit of metadata. if isinstance(fn, render.RenderFunction): @@ -849,7 +956,7 @@ def set_fn(fn: render.RenderFunction) -> None: self._suspend_when_hidden[output_name] = suspend_when_hidden @Effect( - suspended=suspend_when_hidden and self._is_hidden(output_name), + suspended=suspend_when_hidden and self._session._is_hidden(output_name), priority=priority, ) async def output_obs(): @@ -918,14 +1025,4 @@ def _manage_hidden(self) -> None: self._effects[name].resume() def _should_suspend(self, name: str) -> bool: - return self._suspend_when_hidden[name] and self._is_hidden(name) - - def _is_hidden(self, name: str) -> bool: - with isolate(): - hidden_value_obj = cast( - Value[bool], self._session.input[f".clientdata_output_{name}_hidden"] - ) - if not hidden_value_obj.is_set(): - return True - - return hidden_value_obj() + return self._suspend_when_hidden[name] and self._session._is_hidden(name) diff --git a/shiny/session/_utils.py b/shiny/session/_utils.py index 1e2372040..e60bb51f3 100644 --- a/shiny/session/_utils.py +++ b/shiny/session/_utils.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from ._session import Session +from .._namespaces import namespace_context if sys.version_info >= (3, 8): from typing import TypedDict @@ -62,7 +63,8 @@ def session_context(session: Optional["Session"]): """ token: Token[Union[Session, None]] = _current_session.set(session) try: - yield + with namespace_context(session.ns if session else None): + yield finally: _current_session.reset(token) diff --git a/shiny/ui/_download_button.py b/shiny/ui/_download_button.py index fceb14756..3a85cd937 100644 --- a/shiny/ui/_download_button.py +++ b/shiny/ui/_download_button.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, TagChildArg, TagAttrArg, css from .._docstring import add_example +from .._namespaces import resolve_id from .._shinyenv import is_pyodide @@ -46,7 +47,7 @@ def download_button( icon, label, {"class": "btn btn-default shiny-download-link", "style": css(width=width)}, - id=id, + id=resolve_id(id), # This is a fake link that just results in a 404. It will be replaced by a # working link after the server side logic runs, so this link will only be # visited in cases where the user clicks the button too fast, or if the server @@ -99,7 +100,7 @@ def download_link( icon, label, {"class": "shiny-download-link", "style": css(width=width)}, - id=id, + id=resolve_id(id), href="session/0/download/missing_download", target="_blank", download=None if is_pyodide else True, diff --git a/shiny/ui/_input_action_button.py b/shiny/ui/_input_action_button.py index 554ee138f..6703faec4 100644 --- a/shiny/ui/_input_action_button.py +++ b/shiny/ui/_input_action_button.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, TagChildArg, TagAttrArg, css from .._docstring import add_example +from .._namespaces import resolve_id @add_example() @@ -53,7 +54,7 @@ def input_action_button( {"class": "btn btn-default action-button", "style": css(width=width)}, icon, label, - id=id, + id=resolve_id(id), type="button", **kwargs, ) @@ -98,4 +99,11 @@ def input_action_link( ~shiny.event """ - return tags.a({"class": "action-button"}, icon, label, id=id, href="#", **kwargs) + return tags.a( + {"class": "action-button"}, + icon, + label, + id=resolve_id(id), + href="#", + **kwargs, + ) diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index 2b312a9ad..f8c346fb2 100644 --- a/shiny/ui/_input_check_radio.py +++ b/shiny/ui/_input_check_radio.py @@ -10,6 +10,7 @@ from .._docstring import add_example +from .._namespaces import resolve_id from ._utils import shiny_input_label # Canonical format for representing select options. @@ -58,7 +59,9 @@ def input_checkbox( div( tags.label( tags.input( - id=id, type="checkbox", checked="checked" if value else None + id=resolve_id(id), + type="checkbox", + checked="checked" if value else None, ), span(label), ), @@ -119,7 +122,7 @@ def input_checkbox_group( input_label = shiny_input_label(id, label) options = _generate_options( - id=id, + id=resolve_id(id), type="checkbox", choices=choices, selected=selected, @@ -128,7 +131,7 @@ def input_checkbox_group( return div( input_label, options, - id=id, + id=resolve_id(id), style=css(width=width), class_="form-group shiny-input-checkboxgroup shiny-input-container" + (" shiny-input-container-inline" if inline else ""), @@ -186,7 +189,7 @@ def input_radio_buttons( input_label = shiny_input_label(id, label) options = _generate_options( - id=id, + id=resolve_id(id), type="radio", choices=choices, selected=selected, @@ -195,7 +198,7 @@ def input_radio_buttons( return div( input_label, options, - id=id, + id=resolve_id(id), style=css(width=width), class_="form-group shiny-input-radiogroup shiny-input-container" + (" shiny-input-container-inline" if inline else ""), diff --git a/shiny/ui/_input_date.py b/shiny/ui/_input_date.py index e5a53a350..7abd4d4f3 100644 --- a/shiny/ui/_input_date.py +++ b/shiny/ui/_input_date.py @@ -8,6 +8,7 @@ from .._docstring import add_example from ._html_dependencies import datepicker_deps +from .._namespaces import resolve_id from ._utils import shiny_input_label @@ -109,7 +110,7 @@ def input_date( return div( shiny_input_label(id, label), _date_input_tag( - id=id, + id=resolve_id(id), value=value, min=min, max=max, @@ -121,7 +122,7 @@ def input_date( data_date_dates_disabled=json.dumps(datesdisabled), data_date_days_of_week_disabled=json.dumps(daysofweekdisabled), ), - id=id, + id=resolve_id(id), class_="shiny-date-input form-group shiny-input-container", style=css(width=width), ) @@ -227,7 +228,7 @@ def input_date_range( shiny_input_label(id, label), div( _date_input_tag( - id=id, + id=resolve_id(id), value=start, min=min, max=max, @@ -243,7 +244,7 @@ def input_date_range( class_="input-group-addon input-group-prepend input-group-append", ), _date_input_tag( - id=id, + id=resolve_id(id), value=end, min=min, max=max, @@ -256,7 +257,7 @@ def input_date_range( # input-daterange class is needed for dropdown behavior class_="input-daterange input-group input-group-sm", ), - id=id, + id=resolve_id(id), class_="shiny-date-range-input form-group shiny-input-container", style=css(width=width), ) diff --git a/shiny/ui/_input_file.py b/shiny/ui/_input_file.py index 2a43bf917..274768799 100644 --- a/shiny/ui/_input_file.py +++ b/shiny/ui/_input_file.py @@ -11,6 +11,7 @@ from htmltools import Tag, TagChildArg, css, div, span, tags from .._docstring import add_example +from .._namespaces import resolve_id from ._utils import shiny_input_label @@ -89,7 +90,7 @@ def input_file( btn_file = span( button_label, tags.input( - id=id, + id=resolve_id(id), name=id, type="file", multiple="multiple" if multiple else None, diff --git a/shiny/ui/_input_numeric.py b/shiny/ui/_input_numeric.py index 5fc60f3f5..a1fcf5996 100644 --- a/shiny/ui/_input_numeric.py +++ b/shiny/ui/_input_numeric.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, div, css, TagChildArg from .._docstring import add_example +from .._namespaces import resolve_id from ._utils import shiny_input_label @@ -57,7 +58,7 @@ def input_numeric( return div( shiny_input_label(id, label), tags.input( - id=id, + id=resolve_id(id), type="number", class_="form-control", value=value, diff --git a/shiny/ui/_input_password.py b/shiny/ui/_input_password.py index 8e7ff8446..8c14e961b 100644 --- a/shiny/ui/_input_password.py +++ b/shiny/ui/_input_password.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, div, css, TagChildArg from .._docstring import add_example +from .._namespaces import resolve_id from ._utils import shiny_input_label @@ -50,7 +51,7 @@ def input_password( return div( shiny_input_label(id, label), tags.input( - id=id, + id=resolve_id(id), type="password", value=value, class_="form-control", diff --git a/shiny/ui/_input_select.py b/shiny/ui/_input_select.py index bcc63c627..ec14a6b9c 100644 --- a/shiny/ui/_input_select.py +++ b/shiny/ui/_input_select.py @@ -9,6 +9,7 @@ from .._docstring import add_example from ._html_dependencies import selectize_deps +from .._namespaces import resolve_id from ._utils import shiny_input_label _Choices = Dict[str, TagChildArg] @@ -166,7 +167,7 @@ def input_select( div( tags.select( *choices_tags, - id=id, + id=resolve_id(id), class_=None if selectize else "form-select", multiple=multiple, width=width, diff --git a/shiny/ui/_input_slider.py b/shiny/ui/_input_slider.py index aca2a3ce5..5db18bfa9 100644 --- a/shiny/ui/_input_slider.py +++ b/shiny/ui/_input_slider.py @@ -14,6 +14,7 @@ from .._docstring import add_example from ._html_dependencies import ionrangeslider_deps +from .._namespaces import resolve_id from ._utils import shiny_input_label # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, @@ -169,6 +170,8 @@ def input_slider( scale_factor = math.ceil(n_steps / 10) n_ticks = n_steps / scale_factor + id = resolve_id(id) + props: Dict[str, TagAttrArg] = { "class_": "js-range-slider", "id": id, diff --git a/shiny/ui/_input_text.py b/shiny/ui/_input_text.py index 05a25bf91..72615732c 100644 --- a/shiny/ui/_input_text.py +++ b/shiny/ui/_input_text.py @@ -11,6 +11,7 @@ from htmltools import Tag, TagChildArg, css, div, tags from .._docstring import add_example +from .._namespaces import resolve_id from ._utils import shiny_input_label @@ -69,7 +70,7 @@ def input_text( return div( shiny_input_label(id, label), tags.input( - id=id, + id=resolve_id(id), type="text", class_="form-control", value=value, @@ -157,7 +158,7 @@ def input_text_area( area = tags.textarea( value, - id=id, + id=resolve_id(id), class_="form-control", style=css(width=None if width else "100%", height=height, resize=resize), placeholder=placeholder, diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index 725a8af0a..9a6ed93cb 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -41,6 +41,7 @@ from ._input_date import _as_date_attr from ._input_select import SelectChoicesArg, _normalize_choices, _render_choices from ._input_slider import SliderValueArg, SliderStepArg, _slider_type, _as_numeric +from .._namespaces import resolve_id from .._utils import drop_none from ..session import Session, require_active_session @@ -192,7 +193,7 @@ def update_checkbox_group( """ _update_choice_input( - id=id, + id=resolve_id(id), type="checkbox", label=label, choices=choices, @@ -244,7 +245,7 @@ def update_radio_buttons( """ _update_choice_input( - id=id, + id=resolve_id(id), type="radio", label=label, choices=choices, @@ -268,7 +269,11 @@ def _update_choice_input( options = None if choices is not None: opts = _generate_options( - id=id, type=type, choices=choices, selected=selected, inline=inline + id=resolve_id(id), + type=type, + choices=choices, + selected=selected, + inline=inline, ) options = session._process_ui(opts)["html"] msg = {"label": label, "options": options, "value": selected} diff --git a/shiny/ui/_navs.py b/shiny/ui/_navs.py index 77abcb04b..78b0852aa 100644 --- a/shiny/ui/_navs.py +++ b/shiny/ui/_navs.py @@ -27,6 +27,7 @@ from ._bootstrap import row, column from .._docstring import add_example from ._html_dependencies import bootstrap_deps +from .._namespaces import resolve_id from ..types import NavSetArg from .._utils import private_random_int @@ -408,7 +409,7 @@ def navset_tab( return NavSet( *args, ul_class="nav nav-tabs", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -460,7 +461,7 @@ def navset_pill( return NavSet( *args, ul_class="nav nav-pills", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -510,7 +511,7 @@ def navset_hidden( return NavSet( *args, ul_class="nav nav-hidden", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -590,7 +591,7 @@ def navset_tab_card( return NavSetCard( *args, ul_class="nav nav-tabs card-header-tabs", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -646,7 +647,7 @@ def navset_pill_card( return NavSetCard( *args, ul_class="nav nav-pills card-header-pills", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -740,7 +741,7 @@ def navset_pill_list( return NavSetPillList( *args, ul_class="nav nav-pills nav-stacked", - id=id, + id=resolve_id(id) if id else None, selected=selected, header=header, footer=footer, @@ -901,7 +902,7 @@ def navset_bar( return NavSetBar( *args, ul_class="nav navbar-nav", - id=id, + id=resolve_id(id) if id else None, selected=selected, title=title, position=position, diff --git a/shiny/ui/_output.py b/shiny/ui/_output.py index 77d9844e0..782153263 100644 --- a/shiny/ui/_output.py +++ b/shiny/ui/_output.py @@ -10,6 +10,7 @@ from htmltools import tags, Tag, div, css, TagAttrArg, TagFunction from .._docstring import add_example +from .._namespaces import resolve_id @add_example() @@ -42,7 +43,7 @@ def output_plot( ~shiny.render.plot ~shiny.ui.output_image """ - res = output_image(id=id, width=width, height=height, inline=inline) + res = output_image(id=resolve_id(id), width=width, height=height, inline=inline) res.add_class("shiny-plot-output") return res @@ -76,7 +77,7 @@ def output_image( """ func = tags.span if inline else div style = None if inline else css(width=width, height=height) - return func(id=id, class_="shiny-image-output", style=style) + return func(id=resolve_id(id), class_="shiny-image-output", style=style) @add_example() @@ -111,7 +112,7 @@ def output_text( if not container: container = tags.span if inline else tags.div - return container(id=id, class_="shiny-text-output") + return container(id=resolve_id(id), class_="shiny-text-output") def output_text_verbatim(id: str, placeholder: bool = False) -> Tag: @@ -145,7 +146,7 @@ def output_text_verbatim(id: str, placeholder: bool = False) -> Tag: """ cls = "shiny-text-output" + (" noplaceholder" if not placeholder else "") - return tags.pre(id=id, class_=cls) + return tags.pre(id=resolve_id(id), class_=cls) @add_example() @@ -181,4 +182,4 @@ def output_ui( if not container: container = tags.span if inline else tags.div - return container({"class": "shiny-html-output"}, id=id, **kwargs) + return container({"class": "shiny-html-output"}, id=resolve_id(id), **kwargs) diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index fb6a2a877..176629050 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -28,6 +28,7 @@ from .._docstring import add_example from ._html_dependencies import bootstrap_deps from ._navs import navset_bar +from .._namespaces import resolve_id from ..types import MISSING, MISSING_TYPE, NavSetArg from ._utils import get_window_title @@ -114,7 +115,7 @@ def page_navbar( navset_bar( *args, title=title, - id=id, + id=resolve_id(id) if id else None, selected=selected, position=position, header=header, diff --git a/tests/test_modules.py b/tests/test_modules.py index ae8fac20e..f526150d8 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,92 +1,51 @@ """Tests for `Module`.""" -from typing import Callable, Dict, Union, cast +import asyncio +from typing import Dict, Union, cast import pytest + +from htmltools import Tag, TagList from shiny import * -from shiny.session import get_current_session from shiny._connection import MockConnection -from shiny._modules import ModuleInputs, ModuleSession -from shiny._utils import run_coro_sync -from htmltools import TagChildArg +from shiny._namespaces import resolve_id +from shiny.session import get_current_session -def mod_ui(ns: Callable[[str], str]) -> TagChildArg: - return ui.TagList( - ui.input_action_button(id=ns("button"), label="module1"), - ui.output_text_verbatim(id=ns("out")), +@module.ui +def mod_inner_ui() -> TagList: + return TagList( + ui.input_action_button("button", label="inner"), + ui.output_text(resolve_id("out")), ) -# Note: We currently can't test Session; this is just here for future use. -def mod_server(input: Inputs, output: Outputs, session: Session): - count: reactive.Value[int] = reactive.Value(0) +@module.ui +def mod_outer_ui() -> TagList: + return TagList(mod_inner_ui("inner"), ui.output_text("out2")) - @reactive.Effect - @event(session.input.button) - def _(): - count.set(count() + 1) - @output - @render.text - def out() -> str: - return f"Click count is {count()}" - - -mod = Module(mod_ui, mod_server) +def get_id(x: TagList, child_idx: int = 0) -> str: + return cast(Tag, x[child_idx]).attrs["id"] def test_module_ui(): - x = cast(ui.TagList, mod.ui("mod1")) - assert cast(ui.Tag, x[0]).attrs["id"] == "mod1-button" - assert cast(ui.Tag, x[1]).attrs["id"] == "mod1-out" + x = mod_inner_ui("inner") + assert get_id(x, 0) == "inner-button" + assert get_id(x, 1) == "inner-out" + y = mod_outer_ui("outer") + assert get_id(y, 0) == "outer-inner-button" + assert get_id(y, 1) == "outer-inner-out" + assert get_id(y, 2) == "outer-out2" @pytest.mark.asyncio -async def test_inputs_proxy(): - input = Inputs(a=1) - input_proxy = ModuleInputs("mod1", input) - - with reactive.isolate(): - assert input.a() == 1 - # 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 - - input_proxy.a._set(2) - - with reactive.isolate(): - assert input.a() == 1 - assert input_proxy.a() == 2 - assert input_proxy["a"]() == 2 - assert input["mod1-a"]() == 2 - - # Nested input proxies - input_proxy_proxy = ModuleInputs("mod2", input_proxy) - with reactive.isolate(): - assert input.a() == 1 - assert input_proxy.a() == 2 - # 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 - - input_proxy_proxy.a._set(3) - - with reactive.isolate(): - assert input.a() == 1 - assert input_proxy.a() == 2 - assert input_proxy_proxy.a() == 3 - assert input_proxy_proxy["a"]() == 3 - assert input["mod1-mod2-a"]() == 3 - - -def test_current_session(): - - sessions: Dict[str, Union[Session, None]] = {} - - def inner(input: Inputs, output: Outputs, session: Session): +async def test_session_scoping(): + + sessions: Dict[str, Union[Session, None, str]] = {} + + @module.server + def inner_server(input: Inputs, output: Outputs, session: Session): @reactive.Calc def out(): return get_current_session() @@ -96,25 +55,26 @@ def _(): sessions["inner"] = session sessions["inner_current"] = get_current_session() sessions["inner_calc_current"] = out() + sessions["inner_id"] = session.ns("foo") + sessions["inner_ui_id"] = get_id(mod_outer_ui("outer"), 0) - mod_inner = Module(ui.TagList, inner) - - def outer(input: Inputs, output: Outputs, session: Session): + @module.server + def outer_server(input: Inputs, output: Outputs, session: Session): @reactive.Calc def out(): return get_current_session() @reactive.Effect def _(): - mod_inner.server("mod_inner") + inner_server("mod_inner") sessions["outer"] = session sessions["outer_current"] = get_current_session() sessions["outer_calc_current"] = out() - - mod_outer = Module(ui.TagList, outer) + sessions["outer_id"] = session.ns("foo") + sessions["outer_ui_id"] = get_id(mod_outer_ui("outer"), 0) def server(input: Inputs, output: Outputs, session: Session): - mod_outer.server("mod_outer") + outer_server("mod_outer") @reactive.Calc def out(): @@ -125,21 +85,32 @@ def _(): sessions["top"] = session sessions["top_current"] = get_current_session() sessions["top_calc_current"] = out() + sessions["top_id"] = session.ns("foo") + sessions["top_ui_id"] = get_id(mod_outer_ui("outer"), 0) + + conn = MockConnection() + sess = App(ui.TagList(), server)._create_session(conn) + + async def mock_client(): + conn.cause_receive('{"method":"init","data":{}}') + conn.cause_disconnect() - App(ui.TagList(), server)._create_session(MockConnection()) - run_coro_sync(reactive.flush()) + await asyncio.gather(mock_client(), sess._run()) assert sessions["inner"] is sessions["inner_current"] assert sessions["inner_current"] is sessions["inner_calc_current"] - assert isinstance(sessions["inner_current"], ModuleSession) - assert sessions["inner_current"]._ns == "mod_inner" + assert isinstance(sessions["inner_current"], Session) + assert sessions["inner_id"] == "mod_outer-mod_inner-foo" + assert sessions["inner_ui_id"] == "mod_outer-mod_inner-outer-inner-button" assert sessions["outer"] is sessions["outer_current"] assert sessions["outer_current"] is sessions["outer_calc_current"] - assert isinstance(sessions["outer_current"], ModuleSession) - assert sessions["outer_current"]._ns == "mod_outer" + assert isinstance(sessions["outer_current"], Session) + assert sessions["outer_id"] == "mod_outer-foo" + assert sessions["outer_ui_id"] == "mod_outer-outer-inner-button" assert sessions["top"] is sessions["top_current"] assert sessions["top_current"] is sessions["top_calc_current"] assert isinstance(sessions["top_current"], Session) - assert not isinstance(sessions["top_current"], ModuleSession) + assert sessions["top_id"] == "foo" + assert sessions["top_ui_id"] == "outer-inner-button" diff --git a/tests/test_namespaces.py b/tests/test_namespaces.py new file mode 100644 index 000000000..adfdc95c3 --- /dev/null +++ b/tests/test_namespaces.py @@ -0,0 +1,39 @@ +from shiny._namespaces import namespace_context, resolve_id + + +def test_namespaces(): + outer = resolve_id("outer") + assert outer == "outer" + + with namespace_context(outer): + # Check if the namespace_context ("outer") is respected during resolve_id + inner = resolve_id("inner") + assert inner == "outer-inner" + + # You can also use a ResolvedId as a namespace just by calling it with an id str + assert outer("inner") == "outer-inner" + + # If an id is already resolved (based on ResolvedId class), resolving it further + # does nothing + assert resolve_id(outer) == "outer" + + # When namespace contexts are stacked, inner one wins + with namespace_context(inner): + assert resolve_id("inmost") == "outer-inner-inmost" + + # Namespace contexts nest with existing context when string is used + with namespace_context("inner"): + assert resolve_id("inmost") == "outer-inner-inmost" + + # Re-installing the same context as is already in place + with namespace_context(outer): + assert resolve_id("inmost") == "outer-inmost" + + # You can remove the context with None or "" + with namespace_context(None): + assert resolve_id("foo") == "foo" + with namespace_context(""): + assert resolve_id("foo") == "foo" + + # Check that this still works after another context was installed/removed + assert resolve_id("inner") == "outer-inner" diff --git a/tests/test_poll.py b/tests/test_poll.py index 13382eb97..01c6b3249 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -12,6 +12,7 @@ from shiny import * from shiny import _utils from shiny.reactive import * +from shiny._namespaces import Root from .mocktime import MockTime @@ -25,6 +26,8 @@ class OnEndedSessionCallbacks: Eventually we should have a proper mock of Session, then we can retire this. """ + ns = Root + def __init__(self): self._on_ended_callbacks = _utils.Callbacks() # Unfortunately we have to lie here and say we're a session. Obvously, any