diff --git a/CHANGES.rst b/CHANGES.rst
index ca909ba8..ae68988e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -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
---------------
diff --git a/docs/fields.rst b/docs/fields.rst
index 3e0b5691..355bc753 100644
--- a/docs/fields.rst
+++ b/docs/fields.rst
@@ -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
@@ -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
@@ -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)
@@ -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):
@@ -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
@@ -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
@@ -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**::
@@ -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):
@@ -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
@@ -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)``
diff --git a/docs/widgets.rst b/docs/widgets.rst
index 1bb48895..0e97912a 100644
--- a/docs/widgets.rst
+++ b/docs/widgets.rst
@@ -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
diff --git a/src/wtforms/__init__.py b/src/wtforms/__init__.py
index dfc65702..11ceed46 100644
--- a/src/wtforms/__init__.py
+++ b/src/wtforms/__init__.py
@@ -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
@@ -84,4 +85,5 @@
"ColorField",
"Choice",
"SelectChoice",
+ "DataListChoice",
]
diff --git a/src/wtforms/_compat.py b/src/wtforms/_compat.py
new file mode 100644
index 00000000..fce90619
--- /dev/null
+++ b/src/wtforms/_compat.py
@@ -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)
diff --git a/src/wtforms/datalist.py b/src/wtforms/datalist.py
index b29f89da..d1b41294 100644
--- a/src/wtforms/datalist.py
+++ b/src/wtforms/datalist.py
@@ -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 ````'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:
@@ -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
@@ -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)
diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py
index 155069e1..e911bab9 100644
--- a/src/wtforms/fields/choices.py
+++ b/src/wtforms/fields/choices.py
@@ -1,9 +1,14 @@
import warnings
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
from wtforms import widgets
+from wtforms._compat import get_signature
from wtforms.fields.core import Field
from wtforms.validators import ValidationError
@@ -28,24 +33,62 @@ def coerce(v):
return coerce
-@dataclass
-class Choice:
+class Choice(NamedTuple):
"""
- A dataclass that represents an available choice for choice fields.
+ A rendered option yielded by
+ :meth:`SelectFieldBase.iter_choices` and
+ :meth:`SelectFieldBase.iter_groups`.
+
+ ``selected`` is computed against the field's current data. To
+ declare options on a :class:`SelectField`, use
+ :class:`SelectChoice` instead.
:param value:
The value that will be sent in the request.
:param label:
The label of the option.
+ :param selected:
+ Whether the option is currently selected. Set by ``iter_choices``;
+ you rarely set this yourself.
:param render_kw:
A dict containing HTML attributes that will be rendered
with the option.
"""
value: str
- label: str | None = None
- render_kw: dict | None = None
- _selected: bool = field(default=False, kw_only=True)
+ label: str
+ selected: bool
+ render_kw: dict
+
+
+@dataclass
+class SelectChoice:
+ """
+ An option declared via :class:`SelectField` and
+ :class:`SelectMultipleField`'s ``choices=`` parameter.
+
+ :param value:
+ The value that will be sent in the request.
+ :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.
+ :param optgroup:
+ The `` `` HTML tag in which the option will be rendered.
+ """
+
+ value: str
+ label: str = None # type: ignore[assignment]
+ render_kw: dict = field(default_factory=dict)
+ optgroup: str | None = None
+
+ 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, self.optgroup))
@classmethod
def from_enum(cls, enum_cls, *, label=None):
@@ -60,48 +103,75 @@ def from_enum(cls, enum_cls, *, label=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]
-
-@dataclass
-class SelectChoice(Choice):
- """
- A :class:`Choice` augmented with an ```` hint for
- :class:`SelectField`.
-
- :param optgroup:
- The ```` HTML tag in which the option will be rendered.
- """
-
- optgroup: str | None = None
-
@classmethod
def from_input(cls, input, optgroup=None):
+ """Coerce a value passed by the user via ``choices=...`` into a
+ :class:`SelectChoice`.
+ """
if isinstance(input, SelectChoice):
if optgroup:
- input.optgroup = optgroup
+ return replace(input, optgroup=optgroup)
return input
if isinstance(input, Choice):
+ warnings.warn(
+ "Passing Choice to a SelectField is deprecated; Choice is the "
+ "output type returned by iter_choices(). Use SelectChoice "
+ "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,
optgroup=optgroup,
- _selected=input._selected,
)
if isinstance(input, str):
return cls(value=input, optgroup=optgroup)
if isinstance(input, tuple):
- warnings.warn(
- "Passing SelectField choices as tuples is deprecated and will be "
- "removed in wtforms 3.4. Please use Choice or SelectChoice instead.",
- DeprecationWarning,
- stacklevel=2,
- )
+ if len(input) not in (2, 3):
+ raise ValueError(
+ f"SelectField choice tuple must have 2 or 3 elements, "
+ f"got {len(input)}"
+ )
return cls(*input, optgroup=optgroup)
+def _normalize_iter_choice(choice):
+ """Coerce a value yielded by :meth:`SelectFieldBase.iter_choices` or
+ :meth:`SelectFieldBase.iter_groups` into a :class:`Choice`.
+ """
+ if isinstance(choice, Choice):
+ return choice
+ if isinstance(choice, tuple):
+ warnings.warn(
+ "Yielding raw tuples from iter_choices() or iter_groups() is "
+ "deprecated; yield Choice instances instead. Will be removed "
+ "in WTForms 4.0.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ if len(choice) == 4:
+ value, label, selected, render_kw = choice
+ elif len(choice) == 3:
+ value, label, selected = choice
+ render_kw = {}
+ else:
+ raise TypeError(
+ f"iter_choices()/iter_groups() yielded a tuple of unsupported "
+ f"length: {len(choice)}"
+ )
+ return Choice(value=value, label=label, selected=selected, render_kw=render_kw)
+ raise TypeError(
+ f"iter_choices()/iter_groups() yielded an unsupported type: "
+ f"{type(choice).__name__}"
+ )
+
+
class SelectFieldBase(Field):
option_widget = widgets.Option()
@@ -119,48 +189,72 @@ def __init__(self, label=None, validators=None, option_widget=None, **kwargs):
self.option_widget = option_widget
def iter_choices(self):
+ """Provide data for choice widget rendering.
+
+ Should yield :class:`Choice` instances.
"""
- Provides data for choice widget rendering. Must return a sequence or
- iterable of SelectChoice.
- """
raise NotImplementedError()
+ def _iter_choices_normalized(self):
+ """Wrap :meth:`iter_choices` to always yield :class:`Choice`."""
+ for choice in self.iter_choices():
+ yield _normalize_iter_choice(choice)
+
+ def has_groups(self):
+ """Whether the field's choices include any ``optgroup`` hint."""
+ return False
+
+ def iter_groups(self):
+ """Yield ``(group_label, [Choice, ...])`` pairs for grouped rendering."""
+ raise NotImplementedError()
+
+ def _iter_groups_normalized(self):
+ """Wrap :meth:`iter_groups` to always yield ``(name, [Choice, ...])``."""
+ for name, group in self.iter_groups():
+ yield name, [_normalize_iter_choice(c) for c in group]
+
def __iter__(self):
opts = dict(
widget=self.option_widget,
validators=self.validators,
name=self.name,
render_kw=self.render_kw,
- _form=None,
+ _form=self._form,
_meta=self.meta,
)
- for i, choice in enumerate(self.iter_choices()):
+ for i, choice in enumerate(self._iter_choices_normalized()):
opt = self._Option(
id=f"{self.id}-{i}",
label=choice.label or choice.value,
**opts,
)
opt.choice = choice
- opt.checked = choice._selected
+ opt.checked = choice.selected
opt.process(None, choice.value)
yield opt
- def choices_from_input(self, choices):
+ def _choices_from_input(self, choices):
+ """Parse the user-supplied ``choices`` into a list of :class:`SelectChoice`."""
if callable(choices):
- choices = choices()
+ choices = self._invoke_choices_callback(choices)
if choices is None:
return None
if isinstance(choices, dict):
- warnings.warn(
- "Passing SelectField choices in a dict deprecated and will be removed "
- "in wtforms 3.4. Please pass a list of SelectChoice objects with a "
- "custom optgroup attribute instead.",
- DeprecationWarning,
- stacklevel=2,
- )
-
+ if self._is_shorthand_dict(choices):
+ result = []
+ for key, value in choices.items():
+ if isinstance(value, dict):
+ for inner_value, inner_label in value.items():
+ result.append(
+ SelectChoice(
+ value=inner_value, label=inner_label, optgroup=key
+ )
+ )
+ else:
+ result.append(SelectChoice(value=key, label=value))
+ return result
return [
SelectChoice.from_input(input, optgroup)
for optgroup, inputs in choices.items()
@@ -169,6 +263,57 @@ def choices_from_input(self, choices):
return [SelectChoice.from_input(input) for input in choices]
+ @staticmethod
+ def _is_shorthand_dict(choices):
+ """``True`` if ``choices`` matches the shorthand dict syntax.
+
+ Shorthand is ``dict[str, str | dict[str, str]]``.
+ """
+ return all(isinstance(v, (str, dict)) for v in choices.values())
+
+ @staticmethod
+ def _warn_legacy_choices(choices):
+ """Emit a one-shot ``DeprecationWarning`` for legacy ``choices`` shapes.
+
+ Legacy shapes are raw tuples or ``dict``.
+ """
+ if isinstance(choices, dict):
+ if SelectFieldBase._is_shorthand_dict(choices):
+ return
+ warnings.warn(
+ "Passing SelectField choices in a dict is deprecated and will be "
+ "removed in wtforms 3.4. Please pass a list of SelectChoice "
+ "objects with a custom optgroup attribute instead.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ items = (i for v in choices.values() for i in v)
+ else:
+ items = choices
+ for item in items:
+ if isinstance(item, (SelectChoice, Choice)):
+ continue
+ if isinstance(item, tuple):
+ warnings.warn(
+ "Passing SelectField choices as tuples is deprecated and "
+ "will be removed in wtforms 3.4. Please use SelectChoice "
+ "instead.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ return
+
+ def _invoke_choices_callback(self, cb):
+ try:
+ sig = get_signature(cb)
+ except (ValueError, TypeError):
+ return cb()
+ try:
+ sig.bind(self._form, self)
+ except TypeError:
+ return cb()
+ return cb(self._form, self)
+
class _Option(Field):
def _value(self):
return str(self.data)
@@ -192,7 +337,18 @@ def __init__(
if isinstance(coerce, type) and issubclass(coerce, Enum):
coerce = _enum_coerce(coerce)
self.coerce = coerce
- self.choices = self.choices_from_input(choices)
+ if callable(choices):
+ self._choices_callable = choices
+ self.choices = None
+ else:
+ self._choices_callable = None
+ if choices is None:
+ self.choices = None
+ else:
+ self._warn_legacy_choices(choices)
+ self.choices = (
+ dict(choices) if isinstance(choices, dict) else list(choices)
+ )
self.validate_choice = validate_choice
self.invalid_value_message = invalid_value_message or self.gettext(
"Invalid Choice: could not coerce."
@@ -202,10 +358,41 @@ def __init__(
)
def iter_choices(self):
- choices = self.choices_from_input(self.choices) or []
- for choice in choices:
- choice._selected = self.coerce(choice.value) == self.data
- return choices
+ choices = self._choices_from_input(self.choices) or []
+ return [
+ Choice(
+ value=c.value,
+ label=c.label,
+ selected=self.coerce(c.value) == self.data,
+ render_kw=c.render_kw,
+ )
+ for c in choices
+ ]
+
+ def has_groups(self):
+ choices = self._choices_from_input(self.choices) or []
+ return any(c.optgroup is not None for c in choices)
+
+ def iter_groups(self):
+ choices = self._choices_from_input(self.choices) or []
+ for optgroup, group in groupby(choices, key=attrgetter("optgroup")):
+ yield (
+ optgroup,
+ [
+ Choice(
+ value=c.value,
+ label=c.label,
+ selected=self.coerce(c.value) == self.data,
+ render_kw=c.render_kw,
+ )
+ for c in group
+ ],
+ )
+
+ def post_process(self):
+ super().post_process()
+ if self._choices_callable is not None:
+ self.choices = self._invoke_choices_callback(self._choices_callable)
def process_data(self, value):
try:
@@ -233,7 +420,7 @@ def pre_validate(self, form):
if self.choices is None:
raise TypeError(self.gettext("Choices cannot be None."))
- if not any(choice._selected for choice in self.iter_choices()):
+ if not any(choice.selected for choice in self._iter_choices_normalized()):
raise ValidationError(self.invalid_choice_message)
@@ -276,11 +463,34 @@ def __init__(
self.invalid_choice_message = invalid_choice_message
def iter_choices(self):
- choices = self.choices_from_input(self.choices) or []
- if self.data:
- for choice in choices:
- choice._selected = self.coerce(choice.value) in self.data
- return choices
+ choices = self._choices_from_input(self.choices) or []
+ data = self.data or ()
+ return [
+ Choice(
+ value=c.value,
+ label=c.label,
+ selected=self.coerce(c.value) in data,
+ render_kw=c.render_kw,
+ )
+ for c in choices
+ ]
+
+ def iter_groups(self):
+ choices = self._choices_from_input(self.choices) or []
+ data = self.data or ()
+ for optgroup, group in groupby(choices, key=attrgetter("optgroup")):
+ yield (
+ optgroup,
+ [
+ Choice(
+ value=c.value,
+ label=c.label,
+ selected=self.coerce(c.value) in data,
+ render_kw=c.render_kw,
+ )
+ for c in group
+ ],
+ )
def process_data(self, value):
try:
@@ -304,7 +514,9 @@ def pre_validate(self, form):
if self.choices is None:
raise TypeError(self.gettext("Choices cannot be None."))
- acceptable = {self.coerce(choice.value) for choice in self.iter_choices()}
+ acceptable = {
+ self.coerce(choice.value) for choice in self._iter_choices_normalized()
+ }
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
diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py
index 9b75ae44..c0ca9888 100644
--- a/src/wtforms/fields/core.py
+++ b/src/wtforms/fields/core.py
@@ -110,6 +110,8 @@ def __init__(
else:
raise TypeError("Must provide one of _form or _meta")
+ self._form = _form
+
self.default = default
self.description = description
self.invalid_value_message = invalid_value_message
@@ -348,6 +350,17 @@ def process(self, formdata, data=unset_value, extra_filters=None):
except ValueError as e:
self.process_errors.append(e.args[0])
+ def post_process(self):
+ """Hook called after every field in the enclosing form has been processed.
+
+ Override this when a field needs to read other fields' processed data,
+ for example to resolve dynamic choices that depend on the form state.
+ The base implementation resolves any inline :class:`~wtforms.DataList`
+ attached to the field.
+ """
+ if self._datalist is not None and not isinstance(self._datalist, str):
+ self._datalist._resolve(self)
+
def process_data(self, value):
"""
Process the Python data applied to this field and store the result.
diff --git a/src/wtforms/fields/form.py b/src/wtforms/fields/form.py
index ac516b18..61992e60 100644
--- a/src/wtforms/fields/form.py
+++ b/src/wtforms/fields/form.py
@@ -54,9 +54,24 @@ def process(self, formdata, data=unset_value, extra_filters=None):
prefix = self.name + self.separator
if isinstance(data, dict):
- self.form = self.form_class(formdata=formdata, prefix=prefix, **data)
+ user_meta = data.get("meta") or {}
+ data_kwargs = {k: v for k, v in data.items() if k != "meta"}
+ self.form = self.form_class(
+ formdata=formdata,
+ prefix=prefix,
+ meta={**user_meta, "_parent_form": self._form},
+ **data_kwargs,
+ )
else:
- self.form = self.form_class(formdata=formdata, obj=data, prefix=prefix)
+ self.form = self.form_class(
+ formdata=formdata,
+ obj=data,
+ prefix=prefix,
+ meta={"_parent_form": self._form},
+ )
+
+ def post_process(self):
+ self.form.post_process()
def validate(self, form, extra_validators=()):
if extra_validators:
diff --git a/src/wtforms/fields/list.py b/src/wtforms/fields/list.py
index 54f2f689..45888bae 100644
--- a/src/wtforms/fields/list.py
+++ b/src/wtforms/fields/list.py
@@ -114,6 +114,10 @@ def _extract_indices(self, prefix, formdata):
if k.isdigit():
yield int(k)
+ def post_process(self):
+ for entry in self.entries:
+ entry.post_process()
+
def validate(self, form, extra_validators=()):
"""
Validate this FieldList.
@@ -175,7 +179,7 @@ def _add_entry(self, formdata=None, data=unset_value, index=None):
_meta=self.meta,
translations=self._translations,
)
- field = self.meta.bind_field(None, self.unbound_field, options)
+ field = self.meta.bind_field(self._form, self.unbound_field, options)
field.index = index
field.process(formdata, data)
self.entries.append(field)
diff --git a/src/wtforms/form.py b/src/wtforms/form.py
index 76762785..bf5551e0 100644
--- a/src/wtforms/form.py
+++ b/src/wtforms/form.py
@@ -15,6 +15,8 @@ class BaseForm:
validation, and data and error proxying.
"""
+ _parent_form = None
+
def __init__(self, fields, prefix="", meta=_default_meta):
"""
:param fields:
@@ -30,6 +32,7 @@ def __init__(self, fields, prefix="", meta=_default_meta):
prefix += "-"
self.meta = meta
+ self._parent_form = getattr(meta, "_parent_form", None)
self._form_error_key = ""
self._prefix = prefix
self._fields = OrderedDict()
@@ -127,6 +130,26 @@ def process(self, formdata=None, obj=None, data=None, extra_filters=None, **kwar
field.process(formdata, data, extra_filters=field_extra_filters)
+ if self._parent_form is None:
+ self.post_process()
+
+ def post_process(self):
+ """Hook called at the end of :meth:`process` on the root form.
+
+ Runs the :meth:`~fields.Field.post_process` hook on every field, after
+ all fields have been processed. Override this on a form subclass to add
+ cross-field finalization logic; call ``super().post_process()`` to keep
+ per-field hooks running.
+
+ ``post_process`` is only triggered automatically on the root form. Forms
+ nested inside a :class:`~fields.FormField` (or via :class:`~fields.FieldList`
+ entries) propagate the call through :meth:`fields.FormField.post_process`
+ and :meth:`fields.FieldList.post_process`, so every nested field's
+ ``post_process`` runs exactly once per processing cycle.
+ """
+ for field in self._fields.values():
+ field.post_process()
+
def validate(self, extra_validators=None):
"""
Validates the form by calling `validate` on each field.
@@ -206,6 +229,7 @@ def __call__(cls, *args, **kwargs):
if "Meta" in mro_class.__dict__:
bases.append(mro_class.Meta)
cls._wtforms_meta = type("Meta", tuple(bases), {})
+
return type.__call__(cls, *args, **kwargs)
def __setattr__(cls, name, value):
diff --git a/src/wtforms/widgets/core.py b/src/wtforms/widgets/core.py
index a4e179e7..9b95bd0e 100644
--- a/src/wtforms/widgets/core.py
+++ b/src/wtforms/widgets/core.py
@@ -1,6 +1,10 @@
+import warnings
+
from markupsafe import escape
from markupsafe import Markup
+from wtforms._compat import get_signature
+
__all__ = (
"Button",
"CheckboxInput",
@@ -172,7 +176,7 @@ def __call__(self, datalist, field=None, **kwargs):
options = []
for choice in datalist.iter_choices(field):
option_attrs = {"value": choice.value}
- if choice.label is not None:
+ if choice.label is not None and choice.label != choice.value:
option_attrs["label"] = choice.label
if choice.render_kw:
option_attrs = {**choice.render_kw, **option_attrs}
@@ -420,15 +424,19 @@ def __call__(self, field, **kwargs):
kwargs[k] = getattr(flags, k)
select_params = html_params(name=field.name, **kwargs)
html = [f""]
- choice_groups = self.sort_by_optgroup(field.iter_choices())
- for optgroup, choices in choice_groups.items():
- if optgroup:
- optgroup_params = html_params(label=optgroup)
- html.append(f"")
- for choice in choices:
- html.append(self.render_option(choice))
- if optgroup:
- html.append(" ")
+ render = type(self)._dispatch_render_option()
+ if field.has_groups():
+ for optgroup, choices in field._iter_groups_normalized():
+ if optgroup is not None:
+ optgroup_params = html_params(label=optgroup)
+ html.append(f"")
+ for choice in choices:
+ html.append(render(choice))
+ if optgroup is not None:
+ html.append(" ")
+ else:
+ for choice in field._iter_choices_normalized():
+ html.append(render(choice))
html.append(" ")
return Markup("".join(html))
@@ -438,17 +446,48 @@ def render_option(cls, choice, **kwargs):
if isinstance(value, bool):
value = str(value)
options = {"value": value, **(choice.render_kw or {}), **kwargs}
- if choice._selected:
+ if choice.selected:
options["selected"] = True
label = escape(choice.label or choice.value)
return Markup(f"{label} ")
@classmethod
- def sort_by_optgroup(cls, choices):
- optgroups = {}
- for choice in choices:
- optgroups.setdefault(choice.optgroup, []).append(choice)
- return optgroups
+ def _dispatch_render_option(cls):
+ """Return a callable ``(choice) -> str`` that invokes :meth:`render_option`.
+
+ Picks the right signature for the override.
+ """
+ ro = cls.render_option
+ try:
+ sig = get_signature(ro)
+ except (ValueError, TypeError):
+ return ro
+ positional_required = [
+ p
+ for p in sig.parameters.values()
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
+ and p.default is p.empty
+ ]
+ if len(positional_required) <= 1:
+ return ro
+ warnings.warn(
+ f"{cls.__module__}.{cls.__qualname__}.render_option uses the "
+ "pre-3.3 signature (value, label, selected, **kwargs). Override "
+ "render_option(cls, choice, **kwargs) instead — choice is a "
+ "SelectChoice. The legacy signature will be removed in WTForms "
+ "4.0.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
+ accepts_kwargs = any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values())
+
+ def adapter(choice):
+ args = (choice.value, choice.label or choice.value, choice.selected)
+ if accepts_kwargs:
+ return ro(*args, **(choice.render_kw or {}))
+ return ro(*args)
+
+ return adapter
class Option:
diff --git a/tests/conftest.py b/tests/conftest.py
index a7be695b..657f5126 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
import pytest
-from wtforms.fields.choices import SelectChoice
+from wtforms.fields.choices import Choice
from wtforms.i18n import DummyTranslations
@@ -28,8 +28,8 @@ def basic_widget_dummy_field(dummy_field_class):
def select_dummy_field(dummy_field_class):
return dummy_field_class(
[
- SelectChoice("foo", "lfoo", _selected=True),
- SelectChoice("bar", "lbar", _selected=False),
+ Choice("foo", "lfoo", selected=True, render_kw={}),
+ Choice("bar", "lbar", selected=False, render_kw={}),
]
)
@@ -56,6 +56,7 @@ def __init__(
label=None,
id=None,
field_type="StringField",
+ groups=None,
):
self.data = data
self.name = name
@@ -64,6 +65,7 @@ def __init__(
self.label = label
self.id = id if id else ""
self.type = field_type
+ self._groups = groups
def __call__(self, **other):
return self.data
@@ -80,11 +82,17 @@ def _value(self):
def iter_choices(self):
return iter(self.data)
- def iter_groups(self):
- return []
+ def _iter_choices_normalized(self):
+ return iter(self.data)
def has_groups(self):
- return False
+ return self._groups is not None
+
+ def iter_groups(self):
+ return iter(self._groups or [])
+
+ def _iter_groups_normalized(self):
+ return iter(self._groups or [])
def gettext(self, string):
return self._translations.gettext(string)
diff --git a/tests/fields/test_form.py b/tests/fields/test_form.py
index 36d1076a..776e24c7 100644
--- a/tests/fields/test_form.py
+++ b/tests/fields/test_form.py
@@ -2,7 +2,9 @@
from tests.common import DummyPostData
from wtforms import validators
+from wtforms.fields import FieldList
from wtforms.fields import FormField
+from wtforms.fields import SelectField
from wtforms.fields import StringField
from wtforms.form import Form
@@ -109,6 +111,102 @@ def validate_a(self, field):
form.validate()
+def test_post_process_propagates_through_form_field():
+ """``post_process`` of a nested form runs before its enclosing ``FormField``."""
+ captured = []
+
+ class Inner(Form):
+ x = StringField()
+
+ def post_process(self):
+ super().post_process()
+ captured.append(("inner", self.x.data))
+
+ class Outer(Form):
+ block = FormField(Inner)
+
+ def post_process(self):
+ super().post_process()
+ captured.append(("outer", self.block.form.x.data))
+
+ Outer(DummyPostData({"block-x": "v"}))
+ assert captured == [("inner", "v"), ("outer", "v")]
+
+
+def test_post_process_runs_once_per_field():
+ """The ``choices`` callable runs exactly once per processing cycle."""
+ counter = {"n": 0}
+
+ def choices(form, field):
+ counter["n"] += 1
+ return ["a", "b"]
+
+ Inner = make_form(item=SelectField(choices=choices))
+ Outer = make_form(block=FormField(Inner))
+
+ form = Outer(DummyPostData({"block-item": "a"}))
+ assert counter["n"] == 1
+ assert form.block.form.item.choices is not None
+
+
+def test_post_process_propagates_through_field_list():
+ """``post_process`` is invoked on every ``FieldList`` entry."""
+ counter = {"n": 0}
+
+ def choices(form, field):
+ counter["n"] += 1
+ return ["a", "b"]
+
+ Inner = make_form(item=SelectField(choices=choices))
+ Outer = make_form(items=FieldList(FormField(Inner), min_entries=2))
+
+ form = Outer()
+ assert counter["n"] == 2
+ for entry in form.items.entries:
+ assert entry.form.item.choices is not None
+
+
+def test_post_process_mutation_propagates_top_down():
+ """A mutation done before super() in the root's post_process is visible
+ to nested fields' post_process."""
+ captured = []
+
+ def choices(form, field):
+ captured.append(form._parent_form.tenant.data)
+ return ["a"]
+
+ class Inner(Form):
+ item = SelectField(choices=choices)
+
+ class Outer(Form):
+ tenant = StringField()
+ block = FormField(Inner)
+
+ def post_process(self):
+ self.tenant.data = (self.tenant.data or "").upper()
+ super().post_process()
+
+ Outer(DummyPostData({"tenant": "acme", "block-item": "a"}))
+ assert captured == ["ACME"]
+
+
+def test_choices_callback_in_subform_can_read_parent():
+ """A ``choices`` callable in a nested form can reach the parent form."""
+ captured = []
+
+ def choices(form, field):
+ captured.append(form._parent_form.tenant.data)
+ return ["a", "b"]
+
+ Inner = make_form(group=SelectField(choices=choices))
+ Outer = make_form(tenant=StringField(), block=FormField(Inner))
+
+ form = Outer(DummyPostData({"tenant": "acme", "block-group": "a"}))
+ list(form.block.form.group)
+
+ assert captured == ["acme"]
+
+
def test_populate_missing_obj(F1):
obj = AttrDict(a=None)
obj2 = AttrDict(a=AttrDict(a="mmm"))
diff --git a/tests/fields/test_radio.py b/tests/fields/test_radio.py
index 0c753f56..a76271a7 100644
--- a/tests/fields/test_radio.py
+++ b/tests/fields/test_radio.py
@@ -1,7 +1,7 @@
from tests.common import DummyPostData
from wtforms import validators
from wtforms.fields import RadioField
-from wtforms.fields.choices import Choice
+from wtforms.fields.choices import SelectChoice
from wtforms.form import Form
@@ -10,10 +10,14 @@ def make_form(name="F", **fields):
class F(Form):
- a = RadioField(choices=[Choice("a", "hello"), Choice("b", "bye")], default="a")
- b = RadioField(choices=[Choice(1, "Item 1"), Choice(2, "Item 2")], coerce=int)
+ a = RadioField(
+ choices=[SelectChoice("a", "hello"), SelectChoice("b", "bye")], default="a"
+ )
+ b = RadioField(
+ choices=[SelectChoice(1, "Item 1"), SelectChoice(2, "Item 2")], coerce=int
+ )
c = RadioField(
- choices=[Choice("a", "Item 1"), Choice("b", "Item 2")],
+ choices=[SelectChoice("a", "Item 1"), SelectChoice("b", "Item 2")],
validators=[validators.InputRequired()],
)
@@ -55,7 +59,7 @@ def test_text_coercion():
# Regression test for text coercion scenarios where the value is a boolean.
F = make_form(
a=RadioField(
- choices=[Choice(True, "yes"), Choice(False, "no")],
+ choices=[SelectChoice(True, "yes"), SelectChoice(False, "no")],
coerce=lambda x: False if x == "False" else bool(x),
)
)
@@ -72,7 +76,7 @@ def test_text_coercion():
def test_callable_choices():
def choices():
- return [Choice("a", "hello"), Choice("b", "bye")]
+ return [SelectChoice("a", "hello"), SelectChoice("b", "bye")]
class F(Form):
a = RadioField(choices=choices, default="a")
@@ -113,7 +117,7 @@ def test_required_validator():
def test_render_kw_preserved():
F = make_form(
a=RadioField(
- choices=[Choice(True, "yes"), Choice(False, "no")],
+ choices=[SelectChoice(True, "yes"), SelectChoice(False, "no")],
render_kw=dict(disabled=True),
)
)
diff --git a/tests/fields/test_select.py b/tests/fields/test_select.py
index 466dec51..7618118c 100644
--- a/tests/fields/test_select.py
+++ b/tests/fields/test_select.py
@@ -4,11 +4,6 @@
import pytest
-if sys.version_info >= (3, 11):
- from enum import StrEnum
-else:
- StrEnum = None
-
from tests.common import DummyPostData
from wtforms import validators
from wtforms import widgets
@@ -18,6 +13,11 @@
from wtforms.fields import SelectField
from wtforms.form import Form
+if sys.version_info >= (3, 11):
+ from enum import StrEnum
+else:
+ StrEnum = None
+
def make_form(name="F", **fields):
return type(str(name), (Form,), fields)
@@ -31,7 +31,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def add_choice(self, choice):
- self.items.choices.append(Choice(choice, choice))
+ self.items.choices.append(SelectChoice(choice, choice))
f1 = F()
f2 = F()
@@ -39,23 +39,23 @@ def add_choice(self, choice):
f1.add_choice("a")
f2.add_choice("b")
- assert f1.items.choices == [Choice("a", "a")]
- assert f2.items.choices == [Choice("b", "b")]
+ assert f1.items.choices == [SelectChoice("a", "a")]
+ assert f2.items.choices == [SelectChoice("b", "b")]
assert f1.items.choices is not f2.items.choices
class F(Form):
a = SelectField(
choices=[
- Choice("a", "hello"),
- Choice("btest", "bye"),
+ SelectChoice("a", "hello"),
+ SelectChoice("btest", "bye"),
],
default="a",
)
b = SelectField(
choices=[
- Choice(1, "Item 1"),
- Choice(2, "Item 2"),
+ SelectChoice(1, "Item 1"),
+ SelectChoice(2, "Item 2"),
],
coerce=int,
option_widget=widgets.TextInput(),
@@ -111,8 +111,19 @@ def test_iterable_options():
)
+def test_option_subfields_carry_parent_form():
+ """Option subfields yielded by ``__iter__`` expose the enclosing form,
+ matching the propagation already done for ``_meta``."""
+ F = make_form(
+ a=SelectField(choices=[SelectChoice("a", "Foo"), SelectChoice("b", "Bar")])
+ )
+ form = F()
+ for opt in form.a:
+ assert opt._form is form
+
+
def test_default_coerce():
- F = make_form(a=SelectField(choices=[Choice("a", "Foo")]))
+ F = make_form(a=SelectField(choices=[SelectChoice("a", "Foo")]))
form = F(DummyPostData(a=[]))
assert not form.validate()
assert form.a.data is None
@@ -121,7 +132,7 @@ def test_default_coerce():
def test_validate_choices():
- F = make_form(a=SelectField(choices=[Choice("a", "Foo")]))
+ F = make_form(a=SelectField(choices=[SelectChoice("a", "Foo")]))
form = F(DummyPostData(a=["b"]))
assert not form.validate()
assert form.a.data == "b"
@@ -141,7 +152,7 @@ def test_validate_choices_when_empty():
def test_invalid_value_message():
F = make_form(
a=SelectField(
- choices=[Choice(1, "Foo")],
+ choices=[SelectChoice(1, "Foo")],
coerce=int,
invalid_value_message="Submitted value could not be parsed.",
)
@@ -154,7 +165,7 @@ def test_invalid_value_message():
def test_invalid_choice_message():
F = make_form(
a=SelectField(
- choices=[Choice("a", "Foo")],
+ choices=[SelectChoice("a", "Foo")],
invalid_choice_message="Pick one of the available options.",
)
)
@@ -171,7 +182,9 @@ def test_validate_choices_when_none():
def test_dont_validate_choices():
- F = make_form(a=SelectField(choices=[Choice("a", "Foo")], validate_choice=False))
+ F = make_form(
+ a=SelectField(choices=[SelectChoice("a", "Foo")], validate_choice=False)
+ )
form = F(DummyPostData(a=["b"]))
assert form.validate()
assert form.a.data == "b"
@@ -218,10 +231,28 @@ def choices():
]
+def test_callable_choices_receives_form_and_field():
+ """A ``(form, field)`` callable receives the bound form and field."""
+ captured = []
+
+ def choices(form, field):
+ captured.append((form, field))
+ return ["foo", "bar"]
+
+ F = make_form(a=SelectField(choices=choices))
+ form = F(a="bar")
+
+ assert list(str(x) for x in form.a) == [
+ 'foo ',
+ 'bar ',
+ ]
+ assert captured == [(form, form.a)]
+
+
def test_requried_flag():
F = make_form(
c=SelectField(
- choices=[Choice("a", "hello"), Choice("b", "bye")],
+ choices=[SelectChoice("a", "hello"), SelectChoice("b", "bye")],
validators=[validators.InputRequired()],
)
)
@@ -237,7 +268,7 @@ def test_requried_flag():
def test_required_validator():
F = make_form(
c=SelectField(
- choices=[Choice("a", "hello"), Choice("b", "bye")],
+ choices=[SelectChoice("a", "hello"), SelectChoice("b", "bye")],
validators=[validators.InputRequired()],
)
)
@@ -272,7 +303,7 @@ def test_optgroup():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "Foo", None, "hello", _selected=True)
+ Choice("a", "Foo", selected=True, render_kw={})
]
@@ -294,15 +325,19 @@ def test_optgroup_shortcut():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice("foo", None, None, "hello", _selected=False),
- SelectChoice("bar", None, None, "hello", _selected=True),
+ Choice("foo", "foo", selected=False, render_kw={}),
+ Choice("bar", "bar", selected=True, render_kw={}),
]
def test_option_render_kw():
F = make_form(
a=SelectField(
- choices=[Choice("a", "Foo", {"title": "foobar", "data-foo": "bar"})]
+ choices=[
+ SelectChoice(
+ "a", "Foo", render_kw={"title": "foobar", "data-foo": "bar"}
+ )
+ ]
)
)
form = F(a="a")
@@ -312,8 +347,11 @@ def test_option_render_kw():
in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, None, _selected=True
+ Choice(
+ "a",
+ "Foo",
+ selected=True,
+ render_kw={"title": "foobar", "data-foo": "bar"},
)
]
@@ -323,7 +361,10 @@ def test_optgroup_option_render_kw():
a=SelectField(
choices=[
SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, "hello"
+ "a",
+ "Foo",
+ render_kw={"title": "foobar", "data-foo": "bar"},
+ optgroup="hello",
)
]
)
@@ -336,12 +377,123 @@ def test_optgroup_option_render_kw():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, "hello", _selected=True
+ Choice(
+ "a",
+ "Foo",
+ selected=True,
+ render_kw={"title": "foobar", "data-foo": "bar"},
)
]
+def test_has_groups_false_without_optgroup():
+ """``has_groups()`` is False when no choice carries an ``optgroup``."""
+ F = make_form(
+ a=SelectField(choices=[SelectChoice("a", "Foo"), SelectChoice("b", "Bar")])
+ )
+ assert F().a.has_groups() is False
+
+
+def test_has_groups_true_with_any_optgroup():
+ """``has_groups()`` is True as soon as at least one choice is grouped."""
+ F = make_form(
+ a=SelectField(
+ choices=[
+ SelectChoice("a", "Foo"),
+ SelectChoice("b", "Bar", optgroup="g1"),
+ ]
+ )
+ )
+ assert F().a.has_groups() is True
+
+
+def test_iter_groups_preserves_order():
+ """``iter_groups()`` preserves choice order: consecutive choices sharing
+ the same ``optgroup`` form one group, non-consecutive ones yield
+ separate ``(optgroup, [...])`` pairs (``itertools.groupby`` semantics).
+ Ungrouped choices are yielded as ``(None, [...])`` at their position."""
+ F = make_form(
+ a=SelectField(
+ choices=[
+ SelectChoice("foo", "lfoo", optgroup="g1"),
+ SelectChoice("baz", "lbaz", optgroup="g2"),
+ SelectChoice("abc", "labc"),
+ SelectChoice("bar", "lbar", optgroup="g1"),
+ SelectChoice("xyz", "lxyz"),
+ ]
+ )
+ )
+ form = F(a="foo")
+ groups = list(form.a.iter_groups())
+
+ assert groups == [
+ ("g1", [Choice("foo", "lfoo", selected=True, render_kw={})]),
+ ("g2", [Choice("baz", "lbaz", selected=False, render_kw={})]),
+ (None, [Choice("abc", "labc", selected=False, render_kw={})]),
+ ("g1", [Choice("bar", "lbar", selected=False, render_kw={})]),
+ (None, [Choice("xyz", "lxyz", selected=False, render_kw={})]),
+ ]
+
+
+def test_iter_groups_items_unpack_as_3_2_tuples():
+ """Items yielded inside each group unpack like the 3.2 4-tuple
+ ``(value, label, selected, render_kw)``."""
+ F = make_form(
+ a=SelectField(
+ choices=[SelectChoice("a", "Foo", optgroup="g")],
+ )
+ )
+ form = F(a="a")
+ for _label, items in form.a.iter_groups():
+ for value, label, selected, render_kw in items:
+ assert (value, label, selected, render_kw) == ("a", "Foo", True, {})
+
+
+def test_dict_str_str_flat_choices():
+ """``{value: label}`` is a flat shorthand for ``[SelectChoice(value, label)]``."""
+ F = make_form(a=SelectField(choices={"py": "Python", "rs": "Rust"}))
+ form = F(a="py")
+ assert 'Python ' in form.a()
+ assert 'Rust ' in form.a()
+ assert form.validate()
+
+
+def test_dict_str_dict_optgroup_choices():
+ """``{label: {value: label}}`` denotes optgroups."""
+ F = make_form(
+ a=SelectField(
+ choices={
+ "Compiled": {"rs": "Rust", "c": "C"},
+ "Interpreted": {"py": "Python"},
+ }
+ )
+ )
+ form = F(a="rs")
+ html = form.a()
+ assert '' in html
+ assert 'Rust ' in html
+ assert '' in html
+ assert 'Python ' in html
+
+
+def test_dict_mixed_flat_and_optgroup_choices():
+ """``str`` values are flat options; ``dict`` values are optgroups —
+ both may appear at the top level."""
+ F = make_form(
+ a=SelectField(
+ choices={
+ "py": "Python",
+ "Functional": {"hs": "Haskell", "ml": "OCaml"},
+ }
+ )
+ )
+ html = F().a()
+ assert 'Python ' in html
+ assert '' in html
+ assert 'Haskell ' in html
+ assert 'OCaml ' in html
+
+
def test_tuple_choices_deprecation():
F = make_form(a=SelectField(choices=[("a", "Foo")]))
with pytest.warns(DeprecationWarning):
@@ -349,12 +501,12 @@ def test_tuple_choices_deprecation():
assert 'Foo ' in form.a()
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "Foo", None, None, _selected=True)
+ Choice("a", "Foo", selected=True, render_kw={})
]
def test_dict_choices_deprecation_with_choice_object():
- F = make_form(a=SelectField(choices={"hello": [Choice("a", "Foo")]}))
+ F = make_form(a=SelectField(choices={"hello": [SelectChoice("a", "Foo")]}))
with pytest.warns(DeprecationWarning):
form = F(a="a")
@@ -364,7 +516,7 @@ def test_dict_choices_deprecation_with_choice_object():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "Foo", None, "hello", _selected=True)
+ Choice("a", "Foo", selected=True, render_kw={})
]
@@ -379,10 +531,87 @@ def test_dict_choices_deprecation_with_tuple():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "Foo", None, "hello", _selected=True)
+ Choice("a", "Foo", selected=True, render_kw={})
]
+def test_self_choices_preserves_user_supplied_shape():
+ """`self.choices` keeps the shape the user passed (raw tuples remain
+ tuples), so subclasses doing ``for value, label in self.choices``
+ per the WTForms 3.2 contract keep working."""
+ F = make_form(a=SelectField(choices=[("a", "Apple"), ("b", "Banana")]))
+ with pytest.warns(DeprecationWarning, match="tuples"):
+ form = F()
+ for value, label in form.a.choices:
+ assert (value, label) in {("a", "Apple"), ("b", "Banana")}
+
+
+def test_legacy_subclass_yielding_tuples_keeps_working():
+ """A subclass overriding ``iter_choices`` to yield raw 4-tuples
+ ``(value, label, selected, render_kw)`` per the WTForms 3.2 contract
+ still renders, validates and iterates — with a ``DeprecationWarning``."""
+
+ class LegacySelect(SelectField):
+ def iter_choices(self):
+ yield ("a", "Apple", self.data == "a", {})
+ yield ("b", "Banana", self.data == "b", {})
+
+ F = make_form(s=LegacySelect(choices=[SelectChoice("a"), SelectChoice("b")]))
+ form = F(s="a")
+
+ with pytest.warns(DeprecationWarning, match="raw tuples"):
+ html = form.s()
+ assert 'Apple ' in html
+ assert 'Banana ' in html
+
+ with pytest.warns(DeprecationWarning, match="raw tuples"):
+ assert form.validate() is True
+
+ with pytest.warns(DeprecationWarning, match="raw tuples"):
+ opts = list(form.s)
+ assert [(opt.checked, str(opt.label.text)) for opt in opts] == [
+ (True, "Apple"),
+ (False, "Banana"),
+ ]
+
+
+def test_legacy_subclass_yielding_3_tuples_keeps_working():
+ """Pre-3.1 contract: 3-tuples ``(value, label, selected)`` also work."""
+
+ class LegacySelect(SelectField):
+ def iter_choices(self):
+ yield ("a", "Apple", False)
+ yield ("b", "Banana", False)
+
+ F = make_form(s=LegacySelect(choices=[SelectChoice("a"), SelectChoice("b")]))
+ form = F()
+ with pytest.warns(DeprecationWarning, match="raw tuples"):
+ html = form.s()
+ assert 'Apple ' in html
+
+
+def test_iter_groups_override_yielding_tuples_keeps_working():
+ """A subclass overriding ``iter_groups`` to yield raw tuples inside the
+ group list still renders — with a ``DeprecationWarning``."""
+
+ class GroupedSelect(SelectField):
+ def has_groups(self):
+ return True
+
+ def iter_groups(self):
+ yield "Fruits", [("a", "Apple", self.data == "a", {})]
+ yield "Veggies", [("c", "Carrot", self.data == "c", {})]
+
+ F = make_form(s=GroupedSelect(choices=[SelectChoice("a"), SelectChoice("c")]))
+ form = F(s="a")
+ with pytest.warns(DeprecationWarning, match="raw tuples"):
+ html = form.s()
+ assert '' in html
+ assert 'Apple ' in html
+ assert '' in html
+ assert 'Carrot ' in html
+
+
class _Plain(Enum):
RED = 1
GREEN = 2
@@ -403,17 +632,17 @@ class _Level(IntEnum):
def test_choice_from_enum_plain():
"""Plain Enum without ``__str__`` falls back to ``member.name`` for the label."""
- assert Choice.from_enum(_Plain) == [
- Choice(value="RED", label="RED"),
- Choice(value="GREEN", label="GREEN"),
+ assert SelectChoice.from_enum(_Plain) == [
+ SelectChoice(value="RED", label="RED"),
+ SelectChoice(value="GREEN", label="GREEN"),
]
def test_choice_from_enum_with_dunder_str():
"""An Enum that overrides ``__str__`` uses ``str(member)`` as label."""
- assert Choice.from_enum(_Pretty) == [
- Choice(value="RED", label="Red"),
- Choice(value="GREEN", label="Green"),
+ assert SelectChoice.from_enum(_Pretty) == [
+ SelectChoice(value="RED", label="Red"),
+ SelectChoice(value="GREEN", label="Green"),
]
@@ -425,31 +654,31 @@ class _Status(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
- assert Choice.from_enum(_Status) == [
- Choice(value="ACTIVE", label="active"),
- Choice(value="INACTIVE", label="inactive"),
+ assert SelectChoice.from_enum(_Status) == [
+ SelectChoice(value="ACTIVE", label="active"),
+ SelectChoice(value="INACTIVE", label="inactive"),
]
def test_choice_from_enum_intenum():
"""IntEnum has no ``__str__`` injected; falls back to ``member.name``."""
- assert Choice.from_enum(_Level) == [
- Choice(value="LOW", label="LOW"),
- Choice(value="HIGH", label="HIGH"),
+ assert SelectChoice.from_enum(_Level) == [
+ SelectChoice(value="LOW", label="LOW"),
+ SelectChoice(value="HIGH", label="HIGH"),
]
def test_choice_from_enum_custom_label():
"""A ``label=`` callable overrides the default."""
- assert Choice.from_enum(_Plain, label=lambda m: m.name.title()) == [
- Choice(value="RED", label="Red"),
- Choice(value="GREEN", label="Green"),
+ assert SelectChoice.from_enum(_Plain, label=lambda m: m.name.title()) == [
+ SelectChoice(value="RED", label="Red"),
+ SelectChoice(value="GREEN", label="Green"),
]
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=Choice.from_enum(_Plain), coerce=_Plain))
+ F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain))
form = F(DummyPostData(a=["RED"]))
assert form.a.data is _Plain.RED
assert form.validate()
@@ -457,22 +686,33 @@ def test_select_field_enum_coerce_round_trip():
def test_select_field_enum_coerce_accepts_member():
"""``coerce=EnumCls`` accepts an already-coerced member without re-lookup."""
- F = make_form(a=SelectField(choices=Choice.from_enum(_Plain), coerce=_Plain))
+ F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_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=Choice.from_enum(_Plain), coerce=_Plain))
+ F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_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_iter_choices_tuple_unpacking():
+ """``iter_choices()`` yields ``Choice`` 4-tuples — unpacking matches the
+ 3.2 yield shape ``(value, label, selected, render_kw)``."""
+ F = make_form(
+ a=SelectField(choices=[SelectChoice("a", "Foo"), SelectChoice("b", "Bar")])
+ )
+ form = F(a="a")
+ unpacked = [(v, lab, sel, rk) for v, lab, sel, rk in form.a.iter_choices()]
+ assert unpacked == [("a", "Foo", True, {}), ("b", "Bar", False, {})]
+
+
def test_select_field_enum_renders_selected():
"""Pre-selecting a member highlights the right option."""
- F = make_form(a=SelectField(choices=Choice.from_enum(_Plain), coerce=_Plain))
+ F = make_form(a=SelectField(choices=SelectChoice.from_enum(_Plain), coerce=_Plain))
form = F(a=_Plain.GREEN)
assert 'GREEN ' in form.a()
diff --git a/tests/fields/test_selectmultiple.py b/tests/fields/test_selectmultiple.py
index d29ea9dc..d0557f01 100644
--- a/tests/fields/test_selectmultiple.py
+++ b/tests/fields/test_selectmultiple.py
@@ -17,12 +17,16 @@ def make_form(name="F", **fields):
class F(Form):
a = SelectMultipleField(
- choices=[Choice("a", "hello"), Choice("b", "bye"), Choice("c", "something")],
+ choices=[
+ SelectChoice("a", "hello"),
+ SelectChoice("b", "bye"),
+ SelectChoice("c", "something"),
+ ],
default=("a",),
)
b = SelectMultipleField(
coerce=int,
- choices=[Choice(1, "A"), Choice(2, "B"), Choice(3, "C")],
+ choices=[SelectChoice(1, "A"), SelectChoice(2, "B"), SelectChoice(3, "C")],
default=("1", "3"),
)
@@ -35,9 +39,9 @@ def test_defaults():
form.a.data = None
assert form.validate()
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "hello", None, None, _selected=False),
- SelectChoice("b", "bye", None, None, _selected=False),
- SelectChoice("c", "something", None, None, _selected=False),
+ Choice("a", "hello", selected=False, render_kw={}),
+ Choice("b", "bye", selected=False, render_kw={}),
+ Choice("c", "something", selected=False, render_kw={}),
]
@@ -45,9 +49,9 @@ def test_with_data():
form = F(DummyPostData(a=["a", "c"]))
assert form.a.data == ["a", "c"]
assert list(form.a.iter_choices()) == [
- SelectChoice("a", "hello", None, None, _selected=True),
- SelectChoice("b", "bye", None, None, _selected=False),
- SelectChoice("c", "something", None, None, _selected=True),
+ Choice("a", "hello", selected=True, render_kw={}),
+ Choice("b", "bye", selected=False, render_kw={}),
+ Choice("c", "something", selected=True, render_kw={}),
]
assert form.b.data == []
form = F(DummyPostData(b=["1", "2"]))
@@ -106,7 +110,7 @@ def test_validate_choices_when_empty():
def test_invalid_value_message():
F = make_form(
a=SelectMultipleField(
- choices=[Choice(1, "Foo")],
+ choices=[SelectChoice(1, "Foo")],
coerce=int,
invalid_value_message="One or more submitted values could not be parsed.",
)
@@ -119,7 +123,7 @@ def test_invalid_value_message():
def test_invalid_choice_message():
F = make_form(
a=SelectMultipleField(
- choices=[Choice("a", "Foo")],
+ choices=[SelectChoice("a", "Foo")],
invalid_choice_message="Pick only the available options.",
)
)
@@ -131,7 +135,7 @@ def test_invalid_choice_message():
def test_invalid_choice_message_callable():
F = make_form(
a=SelectMultipleField(
- choices=[Choice("a", "Foo")],
+ choices=[SelectChoice("a", "Foo")],
invalid_choice_message=lambda n: (
f"Pick {n} available option."
if n == 1
@@ -158,7 +162,7 @@ def test_validate_choices_when_none():
def test_dont_validate_choices():
F = make_form(
- a=SelectMultipleField(choices=[Choice("a", "Foo")], validate_choice=False)
+ a=SelectMultipleField(choices=[SelectChoice("a", "Foo")], validate_choice=False)
)
form = F(DummyPostData(a=["b"]))
assert form.validate()
@@ -175,7 +179,7 @@ def test_choices_can_be_none_when_choice_validation_is_disabled():
def test_requried_flag():
F = make_form(
c=SelectMultipleField(
- choices=[Choice("a", "hello"), Choice("b", "bye")],
+ choices=[SelectChoice("a", "hello"), SelectChoice("b", "bye")],
validators=[validators.InputRequired()],
)
)
@@ -191,7 +195,7 @@ def test_requried_flag():
def test_required_validator():
F = make_form(
c=SelectMultipleField(
- choices=[Choice("a", "hello"), Choice("b", "bye")],
+ choices=[SelectChoice("a", "hello"), SelectChoice("b", "bye")],
validators=[validators.InputRequired()],
)
)
@@ -219,7 +223,11 @@ def test_render_kw_preserved():
def test_option_render_kw():
F = make_form(
a=SelectMultipleField(
- choices=[Choice("a", "Foo", {"title": "foobar", "data-foo": "bar"})]
+ choices=[
+ SelectChoice(
+ "a", "Foo", render_kw={"title": "foobar", "data-foo": "bar"}
+ )
+ ]
)
)
form = F(a="a")
@@ -229,8 +237,11 @@ def test_option_render_kw():
in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, None, _selected=True
+ Choice(
+ "a",
+ "Foo",
+ selected=True,
+ render_kw={"title": "foobar", "data-foo": "bar"},
)
]
@@ -240,7 +251,10 @@ def test_optgroup_option_render_kw():
a=SelectMultipleField(
choices=[
SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, "hello"
+ "a",
+ "Foo",
+ render_kw={"title": "foobar", "data-foo": "bar"},
+ optgroup="hello",
)
]
)
@@ -253,8 +267,11 @@ def test_optgroup_option_render_kw():
" " in form.a()
)
assert list(form.a.iter_choices()) == [
- SelectChoice(
- "a", "Foo", {"title": "foobar", "data-foo": "bar"}, "hello", _selected=True
+ Choice(
+ "a",
+ "Foo",
+ selected=True,
+ render_kw={"title": "foobar", "data-foo": "bar"},
)
]
@@ -262,7 +279,7 @@ def test_optgroup_option_render_kw():
def test_can_supply_coercable_values_as_options():
F = make_form(
a=SelectMultipleField(
- choices=[Choice("1", "One"), Choice("2", "Two")],
+ choices=[SelectChoice("1", "One"), SelectChoice("2", "Two")],
coerce=int,
)
)
@@ -281,7 +298,7 @@ class _Color(Enum):
def test_select_multiple_enum_round_trip():
"""``coerce=EnumCls`` works for SelectMultipleField too."""
F = make_form(
- a=SelectMultipleField(choices=Choice.from_enum(_Color), coerce=_Color)
+ a=SelectMultipleField(choices=SelectChoice.from_enum(_Color), coerce=_Color)
)
form = F(DummyPostData(a=["RED", "BLUE"]))
assert form.validate()
diff --git a/tests/test_datalist.py b/tests/test_datalist.py
index 48f29cfc..9f5beb34 100644
--- a/tests/test_datalist.py
+++ b/tests/test_datalist.py
@@ -1,8 +1,8 @@
import pytest
from tests.common import DummyPostData
-from wtforms import Choice
from wtforms import DataList
+from wtforms import DataListChoice
from wtforms import EmailField
from wtforms import FieldList
from wtforms import Form
@@ -17,7 +17,10 @@ class StrForm(Form):
class ChoiceForm(Form):
country = StringField(
datalist=DataList(
- choices=[Choice("FR", "France"), Choice("US", "United States")]
+ choices=[
+ DataListChoice("FR", "France"),
+ DataListChoice("US", "United States"),
+ ]
)
)
@@ -33,7 +36,7 @@ def test_str_choices_render_options():
def test_choice_choices_render_value_and_label():
- """``Choice`` instances render both ``value=`` and ``label=`` attributes."""
+ """``DataListChoice`` instances render both ``value=`` and ``label=`` attributes."""
form = ChoiceForm()
html = str(form.country.datalist())
assert 'value="FR"' in html
@@ -49,13 +52,13 @@ def test_choice_choices_render_value_and_label():
pytest.param(None, "default", id="data-is-none"),
],
)
-def test_callable_choices_receives_field(postdata, expected):
- """A callable ``DataList`` is invoked with the bound field — its
+def test_callable_choices_receives_form_and_field(postdata, expected):
+ """A callable ``DataList`` is invoked with ``(form, field)`` — its
``field.data`` (or ``None`` when no value is bound) drives the result."""
class F(Form):
query = StringField(
- datalist=DataList(lambda field: [f"{field.data or 'default'}-x"])
+ datalist=DataList(lambda form, field: [f"{field.data or 'default'}-x"])
)
html = str(F(postdata).query.datalist())
@@ -172,7 +175,7 @@ class F(Form):
items = FieldList(
StringField(
datalist=DataList(
- choices=lambda field: [
+ choices=lambda form, field: [
f"{field.data}-1",
f"{field.data}-2",
]
@@ -223,10 +226,13 @@ class F(Form):
def test_choice_render_kw_on_option():
- """``render_kw`` on a ``Choice`` is applied as attributes on its ````."""
+ """``render_kw`` on a ``DataListChoice`` is applied as attributes on its
+ `` ``."""
class F(Form):
- x = StringField(datalist=DataList([Choice("x", render_kw={"disabled": True})]))
+ x = StringField(
+ datalist=DataList([DataListChoice("x", render_kw={"disabled": True})])
+ )
html = str(F().x.datalist())
assert "disabled" in html
@@ -242,44 +248,6 @@ class F(Form):
assert html == ' '
-@pytest.mark.parametrize(
- ("choices", "data", "selected"),
- [
- pytest.param(
- [Choice("FR", "France"), Choice("US", "United States")],
- {"country": "FR"},
- ["FR"],
- id="static-match",
- ),
- pytest.param(
- [Choice("FR"), Choice("US")],
- None,
- [],
- id="no-data-no-flag",
- ),
- pytest.param(
- lambda field: [Choice("FR")] if field.data == "FR" else [],
- {"country": "FR"},
- ["FR"],
- id="callable-match",
- ),
- ],
-)
-def test_iter_choices_flags_selected(choices, data, selected):
- """``iter_choices(field)`` flags Choices whose value matches ``field.data``."""
-
- class F(Form):
- country = StringField(datalist=DataList(choices))
-
- form = F(data=data) if data else F()
- flagged = [
- c.value
- for c in form.country._datalist.iter_choices(form.country)
- if c._selected
- ]
- assert flagged == selected
-
-
def test_widget_replaces_default_rendering():
"""A custom ``widget`` callable replaces the default ```` markup."""
@@ -314,7 +282,7 @@ def widget(datalist, field=None, **kwargs):
class F(Form):
query = StringField(
datalist=DataList(
- choices=lambda field: [f"{field.data}-x"],
+ choices=lambda form, field: [f"{field.data}-x"],
widget=widget,
)
)
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index e6ad4afa..8d86bd41 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -1,7 +1,7 @@
import pytest
from markupsafe import Markup
-from wtforms.fields.choices import SelectChoice
+from wtforms.fields.choices import Choice
from wtforms.widgets.core import CheckboxInput
from wtforms.widgets.core import ColorInput
from wtforms.widgets.core import FileInput
@@ -201,16 +201,17 @@ def test_select(select_dummy_field):
def test_select_mixed_grouped_and_ungrouped_choices(dummy_field_class):
- """Choices with and without ``optgroup`` render in groups by first
- appearance, preserving each choice's intra-group order; ungrouped
- choices share a single bucket rendered without an ```` wrapper."""
+ """Choices with and without ``optgroup`` render in order: consecutive
+ choices sharing the same ``optgroup`` form one ````, non-
+ consecutive ones yield separate ```` wrappers
+ (``itertools.groupby`` semantics)."""
field = dummy_field_class(
- [
- SelectChoice("foo", "lfoo", optgroup="g1", _selected=True),
- SelectChoice("baz", "lbaz", optgroup="g2"),
- SelectChoice("abc", "labc"),
- SelectChoice("bar", "lbar", optgroup="g1"),
- SelectChoice("xyz", "lxyz"),
+ groups=[
+ ("g1", [Choice("foo", "lfoo", selected=True, render_kw={})]),
+ ("g2", [Choice("baz", "lbaz", selected=False, render_kw={})]),
+ (None, [Choice("abc", "labc", selected=False, render_kw={})]),
+ ("g1", [Choice("bar", "lbar", selected=False, render_kw={})]),
+ (None, [Choice("xyz", "lxyz", selected=False, render_kw={})]),
]
)
field.name = "f"
@@ -219,43 +220,94 @@ def test_select_mixed_grouped_and_ungrouped_choices(dummy_field_class):
''
''
'lfoo '
- 'lbar '
" "
''
'lbaz '
" "
'labc '
+ ''
+ 'lbar '
+ " "
'lxyz '
" "
)
+def test_select_dispatches_to_legacy_render_option_signature(dummy_field_class):
+ """A subclass overriding ``Select.render_option`` with the WTForms 3.2
+ signature ``(cls, value, label, selected, **kwargs)`` keeps working,
+ emitting a ``DeprecationWarning``."""
+
+ captured = {}
+
+ class LegacySelect(Select):
+ @classmethod
+ def render_option(cls, value, label, selected, **kwargs):
+ captured["args"] = (value, label, selected, kwargs)
+ return Markup(f"{label} ")
+
+ field = dummy_field_class(
+ [Choice("foo", "lfoo", selected=True, render_kw={})],
+ )
+ field.name = "f"
+
+ with pytest.warns(DeprecationWarning, match="pre-3.3 signature"):
+ html = LegacySelect()(field)
+ assert "lfoo " in html
+ assert captured["args"] == ("foo", "lfoo", True, {})
+
+
+def test_select_dispatches_to_legacy_no_kwargs_signature(dummy_field_class):
+ """Strict 3-positional signature without ``**kwargs`` is supported:
+ render_kw is dropped on the floor rather than crashing."""
+
+ captured = {}
+
+ class StrictLegacySelect(Select):
+ @classmethod
+ def render_option(cls, value, label, mixed):
+ captured["args"] = (value, label, mixed)
+ return Markup(f"{label} ")
+
+ field = dummy_field_class(
+ [Choice("foo", "lfoo", selected=True, render_kw={"class_": "x"})],
+ )
+ field.name = "f"
+
+ with pytest.warns(DeprecationWarning, match="pre-3.3 signature"):
+ html = StrictLegacySelect()(field)
+ assert "lfoo " in html
+ assert captured["args"] == ("foo", "lfoo", True)
+
+
def test_render_option():
assert (
- Select.render_option(SelectChoice("bar", "foo", _selected=False))
+ Select.render_option(Choice("bar", "foo", selected=False, render_kw={}))
== 'foo '
)
assert (
- Select.render_option(SelectChoice(True, "foo", _selected=True))
+ Select.render_option(Choice(True, "foo", selected=True, render_kw={}))
== 'foo '
)
assert (
- Select.render_option(SelectChoice(False, "foo", _selected=False))
+ Select.render_option(Choice(False, "foo", selected=False, render_kw={}))
== 'foo '
)
assert (
Select.render_option(
- SelectChoice("bar", ' foo', _selected=False)
+ Choice("bar", ' foo', selected=False, render_kw={})
)
== '<i class="bar"></i>foo '
)
assert (
Select.render_option(
- SelectChoice("bar", Markup(' foo'), _selected=False)
+ Choice(
+ "bar", Markup(' foo'), selected=False, render_kw={}
+ )
)
== ' foo '
)