Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
.. currentmodule:: wtforms

Version 3.3.0b3
---------------

Unreleased

- 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
---------------

Expand Down
59 changes: 40 additions & 19 deletions docs/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<optgroup>``**::

Use :class:`SelectChoice` to assign an option to an ``<optgroup>``.
**Select fields with optgroup**::

class PastebinEntry(Form):
language = SelectField('Programming Language', choices=[
Expand All @@ -349,6 +347,8 @@ Choice Fields
SelectChoice('text', 'Plain Text'),
])

Use :class:`SelectChoice` to assign an option to an ``<optgroup>``.

**Select fields with dynamic choice values**::

def available_groups():
Expand Down Expand Up @@ -400,28 +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=Color)
color = SelectField(
choices=enum_choices(Color),
coerce=enum_coerce(Color),
)

: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)``.

: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
``form.color.data`` is a ``Color`` item after submit. Pre-selecting
works the usual way, with an Enum item: ``PaintForm(color=Color.RED)``.
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=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**::

Expand Down Expand Up @@ -454,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
-------------

Expand Down Expand Up @@ -580,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
Expand Down Expand Up @@ -658,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

Expand Down
2 changes: 2 additions & 0 deletions src/wtforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,4 +87,5 @@
"Choice",
"SelectChoice",
"DataListChoice",
"enum_datalist",
]
27 changes: 16 additions & 11 deletions src/wtforms/datalist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`."""
Expand Down Expand Up @@ -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 ``<option>`` value (``"value"`` by
default, ``"name"`` otherwise) and ``label`` defaults to ``str(member)``
when the Enum defines its own ``__str__``, else ``member.name``.

A ``<datalist>`` only suggests values for a free-text field, so there is
no coercion counterpart — the field stores whatever string is submitted.
"""
return _enum_options(enum_cls, by, label, DataListChoice)


class DataList:
"""A ``<datalist>`` of suggestions attached to a single field.

Expand Down
4 changes: 4 additions & 0 deletions src/wtforms/fields/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from wtforms.fields.choices import Choice
from wtforms.fields.choices import enum_choices
from wtforms.fields.choices import enum_coerce
from wtforms.fields.choices import RadioField
from wtforms.fields.choices import SelectChoice
from wtforms.fields.choices import SelectField
Expand Down Expand Up @@ -46,6 +48,8 @@
"SelectMultipleField",
"SelectFieldBase",
"RadioField",
"enum_choices",
"enum_coerce",
"DateTimeField",
"DateField",
"TimeField",
Expand Down
82 changes: 54 additions & 28 deletions src/wtforms/fields/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -90,19 +77,6 @@ def __post_init__(self):
def __iter__(self):
return iter((self.value, self.label, self.render_kw, self.optgroup))

@classmethod
def from_enum(cls, enum_cls, *, label=None):
"""Build a list of choices from an :class:`enum.Enum` class.

The HTML value of each option is the item ``name``. The label
defaults to ``str(item)`` when the Enum defines its own
``__str__``, otherwise to ``item.name``. Pass ``label=`` (a
callable taking an item) to override.
"""
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, optgroup=None):
"""Coerce a value passed by the user via ``choices=...`` into a
Expand Down Expand Up @@ -141,6 +115,60 @@ def from_input(cls, input, optgroup=None):
return cls(*input, optgroup=optgroup)


def _enum_options(enum_cls, by, label, factory):
if by not in ("value", "name"):
raise ValueError(f"by must be 'value' or 'name', got {by!r}")
if label is None:
label = str if "__str__" in enum_cls.__dict__ else lambda m: m.name
return [factory(value=getattr(m, by), label=label(m)) for m in enum_cls]


def enum_choices(enum_cls, *, by="value", label=None):
"""Build a list of :class:`SelectChoice` from an :class:`enum.Enum` class.

``by`` selects which member attribute becomes the HTML ``value=``:
``"value"`` (the default) emits ``member.value``, ``"name"`` emits
``member.name``. Use ``"name"`` for enums whose values are not good
transport identifiers (non-string, non-unique or non-serialisable).

The label defaults to ``str(member)`` when the Enum defines its own
``__str__``, otherwise to ``member.name``. Pass ``label=`` (a callable
taking a member) to override.

Pair with :func:`enum_coerce` using the same ``by`` to round-trip the
submitted string back into a member.
"""
return _enum_options(enum_cls, by, label, SelectChoice)


def enum_coerce(enum_cls, *, by="value"):
"""Return a ``coerce`` callable mapping a submitted string back to an
:class:`enum.Enum` member.

``by`` must match the one passed to :func:`enum_choices`: ``"value"``
(the default) resolves ``str(member.value)`` — so ``IntEnum`` values
round-trip too — and ``"name"`` resolves ``member.name``. Already-coerced
members pass through unchanged.
"""
if by not in ("value", "name"):
raise ValueError(f"by must be 'value' or 'name', got {by!r}")

def coerce(v):
if isinstance(v, enum_cls):
return v

try:
if by == "value":
by_value = {str(m.value): m for m in enum_cls}
return by_value[str(v)]
else:
return enum_cls[v]
except KeyError as e:
raise ValueError(str(e)) from e

return coerce


def _normalize_iter_choice(choice):
"""Coerce a value yielded by :meth:`SelectFieldBase.iter_choices` or
:meth:`SelectFieldBase.iter_groups` into a :class:`Choice`.
Expand Down Expand Up @@ -334,8 +362,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
Expand Down
Loading
Loading