Repository: wtforms/wtforms (python)

Issue:
SelectField - can choices tuples be expanded to allow for tooltips
At present the choices list should contain tuples:
`[ ("option-value", "display-text"), (..., ...), ... ]`

Can it be expanded to optionally allow a **tool-tip** as 3rd tuple element which is then rendered as a `title` attribute in the <option> element:
`("option-value", "display-text", "tool-tip")` --> `<option value="optional-value" title="tool-tip">display-text</option>`

Pull request description:
`SelectField` choice refactoring
Fixes #692. Related to #381.

`SelectField` `choice` argument can take a list of 3 items tuples, the last one being a dict that will be rendered as `<option>` HTML parameter.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index b993f04..923df9b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -10,6 +10,8 @@ Unreleased
 -   Translation improvements :pr:`732` :pr:`734` :pr:`754`
 -   Implement :class:`~fields.ColorField` :pr:`755`
 -   Delayed import of ``email_validator``. :issue:`727`
+-   ``<option>`` attributes can be passed by the :class:`~fields.SelectField`
+    ``choices`` parameter :issue:`692` :pr:`738`
 -   Use the standard datetime formats by default for
     :class:`~fields.DateTimeLocalField`  :pr:`761`
 -   Python 3.11 support :pr:`763`
diff --git a/docs/fields.rst b/docs/fields.rst
index 3a48f2c..75f6efa 100644
--- a/docs/fields.rst
+++ b/docs/fields.rst
@@ -278,8 +278,10 @@ refer to a single input from the form.
 
     Select fields take a ``choices`` parameter which is either:
 
-    * a list of ``(value, label)`` pairs. It can also be a list of only values, in
-      which case the value is used as the label. The value can be of any
+    * a list of ``(value, label)`` or ``(value, label, render_kw)`` tuples.
+      It can also be a list of only values, in which case the value is used
+      as the label. If set, the ``render_kw`` dictionnary will be rendered as
+      HTML ``<option>`` parameters. The value can be of any
       type, but because form data is sent to the browser as strings, you
       will need to provide a ``coerce`` function that converts a string
       back to the expected type.
diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py
index d24f1a0..bab248d 100644
--- a/src/wtforms/fields/choices.py
+++ b/src/wtforms/fields/choices.py
@@ -49,8 +49,10 @@ class SelectFieldBase(Field):
             _form=None,
             _meta=self.meta,
         )
-        for i, (value, label, checked) in enumerate(self.iter_choices()):
-            opt = self._Option(label=label, id="%s-%d" % (self.id, i), **opts)
+        for i, (value, label, checked, render_kw) in enumerate(self.iter_choices()):
+            opt = self._Option(
+                label=label, id="%s-%d" % (self.id, i), **opts, **render_kw
+            )
             opt.process(None, value)
             opt.checked = checked
             yield opt
@@ -112,8 +114,9 @@ class SelectField(SelectFieldBase):
         else:
             _choices = zip(choices, choices)
 
-        for value, label in _choices:
-            yield (value, label, self.coerce(value) == self.data)
+        for value, label, *other_args in _choices:
+            render_kw = other_args[0] if len(other_args) else {}
+            yield (value, label, self.coerce(value) == self.data, render_kw)
 
     def process_data(self, value):
         try:
@@ -138,7 +141,7 @@ class SelectField(SelectFieldBase):
         if not self.validate_choice:
             return
 
-        for _, _, match in self.iter_choices():
+        for _, _, match, _ in self.iter_choices():
             if match:
                 break
         else:
@@ -163,9 +166,10 @@ class SelectMultipleField(SelectField):
         else:
             _choices = []
 
-        for value, label in _choices:
+        for value, label, *args in _choices:
             selected = self.data is not None and self.coerce(value) in self.data
-            yield (value, label, selected)
+            render_kw = args[0] if len(args) else {}
+            yield (value, label, selected, render_kw)
 
     def process_data(self, value):
         try:
diff --git a/src/wtforms/widgets/core.py b/src/wtforms/widgets/core.py
index 345d9c5..ee5bc60 100644
--- a/src/wtforms/widgets/core.py
+++ b/src/wtforms/widgets/core.py
@@ -358,12 +358,12 @@ class Select:
         if field.has_groups():
             for group, choices in field.iter_groups():
                 html.append("<optgroup %s>" % html_params(label=group))
-                for val, label, selected in choices:
-                    html.append(self.render_option(val, label, selected))
+                for val, label, selected, render_kw in choices:
+                    html.append(self.render_option(val, label, selected, **render_kw))
                 html.append("</optgroup>")
         else:
-            for val, label, selected in field.iter_choices():
-                html.append(self.render_option(val, label, selected))
+            for val, label, selected, render_kw in field.iter_choices():
+                html.append(self.render_option(val, label, selected, **render_kw))
         html.append("</select>")
         return Markup("".join(html))
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index a9223c1..80c54f6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -28,7 +28,7 @@ def basic_widget_dummy_field(dummy_field_class):
 
 @pytest.fixture
 def select_dummy_field(dummy_field_class):
-    return dummy_field_class([("foo", "lfoo", True), ("bar", "lbar", False)])
+    return dummy_field_class([("foo", "lfoo", True, {}), ("bar", "lbar", False, {})])
 
 
 @pytest.fixture
diff --git a/tests/fields/test_select.py b/tests/fields/test_select.py
index dd0d263..82db6f0 100644
--- a/tests/fields/test_select.py
+++ b/tests/fields/test_select.py
@@ -219,7 +219,7 @@ def test_optgroup():
         '<option selected value="a">Foo</option>'
         "</optgroup>" in form.a()
     )
-    assert list(form.a.iter_choices()) == [("a", "Foo", True)]
+    assert list(form.a.iter_choices()) == [("a", "Foo", True, {})]
 
 
 def test_optgroup_shortcut():
@@ -232,7 +232,10 @@ def test_optgroup_shortcut():
         '<option selected value="bar">bar</option>'
         "</optgroup>" in form.a()
     )
-    assert list(form.a.iter_choices()) == [("foo", "foo", False), ("bar", "bar", True)]
+    assert list(form.a.iter_choices()) == [
+        ("foo", "foo", False, {}),
+        ("bar", "bar", True, {}),
+    ]
 
 
 @pytest.mark.parametrize("choices", [[], ()])
@@ -241,3 +244,36 @@ def test_empty_optgroup(choices):
     form = F(a="bar")
     assert '<optgroup label="hello"></optgroup>' in form.a()
     assert list(form.a.iter_choices()) == []
+
+
+def test_option_render_kw():
+    F = make_form(
+        a=SelectField(choices=[("a", "Foo", {"title": "foobar", "data-foo": "bar"})])
+    )
+    form = F(a="a")
+
+    assert (
+        '<option data-foo="bar" selected title="foobar" value="a">Foo</option>'
+        in form.a()
+    )
+    assert list(form.a.iter_choices()) == [
+        ("a", "Foo", True, {"title": "foobar", "data-foo": "bar"})
+    ]
+
+
+def test_optgroup_option_render_kw():
+    F = make_form(
+        a=SelectField(
+            choices={"hello": [("a", "Foo", {"title": "foobar", "data-foo": "bar"})]}
+        )
+    )
+    form = F(a="a")
+
+    assert (
+        '<optgroup label="hello">'
+        '<option data-foo="bar" selected title="foobar" value="a">Foo</option>'
+        "</optgroup>" in form.a()
+    )
+    assert list(form.a.iter_choices()) == [
+        ("a", "Foo", True, {"title": "foobar", "data-foo": "bar"})
+    ]
diff --git a/tests/fields/test_selectmultiple.py b/tests/fields/test_selectmultiple.py
index 14328e2..426c36b 100644
--- a/tests/fields/test_selectmultiple.py
+++ b/tests/fields/test_selectmultiple.py
@@ -27,16 +27,16 @@ def test_defaults():
     # Test for possible regression with null data
     form.a.data = None
     assert form.validate()
-    assert list(form.a.iter_choices()) == [(v, l, False) for v, l in form.a.choices]
+    assert list(form.a.iter_choices()) == [(v, l, False, {}) for v, l in form.a.choices]
 
 
 def test_with_data():
     form = F(DummyPostData(a=["a", "c"]))
     assert form.a.data == ["a", "c"]
     assert list(form.a.iter_choices()) == [
-        ("a", "hello", True),
-        ("b", "bye", False),
-        ("c", "something", True),
+        ("a", "hello", True, {}),
+        ("b", "bye", False, {}),
+        ("c", "something", True, {}),
     ]
     assert form.b.data == []
     form = F(DummyPostData(b=["1", "2"]))
@@ -149,3 +149,38 @@ def test_render_kw_preserved():
         '<option value="bar">bar</option>'
         "</select>"
     )
+
+
+def test_option_render_kw():
+    F = make_form(
+        a=SelectMultipleField(
+            choices=[("a", "Foo", {"title": "foobar", "data-foo": "bar"})]
+        )
+    )
+    form = F(a="a")
+
+    assert (
+        '<option data-foo="bar" selected title="foobar" value="a">Foo</option>'
+        in form.a()
+    )
+    assert list(form.a.iter_choices()) == [
+        ("a", "Foo", True, {"title": "foobar", "data-foo": "bar"})
+    ]
+
+
+def test_optgroup_option_render_kw():
+    F = make_form(
+        a=SelectMultipleField(
+            choices={"hello": [("a", "Foo", {"title": "foobar", "data-foo": "bar"})]}
+        )
+    )
+    form = F(a="a")
+
+    assert (
+        '<optgroup label="hello">'
+        '<option data-foo="bar" selected title="foobar" value="a">Foo</option>'
+        "</optgroup>" in form.a()
+    )
+    assert list(form.a.iter_choices()) == [
+        ("a", "Foo", True, {"title": "foobar", "data-foo": "bar"})
+    ]