diff --git a/CHANGELOG.md b/CHANGELOG.md index 557204184..6a7eb3069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * When `@expressify` cannot locate a function's definition, the error now names the function as it appears in the source (rather than a `__name__` a decorator may have rewritten), points at the file and line it looked at, and lists the likely causes — an `async def`, a decorator below `expressify()` that returns a wrapper instead of the original function, or a source file modified after import. (#2016) +* The choice inputs (`ui.input_checkbox_group()`, `ui.input_radio_buttons()`, `ui.input_select()`, `ui.input_selectize()`, `ui.toolbar_input_select()`) and their `update_*()` counterparts now accept non-string choice values and `selected` values, such as the `int` keys of a `dict[int, str]` passed as `choices`. The `update_*()` functions previously left the input unchanged or cleared it entirely, and any label sent in the same call was lost. (#2420) + +* `ui.update_radio_buttons(selected=)` now accepts a one-element list or tuple, which previously left the radio group unchanged. Passing more than one value raises a `ValueError`, since a radio group can only show one. Passing `selected=[]` still clears the selection. (#2420) + +* `ui.update_radio_buttons(choices=[])` now clears the set of choices. This was the documented behavior, but didn't actually work and raised an `IndexError` instead. `ui.input_radio_buttons(choices=[])` still raises, but now with a more helpful `ValueError` that names `choices`. (#2420) + +* Choice values whose string forms collide, such as `choices=[0, "0"]`, now raise a `ValueError`. Previously both options rendered with the same underlying value, so the input could not report which one the user chose. In a select input's `choices`, optgroup labels share the top-level mapping with choice values, so a label and a value must not collide either. Two separate optgroups may still hold the same choice value. (#2420) + +* A `selected` value now names a choice by its string form. The input constructors previously compared raw Python values, so `ui.input_radio_buttons(choices={1: "one"}, selected=True)` checked the `1` option; it no longer does, which matches R Shiny. An integral `float` still names the integer it equals, so `selected=1.0` continues to match a choice value of `1`. (#2420) + +* `None` and `bool` choice values now render as `value="None"`, `value="False"`, and `value="True"`. Previously the browser reported `"on"` or `""` for these options, so `None` and `False` were indistinguishable and neither could be updated. Apps and saved bookmarks that hold the old `""`/`"on"` values will read the new strings. (#2420) + ## [1.7.0] - 2026-07-28 ### New features diff --git a/shiny/ui/_choices.py b/shiny/ui/_choices.py new file mode 100644 index 000000000..3dacfafbd --- /dev/null +++ b/shiny/ui/_choices.py @@ -0,0 +1,228 @@ +""" +Shared normalization utilities for the choice-based inputs (checkbox group, +radio buttons, select, selectize, toolbar select). + +Choice values become HTML ``value`` attributes, so the client only ever sees their +string form. Everything here exists to make the server and client agree: choice values +are coerced to ``str`` once, and ``selected`` is coerced the same way so that the +regenerated options markup and the ``value`` sent in an ``update_*()`` message match +by construction. + +See https://github.com/posit-dev/py-shiny/issues/2272 and +https://github.com/posit-dev/py-shiny/pull/2420 for details. +""" + +from __future__ import annotations + +from collections.abc import Collection, Iterable, Mapping +from typing import Any, TypeVar, Union, cast + +# A choice value. The client only ever sees ``str(value)``, so these are the types with +# an unambiguous string form -- the ones the choice inputs document and test. +ChoiceValue = Union[str, int, float, bool, None] + +# A choice value in a `Mapping` key position, which has to stay `Any`: `Mapping`'s key +# type is invariant, so `Mapping[ChoiceValue, TagChild]` rejects `dict[int, str]` -- and +# even `dict[str, str]`. The runtime contract is the same as `ChoiceValue`. +ChoiceKey = Any + +V = TypeVar("V") + + +def duplicate_key_error( + key: str, *, first: tuple[Any, Any], second: tuple[Any, Any] +) -> ValueError: + """ + Build the error for two keys whose string forms collide. + + ``first`` and ``second`` are the colliding ``(key, label)`` pairs. A key whose label + is a ``Mapping`` heads an optgroup, so it names a group rather than a choice. Only a + select input's top-level ``choices`` can hold one, so the wording follows from the + labels rather than from the calling input. + """ + (first_key, first_label), (second_key, second_label) = first, second + kinds = tuple( + "optgroup label" if isinstance(label, Mapping) else "choice value" + for label in (first_label, second_label) + ) + + if kinds == ("choice value", "choice value"): + return ValueError( + f"Duplicate choice value {key!r}: {first_key!r} and {second_key!r} are " + "distinct but are identical as strings. Choice values must be unique when " + "converted to strings." + ) + + return ValueError( + f"Duplicate key {key!r} in `choices`: the {kinds[0]} {first_key!r} and the " + f"{kinds[1]} {second_key!r} are distinct but are identical as strings. A select " + "input's `choices` (values and optgroup labels) must be unique when converted " + "to strings." + ) + + +def normalize_choices_mapping(x: Mapping[Any, V]) -> dict[str, V]: + """ + Coerce choice values (the mapping's keys) to ``str``, preserving order. + + Raises + ------ + ValueError + If two keys collide once stringified. The normalized form is a ``dict`` keyed by + the string form, so one of the two entries would otherwise disappear from the + rendered input with no diagnostic. + """ + normalized: dict[str, V] = {} + originals: dict[str, Any] = {} + + for key, label in x.items(): + str_key = str(key) + if str_key in normalized: + raise duplicate_key_error( + str_key, + first=(originals[str_key], normalized[str_key]), + second=(key, label), + ) + normalized[str_key] = label + originals[str_key] = key + + return normalized + + +def resolve_selected(selected: Any, choice_values: Collection[str]) -> Any: + """ + Replace each ``selected`` entry with the string form of the choice value it names. + + Matching is on the string form, since that is the only thing the client can match + against, so a ``selected`` of ``True`` does not name the choice value ``1``. An + integral ``float`` is the one exception: it also matches the integer it equals, so + ``selected=[1.0]`` against ``choices={1: "a"}`` resolves to ``"1"``, the value the + option actually carries. (R Shiny gets that case for free, since + ``as.character(1.0)`` is ``"1"``.) + + An entry that names no choice passes through as its own string form, so an + ``update_*()`` message still carries what the caller asked for. + """ + if selected is None: + return None + + def resolve_one(value: Any) -> str: + as_str = str(value) + if as_str in choice_values: + return as_str + # `str(1.0)` is `"1.0"`, so an integral float misses the `1` it names. `bool` is + # a subclass of `int` rather than `float`, so `True` stays `"True"` here. + if isinstance(value, float) and value.is_integer(): + as_int = str(int(value)) + if as_int in choice_values: + return as_int + return as_str + + if isinstance(selected, (str, bytes)) or not isinstance(selected, Iterable): + return resolve_one(selected) + return [resolve_one(value) for value in cast("Iterable[Any]", selected)] + + +def as_raw_list(x: Any) -> list[Any]: + """ + Coerce ``selected`` to a list of raw (un-stringified) choice values. + + ``str`` and ``bytes`` are iterable but represent a single choice, so they are + treated as scalars. Any other iterable, including the ``dict_keys``, ``set``, and + generator shapes that a caller may build a selection from, is consumed as a + sequence. Handling them here keeps them from falling into the scalar branch, where + they would be stringified to their unusable ``repr``. + """ + if x is None: + return [] + elif isinstance(x, (str, bytes)) or not isinstance(x, Iterable): + return [x] + else: + return list(cast("Iterable[Any]", x)) + + +def normalize_selected(x: Any) -> str | list[str] | None: + """ + Coerce ``selected`` to string(s), preserving scalar vs. sequence shape. + + Shape matters on the wire: the checkbox group's client-side ``setValue()`` expects + an array while the radio group's expects a scalar, so a scalar ``selected`` must not + grow into a list on its way out. Tuples become lists purely for payload consistency + (``json.dumps()`` already serializes a tuple as a JSON array). + """ + if x is None: + return None + elif isinstance(x, str): + return x + elif isinstance(x, bytes) or not isinstance(x, Iterable): + return str(x) + else: + return [str(v) for v in cast("Iterable[Any]", x)] + + +def normalize_selected_list(x: Any) -> list[str] | None: + """Coerce ``selected`` to a list of strings, for clients that expect an array.""" + if x is None: + return None + return [str(v) for v in as_raw_list(x)] + + +def normalize_selected_scalar(x: Any) -> str | None: + """ + Coerce ``selected`` to a single string, for clients that expect a bare scalar. + + A sequence collapses to its first element, and an empty one to ``None``. + """ + if x is None: + return None + values = as_raw_list(x) + if not values: + return None + return str(values[0]) + + +def normalize_selected_radio(x: Any) -> str | list[str] | None: + """ + Coerce ``selected`` for the radio-button binding, which expects a scalar and + throws on a non-empty array. + + A one-element sequence unwraps to that element. An empty sequence stays ``[]``, + which clears the selection (returning ``None`` instead would drop ``value`` + from the message and leave the previous selection in place). + + Raises + ------ + ValueError + If the sequence holds more than one value. + """ + if x is None: + return None + values = as_raw_list(x) + if not values: + return [] + if len(values) > 1: + raise ValueError( + f"`selected` must name a single choice for a radio button group, but " + f"{len(values)} were given: {values!r}. Pass one value, or `[]` to clear " + "the selection." + ) + return str(values[0]) + + +class ChoiceSelection: + """ + Tests whether a choice value is selected: ``choice_value in selection``. + + Comparison is on the string form, since that is the only thing the client can match + against. Entries that name a choice value without stringifying identically are + handled by :func:`resolve_selected` before they get here. + """ + + def __init__(self, selected: Any) -> None: + self._strings = {str(v) for v in as_raw_list(selected)} + + def __contains__(self, choice: Any) -> bool: + return str(choice) in self._strings + + def __bool__(self) -> bool: + return bool(self._strings) diff --git a/shiny/ui/_input_check_radio.py b/shiny/ui/_input_check_radio.py index 12a1d0958..981fcaee1 100644 --- a/shiny/ui/_input_check_radio.py +++ b/shiny/ui/_input_check_radio.py @@ -6,27 +6,37 @@ "input_radio_buttons", ) -from typing import Mapping, Optional, Union +from typing import Iterable, Mapping, Optional, Sequence, Union from htmltools import Tag, TagAttrs, TagChild, css, div, span, tags from .._docstring import add_example from ..bookmark import restore_input from ..module import resolve_id +from ._choices import ( + ChoiceKey, + ChoiceSelection, + ChoiceValue, + normalize_choices_mapping, + resolve_selected, +) from ._html_deps_shinyverse import components_dependencies from ._utils import shiny_input_label -# Canonical format for representing select options. -_Choices = Mapping[str, TagChild] - -# Formats available to the user +# Formats available to the user. Choice values are coerced with `str()`, so e.g. the +# `int` keys of a `dict[int, str]` are supported. ChoicesArg = Union[ - # ["a", "b", "c"] - "list[str]", - # ("a", "b", "c") - "tuple[str, ...]", - # {"a": "Choice A", "b": tags.i("Choice B")} - _Choices, + # [0, 1, 2] or ("a", "b", "c") + Sequence[ChoiceValue], + # {"a": "Choice A", 0: tags.i("Choice B")} + Mapping[ChoiceKey, TagChild], +] + +# A single choice value, or several. Coerced with `str()`, so e.g. the `int` keys of a +# `dict[int, str]` passed as `choices` work here too. +SelectedArg = Union[ + ChoiceValue, + Sequence[ChoiceValue], ] @@ -177,7 +187,7 @@ def input_checkbox_group( label: TagChild, choices: ChoicesArg, *, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectedArg] = None, inline: bool = False, width: Optional[str] = None, ) -> Tag: @@ -249,7 +259,7 @@ def input_radio_buttons( label: TagChild, choices: ChoicesArg, *, - selected: Optional[str] = None, + selected: Optional[SelectedArg] = None, inline: bool = False, width: Optional[str] = None, ) -> Tag: @@ -318,33 +328,35 @@ def _generate_options( id: str, type: str, choices: ChoicesArg, - selected: Optional[str | list[str] | tuple[str, ...]], + selected: Optional[SelectedArg], inline: bool, ) -> Tag: choicez = _normalize_choices(choices) - if selected is None: - if type == "radio": - selected = list(choicez.keys())[0] - else: - selected = [] + # A radio group must always have something checked, so an omitted `selected` falls + # back to the first choice. Note the check is against `None` specifically: an empty + # `selected` is an explicit request for nothing checked, and must stay that way. + if selected is None and type == "radio": + if not choicez: + raise ValueError( + "`choices` cannot be empty for a radio button group unless " + "`selected` is given." + ) + selected = next(iter(choicez)) - if isinstance(selected, tuple): - selected = list(selected) - elif not isinstance(selected, list): - selected = [selected] + selection = ChoiceSelection(resolve_selected(selected, choicez.keys())) return div( [ _generate_option( id, type, - value=choice[0], - label=choice[1], - checked=choice[0] in selected, + value=value, + label=label, + checked=value in selection, inline=inline, ) - for choice in choicez.items() + for value, label in choicez.items() ], class_="shiny-options-group", ) @@ -379,8 +391,16 @@ def _generate_option( ) -def _normalize_choices(x: ChoicesArg) -> _Choices: - if isinstance(x, (list, tuple)): - return {k: k for k in x} +def _normalize_choices(x: ChoicesArg) -> dict[str, TagChild]: + """ + Normalize choices, coercing choice values to `str` so they match the + string form the client reports. + """ + if isinstance(x, Mapping): + return normalize_choices_mapping(x) + elif isinstance(x, Iterable) and not isinstance(x, (str, bytes)): + return normalize_choices_mapping({k: k for k in x}) else: - return x + # A bare `str` satisfies `Sequence[ChoiceValue]` statically, but iterating it + # would turn each character into its own choice. + raise TypeError("`choices` must be a list, tuple, or dict.") diff --git a/shiny/ui/_input_select.py b/shiny/ui/_input_select.py index eeb6b0a56..a84a118b0 100644 --- a/shiny/ui/_input_select.py +++ b/shiny/ui/_input_select.py @@ -11,32 +11,45 @@ ) import copy from json import dumps -from typing import Any, Mapping, Optional, Union, cast +from typing import Any, Iterable, Mapping, Optional, Sequence, Union, cast from htmltools import Tag, TagAttrs, TagChild, TagList, css, div, tags from .._docstring import add_example from ..bookmark import restore_input from ..module import resolve_id +from ._choices import ( + ChoiceKey, + ChoiceSelection, + ChoiceValue, + normalize_choices_mapping, + resolve_selected, +) from ._html_deps_external import selectize_deps from ._utils import JSEval, extract_js_keys, shiny_input_label +# Canonical format for representing select options. Choice values are strings here: +# `_normalize_choices()` has already coerced them. _Choices = Mapping[str, str] _OptGrpChoices = Mapping[str, _Choices] -# Canonical format for representing select options. _SelectChoices = Union[_Choices, _OptGrpChoices] -# Formats available to the user +# Formats available to the user. Choice values are coerced with `str()`, so e.g. the +# `int` keys of a `dict[int, str]` are supported. SelectChoicesArg = Union[ - # ["a", "b", "c"] - "list[str]", - # ("a", "b", "c") - "tuple[str, ...]", - # {"a": "Choice A", "b": tags.i("Choice B")} - _Choices, - # optgroup {"Group A": {"a1": "Choice A1", "a2": tags.i("Choice A2")}, "Group B": {}} - _OptGrpChoices, + # [0, 1, 2] or ("a", "b", "c") + Sequence[ChoiceValue], + # {"a": "Choice A", 0: "Choice B"} + Mapping[ChoiceKey, str], + # optgroup {"Group A": {"a1": "Choice A1", "a2": "Choice A2"}, "Group B": {}} + Mapping[ChoiceKey, Mapping[ChoiceKey, str]], +] + +# A single choice value, or several. +SelectSelectedArg = Union[ + ChoiceValue, + Sequence[ChoiceValue], ] @@ -54,7 +67,7 @@ def input_selectize( label: TagChild, choices: SelectChoicesArg, *, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectSelectedArg] = None, multiple: bool = False, width: Optional[str] = None, remove_button: Optional[bool] = None, @@ -134,7 +147,7 @@ def input_select( label: TagChild, choices: SelectChoicesArg, *, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectSelectedArg] = None, multiple: bool = False, selectize: bool | MISSING_TYPE = DEPRECATED, width: Optional[str] = None, @@ -239,7 +252,7 @@ def _input_select_impl( label: TagChild, choices: SelectChoicesArg, *, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectSelectedArg] = None, multiple: bool = False, selectize: bool = False, width: Optional[str] = None, @@ -259,6 +272,8 @@ def _input_select_impl( selected = restore_input(resolved_id, selected) if selected is None and not multiple: selected = _find_first_option(choices_) + else: + selected = resolve_selected(selected, _choice_value_strings(choices_)) if options is None: options = {} @@ -331,29 +346,63 @@ def _update_options( def _normalize_choices(x: SelectChoicesArg) -> _SelectChoices: - if x is None: + """ + Normalize choices, coercing choice values to `str` so the rendered option + `value` attributes and the `value` sent in `update_*()` messages agree. + Optgroup labels and nested choice values get the same treatment. + + See https://github.com/posit-dev/py-shiny/issues/2272. + """ + if isinstance(x, Iterable) and not isinstance(x, (str, bytes, Mapping)): + # A sequence's entries are both the choice value and the label, so the label is + # the value's string form. + return normalize_choices_mapping({k: str(k) for k in x}) + elif not isinstance(x, Mapping): + # A bare `str` satisfies `Sequence[ChoiceValue]` statically, but iterating it + # would turn each character into its own choice. raise TypeError("`choices` must be a list, tuple, or dict.") - elif isinstance(x, (list, tuple)): - return {k: k for k in x} - else: - return x + # The top-level mapping holds optgroup labels alongside choice values, so a + # collision there is reported as either one. + normalized = normalize_choices_mapping(x) -def _contains_html(x: _SelectChoices) -> bool: - for v in x.values(): - if isinstance(v, Mapping): - # Check the `_Choices` values of `_OptGrpChoices` - for vv in v.values(): - if not isinstance(vv, str): - return True + # The result may mix flat options and optgroups at the top level (e.g. + # `{"a": "A", "Group B": {...}}`). That matches neither arm of the + # `_SelectChoices` union, hence the `cast`, but `_render_choices()` + # checks each value with `isinstance`, so the mix is fine at runtime. + result: dict[str, Any] = { + key: (normalize_choices_mapping(value) if isinstance(value, Mapping) else value) + for key, value in normalized.items() + } + return cast(_SelectChoices, result) + + +def _choice_value_strings(x: _SelectChoices) -> set[str]: + """ + Collect the choice values of already-normalized choices, for `resolve_selected()`. + + Optgroup labels are not choice values, so only the options nested inside a group are + collected (never the group key itself). Two groups may hold the same choice value: + duplicates are legal HTML, and the browser reports only the value, so the options are + indistinguishable either way. + """ + values: set[str] = set() + for key, value in x.items(): + if isinstance(value, Mapping): + values.update(value.keys()) else: - if not isinstance(v, str): - return True - return False + values.add(key) + return values def _render_choices( - x: _SelectChoices, selected: Optional[str | list[str]] = None + x: _SelectChoices, selected: Optional[SelectSelectedArg] = None +) -> TagList: + return _render_choices_with_selection(x, ChoiceSelection(selected)) + + +def _render_choices_with_selection( + x: _SelectChoices, selection: ChoiceSelection ) -> TagList: result = TagList() @@ -364,17 +413,16 @@ def _render_choices( if isinstance(v, Mapping): result.append( tags.optgroup( - *(_render_choices(cast(_SelectChoices, v), selected)), label=k + *( + _render_choices_with_selection( + cast(_SelectChoices, v), selection + ) + ), + label=k, ) ) else: - is_selected = False - if isinstance(selected, list): - is_selected = k in selected - else: - is_selected = k == selected - - result.append(tags.option(v, value=k, selected=is_selected)) + result.append(tags.option(v, value=k, selected=k in selection)) return result diff --git a/shiny/ui/_input_update.py b/shiny/ui/_input_update.py index ccbdac4a8..80beeb6ad 100644 --- a/shiny/ui/_input_update.py +++ b/shiny/ui/_input_update.py @@ -36,9 +36,28 @@ from ..module import ResolvedId, resolve_id from ..session import require_active_session, session_context from ..types import ActionButtonValue -from ._input_check_radio import ChoicesArg, _generate_options +from ._choices import ( + normalize_selected, + normalize_selected_list, + normalize_selected_radio, + resolve_selected, +) +from ._input_check_radio import ( + ChoicesArg, + SelectedArg, + _generate_options, +) +from ._input_check_radio import _normalize_choices as _check_radio_normalize_choices from ._input_date import _as_date_attr -from ._input_select import SelectChoicesArg, _normalize_choices, _render_choices +from ._input_select import ( + SelectChoicesArg, + SelectSelectedArg, +) +from ._input_select import _choice_value_strings as _select_choice_value_strings +from ._input_select import ( + _normalize_choices, + _render_choices, +) from ._input_slider import SliderStepArg, SliderValueArg, _as_numeric, _slider_type from ._utils import JSEval, _session_on_flush_send_msg, extract_js_keys @@ -313,7 +332,7 @@ def update_checkbox_group( *, label: Optional[TagChild] = None, choices: Optional[ChoicesArg] = None, - selected: Optional[str | list[str] | tuple[str, ...]] = None, + selected: Optional[SelectedArg] = None, inline: bool = False, session: Optional[Session] = None, ) -> None: @@ -365,7 +384,7 @@ def update_radio_buttons( *, label: Optional[TagChild] = None, choices: Optional[ChoicesArg] = None, - selected: Optional[str] = None, + selected: Optional[SelectedArg] = None, inline: bool = False, session: Optional[Session] = None, ) -> None: @@ -416,29 +435,53 @@ def _update_choice_input( type: Literal["checkbox", "radio"], label: Optional[TagChild] = None, choices: Optional[ChoicesArg] = None, - selected: Optional[str | list[str] | tuple[str, ...]] = None, + selected: Optional[SelectedArg] = None, inline: bool = False, session: Optional[Session] = None, ) -> None: session = require_active_session(session) + + # The client matches `value` against the options' HTML `value` attributes, which are + # always strings. Resolves `selected` against the choices (see issue 2272) + if choices is not None: + selected = resolve_selected( + selected, _check_radio_normalize_choices(choices).keys() + ) + + # A radio group's client-side `setValue()` throws on a non-empty array, so collapse + # to a scalar; a checkbox group keeps its array. + if type == "radio": + selected_values = normalize_selected_radio(selected) + else: + selected_values = normalize_selected(selected) + options = None if choices is not None: # https://github.com/posit-dev/py-shiny/issues/708#issuecomment-1696352934 with session_context(session): resolved_id = resolve_id(id) + # `choices=[]` is the documented way to clear the choices. Pass "nothing + # selected" explicitly so `_generate_options()` renders an empty radio group + # instead of raising + options_selected: str | list[str] | None = selected_values + if type == "radio" and not choices and options_selected is None: + options_selected = [] + opts = _generate_options( id=resolved_id, type=type, choices=choices, - selected=selected, + # `_generate_options()` re-normalizes `selected` (the `input_*()` + # constructors pass it raw), which is a no-op on the value resolved above. + selected=options_selected, inline=inline, ) options = session._process_ui(opts)["html"] msg = { "label": session._process_ui(label) if label is not None else None, "options": options, - "value": selected, + "value": selected_values, } session.send_input_message(id, drop_none(msg)) @@ -656,7 +699,7 @@ def update_select( *, label: Optional[TagChild] = None, choices: Optional[SelectChoicesArg] = None, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectSelectedArg] = None, session: Optional[Session] = None, ) -> None: """ @@ -691,16 +734,18 @@ def update_select( session = require_active_session(session) - selected_values = selected - if isinstance(selected, str): - selected_values = [selected] - if choices is None: options = None else: - option_tags = _render_choices(_normalize_choices(choices), selected) + # jQuery matches option values with strict `===`, so a non-string `selected` + # silently deselects everything. Resolve to the string form the options render. + choices_ = _normalize_choices(choices) + selected = resolve_selected(selected, _select_choice_value_strings(choices_)) + option_tags = _render_choices(choices_, selected) options = session._process_ui(option_tags)["html"] + selected_values = normalize_selected_list(selected) + msg = { "label": session._process_ui(label) if label is not None else None, "options": options, @@ -722,7 +767,7 @@ def update_selectize( *, label: Optional[TagChild] = None, choices: Optional[SelectChoicesArg] = None, - selected: Optional[str | list[str]] = None, + selected: Optional[SelectSelectedArg] = None, options: Optional[dict[str, str | float | JSEval]] = None, server: bool = False, session: Optional[Session] = None, @@ -782,7 +827,8 @@ def update_selectize( # [{"label": "Foo", "value": "foo", "optgroup": "foo"}, ...] flat_choices: list[FlatSelectChoice] = [] if choices is not None: - for k, v in _normalize_choices(choices).items(): + choices_ = _normalize_choices(choices) + for k, v in choices_.items(): if not isinstance(v, Mapping): flat_choices.append(FlatSelectChoice(value=k, label=v)) else: # The optgroup case @@ -793,9 +839,9 @@ def update_selectize( ] ) - selected_values = selected - if isinstance(selected, str): - selected_values = [selected] + selected = resolve_selected(selected, _select_choice_value_strings(choices_)) + + selected_values = normalize_selected_list(selected) # Find any selected choices now so we have them ready to send to the client if selected_values is None: diff --git a/shiny/ui/_toolbar.py b/shiny/ui/_toolbar.py index 9f0e4aaf4..7e29e0356 100644 --- a/shiny/ui/_toolbar.py +++ b/shiny/ui/_toolbar.py @@ -21,9 +21,15 @@ from ..module import resolve_id from ..session import Session, require_active_session from ..types import MISSING, MISSING_TYPE +from ._choices import ( + ChoiceValue, + normalize_selected_scalar, + resolve_selected, +) from ._html_deps_shinyverse import components_dependencies from ._input_select import ( SelectChoicesArg, + _choice_value_strings, _find_first_option, _normalize_choices, _render_choices, @@ -447,7 +453,7 @@ def toolbar_input_select( label: str, choices: SelectChoicesArg, *, - selected: Optional[str] = None, + selected: Optional[ChoiceValue] = None, icon: Optional[TagChild] = None, show_label: bool = False, tooltip: bool | str | MISSING_TYPE = MISSING, @@ -533,6 +539,8 @@ def toolbar_input_select( if selected is None: selected = _find_first_option(choices_normalized) + else: + selected = resolve_selected(selected, _choice_value_strings(choices_normalized)) # Select element gets its own ID for label association select_id = f"{resolved_id}-select" @@ -615,7 +623,7 @@ def update_toolbar_input_select( label: Optional[str] = None, show_label: Optional[bool] = None, choices: Optional[SelectChoicesArg] = None, - selected: Optional[str] = None, + selected: Optional[ChoiceValue] = None, icon: Optional[TagChild] = None, session: Optional[Session] = None, ) -> None: @@ -670,10 +678,13 @@ def update_toolbar_input_select( options_processed = None if choices is not None: choices_normalized = _normalize_choices(choices) + selected = resolve_selected(selected, _choice_value_strings(choices_normalized)) options_html = _render_choices(choices_normalized, selected) options_processed = str(options_html) - selected_processed = str(selected) if selected is not None else None + # A toolbar select is single-selection, so this collapses to a bare string. Shares + # the choice-value coercion used by the other choice inputs. + selected_processed = normalize_selected_scalar(selected) message = drop_none( { diff --git a/tests/pytest/test_input_check_radio.py b/tests/pytest/test_input_check_radio.py new file mode 100644 index 000000000..db3564d6f --- /dev/null +++ b/tests/pytest/test_input_check_radio.py @@ -0,0 +1,411 @@ +""" +Tests for `input_checkbox_group()` / `input_radio_buttons()` and their `update_*()` +counterparts, plus the choice-value normalization they share with the select inputs. + +The `update_*()` functions must coerce choice values to strings the same way the input +constructors do, so that `choices={0: "a"}, selected=[0]` behaves identically to +`choices={"0": "a"}, selected=["0"]` (https://github.com/posit-dev/py-shiny/issues/2272). +""" + +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest +from htmltools import TagChild, TagList + +from shiny import ui +from shiny.express._stub_session import ExpressStubSession +from shiny.session import Session, session_context +from shiny.session._session import RenderedDeps + +CHOICES_INT: dict[int, str] = {0: "a", 1: "b", 2: "c"} +CHOICES_STR: dict[str, str] = {"0": "a", "1": "b", "2": "c"} + + +class _MessageCapturingSession(ExpressStubSession): + """Stub session that records `send_input_message()` payloads and renders UI.""" + + def __init__(self) -> None: + super().__init__() + self.messages: list[dict[str, object]] = [] + + def send_input_message(self, id: str, message: dict[str, object]) -> None: + # `AppSession._send_message()` runs the payload through `json.dumps()`, so a + # value that is not serializable fails at runtime rather than here. Assert it + # up front so the tests cover the wire format, not just the dict. + json.dumps(message) + self.messages.append(message) + + def _process_ui(self, ui: TagChild) -> RenderedDeps: + return {"deps": [], "html": TagList(ui).get_html_string()} + + +@pytest.fixture +def session() -> _MessageCapturingSession: + return _MessageCapturingSession() + + +def _send( + session: _MessageCapturingSession, fn: Any, **kwargs: Any +) -> dict[str, object]: + with session_context(cast(Session, session)): + fn("x", **kwargs) + assert len(session.messages) == 1 + return session.messages.pop() + + +def _checked_count(html: str) -> int: + return html.count('checked="checked"') + + +# ============================================================================ +# input_checkbox_group() / input_radio_buttons(): int values work today +# ============================================================================ +def test_input_checkbox_group_int_values_match_str_values(): + int_html = ui.input_checkbox_group( + "x", label="L", choices=CHOICES_INT, selected=[0, 2] + ).get_html_string() + str_html = ui.input_checkbox_group( + "x", label="L", choices=CHOICES_STR, selected=["0", "2"] + ).get_html_string() + assert int_html == str_html + assert _checked_count(int_html) == 2 + + +def test_input_radio_buttons_int_values_match_str_values(): + int_html = ui.input_radio_buttons( + "x", label="L", choices=CHOICES_INT, selected=1 + ).get_html_string() + str_html = ui.input_radio_buttons( + "x", label="L", choices=CHOICES_STR, selected="1" + ).get_html_string() + assert int_html == str_html + + +@pytest.mark.parametrize( + "choices, selected", + [ + # The mixed cases are why normalization lives in the shared pipeline rather + # than only on the outgoing message: coercing just the payload would leave the + # options markup with nothing marked checked. + ({0: "a", 1: "b"}, ["0"]), + ({"0": "a", "1": "b"}, [0]), + ], +) +def test_input_checkbox_group_mixed_value_types( + choices: dict[Any, str], selected: list[Any] +): + html = ui.input_checkbox_group( + "x", label="L", choices=choices, selected=selected + ).get_html_string() + assert _checked_count(html) == 1 + assert 'value="0"' in html + + +def test_input_checkbox_group_numerically_equal_selected(): + # `str(1.0)` is `"1.0"`, so an integral float is matched against the integer it + # names as well, and renders as the `"1"` the client will match on. R Shiny gets + # this case for free, since `as.character(1.0)` is `"1"`. + html = ui.input_checkbox_group( + "x", label="L", choices={1: "a", 2: "b"}, selected=[1.0] + ).get_html_string() + assert _checked_count(html) == 1 + assert 'value="1" checked="checked"' in html + + +def test_bool_selected_does_not_name_an_int_choice( + session: _MessageCapturingSession, +): + # Matching is on the string form, so `True` does not name the choice value `1` even + # though `True == 1`. This matches R Shiny, where `"TRUE"` and `"1"` differ. + html = ui.input_radio_buttons( + "x", label="L", choices={1: "one"}, selected=True + ).get_html_string() + assert _checked_count(html) == 0 + + msg = _send(session, ui.update_radio_buttons, choices={1: "one"}, selected=True) + assert msg["value"] == "True" + + +def test_choices_must_not_be_a_bare_string(): + # A `str` is a `Sequence[str]`, so it satisfies the `choices` annotation; iterating + # it would silently turn each character into its own choice. + for input_fn in (ui.input_checkbox_group, ui.input_radio_buttons, ui.input_select): + with pytest.raises(TypeError, match="must be a list, tuple, or dict"): + input_fn("x", label="L", choices=cast(Any, "abc")) + + +def test_choice_values_are_rendered_for_falsy_types(): + # htmltools omits an attribute whose value is `None`/`False` and renders `True` as + # `value=""`, so without the `str()` coercion these options would carry no usable + # value and the browser would report the default `"on"`. + html = ui.input_checkbox_group( + "x", label="L", choices={None: "n", False: "f", True: "t"} + ).get_html_string() + assert 'value="None"' in html + assert 'value="False"' in html + assert 'value="True"' in html + + +def test_choice_values_colliding_as_strings_raise(): + # Keying on `str()` would otherwise silently drop one of these options. + with pytest.raises(ValueError, match="Duplicate choice value '0'"): + ui.input_checkbox_group("x", label="L", choices={0: "zero", "0": "oh"}) + + with pytest.raises(ValueError, match="Duplicate choice value '0'"): + ui.input_checkbox_group("x", label="L", choices=[0, "0"]) + + +def test_input_radio_buttons_empty_choices_raise(): + # A radio group with no explicit `selected` checks its first choice, so there has to + # be one. This used to be an `IndexError` from inside a private helper. + with pytest.raises(ValueError, match="`choices` cannot be empty"): + ui.input_radio_buttons("x", label="L", choices=[]) + + +def test_input_radio_buttons_selected_shapes(): + # `selected=None` falls back to the first choice; an explicitly empty `selected` is a + # request for nothing checked and must stay that way. + assert ( + _checked_count( + ui.input_radio_buttons("x", label="L", choices=["a", "b"]).get_html_string() + ) + == 1 + ) + assert ( + _checked_count( + ui.input_radio_buttons( + "x", label="L", choices=["a", "b"], selected=[] + ).get_html_string() + ) + == 0 + ) + + +# ============================================================================ +# update_checkbox_group(): the wire payload must match the string-valued one +# ============================================================================ +def test_update_checkbox_group_int_values_match_str_values( + session: _MessageCapturingSession, +): + int_msg = _send( + session, + ui.update_checkbox_group, + choices=CHOICES_INT, + selected=list(CHOICES_INT.keys()), + ) + str_msg = _send( + session, + ui.update_checkbox_group, + choices=CHOICES_STR, + selected=list(CHOICES_STR.keys()), + ) + assert int_msg == str_msg + # The client matches `value` against the HTML `value` attributes, which are + # always strings. + assert int_msg["value"] == ["0", "1", "2"] + # Pin the options markup absolutely too: comparing the two payloads alone cannot + # catch a bug in `_generate_options()`, since both sides would shift together. + options = int_msg["options"] + assert isinstance(options, str) + assert _checked_count(options) == 3 + assert 'value="0" checked="checked"' in options + + +def test_update_checkbox_group_int_selected_without_choices( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_checkbox_group, selected=[0, 2]) + assert msg == {"value": ["0", "2"]} + + +def test_update_checkbox_group_selected_tuple_becomes_list_of_str( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_checkbox_group, selected=(0, 1)) + assert msg == {"value": ["0", "1"]} + + +def test_update_checkbox_group_scalar_selected_stays_scalar( + session: _MessageCapturingSession, +): + # A scalar `selected` must not become a list -- the client handles both, but + # the shape should round-trip unchanged (apart from the `str()` coercion). + msg = _send(session, ui.update_checkbox_group, selected=1) + assert msg == {"value": "1"} + + +@pytest.mark.parametrize( + "selected, expected", + [ + ({0: "a", 1: "b"}.keys(), ["0", "1"]), + ({0}, ["0"]), + ((i for i in [0, 1]), ["0", "1"]), + (range(2), ["0", "1"]), + ], +) +def test_update_checkbox_group_accepts_any_iterable_selected( + session: _MessageCapturingSession, selected: Any, expected: list[str] +): + # These are iterable but not `list`/`tuple`. Treating them as scalars would stringify + # them to an unusable `repr` (`"dict_keys([0, 1])"`) that matches no option. + msg = _send(session, ui.update_checkbox_group, selected=selected) + assert msg == {"value": expected} + + +def test_update_checkbox_group_inline_is_preserved_when_requested( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_checkbox_group, choices=["a", "b"], inline=True) + options = msg["options"] + assert isinstance(options, str) + assert "checkbox-inline" in options + + +# ============================================================================ +# update_radio_buttons(): same defect, same file +# ============================================================================ +def test_update_radio_buttons_int_values_match_str_values( + session: _MessageCapturingSession, +): + int_msg = _send(session, ui.update_radio_buttons, choices=CHOICES_INT, selected=1) + str_msg = _send(session, ui.update_radio_buttons, choices=CHOICES_STR, selected="1") + assert int_msg == str_msg + assert int_msg["value"] == "1" + + +def test_update_radio_buttons_int_selected_without_choices( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_radio_buttons, selected=2) + assert msg == {"value": "2"} + + +@pytest.mark.parametrize("selected", [["a"], ("a",)]) +def test_update_radio_buttons_one_element_selected_unwraps( + session: _MessageCapturingSession, selected: Any +): + # The radio binding's `setValue()` hands a non-empty array to `$escape()`, which + # calls `.replace()` on it and throws, so the value has to reach it as a scalar. + # Shiny for R arrives at the same place: `as.character()` on a length-1 vector + # reaches the client as a JSON scalar. + msg = _send(session, ui.update_radio_buttons, selected=selected) + assert msg == {"value": "a"} + + +def test_update_radio_buttons_rejects_multiple_selected( + session: _MessageCapturingSession, +): + # A radio group can only show one of them. Quietly keeping the first would discard + # the rest, which is the same silent data loss the duplicate-choice check exists to + # prevent. + with pytest.raises(ValueError, match="must name a single choice"): + _send(session, ui.update_radio_buttons, selected=["a", "b"]) + + +def test_update_radio_buttons_empty_choices_clears_the_group( + session: _MessageCapturingSession, +): + # The shared `_note` on the `update_*` functions documents `choices=[]` as the way + # to clear the set of choices. For radio buttons that raised instead, because an + # empty group has no first option to fall back on. The payload must now match what + # `update_checkbox_group()` sends for the same call. + radio = _send(session, ui.update_radio_buttons, choices=[]) + checkbox = _send(session, ui.update_checkbox_group, choices=[]) + + assert radio == checkbox + assert radio == {"options": '
'} + + +def test_update_radio_buttons_empty_choices_keeps_an_explicit_selected( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_radio_buttons, choices=[], selected="a") + assert msg["value"] == "a" + + +def test_update_radio_buttons_empty_selected_stays_a_list( + session: _MessageCapturingSession, +): + # `selected=[]` is the documented way to clear a selection, and an empty array is the + # one array shape the radio binding special-cases. Collapsing it to `None` would drop + # `value` from the message and leave the previous selection in place. + msg = _send(session, ui.update_radio_buttons, selected=[]) + assert msg == {"value": []} + + +# ============================================================================ +# `selected=None` must stay absent from the payload (drop_none) +# ============================================================================ +def test_update_choice_input_none_selected_is_dropped( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_checkbox_group, label="New label") + assert "value" not in msg + + +# ============================================================================ +# The select inputs share the same normalization +# ============================================================================ +def test_update_select_int_values_match_str_values( + session: _MessageCapturingSession, +): + int_msg = _send(session, ui.update_select, choices=CHOICES_INT, selected=[0]) + str_msg = _send(session, ui.update_select, choices=CHOICES_STR, selected=["0"]) + assert int_msg == str_msg + # jQuery's `.val()` matches option values with a strict `===`, so an `int` here + # would deselect everything. + assert int_msg["value"] == ["0"] + options = int_msg["options"] + assert isinstance(options, str) + assert 'value="0" selected' in options + + +def test_update_selectize_int_values_match_str_values( + session: _MessageCapturingSession, +): + msg = _send(session, ui.update_selectize, choices=CHOICES_INT, selected=0) + assert msg["value"] == ["0"] + + +def test_input_select_int_values_match_str_values(): + int_html = ui.input_select( + "x", label="L", choices=CHOICES_INT, selected=1 + ).get_html_string() + str_html = ui.input_select( + "x", label="L", choices=CHOICES_STR, selected="1" + ).get_html_string() + assert int_html == str_html + + +def test_input_select_optgroup_int_values(): + # Optgroup labels are not choice values, but the options nested inside are. + html = ui.input_select( + "x", + label="L", + choices={"Group": {0: "a", 1: "b"}}, + selected=1, + ).get_html_string() + assert '