fix: Coerce non-string selected in checkbox/radio update functions - #2420
fix: Coerce non-string selected in checkbox/radio update functions#2420jat255 wants to merge 14 commits into
selected in checkbox/radio update functions#2420Conversation
0f6f5d6 to
a36965f
Compare
Addresses review feedback on #2420. The original fix coerced `selected` in the checkbox/radio update path; this moves the normalization into a shared `shiny/ui/_choices.py` used by the checkbox group, radio buttons, select, selectize, and toolbar select, and fixes the edges the narrower fix left open or introduced: - Widen `ChoicesArg`/`selected` annotations so the documented non-string usage type-checks. `Mapping`'s key type is invariant, so the mapping arm has to be keyed on `Any` -- a narrower union rejects even `dict[str, str]`. - Collapse a sequence `selected` to a scalar for `update_radio_buttons()`, which otherwise throws client-side. `selected=[]` still clears, since an empty array is the one shape that binding special-cases. - Accept any iterable `selected` (`dict_keys`, `set`, generators) instead of stringifying it to an unusable `repr`. - Raise on choice values that collide once stringified, rather than silently dropping an option, and raise a `ValueError` naming `choices` for an empty radio group instead of an `IndexError`. - Resolve `selected` against the actual choice values, so a numerically equal entry (`1.0` vs. `1`) both renders and transmits as the value the option carries. Matching on raw equality instead would restore the server/client disagreement this change exists to remove. - Fix `update_select()`/`update_selectize()`, which had the same defect, and drop the ad-hoc coercion in `_toolbar.py`. - Correct the comment claiming the coercion is a no-op for rendered HTML (`None`/`bool` values change) and the docstring claiming tuples are not JSON-serializable (they are).
ec580a6 to
02604e3
Compare
`ui.input_checkbox_group()` and `ui.input_radio_buttons()` accept `choices` dictionaries with non-string keys (e.g. `dict[int, str]`) and non-string `selected` values, because both end up in HTML `value` attributes, where htmltools stringifies them. `ui.update_checkbox_group()` and `ui.update_radio_buttons()` did not: they forwarded `selected` verbatim as the message's `value`, so the client received `[0, 1, 2]` instead of `["0", "1", "2"]`. The checkbox/radio input bindings pass that value through `$escape()`, which calls `String.prototype.replace()` on it and throws on a number -- after `setValue()` has already cleared every checkbox -- so all options silently ended up unselected. Normalize in the shared pipeline instead of at the edge: `_normalize_choices()` now coerces choice keys to `str`, a new `_normalize_selected()` coerces `selected` the same way (preserving scalar vs. sequence shape so radio inputs still get a scalar `value`), and `_update_choice_input()` sends the normalized value. Both `_generate_options()` callers -- the input constructors and the update functions -- now compare and emit the same string form. Fixes #2272
Addresses review feedback on #2420. The original fix coerced `selected` in the checkbox/radio update path; this moves the normalization into a shared `shiny/ui/_choices.py` used by the checkbox group, radio buttons, select, selectize, and toolbar select, and fixes the edges the narrower fix left open or introduced: - Widen `ChoicesArg`/`selected` annotations so the documented non-string usage type-checks. `Mapping`'s key type is invariant, so the mapping arm has to be keyed on `Any` -- a narrower union rejects even `dict[str, str]`. - Collapse a sequence `selected` to a scalar for `update_radio_buttons()`, which otherwise throws client-side. `selected=[]` still clears, since an empty array is the one shape that binding special-cases. - Accept any iterable `selected` (`dict_keys`, `set`, generators) instead of stringifying it to an unusable `repr`. - Raise on choice values that collide once stringified, rather than silently dropping an option, and raise a `ValueError` naming `choices` for an empty radio group instead of an `IndexError`. - Resolve `selected` against the actual choice values, so a numerically equal entry (`1.0` vs. `1`) both renders and transmits as the value the option carries. Matching on raw equality instead would restore the server/client disagreement this change exists to remove. - Fix `update_select()`/`update_selectize()`, which had the same defect, and drop the ad-hoc coercion in `_toolbar.py`. - Correct the comment claiming the coercion is a no-op for rendered HTML (`None`/`bool` values change) and the docstring claiming tuples are not JSON-serializable (they are).
`ChoiceValue` is `Any`, so narrowing with `isinstance(x, Iterable)` leaves the element type unknown, which pyright's strict mode rejects. Restate it as `Iterable[Any]` at the three iteration sites. The two `cast()` calls in `_input_select.py` were no-ops for the same reason and are removed. Typing the test helper to the stub session drops a `pyright: ignore`. `make check-types` runs pyrefly only; the pyright job is separate in CI.
The shared note on the `update_*` functions has documented `choices=[]` as the way to clear the set of choices since the docstrings were written in #42 (Feb 2022). `update_checkbox_group()` and `update_select()` honor it. `update_radio_buttons()` raised `IndexError: list index out of range`, because an empty group has no first option to fall back on when `selected` is omitted. Found while building a demo for this PR's empty-`choices` behavior change. The update path now says "nothing is selected" explicitly, so an empty group renders instead of being rejected, and the payload matches what `update_checkbox_group()` sends for the same call. The constructors still reject empty `choices` with no `selected`, where it is a mistake rather than a request.
Drop the internal mechanics (how the payload was built, which client binding threw, what htmltools does with a `None` attribute) and keep what an app author observes and has to act on. Also drop a stale `#2413` reference that resolves to nothing, and correct the collision entry: both options rendered with the same value rather than one being dropped.
Collapsing any sequence to its first element had no precedent. Shiny for R's `updateInputOptions()` applies `as.character()` and passes the whole vector through, and py-shiny's own `input_select(multiple=False)` marks every listed value as selected rather than picking one. Silently keeping the first value is also the opposite of what this PR does for colliding choice values, where it raises rather than drop one. A one-element sequence still unwraps, which is where the R comparison actually holds: `as.character()` on a length-1 vector reaches the client as a JSON scalar. That covers feeding one input's tuple value into a radio update. More than one value now raises a `ValueError` naming the parameter, since a radio group can only ever show one of them.
`choices=[0, "0"]` raises the same error as the dict form and avoids the key-versus-value question: in a list, the entries are the choice values.
02604e3 to
22c30c5
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes a long-standing mismatch between server-side selected values and the client-side string-only value attributes for Shiny “choice inputs” (checkbox groups, radio buttons, select/selectize, and toolbar select). It centralizes choice/selection normalization so constructors and update_*() messages agree by construction, and adds unit tests to pin both rendered markup and wire payloads.
Changes:
- Introduces
shiny/ui/_choices.pyto normalize choice values to strings and to normalize/shapeselectedconsistently across inputs. - Updates checkbox/radio/select/selectize/toolbar select constructors and update functions to use shared normalization and handle empty radio choices on update.
- Adds a focused pytest suite covering rendered HTML, message payloads, error cases, and select inputs.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
shiny/ui/_choices.py |
New shared normalization utilities for choice values and selected payload shaping. |
shiny/ui/_input_check_radio.py |
Uses shared normalization for checkbox/radio constructors; improves empty-radio error. |
shiny/ui/_input_select.py |
Uses shared normalization for select/selectize constructors and option rendering. |
shiny/ui/_input_update.py |
Updates update_*() functions to resolve/coerce selected consistently and to support clearing radio choices. |
shiny/ui/_toolbar.py |
Applies shared normalization for toolbar select constructor and updater. |
tests/pytest/test_input_check_radio.py |
New unit tests covering HTML + wire payload normalization behaviors and regressions. |
CHANGELOG.md |
Documents behavior changes and fixes related to choice-value coercion and updates. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…_values Addresses review feedback: the fast path returned the selected entry unchanged when its string form matched a choice, which contradicted the docstring. Behavior is identical downstream (all consumers stringify), but returning the canonical value is consistent and handles objects whose __str__ collides with a key without comparing equal to it.
cpsievert
left a comment
There was a problem hiding this comment.
Overall, this is a strong direction. Centralizing normalization keeps rendered options and update payloads consistent and closely follows R Shiny's character-coercion model. The radio handling and markup/payload tests are also solid. My inline questions are about defining edge cases, not the overall architecture.
| # `{"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. | ||
| normalized = normalize_choices_mapping(x) |
There was a problem hiding this comment.
This appears to apply choice-value collision validation to every top-level key. When the associated value is a mapping, however, the key becomes an optgroup label rather than an option value. For example, {0: {1: "one"}, "0": "flat"} raises even though one "0" is an optgroup label and the other is an option value.
R Shiny treats optgroup names as labels and only the nested leaves as option values, so a group label and option value may have the same string. Are optgroup labels intended to participate in py-shiny's choice-value collision policy?
There was a problem hiding this comment.
The error enforces uniqueness of the keys of choices after stringification, and those keys are a mix of choice values and optgroup labels.
A select input's normalized choices is one dict keyed by the string form, and that dict holds optgroup labels next to flat choice values. A top-level collision drops an entry either way, so raising an error in the collision is the alternative to losing an option with no message.
You are right about R. choicesWithNames() returns a named list, which holds duplicates, so R renders both entries and needs no check.
I kept the error and made the message much more explicit (dbb226e):
Duplicate key '0' in `choices`: the optgroup label 0 and the choice value '0'
are distinct but are identical as strings. A select input's `choices` (values
and optgroup labels) must be unique when converted to strings.
Only the top level is affected. Each group's contents go through the same function, and those keys are all choice values, so {"G": {0: "a", "0": "b"}} still reports a duplicate choice value.
| index: dict[str, Any] = {} | ||
| for key, value in x.items(): | ||
| if isinstance(value, Mapping): | ||
| index.update(normalize_choices_indexed(value)[1]) |
There was a problem hiding this comment.
This silently replaces an earlier indexed value when two optgroups contain values with the same string form. For example, {"A": {0: "zero"}, "B": {"0": "oh"}} currently renders two value="0" options, and selected=0 marks both with a selected attribute. In a single-select control, the browser ultimately selects the latter option. This differs from the new behavior within one mapping, where a stringification collision raises.
R Shiny permits duplicate underlying values, both within and across groups, although those values are inherently indistinguishable when reported by the browser. Is py-shiny intentionally adopting a stricter policy, and if so, is that policy meant to apply within each group or across the complete input?
There was a problem hiding this comment.
This method was removed in 4436a04 in the switch to string comparison. Its replacement, _choice_value_strings(), returns a set[str], so nothing is overwritten across groups.
More broadly...
Personally, I think silently dropping things because of the browser's limitations is odd behavior, and my expectation as a Python user would lean towards "this shouldn't be allowed, raise a loud error" rather than implicitly doing something I then have to notice. That is why I went the direction of raising. (I also appreciate this is a deviation from previous practice on the R side, so if you disagree, I'm happy to move to an approach more in line with R)
Why there is a uniqueness rule at all
This is a consequence of where the string coercion happens. One of the key changes of this PR is that normalization happens once in shiny/ui/_choices.py. main keys the choices dict by the raw choice value and coerces to a string later at each point of use. This PR re-keys that dict by the string form once, so every consumer (_render_choices(), ChoiceSelection, _choice_value_strings(), _find_first_option(), the payload builders) reads an already-coerced form, making the rendered markup and the message value agree by construction.
The alternative is to keep the dict keyed by raw values, as main does, and coerce at the two boundaries instead: value=str(k) when rendering, and str(v) in the payload. Both still derive from the same str(), so markup and payload agree, duplicates render as they do in R, and no uniqueness rule is needed.
Some specifics on the duplicate checking
The duplicate check runs on each dict in choices on its own, comparing only that dict's own keys: the outer dict once, then each optgroup's dict. That's where a collision loses a choice, because each dict is re-keyed to a new dict keyed by the string form. Values in two different optgroups are never siblings of one dict, so nothing is dropped and both options render.
Some examples, for clarity:
choices value |
Behavior on current main |
after this PR |
|---|---|---|
{0: "zero", "0": "oh"} |
two value="0" options |
ValueError (duplicate choice value) |
[0, "0"] |
two value="0" options |
ValueError (duplicate choice value) |
{"G": {0: "a", "0": "b"}} |
two value="0" options in one group |
ValueError (duplicate choice value) |
{0: {1: "one"}, "0": "flat"} |
one optgroup + one option, both keyed "0" |
ValueError (label vs value) |
{0: {1: "one"}, "0": {2: "two"}} |
two <optgroup label="0"> |
ValueError (label vs label) |
{"A": {0: "zero"}, "B": {"0": "oh"}} |
both render, selected=0 marks the first |
both render, selected=0 marks both |
{"A": {1: "one"}, "B": {1: "uno"}} |
both render, selected=1 marks both |
unchanged |
[0, False] |
one option, value="0", label False |
unchanged |
As I understand it, R Shiny permits duplicates in all of the above. So this PR is stricter than R most of the time, and closer to R in the two cross-group rows. The last row diverges from R in both columns, which is covered below.
Marking both options is mostly pre-existing. main already does it when two groups share a value at the same type, as in {"A": {1: "one"}, "B": {1: "uno"}}. Only mixed types change, and there main picked by raw type while both options render value="0" either way.
Values that collide as dict keys still merge silently
choices=[0, False] names two choices, but renders one option: value="0" with the label False, and no error. [1, True] and [1, 1.0] behave the same way. In each case one choice disappears, and the option that survives takes its label from the entry that was dropped. This is because a list is turned into {k: k for k in x}, keyed by the raw value, and 0 and False are the same dict key in Python. Their string forms "0" and "False" are different, so keying by the string form instead keeps both options. That needs no new error, but I can see the argument for making it raise one just to be consistent, which we can fold into this work if wanted.
The same collision in a dict cannot be fixed. {0: "zero", False: "oh"} arrives here as {0: "oh"}, because Python merges the keys before the function is ever called, so there's nothing we can do about it at this point.
Addresses @cpsievert's review on #2420. `resolve_selected()` (was `resolve_selected_values()`) now matches on the string form only, since that is all the client ever sees. The general `==` fallback it replaces made `selected=True` name a choice value of `1`, and could raise or return a non-`bool` for array-like and custom types. An integral `float` still matches the integer it names, so `selected=1.0` against `choices={1: "a"}` keeps working, as it does in R Shiny where `as.character(1.0)` is `"1"`. Resolution now yields the choice's string form rather than the choice value as the caller wrote it, so the string-to-original index is gone (`normalize_choices_indexed()`, `_choice_value_index()`). Every consumer stringified the result anyway. Dropping it also removes the silent last-one-wins overwrite when two optgroups held the same choice value; the markup for that case is unchanged and matches R Shiny, which permits duplicate values. `ChoiceValue` is now `str | int | float | bool | None` instead of `Any`, so the public annotations no longer accept an arbitrary object. Sequence arms use `Sequence[ChoiceValue]` (invariance rules out `list[ChoiceValue]`, which would reject `list[str]`), and `Mapping` keys stay `Any` because an exact key type rejects `dict[int, str]`. A bare `str` satisfies `Sequence[str]`, so `choices` that is a string, or is not iterable, now raises a clear `TypeError` instead of an `AttributeError` from a private helper. A select input's top-level `choices` maps optgroup labels alongside choice values, so its duplicate-key error now names both. The check has to stay: the normalized form is a `dict` keyed by the string, so a collision there would drop an entry with no diagnostic. Also drops the redundant underscore prefixes inside the private `_choices` module (`_V`, `_as_raw_list`), and removes `_contains_html()`, dead since #2049 removed the deprecation warning that was its only caller.
The `keys_are` parameter took the noun that the error message uses, so the switch value carried the message wording. Only two cases exist, so the caller now passes `keys_can_be_optgroup_labels` and one helper builds the message. Both errors are unchanged.
`normalize_choices_mapping()` needed a flag to say whether a key could be an optgroup label, which put the calling input's shape in the callee's signature. The labels already carry that fact: a key whose label is a `Mapping` heads an optgroup, and only a select input's top-level `choices` holds one. So `duplicate_key_error()` now takes both colliding `(key, label)` pairs and names each side, and the flag is gone. The wording follows the data rather than the caller, which the nested case needs: a select input normalizes each optgroup's contents through the same function, and those keys are all choice values.
|
@cpsievert thanks for your comments and sorry this got interrupted for a while. It should be ready for re-review. The main changes since your review:
I left two threads unresolved above about the optgroup collision-policy questions, in case you want to take a look. I'm comfortable with where things are at now, but happy to make any changes due to the slight divergence from R shiny behavior. |
Fixes #2272
Problem
ui.input_checkbox_group()andui.input_radio_buttons()accept non-string choice values, such as theintkeys of adict[int, str]. Theupdate_*()functions did not.Choice values become HTML
valueattributes, so the client always sees them as strings. The constructors compared the raw choice values against the rawselected, so both sides agreed._update_choice_input()sentselectedwithout change, so the client received{"value": [0, 1, 2]}instead of{"value": ["0", "1", "2"]}.This is not a quiet mismatch.
CheckboxGroupInputBinding.setValueclears every box, then calls$escape()on each value.$escape()calls.replace(), which throws aTypeErroron a number. This is why the reporter saw no boxes checked.update_radio_buttons()andupdate_select()had the same cause and a different result. Both throw before they change anything, so a label update in the same message is lost.Fix
The five choice inputs (checkbox group, radio buttons, select, selectize, toolbar select) now share one normalization module,
shiny/ui/_choices.py. Each entry point normalizeschoicesinto a canonicaldictkeyed by the string form, then resolvesselectedagainst that dict's keys once. Everything downstream reads those resolved values, including the rendered options, theselectedattributes, and thevaluein theupdate_*()payload. This ensures the markup and the payload cannot disagree, because neither re-derives the value.This decision differs from a more simple fix at the wire boundary, which is the approach that gave rise to the issue in
_update_choice_input()raised in #2272. The constructors, the select inputs, and the toolbar each carried their own comparison, leading to possible inconsistencies. Normalizing at the entry point closes the entire class of bug, but has a larger surface area as a result.This PR also widens the type annotations, so the documented calls pass a type checker. A choice value becomes
ChoiceValue = str | int | float | bool | None. The key positions of a choice mapping stay loosely typed asChoiceKey = Any. (This is because aMappingtype's key parameter is invariant, so a checker demands an exact match there rather than a subtype.Mapping[ChoiceValue, str]therefore accepts only a literaldict[ChoiceValue, str], and rejectsdict[int, str]anddict[str, str]alike, even though those are valid transitively. The "proper" fix for this would likely be to add more extensive boundedTypeVars, though that would expand the scope even more).update_radio_buttons()collapses a list or tupleselectedto a single value, because the binding throws on a non-empty array. An emptyselectedstays an empty list. That is the documented way to clear a selection, and the one array shape the binding accepts.A separate bug, found while testing this PR
ui.update_radio_buttons(choices=[])raisedIndexError: list index out of rangeinstead of clearing the choices. The shared note on theupdate_*functions documentschoices=[]as the way to clear the set of choices. That text dates from #42, in February 2022, so the call has never worked for radio buttons.ui.update_checkbox_group()andui.update_select()both honor it and worked fine.(see this note in

shiny/ui/_input_update.py, onmain):The radio group failed because an empty group has no first option to fall back on when
selectedis omitted. It now sends the same payload asui.update_checkbox_group()for the same call. The constructors still reject an emptychoiceswith noselected.No open issue covers this. #2400 is adjacent but different: it asks to clear the selection rather than the choices, and
selected=[]already does that.Behavior changes (please double check these are acceptable!)
Some of these changes alter how existing apps might behave, so they're work calling out explicitly:
Noneandboolchoice values now rendervalue="None",value="False", andvalue="True". htmltools dropped the attribute before, or wrotevalue=""forTrue, so the browser reported the default"on". The fix requires this, because the client matches on thevalueattribute and an absent attribute leaves nothing to match. Bookmarks that hold the old values now read the new strings.ui.update_checkbox_group("checkbox_group", selected=[True, False]), you'll get this on currentmain:vs. this after this PR:
I believe this is more correct.
choices={0: "a"}withselected=["0"]. This follows from the string coercion that we're doing. It is perhaps less formally correct, though.main, this radio would render unselected because1 != "1":{0: "zero", "0": "oh"}, now raise aValueError. Coercion tostrturns two distinct values into one key, so without this error one option disappears with no message.main, the server would not be aware that these were different. After this PR, there's an explicit error raised:ui.input_radio_buttons(choices=[])raises aValueErrorthat nameschoices, instead of anIndexErrorfrom a private helper. This applies to the constructors only. Theupdate_*()path now clears the group instead, as described above. The fix does not require this error. It is a deliberate addition, because an empty filter result is a realistic way to reach that line.main, this ended up looking like the following. If you createdinput_radio_buttons(choices=[])with an empty choice list, you'd getIndexError: list index out of rangeraised in_generate_options()#L328. After this PR, you'll getValueError:choicescannot be empty for a radio button group unlessselectedis given.from here. Likewise, callingupdate_radio_buttons(choices=[])will actually clear the options for the radio list, rather than the mysteriousIndexError.Judgement-call decisions
This PR's implementation now resolves the values in
selectedagainst the actual choice values. i.e.choices={1: "a"}withselected=[1.0]both renders and sends"1". A comparison on raw equality is not correct here. The old code gave the right answer for1.0and the wrong one for"1", and it compared values the client never sees: the option carriesvalue="1", so aselectedof1.0renders as checked but goes onto the wire as a1.0that no option carries.An
update_*()message has two halves: the regenerated options markup and thevalue. Both now come from a single coercion, so they cannot disagree. A fix that coerces only thevalueleaves the markup with nothing checked for a mixed-type call, such aschoices={0: "a"}withselected=["0"]. The client then re-checks the box from thevalue, which hides the mismatch instead of removing it.Prior art
#2362 diagnosed this problem first and is now closed. Credit to @eeshsaxena. That PR changed
_update_choice_input()only, which coversupdate_checkbox_group()andupdate_radio_buttons(). This PR also fixesupdate_select(),update_selectize(), andtoolbar_input_select(), and it changes the input constructors. #2362 branched from #2358 rather thanmain, so it carried unrelated data-frame commits.Out of scope
The issue also asks for a
boolselectedvalue that checks every box. That is a separate feature request, that I think would be a departure from existing API patterns and this PR does not implement it.Testing
tests/pytest/test_input_check_radio.pycovers the wire payload, the rendered markup, the new errors, and the select inputs. The stub session runs each payload throughjson.dumps(), so the tests cover the wire format and not only the dictionary.1087 unit tests pass locally.
uv run make format check-lint check-types check-pyrightis clean, which covers both pyrefly and pyright. The Playwright input and toolbar tests pass (85 tests). Pyright also reports no errors on a file that holds the documented calls.