Repository: pallets-eco/wtforms

Issue:
SelectMultipleField should support choices specified as coercible values.
### Actual Behavior

```python
>>> class DummyPostData(dict):
...     def getlist(self, key):
...             v = self[key]
...             if not isinstance(v, (list, tuple)):
...                     v = [v]
...             return v
...
>>> import wtforms
>>> class F(wtforms.Form):
...     foo=wtforms.SelectMultipleField(choices=[('1', 'One'), ('2', 'Two')], coerce=int)
...
>>> post_data = DummyPostData(foo=["1", "2"])
>>> form = F(post_data)
>>> form.validate()
False
>>> form.errors
{'foo': ["'1', '2' are not valid choices for this field."]}
```

### Expected Behavior

The form should be valid when the post data is a valid option after coercing both post data and choices:

```python
>>> class DummyPostData(dict):
...     def getlist(self, key):
...             v = self[key]
...             if not isinstance(v, (list, tuple)):
...                     v = [v]
...             return v
...
>>> import wtforms
>>> class F(wtforms.Form):
...     foo=wtforms.SelectMultipleField(choices=[('1', 'One'), ('2', 'Two')], coerce=int)
...
>>> post_data = DummyPostData(foo=["1", "2"])
>>> form = F(post_data)
>>> form.validate()
True
```


### Environment

* Python version: 3.9
* wtforms version: 3.1.1

Hints from issue comments:
@azmeuk: There is indeed a difference of behavior between `SelectField` and `SelectMultipleField`.

```python
>>> import wtforms
>>> from werkzeug.datastructures import ImmutableMultiDict
>>> class F(wtforms.Form):
...     foo=wtforms.SelectMultipleField(choices=[('1', 'One'), ('2', 'Two')], coerce=int)
...     bar=wtforms.SelectField(choices=[('1', 'One'), ('2', 'Two')], coerce=int)
>>> form = F(ImmutableMultiDict({"foo": ["1"], "bar": ["1"]}))
>>> form.validate()
False
>>> form.errors
{'foo': ["'1' is not a valid choice for this field."]}
```@azmeuk: Fixed in version 3.1.2

Solution patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 186501b..18129d4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,5 +1,11 @@
 .. currentmodule:: wtforms
 
+Unreleased
+----------
+
+- Fix :class:`~fields.SelectMultipleField` value coercion on validation.
+  :issue:`822` :pr:`823`
+
 Version 3.1.1
 -------------
 
diff --git a/pyproject.toml b/pyproject.toml
index cc76d67..10f0b52 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,7 +54,8 @@ artifacts = ["src/wtforms/locale/**/*.mo"]
 
 [tool.hatch.build.hooks.custom]
 dependencies = [
-    "Babel>=2.6.0"
+    "Babel>=2.6.0",
+    "setuptools; python_version>='3.12'",
 ]
 
 [tool.pytest.ini_options]
diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py
index 2b1fd2e..62ed497 100644
--- a/src/wtforms/fields/choices.py
+++ b/src/wtforms/fields/choices.py
@@ -121,8 +121,9 @@ class SelectField(SelectFieldBase):
             _choices = zip(choices, choices)
 
         for value, label, *other_args in _choices:
+            selected = self.coerce(value) == self.data
             render_kw = other_args[0] if len(other_args) else {}
-            yield (value, label, self.coerce(value) == self.data, render_kw)
+            yield (value, label, selected, render_kw)
 
     def process_data(self, value):
         try:
@@ -173,9 +174,9 @@ class SelectMultipleField(SelectField):
         else:
             _choices = zip(choices, choices)
 
-        for value, label, *args in _choices:
+        for value, label, *other_args in _choices:
             selected = self.data is not None and self.coerce(value) in self.data
-            render_kw = args[0] if len(args) else {}
+            render_kw = other_args[0] if len(other_args) else {}
             yield (value, label, selected, render_kw)
 
     def process_data(self, value):
@@ -201,9 +202,11 @@ class SelectMultipleField(SelectField):
         if self.choices is None:
             raise TypeError(self.gettext("Choices cannot be None."))
 
-        acceptable = {c[0] for c in self.iter_choices()}
-        if any(d not in acceptable for d in self.data):
-            unacceptable = [str(d) for d in set(self.data) - acceptable]
+        acceptable = [self.coerce(choice[0]) for choice in self.iter_choices()]
+        if any(data not in acceptable for data in self.data):
+            unacceptable = [
+                str(data) for data in set(self.data) if data not in acceptable
+            ]
             raise ValidationError(
                 self.ngettext(
                     "'%(value)s' is not a valid choice for this field.",


Test patch:
diff --git a/tests/fields/test_selectmultiple.py b/tests/fields/test_selectmultiple.py
index 5fd2a99..127fe45 100644
--- a/tests/fields/test_selectmultiple.py
+++ b/tests/fields/test_selectmultiple.py
@@ -190,3 +190,16 @@ def test_optgroup_option_render_kw():
     assert list(form.a.iter_choices()) == [
         ("a", "Foo", True, {"title": "foobar", "data-foo": "bar"})
     ]
+
+
+def test_can_supply_coercable_values_as_options():
+    F = make_form(
+        a=SelectMultipleField(
+            choices=[("1", "One"), ("2", "Two")],
+            coerce=int,
+        )
+    )
+    post_data = DummyPostData(a=["1", "2"])
+    form = F(post_data)
+    assert form.validate()
+    assert form.a.data == [1, 2]