Repository: streamlit/streamlit
Pull request: Add check that individual elements are "python comparable"
<!--
⚠️ BEFORE CONTRIBUTING PLEASE READ OUR CONTRIBUTING GUIDELINES!
https://github.com/streamlit/streamlit/wiki/Contributing
-->

## Describe your changes
Add check that individual elements are comparable and comparison return boolean

## GitHub Issue Link (if applicable)
Closes: #3714 

## Testing Plan

- Explanation of why no additional tests are needed
- Unit Tests (JS and/or Python) Added ✅ 
- E2E Tests
- Any manual testing needed?

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.


Problem statement:
Exception "The truth value of a Series is ambiguous" when using Pandas row object as widget state
### Summary

Note: this is a new issue when I upgraded streamlit to the current version which added session state.

Suppose I use a pandas object as the selected state for a widget like a radio button (code sample below). Then when changes occur in the widget, an exception is thrown:

```
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
```

### Steps to reproduce
Create a widget like a radio selector where the pandas row object (technically a `pd.Series`) is used as the selected object. Certainly one could use something like an id instead and then look up from the DataFrame, but this used to work and I had code that did things this way. It seemed convenient.

```
selected_object = st.radio("", [o for i, o in df.iterrows()], format_func=lambda o: o["someField"])
```

With the session state update, any selection of a different radio button triggers the exception, because in `session_state.py`, the `_widget_changed` method does a boolean comparison of the selected object. This isn't allowed with `pd.Series` objects.

```
    def _widget_changed(self, widget_id: str) -> bool:
        new_value = self._new_widget_state.get(widget_id)
        old_value = self._old_state.get(widget_id)
        changed: bool = new_value != old_value
        return changed
```

**Expected behavior:**

Support pd.Series objects as widget selected values.

**Actual behavior:**

Exception is thrown, application fails to function.

### Is this a regression?

Yes, worked before session state was added.

### Debug info

Seen in streamlit 0.86.0

Hints from issue discussion:
Thanks for reporting this @kahliburke. I'll look into this to see if there's a reasonable fix that doesn't require us to have to special-case the scenario where the widget values are `pd.Series` objects, although I have a hunch that there's not too much else we can do to support this.
Thanks for the response. That is how I have have patched it locally and I can submit a PR if you'd like?
@kahliburke that'd be much appreciated, thanks!

I admittedly haven't put too much time into thinking of a solution that doesn't involve special-casing the behavior for `pd.Series` objects, but I think it'd be worth it to do that for now since it both strictly improves the current situation where things are broken and is a small change we can easily rework in the future if a more clever way of handling this does get figured out.

Commit message: Add check that individual elements are comparable and comparison return boolean

Patch to review:
diff --git a/lib/streamlit/elements/widgets/multiselect.py b/lib/streamlit/elements/widgets/multiselect.py
index 186d81065323..f859a7055b05 100644
--- a/lib/streamlit/elements/widgets/multiselect.py
+++ b/lib/streamlit/elements/widgets/multiselect.py
@@ -50,6 +50,7 @@
     LabelVisibility,
     OptionSequence,
     T,
+    check_python_comparable,
     ensure_indexable,
     is_iterable,
     is_type,
@@ -294,6 +295,7 @@ def _multiselect(
         check_session_state_rules(default_value=default, key=key)
 
         opt = ensure_indexable(options)
+        check_python_comparable(opt)
         maybe_raise_label_warnings(label, label_visibility)
 
         indices = _check_and_convert_to_indices(opt, default)
diff --git a/lib/streamlit/elements/widgets/radio.py b/lib/streamlit/elements/widgets/radio.py
index d9e5b37ab8d4..c9fd2121a87a 100644
--- a/lib/streamlit/elements/widgets/radio.py
+++ b/lib/streamlit/elements/widgets/radio.py
@@ -41,6 +41,7 @@
     LabelVisibility,
     OptionSequence,
     T,
+    check_python_comparable,
     ensure_indexable,
     maybe_raise_label_warnings,
     to_key,
@@ -252,6 +253,7 @@ def _radio(
         check_session_state_rules(default_value=None if index == 0 else index, key=key)
         maybe_raise_label_warnings(label, label_visibility)
         opt = ensure_indexable(options)
+        check_python_comparable(opt)
 
         id = compute_widget_id(
             "radio",
diff --git a/lib/streamlit/elements/widgets/select_slider.py b/lib/streamlit/elements/widgets/select_slider.py
index 72b2efabf360..d7f97b121f95 100644
--- a/lib/streamlit/elements/widgets/select_slider.py
+++ b/lib/streamlit/elements/widgets/select_slider.py
@@ -53,6 +53,7 @@
     LabelVisibility,
     OptionSequence,
     T,
+    check_python_comparable,
     ensure_indexable,
     maybe_raise_label_warnings,
     to_key,
@@ -264,6 +265,7 @@ def _select_slider(
         check_session_state_rules(default_value=value, key=key)
         maybe_raise_label_warnings(label, label_visibility)
         opt = ensure_indexable(options)
+        check_python_comparable(opt)
 
         if len(opt) == 0:
             raise StreamlitAPIException("The `options` argument needs to be non-empty")
diff --git a/lib/streamlit/elements/widgets/selectbox.py b/lib/streamlit/elements/widgets/selectbox.py
index cca2781cde2b..87f715f30e73 100644
--- a/lib/streamlit/elements/widgets/selectbox.py
+++ b/lib/streamlit/elements/widgets/selectbox.py
@@ -40,6 +40,7 @@
     LabelVisibility,
     OptionSequence,
     T,
+    check_python_comparable,
     ensure_indexable,
     maybe_raise_label_warnings,
     to_key,
@@ -234,6 +235,7 @@ def _selectbox(
         maybe_raise_label_warnings(label, label_visibility)
 
         opt = ensure_indexable(options)
+        check_python_comparable(opt)
 
         id = compute_widget_id(
             "selectbox",
diff --git a/lib/streamlit/type_util.py b/lib/streamlit/type_util.py
index c1eba6e872ec..7f0b202c161e 100644
--- a/lib/streamlit/type_util.py
+++ b/lib/streamlit/type_util.py
@@ -49,6 +49,7 @@
 from streamlit import config, errors
 from streamlit import logger as _logger
 from streamlit import string_util
+from streamlit.errors import StreamlitAPIException
 
 if TYPE_CHECKING:
     import graphviz
@@ -656,6 +657,23 @@ def ensure_indexable(obj: OptionSequence[V_co]) -> Sequence[V_co]:
         return list(it)
 
 
+def check_python_comparable(seq: Sequence) -> None:
+    """Check if the sequence elements support "python comparison".
+    That means that the equality operator (==) returns a boolean value.
+    Which is not True for e.g. numpy arrays and pandas series.
+    In case of empty sequences, the check not raise an exception."""
+    try:
+        bool(seq[0] == seq[0])
+    except LookupError:
+        pass
+    except ValueError:
+        raise StreamlitAPIException(
+            f"Options must be python comparable. Got **{type(seq[0]).__name__}**. \n\n"
+            "Please refactor your code, to use a comparable type, e.g. "
+            "using index numbers instead."
+        )
+
+
 def is_pandas_version_less_than(v: str) -> bool:
     """Return True if the current Pandas version is less than the input version.
 


Code review comments:
diff_hunk: @@ -656,6 +657,23 @@
         return list(it)
 
 
+def check_python_comparable(seq: Sequence) -> None:
+    """Check if the sequence elements support "python comparison".
+    That means that the equality operator (==) returns a boolean value.
+    Which is not True for e.g. numpy arrays and pandas series.
+    In case of empty sequences, the check not raise an exception."""
+    try:
+        bool(seq[0] == seq[0])
+    except LookupError:
path: lib/streamlit/type_util.py
text: @user1:
## Empty except

'except' clause does nothing but pass and there is no explanatory comment.

[Show more details](https://github.com/streamlit/streamlit/security/code-scanning/8175)