diff --git a/examples/moduleapp/app.py b/examples/moduleapp/app.py index 9c4e432b6..81c031e90 100644 --- a/examples/moduleapp/app.py +++ b/examples/moduleapp/app.py @@ -1,18 +1,14 @@ -from typing import Callable from shiny 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"), ) diff --git a/shiny/_modules.py b/shiny/_modules.py index 971455cde..638ddcf0a 100644 --- a/shiny/_modules.py +++ b/shiny/_modules.py @@ -1,18 +1,17 @@ -__all__ = ("Module",) +__all__ = ("Module", "namespaced_id") 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 namespaced_id 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 ------- @@ -23,43 +22,16 @@ class ModuleInputs(Inputs): 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 ------- @@ -70,34 +42,17 @@ class ModuleOutputs(Outputs): 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 __init__(self, ns: str, parent_outputs: Outputs): + self._ns = namespaced_id(ns, parent_outputs._ns) # Support nested modules + self._parent = parent_outputs - 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 __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 ------- @@ -108,8 +63,8 @@ class ModuleSession(Session): signature as :class:`shiny.session.Session`. """ - def __init__(self, ns: str, parent_session: Session) -> None: - self._ns: str = ns + def __init__(self, ns: str, parent_session: Session): + self._ns: str = namespaced_id(ns, parent_session._ns) # Support nested modules self._parent: Session = parent_session self.input: ModuleInputs = ModuleInputs(ns, parent_session.input) self.output: ModuleOutputs = ModuleOutputs(ns, parent_session.output) @@ -118,6 +73,11 @@ def __getattr__(self, attr: str) -> Any: return getattr(self._parent, attr) +class MockModuleSession(ModuleSession): + def __init__(self, ns: str): + self._ns = ns + + @add_example() class Module: """ @@ -152,7 +112,11 @@ def ui(self, ns: str, *args: Any) -> TagChildArg: args Additional arguments to pass to the module's UI definition. """ - return self._ui(Module._make_ns_fn(ns), *args) + + # Create a fake session so that namespaced_id() knows + # what the relevant namespace is + with session_context(MockModuleSession(ns)): + return self._ui(*args) def server(self, ns: str, *, session: Optional[Session] = None) -> None: """ @@ -166,15 +130,7 @@ def server(self, ns: str, *, session: Optional[Session] = None) -> None: 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 + + mod_sess = ModuleSession(ns, require_active_session(session)) + with session_context(mod_sess): + return self._server(mod_sess.input, mod_sess.output, mod_sess) diff --git a/shiny/_namespaces.py b/shiny/_namespaces.py new file mode 100644 index 000000000..c6c876167 --- /dev/null +++ b/shiny/_namespaces.py @@ -0,0 +1,34 @@ +# TODO: make this available under the shiny.modules API +__all__ = ("namespaced_id",) + +from typing import Union, Optional + +from .types import MISSING, MISSING_TYPE + + +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.. + """ + if isinstance(ns, MISSING_TYPE): + ns = get_current_namespace() + + if ns is None: + return id + else: + return ns + "_" + id + + +def get_current_namespace() -> Optional[str]: + from .session import get_current_session + + session = get_current_session() + if session is None: + return None + else: + return session._ns diff --git a/shiny/examples/Module/app.py b/shiny/examples/Module/app.py index 9c4e432b6..81c031e90 100644 --- a/shiny/examples/Module/app.py +++ b/shiny/examples/Module/app.py @@ -1,18 +1,14 @@ -from typing import Callable from shiny 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"), ) diff --git a/shiny/session/_session.py b/shiny/session/_session.py index b37484f1c..b949660bd 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 from ..reactive import Value, Effect, Effect_, isolate, flush from ..reactive._core import lock from ..types import SafeException, SilentCancelOutputException, SilentException @@ -140,6 +141,8 @@ def __init__( self.input: Inputs = Inputs() self.output: Outputs = Outputs(self) + self._ns: Optional[str] = None # Only relevant for ModuleSession + self.user: Union[str, None] = None self.groups: Union[List[str], None] = None credentials_json: str = "" @@ -337,6 +340,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: @@ -459,7 +463,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": namespaced_id(id, self._ns), "message": message} self._outbound_message_queues["input_messages"].append(msg) self._request_flush() @@ -610,6 +614,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, @@ -694,13 +699,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 reactive.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. @@ -710,19 +718,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) @@ -749,6 +756,7 @@ 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, @@ -758,7 +766,9 @@ 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) + # 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/shiny/ui/_download_button.py b/shiny/ui/_download_button.py index fceb14756..70d32c14c 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 namespaced_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=namespaced_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=namespaced_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 f2b5cfbf2..fb3d94152 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 namespaced_id @add_example() @@ -52,7 +53,7 @@ def input_action_button( {"class": "btn btn-default action-button", "style": css(width=width)}, icon, label, - id=id, + id=namespaced_id(id), type="button", **kwargs, ) @@ -95,4 +96,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=namespaced_id(id), + href="#", + **kwargs, + ) diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index 76f7877a2..15962fffa 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 namespaced_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=namespaced_id(id), + type="checkbox", + checked="checked" if value else None, ), span(label), ), @@ -118,7 +121,7 @@ def input_checkbox_group( input_label = shiny_input_label(id, label) options = _generate_options( - id=id, + id=namespaced_id(id), type="checkbox", choices=choices, selected=selected, @@ -127,7 +130,7 @@ def input_checkbox_group( return div( input_label, options, - id=id, + id=namespaced_id(id), style=css(width=width), class_="form-group shiny-input-checkboxgroup shiny-input-container" + (" shiny-input-container-inline" if inline else ""), @@ -184,7 +187,7 @@ def input_radio_buttons( input_label = shiny_input_label(id, label) options = _generate_options( - id=id, + id=namespaced_id(id), type="radio", choices=choices, selected=selected, @@ -193,7 +196,7 @@ def input_radio_buttons( return div( input_label, options, - id=id, + id=namespaced_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 edf4c58d0..018ea0f15 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 namespaced_id from ._utils import shiny_input_label @@ -108,7 +109,7 @@ def input_date( return div( shiny_input_label(id, label), _date_input_tag( - id=id, + id=namespaced_id(id), value=value, min=min, max=max, @@ -120,7 +121,7 @@ def input_date( data_date_dates_disabled=json.dumps(datesdisabled), data_date_days_of_week_disabled=json.dumps(daysofweekdisabled), ), - id=id, + id=namespaced_id(id), class_="shiny-date-input form-group shiny-input-container", style=css(width=width), ) @@ -225,7 +226,7 @@ def input_date_range( shiny_input_label(id, label), div( _date_input_tag( - id=id, + id=namespaced_id(id), value=start, min=min, max=max, @@ -241,7 +242,7 @@ def input_date_range( class_="input-group-addon input-group-prepend input-group-append", ), _date_input_tag( - id=id, + id=namespaced_id(id), value=end, min=min, max=max, @@ -254,7 +255,7 @@ def input_date_range( # input-daterange class is needed for dropdown behavior class_="input-daterange input-group input-group-sm", ), - id=id, + id=namespaced_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 5de5b9979..f2f084c8c 100644 --- a/shiny/ui/_input_file.py +++ b/shiny/ui/_input_file.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, div, span, css, TagChildArg from .._docstring import add_example +from .._namespaces import namespaced_id from ._utils import shiny_input_label @@ -64,7 +65,7 @@ def input_file( btn_file = span( button_label, tags.input( - id=id, + id=namespaced_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 ef49f20e6..a50bedb12 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 namespaced_id from ._utils import shiny_input_label @@ -56,7 +57,7 @@ def input_numeric( return div( shiny_input_label(id, label), tags.input( - id=id, + id=namespaced_id(id), type="number", class_="form-control", value=value, diff --git a/shiny/ui/_input_password.py b/shiny/ui/_input_password.py index d2b8b5045..6a26fa5c5 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 namespaced_id from ._utils import shiny_input_label @@ -48,7 +49,7 @@ def input_password( return div( shiny_input_label(id, label), tags.input( - id=id, + id=namespaced_id(id), type="password", value=value, class_="form-control", diff --git a/shiny/ui/_input_select.py b/shiny/ui/_input_select.py index 17a25a168..0506f39c2 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 namespaced_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=namespaced_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 d704baa75..798c0442a 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 namespaced_id from ._utils import shiny_input_label # Even though TypedDict is available in Python 3.8, because it's used with NotRequired, @@ -168,6 +169,8 @@ def input_slider( scale_factor = math.ceil(n_steps / 10) n_ticks = n_steps / scale_factor + id = namespaced_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 773fec655..9de6175e4 100644 --- a/shiny/ui/_input_text.py +++ b/shiny/ui/_input_text.py @@ -5,6 +5,7 @@ from htmltools import tags, Tag, div, css, TagChildArg from .._docstring import add_example +from .._namespaces import namespaced_id from ._utils import shiny_input_label @@ -50,7 +51,7 @@ def input_text( return div( shiny_input_label(id, label), tags.input( - id=id, + id=namespaced_id(id), type="text", class_="form-control", value=value, @@ -124,7 +125,7 @@ def input_text_area( area = tags.textarea( value, - id=id, + id=namespaced_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 a37f9a201..1ba7ed937 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -30,6 +30,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 namespaced_id from .._utils import drop_none from ..session import Session, require_active_session @@ -181,7 +182,7 @@ def update_checkbox_group( """ _update_choice_input( - id=id, + id=namespaced_id(id), type="checkbox", label=label, choices=choices, @@ -233,7 +234,7 @@ def update_radio_buttons( """ _update_choice_input( - id=id, + id=namespaced_id(id), type="radio", label=label, choices=choices, @@ -257,7 +258,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=namespaced_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 c1ce69aeb..2101dc2f2 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 namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_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 b40096672..1f3496f8c 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 namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_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=namespaced_id(id), **kwargs) diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index 223e4cf7c..02126d633 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -24,6 +24,7 @@ from .._docstring import add_example from ._html_dependencies import bootstrap_deps from ._navs import navset_bar +from .._namespaces import namespaced_id from ..types import MISSING, MISSING_TYPE, NavSetArg from ._utils import get_window_title @@ -110,7 +111,7 @@ def page_navbar( navset_bar( *args, title=title, - id=id, + id=namespaced_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 f9365c666..769a3af36 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,6 +1,6 @@ """Tests for `Module`.""" -from typing import Callable, Dict, Union, cast +from typing import Dict, Union, cast import pytest from shiny import * @@ -11,10 +11,10 @@ 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"), ) @@ -38,8 +38,8 @@ def out() -> str: 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" + assert cast(ui.Tag, x[0]).attrs["id"] == "mod1_button" + assert cast(ui.Tag, x[1]).attrs["id"] == "mod1_out" @pytest.mark.asyncio @@ -52,7 +52,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) @@ -60,7 +60,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) @@ -70,7 +70,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) @@ -79,7 +79,7 @@ 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 def test_current_session(): @@ -132,7 +132,7 @@ def _(): 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 sessions["inner_current"]._ns == "mod_outer_mod_inner" assert sessions["outer"] is sessions["outer_current"] assert sessions["outer_current"] is sessions["outer_calc_current"]