Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
228 changes: 228 additions & 0 deletions shiny/ui/_choices.py
Original file line number Diff line number Diff line change
@@ -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)
82 changes: 51 additions & 31 deletions shiny/ui/_input_check_radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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.")
Loading