From d0486737b7b579a915753e084d4898bafab35e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 1 Jun 2026 16:59:25 +0200 Subject: [PATCH 1/3] fix: drop select field magic Enum coerce wrapping, add SelectChoice.coerce_by_name --- CHANGES.rst | 10 ++++++ docs/fields.rst | 22 +++++++++--- src/wtforms/fields/choices.py | 39 ++++++++++++-------- tests/fields/test_select.py | 56 +++++++++++++++++++++++++---- tests/fields/test_selectmultiple.py | 25 +++++++++++-- 5 files changed, 125 insertions(+), 27 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 83f77c2a..d49714f7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,15 @@ .. currentmodule:: wtforms +Version 3.3.0b3 +--------------- + +Unreleased + +- Stop wrapping ``coerce=EnumCls`` into a name lookup on + :class:`~fields.SelectField`. Pair + :meth:`fields.SelectChoice.from_enum` with the new + :meth:`fields.SelectChoice.coerce_by_name` instead. :issue:`922` + Version 3.3.0b2 --------------- diff --git a/docs/fields.rst b/docs/fields.rst index 355bc753..9bdbf87c 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -407,14 +407,28 @@ Choice Fields BLUE = 3 class PaintForm(Form): - color = SelectField(choices=SelectChoice.from_enum(Color), coerce=Color) + color = SelectField( + choices=SelectChoice.from_enum(Color), + coerce=SelectChoice.coerce_by_name(Color), + ) - :meth:`SelectChoice.from_enum` builds the option list from the Enum items; - the HTML ``value`` of each option is the item's ``name``. Passing the - Enum class itself as ``coerce`` installs the matching coercion, so + :meth:`SelectChoice.from_enum` builds the option list from the Enum + items; the HTML ``value`` of each option is the item's ``name``. + :meth:`SelectChoice.coerce_by_name` returns the matching ``coerce`` + callable that resolves names back into members, so ``form.color.data`` is a ``Color`` item after submit. Pre-selecting works the usual way, with an Enum item: ``PaintForm(color=Color.RED)``. + If you'd rather submit ``item.value`` instead of ``item.name``, + pass the Enum class directly as ``coerce``; ``EnumCls(value)`` is + then used to resolve the submitted string:: + + class PaintForm(Form): + color = SelectField( + choices=[(m.value, m.name) for m in Color], + coerce=Color, + ) + By default the option label is ``str(item)`` if the Enum defines its own ``__str__`` (also the case for :class:`enum.StrEnum`), otherwise ``item.name``. To customise, pass a ``label`` callable taking an Enum diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py index e911bab9..3b1bc162 100644 --- a/src/wtforms/fields/choices.py +++ b/src/wtforms/fields/choices.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from dataclasses import field from dataclasses import replace -from enum import Enum from itertools import groupby from operator import attrgetter from typing import NamedTuple @@ -21,18 +20,6 @@ ) -def _enum_coerce(enum_cls): - def coerce(v): - if isinstance(v, enum_cls): - return v - try: - return enum_cls[v] - except KeyError as e: - raise ValueError(str(e)) from e - - return coerce - - class Choice(NamedTuple): """ A rendered option yielded by @@ -98,11 +85,35 @@ def from_enum(cls, enum_cls, *, label=None): defaults to ``str(item)`` when the Enum defines its own ``__str__``, otherwise to ``item.name``. Pass ``label=`` (a callable taking an item) to override. + + Pair with :meth:`coerce_by_name` to round-trip the submitted + name back into an Enum member. """ if label is None: label = str if "__str__" in enum_cls.__dict__ else lambda m: m.name return [cls(value=m.name, label=label(m)) for m in enum_cls] + @staticmethod + def coerce_by_name(enum_cls): + """Return a ``coerce`` callable that resolves member names back + into Enum members. + + Use this when ``choices`` come from :meth:`from_enum`, which + emits ``member.name`` as the HTML ``value=``. If you build + choices with ``member.value`` instead, pass ``coerce=EnumCls`` + directly — that performs the standard ``EnumCls(value)`` lookup. + """ + + def coerce(v): + if isinstance(v, enum_cls): + return v + try: + return enum_cls[v] + except KeyError as e: + raise ValueError(str(e)) from e + + return coerce + @classmethod def from_input(cls, input, optgroup=None): """Coerce a value passed by the user via ``choices=...`` into a @@ -334,8 +345,6 @@ def __init__( **kwargs, ): super().__init__(label, validators, **kwargs) - if isinstance(coerce, type) and issubclass(coerce, Enum): - coerce = _enum_coerce(coerce) self.coerce = coerce if callable(choices): self._choices_callable = choices diff --git a/tests/fields/test_select.py b/tests/fields/test_select.py index 7618118c..9d39b6a5 100644 --- a/tests/fields/test_select.py +++ b/tests/fields/test_select.py @@ -677,29 +677,68 @@ def test_choice_from_enum_custom_label(): def test_select_field_enum_coerce_round_trip(): - """``coerce=EnumCls`` round-trips form data back to an Enum member.""" - F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain)) + """``coerce_by_name`` round-trips form data back to an Enum member.""" + F = make_form( + a=SelectField( + choices=SelectChoice.from_enum(_Plain), + coerce=SelectChoice.coerce_by_name(_Plain), + ) + ) form = F(DummyPostData(a=["RED"])) assert form.a.data is _Plain.RED assert form.validate() def test_select_field_enum_coerce_accepts_member(): - """``coerce=EnumCls`` accepts an already-coerced member without re-lookup.""" - F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain)) + """``coerce_by_name`` accepts an already-coerced member without re-lookup.""" + F = make_form( + a=SelectField( + choices=SelectChoice.from_enum(_Plain), + coerce=SelectChoice.coerce_by_name(_Plain), + ) + ) form = F(a=_Plain.GREEN) assert form.a.data is _Plain.GREEN def test_select_field_enum_coerce_invalid(): """An unknown name fails validation cleanly (KeyError → ValueError).""" - F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain)) + F = make_form( + a=SelectField( + choices=SelectChoice.from_enum(_Plain), + coerce=SelectChoice.coerce_by_name(_Plain), + ) + ) form = F(DummyPostData(a=["BAD"])) assert not form.validate() assert form.a.data is None assert "Invalid Choice: could not coerce." in form.a.errors +def test_select_field_enum_value_lookup(): + """``coerce=EnumCls`` keeps the standard ``EnumCls(value)`` lookup.""" + + class _GH(Enum): + GITHUB = "github" + GITLAB = "gitlab" + + F = make_form( + a=SelectField( + choices=[SelectChoice(m.value, m.name) for m in _GH], + coerce=_GH, + ) + ) + form = F(DummyPostData(a=["github"])) + assert form.a.data is _GH.GITHUB + assert form.validate() + + +def test_select_field_coerce_passed_through(): + """``coerce`` is stored as provided — no implicit wrapping.""" + F = make_form(a=SelectField(choices=[SelectChoice("x", "X")], coerce=_Plain)) + assert F().a.coerce is _Plain + + def test_iter_choices_tuple_unpacking(): """``iter_choices()`` yields ``Choice`` 4-tuples — unpacking matches the 3.2 yield shape ``(value, label, selected, render_kw)``.""" @@ -713,6 +752,11 @@ def test_iter_choices_tuple_unpacking(): def test_select_field_enum_renders_selected(): """Pre-selecting a member highlights the right option.""" - F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain)) + F = make_form( + a=SelectField( + choices=SelectChoice.from_enum(_Plain), + coerce=SelectChoice.coerce_by_name(_Plain), + ) + ) form = F(a=_Plain.GREEN) assert '' in form.a() diff --git a/tests/fields/test_selectmultiple.py b/tests/fields/test_selectmultiple.py index d0557f01..902ee9b5 100644 --- a/tests/fields/test_selectmultiple.py +++ b/tests/fields/test_selectmultiple.py @@ -296,10 +296,31 @@ class _Color(Enum): def test_select_multiple_enum_round_trip(): - """``coerce=EnumCls`` works for SelectMultipleField too.""" + """``coerce_by_name`` works for SelectMultipleField too.""" F = make_form( - a=SelectMultipleField(choices=SelectChoice.from_enum(_Color), coerce=_Color) + a=SelectMultipleField( + choices=SelectChoice.from_enum(_Color), + coerce=SelectChoice.coerce_by_name(_Color), + ) ) form = F(DummyPostData(a=["RED", "BLUE"])) assert form.validate() assert form.a.data == [_Color.RED, _Color.BLUE] + + +def test_select_multiple_enum_value_lookup(): + """``coerce=EnumCls`` keeps the standard value lookup on multi-select.""" + + class _Kind(Enum): + SMALL = "2-5" + BIG = "5-10" + + F = make_form( + a=SelectMultipleField( + choices=[SelectChoice(m.value, m.name) for m in _Kind], + coerce=_Kind, + ) + ) + form = F(DummyPostData(a=["2-5", "5-10"])) + assert form.validate() + assert form.a.data == [_Kind.SMALL, _Kind.BIG] From bbf0d1502f13f983a43bc3e1e6c59303a98b454a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 4 Jun 2026 15:53:41 +0200 Subject: [PATCH 2/3] fix: avoid deprecated syntaxes in the documentation Co-authored-by: Mike Fiedler --- docs/fields.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fields.rst b/docs/fields.rst index 9bdbf87c..2390777e 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -425,7 +425,7 @@ Choice Fields class PaintForm(Form): color = SelectField( - choices=[(m.value, m.name) for m in Color], + choices=[SelectChoice(m.value, m.name) for m in Color], coerce=Color, ) From 8b729c084fe9d376c2763c7549b9a44311d3b22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 9 Jun 2026 09:22:12 +0200 Subject: [PATCH 3/3] refactor: enum_choices and enum_coerce methods migrate SelectChoice.from_enum to enum_choices, and add a symmetric method enum_coerce for enum choices choices coercion --- CHANGES.rst | 9 +- docs/fields.rst | 59 ++++++----- src/wtforms/__init__.py | 2 + src/wtforms/datalist.py | 27 ++--- src/wtforms/fields/__init__.py | 4 + src/wtforms/fields/choices.py | 91 ++++++++++------- tests/fields/test_select.py | 146 ++++++++++++++++++---------- tests/fields/test_selectmultiple.py | 46 ++++++--- tests/test_datalist.py | 20 ++++ 9 files changed, 262 insertions(+), 142 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d49714f7..0c4c7ac4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,10 +5,11 @@ Version 3.3.0b3 Unreleased -- Stop wrapping ``coerce=EnumCls`` into a name lookup on - :class:`~fields.SelectField`. Pair - :meth:`fields.SelectChoice.from_enum` with the new - :meth:`fields.SelectChoice.coerce_by_name` instead. :issue:`922` +- Add :func:`fields.enum_choices`, :func:`fields.enum_coerce` and + :func:`~wtforms.datalist.enum_datalist` for Enum-backed choices, + replacing ``SelectChoice.from_enum`` and friends. :issue:`922` +- An Enum ``coerce`` is no longer wrapped, and options default to + ``member.value``. :issue:`922` Version 3.3.0b2 --------------- diff --git a/docs/fields.rst b/docs/fields.rst index 2390777e..8657144a 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -337,9 +337,7 @@ Choice Fields option cannot be applied to your problem you may wish to skip choice validation (see below). - **Select fields with ````**:: - - Use :class:`SelectChoice` to assign an option to an ````. + **Select fields with optgroup**:: class PastebinEntry(Form): language = SelectField('Programming Language', choices=[ @@ -349,6 +347,8 @@ Choice Fields SelectChoice('text', 'Plain Text'), ]) + Use :class:`SelectChoice` to assign an option to an ````. + **Select fields with dynamic choice values**:: def available_groups(): @@ -400,42 +400,44 @@ Choice Fields **Select fields backed by an Enum**:: from enum import Enum + from wtforms.fields import enum_choices, enum_coerce class Color(Enum): - RED = 1 - GREEN = 2 - BLUE = 3 + RED = "red" + GREEN = "green" + BLUE = "blue" class PaintForm(Form): color = SelectField( - choices=SelectChoice.from_enum(Color), - coerce=SelectChoice.coerce_by_name(Color), + choices=enum_choices(Color), + coerce=enum_coerce(Color), ) - :meth:`SelectChoice.from_enum` builds the option list from the Enum - items; the HTML ``value`` of each option is the item's ``name``. - :meth:`SelectChoice.coerce_by_name` returns the matching ``coerce`` - callable that resolves names back into members, so - ``form.color.data`` is a ``Color`` item after submit. Pre-selecting - works the usual way, with an Enum item: ``PaintForm(color=Color.RED)``. + :func:`~wtforms.fields.enum_choices` builds the option list from + the Enum members; by default the HTML ``value`` of each option is the + member's ``value`` (``"red"``, ``"green"``, ...). + :func:`~wtforms.fields.enum_coerce` returns the matching ``coerce`` + callable that resolves the submitted string back into a member, so + ``form.color.data`` is a ``Color`` member after submit. Pre-selecting + works the usual way, with an Enum member: ``PaintForm(color=Color.RED)``. - If you'd rather submit ``item.value`` instead of ``item.name``, - pass the Enum class directly as ``coerce``; ``EnumCls(value)`` is - then used to resolve the submitted string:: + Use ``by="name"`` on **both** helpers when the Enum value is not a good + transport identifier — non-unique, non-serialisable, or when you simply + want ``member.name`` on the wire:: class PaintForm(Form): color = SelectField( - choices=[SelectChoice(m.value, m.name) for m in Color], - coerce=Color, + choices=enum_choices(Color, by="name"), + coerce=enum_coerce(Color, by="name"), ) - By default the option label is ``str(item)`` if the Enum defines its + By default the option label is ``str(member)`` if the Enum defines its own ``__str__`` (also the case for :class:`enum.StrEnum`), otherwise - ``item.name``. To customise, pass a ``label`` callable taking an Enum - item and returning the label string:: + ``member.name``. To customise, pass a ``label`` callable taking an Enum + member and returning the label string:: - SelectChoice.from_enum(Color, label=lambda item: item.name.title()) - # → [SelectChoice('RED', 'Red'), SelectChoice('GREEN', 'Green'), SelectChoice('BLUE', 'Blue')] + enum_choices(Color, label=lambda member: member.name.title()) + # → [SelectChoice('red', 'Red'), SelectChoice('green', 'Green'), SelectChoice('blue', 'Blue')] **Skipping choice validation**:: @@ -468,6 +470,10 @@ Choice Fields which are not in the given choices list will cause validation on the field to fail. +.. autofunction:: wtforms.fields.enum_choices + +.. autofunction:: wtforms.fields.enum_coerce + Submit fields ------------- @@ -594,8 +600,6 @@ Data Lists .. currentmodule:: wtforms -.. autoclass:: DataListChoice - .. class:: DataList(choices=None, *, render_kw=None, widget=None) A :mdn-tag:`datalist` of suggestions. Unlike @@ -672,6 +676,9 @@ Data Lists :class:`~wtforms.fields.TextAreaField` is silently ignored by the browser. +.. autoclass:: DataListChoice + +.. autofunction:: wtforms.datalist.enum_datalist .. currentmodule:: wtforms.fields diff --git a/src/wtforms/__init__.py b/src/wtforms/__init__.py index 683f5f43..e25bfdbe 100644 --- a/src/wtforms/__init__.py +++ b/src/wtforms/__init__.py @@ -2,6 +2,7 @@ from wtforms import widgets from wtforms.datalist import DataList from wtforms.datalist import DataListChoice +from wtforms.datalist import enum_datalist from wtforms.fields.choices import Choice from wtforms.fields.choices import RadioField from wtforms.fields.choices import SelectChoice @@ -86,4 +87,5 @@ "Choice", "SelectChoice", "DataListChoice", + "enum_datalist", ] diff --git a/src/wtforms/datalist.py b/src/wtforms/datalist.py index d1b41294..e0d34ba5 100644 --- a/src/wtforms/datalist.py +++ b/src/wtforms/datalist.py @@ -4,9 +4,10 @@ from wtforms import widgets from wtforms._compat import get_signature +from wtforms.fields.choices import _enum_options from wtforms.fields.choices import Choice -__all__ = ("DataList", "DataListChoice") +__all__ = ("DataList", "DataListChoice", "enum_datalist") @dataclass @@ -35,16 +36,6 @@ def __post_init__(self): def __iter__(self): return iter((self.value, self.label, self.render_kw)) - @classmethod - def from_enum(cls, enum_cls, *, label=None): - """Build a list of choices from an :class:`enum.Enum` class. - - See :meth:`SelectChoice.from_enum` for details. - """ - if label is None: - label = str if "__str__" in enum_cls.__dict__ else lambda m: m.name - return [cls(value=m.name, label=label(m)) for m in enum_cls] - @classmethod def from_input(cls, input): """Coerce a value passed by the user into a :class:`DataListChoice`.""" @@ -73,6 +64,20 @@ def from_input(cls, input): return cls(*input) +def enum_datalist(enum_cls, *, by="value", label=None): + """Build a list of :class:`DataListChoice` from an :class:`enum.Enum` class. + + Same semantics as :func:`~wtforms.fields.enum_choices`: ``by`` selects + which member attribute becomes the ``