From 7c12bce7dfe174c8b8d751e21b4b4172ad4d287c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 12 May 2026 20:43:29 +0200 Subject: [PATCH 1/9] feat: let fields and nested forms reach their enclosing form Add Field._form and BaseForm._parent_form so fields and nested forms can reach the enclosing form. FieldList is transparent in the chain: a FormField nested in a FieldList points to the form that owns the list, not the list. Also fix FieldList entries which previously got _form=None. --- CHANGES.rst | 8 ++++++++ src/wtforms/fields/choices.py | 2 +- src/wtforms/fields/core.py | 2 ++ src/wtforms/fields/form.py | 16 ++++++++++++++-- src/wtforms/fields/list.py | 2 +- src/wtforms/form.py | 4 ++++ tests/fields/test_select.py | 9 +++++++++ 7 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ca909ba8..3655c8c3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,13 @@ .. currentmodule:: wtforms +Unreleased +---------- + +- 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` + Version 3.3.0b1 --------------- diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py index 155069e1..f4143a7a 100644 --- a/src/wtforms/fields/choices.py +++ b/src/wtforms/fields/choices.py @@ -131,7 +131,7 @@ def __iter__(self): 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()): diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py index 9b75ae44..01cd9aea 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 diff --git a/src/wtforms/fields/form.py b/src/wtforms/fields/form.py index ac516b18..bb2bfdda 100644 --- a/src/wtforms/fields/form.py +++ b/src/wtforms/fields/form.py @@ -54,9 +54,21 @@ 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 validate(self, form, extra_validators=()): if extra_validators: diff --git a/src/wtforms/fields/list.py b/src/wtforms/fields/list.py index 54f2f689..294c18b9 100644 --- a/src/wtforms/fields/list.py +++ b/src/wtforms/fields/list.py @@ -175,7 +175,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..ffc608c6 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() @@ -206,6 +209,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/tests/fields/test_select.py b/tests/fields/test_select.py index 466dec51..558bc628 100644 --- a/tests/fields/test_select.py +++ b/tests/fields/test_select.py @@ -111,6 +111,15 @@ 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=[Choice("a", "Foo"), Choice("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")])) form = F(DummyPostData(a=[])) From 55002e2f2f7651f62495aae44fbf76817ec191b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 12 May 2026 20:44:18 +0200 Subject: [PATCH 2/9] feat: add post_process hooks with single-call propagation Add Field.post_process() and BaseForm.post_process() hooks, invoked at the end of BaseForm.process() on the root form. FormField and FieldList propagate the hook to their nested form or entries, so every nested field's hook runs exactly once. --- CHANGES.rst | 4 ++++ src/wtforms/fields/core.py | 8 ++++++++ src/wtforms/fields/form.py | 3 +++ src/wtforms/fields/list.py | 4 ++++ src/wtforms/form.py | 20 ++++++++++++++++++++ tests/fields/test_form.py | 22 ++++++++++++++++++++++ 6 files changed, 61 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3655c8c3..ad3e2502 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,10 @@ Unreleased ---------- +- 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 diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py index 01cd9aea..7dca2fec 100644 --- a/src/wtforms/fields/core.py +++ b/src/wtforms/fields/core.py @@ -350,6 +350,14 @@ 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 default implementation is a no-op. + """ + 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 bb2bfdda..61992e60 100644 --- a/src/wtforms/fields/form.py +++ b/src/wtforms/fields/form.py @@ -70,6 +70,9 @@ def process(self, formdata, data=unset_value, extra_filters=None): meta={"_parent_form": self._form}, ) + def post_process(self): + self.form.post_process() + def validate(self, form, extra_validators=()): if extra_validators: raise TypeError( diff --git a/src/wtforms/fields/list.py b/src/wtforms/fields/list.py index 294c18b9..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. diff --git a/src/wtforms/form.py b/src/wtforms/form.py index ffc608c6..bf5551e0 100644 --- a/src/wtforms/form.py +++ b/src/wtforms/form.py @@ -130,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. diff --git a/tests/fields/test_form.py b/tests/fields/test_form.py index 36d1076a..c8fd6dba 100644 --- a/tests/fields/test_form.py +++ b/tests/fields/test_form.py @@ -109,6 +109,28 @@ 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_populate_missing_obj(F1): obj = AttrDict(a=None) obj2 = AttrDict(a=AttrDict(a="mmm")) From c1db0ad9c856a496956f88c7a90ee75b9735dd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 12 May 2026 20:45:02 +0200 Subject: [PATCH 3/9] feat: allow SelectField choices callable to receive (form, field) The choices callable may optionally accept (form, field) as positional arguments, mirroring the validator signature. Resolved from post_process so it can read processed data from any field on the form. --- CHANGES.rst | 2 + docs/fields.rst | 12 ++++++ src/wtforms/_compat.py | 15 +++++++ src/wtforms/fields/choices.py | 26 +++++++++++- tests/fields/test_form.py | 76 +++++++++++++++++++++++++++++++++++ tests/fields/test_select.py | 18 +++++++++ 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/wtforms/_compat.py diff --git a/CHANGES.rst b/CHANGES.rst index ad3e2502..7ed9a731 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,8 @@ Unreleased ---------- +- ``choices`` callables on :class:`~fields.SelectField` 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 diff --git a/docs/fields.rst b/docs/fields.rst index 3e0b5691..414c4a56 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -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 [Choice(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): 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/fields/choices.py b/src/wtforms/fields/choices.py index f4143a7a..4804f8a6 100644 --- a/src/wtforms/fields/choices.py +++ b/src/wtforms/fields/choices.py @@ -4,6 +4,7 @@ from enum import Enum from wtforms import widgets +from wtforms._compat import get_signature from wtforms.fields.core import Field from wtforms.validators import ValidationError @@ -147,7 +148,7 @@ def __iter__(self): def choices_from_input(self, choices): if callable(choices): - choices = choices() + choices = self._invoke_choices_callback(choices) if choices is None: return None @@ -169,6 +170,17 @@ def choices_from_input(self, choices): return [SelectChoice.from_input(input) for input in choices] + 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 +204,12 @@ 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 + self.choices = self.choices_from_input(choices) self.validate_choice = validate_choice self.invalid_value_message = invalid_value_message or self.gettext( "Invalid Choice: could not coerce." @@ -207,6 +224,11 @@ def iter_choices(self): choice._selected = self.coerce(choice.value) == self.data return choices + def post_process(self): + super().post_process() + if self._choices_callable is not None: + self.choices = self.choices_from_input(self._choices_callable) + def process_data(self, value): try: # If value is None, don't coerce to a value diff --git a/tests/fields/test_form.py b/tests/fields/test_form.py index c8fd6dba..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 @@ -131,6 +133,80 @@ def post_process(self): 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_select.py b/tests/fields/test_select.py index 558bc628..5dabb2df 100644 --- a/tests/fields/test_select.py +++ b/tests/fields/test_select.py @@ -227,6 +227,24 @@ 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) == [ + '', + '', + ] + assert captured == [(form, form.a)] + + def test_requried_flag(): F = make_form( c=SelectField( From ed5f604ddf7fdb97e4448146fb30fea394c5edff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 12 May 2026 20:45:30 +0200 Subject: [PATCH 4/9] feat: align DataList callable signature and lifecycle with SelectField DataList callable choices follow the same contract as SelectField: the callable accepts (form, field) or no arguments and is invoked once per form processing cycle from post_process. --- CHANGES.rst | 5 +++-- src/wtforms/datalist.py | 19 +++++++++++++++---- src/wtforms/fields/core.py | 5 ++++- tests/test_datalist.py | 12 ++++++------ 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7ed9a731..5a203e68 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,8 +3,9 @@ Unreleased ---------- -- ``choices`` callables on :class:`~fields.SelectField` may accept - ``(form, field)``. Resolved once per processing cycle. :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 diff --git a/src/wtforms/datalist.py b/src/wtforms/datalist.py index b29f89da..600018ed 100644 --- a/src/wtforms/datalist.py +++ b/src/wtforms/datalist.py @@ -18,6 +18,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,16 +27,26 @@ 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 = inspect.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 = [ diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py index 7dca2fec..c0ca9888 100644 --- a/src/wtforms/fields/core.py +++ b/src/wtforms/fields/core.py @@ -355,8 +355,11 @@ def post_process(self): 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 default implementation is a no-op. + 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): """ diff --git a/tests/test_datalist.py b/tests/test_datalist.py index 48f29cfc..c1ef7376 100644 --- a/tests/test_datalist.py +++ b/tests/test_datalist.py @@ -49,13 +49,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 +172,7 @@ class F(Form): items = FieldList( StringField( datalist=DataList( - choices=lambda field: [ + choices=lambda form, field: [ f"{field.data}-1", f"{field.data}-2", ] @@ -258,7 +258,7 @@ class F(Form): id="no-data-no-flag", ), pytest.param( - lambda field: [Choice("FR")] if field.data == "FR" else [], + lambda form, field: [Choice("FR")] if field.data == "FR" else [], {"country": "FR"}, ["FR"], id="callable-match", @@ -314,7 +314,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, ) ) From 4ea8001eb9221c9cef36ac74cf32a1da37c675c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 19 May 2026 17:57:38 +0200 Subject: [PATCH 5/9] refactor: split choice types by role SelectChoice / DataListChoice declare options for SelectField / DataList; Choice is the shape yielded by iter_choices and iter_groups. All three are dataclasses with __iter__ preserving the 3.2 tuple-unpacking contract. --- CHANGES.rst | 8 ++ docs/fields.rst | 40 +++--- docs/widgets.rst | 5 +- src/wtforms/__init__.py | 2 + src/wtforms/datalist.py | 83 +++++++++-- src/wtforms/fields/choices.py | 192 +++++++++++++++++++------- src/wtforms/widgets/core.py | 30 ++-- tests/conftest.py | 16 ++- tests/fields/test_radio.py | 18 ++- tests/fields/test_select.py | 206 +++++++++++++++++----------- tests/fields/test_selectmultiple.py | 61 +++++--- tests/test_datalist.py | 54 ++------ tests/test_widgets.py | 37 ++--- 13 files changed, 476 insertions(+), 276 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5a203e68..b043cc2b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,10 @@ Unreleased ---------- +- 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` @@ -14,6 +18,10 @@ Unreleased 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: + :meth:`~fields.SelectFieldBase.has_groups` / + :meth:`~fields.SelectFieldBase.iter_groups`. 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 414c4a56..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) @@ -374,7 +374,7 @@ Choice Fields of any other field on the form:: def available_groups(form, field): - return [Choice(g.id, g.name) for g in form.tenant.data.groups] + return [SelectChoice(g.id, g.name) for g in form.tenant.data.groups] class UserDetails(Form): tenant = QuerySelectField('Tenant', ...) @@ -389,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 @@ -407,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 @@ -420,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**:: @@ -566,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): @@ -580,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 @@ -588,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/datalist.py b/src/wtforms/datalist.py index 600018ed..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 ```` 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): @@ -61,45 +103,41 @@ 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) @@ -120,12 +158,20 @@ def __init__(self, label=None, validators=None, option_widget=None, **kwargs): self.option_widget = option_widget def iter_choices(self): - """ - Provides data for choice widget rendering. Must return a sequence or - iterable of SelectChoice. + """Provide data for choice widget rendering. + + Should yield :class:`Choice` instances. """ raise NotImplementedError() + 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__(self): opts = dict( widget=self.option_widget, @@ -142,11 +188,12 @@ def __iter__(self): **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 = self._invoke_choices_callback(choices) @@ -154,14 +201,6 @@ def choices_from_input(self, choices): 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, - ) - return [ SelectChoice.from_input(input, optgroup) for optgroup, inputs in choices.items() @@ -209,7 +248,7 @@ def __init__( self.choices = None else: self._choices_callable = None - self.choices = self.choices_from_input(choices) + self.choices = self._choices_from_input(choices) self.validate_choice = validate_choice self.invalid_value_message = invalid_value_message or self.gettext( "Invalid Choice: could not coerce." @@ -219,15 +258,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.choices_from_input(self._choices_callable) + self.choices = self._invoke_choices_callback(self._choices_callable) def process_data(self, value): try: @@ -255,7 +320,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()): raise ValidationError(self.invalid_choice_message) @@ -298,11 +363,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: diff --git a/src/wtforms/widgets/core.py b/src/wtforms/widgets/core.py index a4e179e7..d7c63503 100644 --- a/src/wtforms/widgets/core.py +++ b/src/wtforms/widgets/core.py @@ -172,7 +172,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 +420,18 @@ def __call__(self, field, **kwargs): kwargs[k] = getattr(flags, k) select_params = html_params(name=field.name, **kwargs) html = [f"") return Markup("".join(html)) @@ -438,18 +441,11 @@ 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"") - @classmethod - def sort_by_optgroup(cls, choices): - optgroups = {} - for choice in choices: - optgroups.setdefault(choice.optgroup, []).append(choice) - return optgroups - class Option: """ diff --git a/tests/conftest.py b/tests/conftest.py index a7be695b..959f5539 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,11 @@ def _value(self): def iter_choices(self): return iter(self.data) - def iter_groups(self): - return [] - def has_groups(self): - return False + return self._groups is not None + + def iter_groups(self): + return iter(self._groups or []) def gettext(self, string): return self._translations.gettext(string) 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 5dabb2df..8a6a327a 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(), @@ -114,14 +114,16 @@ 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=[Choice("a", "Foo"), Choice("b", "Bar")])) + 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 @@ -130,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" @@ -150,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.", ) @@ -163,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.", ) ) @@ -180,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" @@ -248,7 +252,7 @@ def choices(form, field): 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()], ) ) @@ -264,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()], ) ) @@ -299,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={}) ] @@ -321,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") @@ -339,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"}, ) ] @@ -350,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", ) ] ) @@ -363,51 +377,76 @@ 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_tuple_choices_deprecation(): - F = make_form(a=SelectField(choices=[("a", "Foo")])) - with pytest.warns(DeprecationWarning): - form = F(a="a") +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 - assert '' in form.a() - assert list(form.a.iter_choices()) == [ - SelectChoice("a", "Foo", None, None, _selected=True) - ] +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_dict_choices_deprecation_with_choice_object(): - F = make_form(a=SelectField(choices={"hello": [Choice("a", "Foo")]})) - with pytest.warns(DeprecationWarning): - form = F(a="a") - assert ( - '' - '' - "" in form.a() +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"), + ] + ) ) - assert list(form.a.iter_choices()) == [ - SelectChoice("a", "Foo", None, "hello", _selected=True) + 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_dict_choices_deprecation_with_tuple(): - F = make_form(a=SelectField(choices={"hello": [("a", "Foo")]})) - with pytest.warns(DeprecationWarning): - form = F(a="a") - - assert ( - '' - '' - "" in form.a() +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")], + ) ) - assert list(form.a.iter_choices()) == [ - SelectChoice("a", "Foo", None, "hello", _selected=True) - ] + 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, {}) class _Plain(Enum): @@ -430,17 +469,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"), ] @@ -452,31 +491,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() @@ -484,22 +523,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 '' 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 c1ef7376..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 @@ -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 ``