Skip to content
Merged
30 changes: 30 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
.. currentmodule:: wtforms

Unreleased
----------

- :class:`~fields.SelectField` ``choices`` accepts a shorthand dict
syntax: ``{value: label}`` for flat options and
``{label: {value: label}}`` for optgroups. :issue:`886`
- Split choice types by role: :class:`~fields.SelectChoice` and
:class:`~fields.DataListChoice` to declare options via ``choices=``,
:class:`~fields.Choice` for what ``iter_choices`` / ``iter_groups``
yield. :issue:`922`
- ``choices`` callables on :class:`~fields.SelectField` and
:class:`~datalist.DataList` may accept ``(form, field)``. Resolved
once per processing cycle. :issue:`922`
- Add :meth:`fields.Field.post_process` and
:meth:`form.BaseForm.post_process` hooks, propagated through
:class:`~fields.FormField` and :class:`~fields.FieldList` for
cross-field finalization. :issue:`922`
- Add ``Field._form`` and ``BaseForm._parent_form`` so fields and nested
forms can reach their enclosing form. Fix :class:`~fields.FieldList`
entries and :class:`~fields.SelectField` option subfields which
previously got ``form=None``. :issue:`922`
- Restore 3.2 compatibility for :class:`~fields.SelectField` subclasses:
tuple yields from :meth:`~fields.SelectFieldBase.iter_choices` /
:meth:`~fields.SelectFieldBase.iter_groups`,
3-positional :meth:`widgets.Select.render_option` overrides,
:meth:`~fields.SelectFieldBase.has_groups` /
:meth:`~fields.SelectFieldBase.iter_groups`, and ``self.choices``
keeping the user-supplied shape. Each emits a ``DeprecationWarning``;
will be removed in WTForms 4.0. :issue:`922`

Version 3.3.0b1
---------------

Expand Down
50 changes: 32 additions & 18 deletions docs/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,10 @@ refer to a single input from the form.
Choice Fields
-------------

.. autoclass:: Choice

.. autoclass:: SelectChoice

.. autoclass:: Choice

.. autoclass:: RadioField(default field arguments, choices=None, coerce=str)

.. code-block:: jinja
Expand All @@ -310,23 +310,23 @@ Choice Fields

Select fields take a ``choices`` parameter which is either:

* a list of :class:`Choice`.
* a list of :class:`SelectChoice`.
It can also be a list of only values, in which case the value is used
as the label. The :class:`Choice` ``render_kw`` mapping is rendered as
as the label. The :class:`SelectChoice` ``render_kw`` mapping is rendered as
HTML :mdn-tag:`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.
* a function taking no argument, and returning a list of :class:`Choice`.
* a function taking no argument, and returning a list of :class:`SelectChoice`.


**Select fields with static choice values**::

class PastebinEntry(Form):
language = SelectField('Programming Language', choices=[
Choice('cpp', 'C++'),
Choice('py', 'Python'),
Choice('text', 'Plain Text'),
SelectChoice('cpp', 'C++'),
SelectChoice('py', 'Python'),
SelectChoice('text', 'Plain Text'),
])

Note that the `choices` keyword is evaluated each time the form is
Expand All @@ -352,7 +352,7 @@ Choice Fields
**Select fields with dynamic choice values**::

def available_groups():
return [Choice(g.id, g.name) for g in Group.query.order_by('name')]
return [SelectChoice(g.id, g.name) for g in Group.query.order_by('name')]

class UserDetails(Form):
group_id = SelectField('Group', coerce=int, choices=available_groups)
Expand All @@ -368,6 +368,18 @@ Choice Fields
keyword arg to :class:`~wtforms.fields.SelectField` says that we use
:func:`int()` to coerce form data. The default coerce is :func:`str()`.

The callable may optionally accept ``(form, field)`` as positional
arguments. When this signature is used, the callable is invoked after the
whole form has been processed, so it can read ``field.data`` and the data
of any other field on the form::

def available_groups(form, field):
return [SelectChoice(g.id, g.name) for g in form.tenant.data.groups]

class UserDetails(Form):
tenant = QuerySelectField('Tenant', ...)
group_id = SelectField('Group', coerce=int, choices=available_groups)

**Coerce function example**::

def coerce_none(value):
Expand All @@ -377,9 +389,9 @@ Choice Fields

class NonePossible(Form):
my_select_field = SelectField('Select an option', choices=[
Choice('1', 'Option 1'),
Choice('2', 'Option 2'),
Choice('None', 'No option'),
SelectChoice('1', 'Option 1'),
SelectChoice('2', 'Option 2'),
SelectChoice('None', 'No option'),
], coerce=coerce_none)

Note when the option None is selected a 'None' str will be passed. By using a coerce
Expand All @@ -395,9 +407,9 @@ Choice Fields
BLUE = 3

class PaintForm(Form):
color = SelectField(choices=Choice.from_enum(Color), coerce=Color)
color = SelectField(choices=SelectChoice.from_enum(Color), coerce=Color)

:meth:`Choice.from_enum` builds the option list from the Enum items;
: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
Expand All @@ -408,8 +420,8 @@ Choice Fields
``item.name``. To customise, pass a ``label`` callable taking an Enum
item and returning the label string::

Choice.from_enum(Color, label=lambda item: item.name.title())
# → [Choice('RED', 'Red'), Choice('GREEN', 'Green'), Choice('BLUE', 'Blue')]
SelectChoice.from_enum(Color, label=lambda item: item.name.title())
# → [SelectChoice('RED', 'Red'), SelectChoice('GREEN', 'Green'), SelectChoice('BLUE', 'Blue')]

**Skipping choice validation**::

Expand Down Expand Up @@ -554,7 +566,7 @@ complex data structures such as lists and nested objects can be represented.
FormField::

class IMForm(Form):
protocol = SelectField(choices=[Choice('aim', 'AIM'), Choice('msn', 'MSN')])
protocol = SelectField(choices=[SelectChoice('aim', 'AIM'), SelectChoice('msn', 'MSN')])
username = StringField()

class ContactForm(Form):
Expand All @@ -568,6 +580,8 @@ Data Lists

.. currentmodule:: wtforms

.. autoclass:: DataListChoice

.. class:: DataList(choices=None, *, render_kw=None, widget=None)

A :mdn-tag:`datalist` of suggestions. Unlike
Expand All @@ -576,7 +590,7 @@ Data Lists
suggestions but the user may type any value. Combine with
:class:`~wtforms.validators.AnyOf` if you need a closed set.

``choices`` is either a list of :class:`~wtforms.fields.Choice`
``choices`` is either a list of :class:`~wtforms.DataListChoice`
(or plain strings, in which case the string is used as both value
and label), or a callable invoked at render time. The callable
may take no argument (``fn()``) for a static list, or ``(field)``
Expand Down
5 changes: 2 additions & 3 deletions docs/widgets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,8 @@ Inside the widget, the following ``field`` attributes are commonly used:
- ``field._value()`` — the string representation of the current value, used
for the ``value=`` attribute of inputs.
- ``field.iter_choices()`` — for :class:`~wtforms.fields.SelectField` and
similar; yields :class:`~wtforms.fields.SelectChoice` objects with
``value``, ``label``, ``render_kw`` and a ``_selected`` flag reflecting the
current selection.
similar; yields :class:`~wtforms.fields.Choice` objects with
``value``, ``label``, ``selected`` and ``render_kw``.

To assemble the HTML, use :func:`~wtforms.widgets.html_params` for attribute
strings, :class:`markupsafe.Markup` to mark the result as safe, and
Expand Down
2 changes: 2 additions & 0 deletions src/wtforms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from wtforms import validators
from wtforms import widgets
from wtforms.datalist import DataList
from wtforms.datalist import DataListChoice
from wtforms.fields.choices import Choice
from wtforms.fields.choices import RadioField
from wtforms.fields.choices import SelectChoice
Expand Down Expand Up @@ -84,4 +85,5 @@
"ColorField",
"Choice",
"SelectChoice",
"DataListChoice",
]
15 changes: 15 additions & 0 deletions src/wtforms/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Compatibility helpers for different Python versions."""

import inspect
import sys

if sys.version_info >= (3, 14):
from annotationlib import Format

def get_signature(callable):
return inspect.signature(callable, annotation_format=Format.FORWARDREF)

else:

def get_signature(callable):
return inspect.signature(callable)
100 changes: 85 additions & 15 deletions src/wtforms/datalist.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,76 @@
import inspect
import warnings
from dataclasses import dataclass
from dataclasses import field

from wtforms import widgets
from wtforms._compat import get_signature
from wtforms.fields.choices import Choice

__all__ = ("DataList",)
__all__ = ("DataList", "DataListChoice")


@dataclass
class DataListChoice:
"""
An option declared via :class:`~wtforms.DataList`'s ``choices=``
parameter.

:param value:
The value rendered as the ``<option>``'s ``value`` attribute.
:param label:
The label of the option. Defaults to ``value`` when omitted.
:param render_kw:
A dict containing HTML attributes that will be rendered
with the option. Defaults to an empty dict when omitted.
"""

value: str
label: str | None = None
render_kw: dict = field(default_factory=dict)

def __post_init__(self):
if self.label is None:
self.label = self.value

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`."""
if isinstance(input, DataListChoice):
return input

if isinstance(input, Choice):
warnings.warn(
"Passing Choice to a DataList is deprecated; Choice is the "
"output type returned by iter_choices(). Use DataListChoice "
"instead. Support for Choice as input will be removed in "
"WTForms 4.0.",
DeprecationWarning,
stacklevel=4,
)
return cls(
value=input.value,
label=input.label,
render_kw=input.render_kw,
)

if isinstance(input, str):
return cls(value=input)

if isinstance(input, tuple):
return cls(*input)


class DataList:
Expand All @@ -18,6 +85,7 @@ class DataList:

def __init__(self, choices=None, *, render_kw=None, widget=None):
self._raw_choices = choices
self._choices = None if callable(choices) else choices
self.render_kw = render_kw or {}
if widget is not None:
self.widget = widget
Expand All @@ -26,27 +94,29 @@ def __init__(self, choices=None, *, render_kw=None, widget=None):
def _clone(self, id):
clone = DataList.__new__(DataList)
clone._raw_choices = self._raw_choices
clone._choices = None if callable(self._raw_choices) else self._raw_choices
clone.render_kw = self.render_kw
clone.widget = self.widget
clone.id = id
return clone

def iter_choices(self, field=None):
def _resolve(self, field):
raw = self._raw_choices
if callable(raw):
n = len(inspect.signature(raw).parameters)
raw = raw(field) if n >= 1 else raw()
if not callable(raw):
return
try:
sig = get_signature(raw)
sig.bind(field._form, field)
except TypeError:
self._choices = raw()
return
self._choices = raw(field._form, field)

def iter_choices(self, field=None):
raw = self._choices
if raw is None:
return []
choices = [
item if isinstance(item, Choice) else Choice(value=item) for item in raw
]
value = field.data if field is not None else None
if value is not None:
for choice in choices:
if choice.value == value:
choice._selected = True
return choices
return [DataListChoice.from_input(item) for item in raw]

def __call__(self, field=None, **kwargs):
return self.widget(self, field=field, **kwargs)
Loading
Loading