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 ```` 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"") 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"") @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) == [ + '', + '', + ] + 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 '' in form.a() + assert '' 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 '' in html + assert '' in html + assert '' 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 '' in html + assert '' in html + assert '' in html + assert '' 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 '' 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 '' in html + assert '' 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 '' 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 '' in html + assert '' in html + assert '' 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 '' 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 ``