Skip to content

Commit

Permalink
refactor: use SelectChoice as SelectField choices
Browse files Browse the repository at this point in the history
- passing choices as `tuple` is deprecated
- makes the code much more simpler, provide stronger
  typing and more explicit arguments
- `<option>` can have custom attributes thanks to
  `SelectField.render_kw`
- `<optgroup>` rendering by passing a `dict` parameter
  is deprecated.
- `SelectField` does not render empty `<optgroup>`
  • Loading branch information
azmeuk committed Jul 22, 2023
1 parent f8fca13 commit 5148434
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 173 deletions.
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Unreleased
- Added shorter format to :class:`~fields.DateTimeLocalField`
defaults :pr:`761`
- Stop support for python 3.7 :pr:`794`
- :class:`~fields.SelectField` refactor. Choices tuples and dicts are
deprecated in favor of :class:`~fields.Choice` :pr:`739`
- ``<option>`` HTML attributes can be passed using
:class:`~fields.Choice` :issue:`692` :pr:`739`

Version 3.0.1
-------------
Expand Down
85 changes: 46 additions & 39 deletions docs/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,28 @@ refer to a single input from the form.

.. autoclass:: MonthField(default field arguments, format='%Y:%m')

.. autoclass:: RadioField(default field arguments, choices=[], coerce=str)
.. autoclass:: SearchField(default field arguments)

.. autoclass:: SubmitField(default field arguments)

.. autoclass:: StringField(default field arguments)

.. code-block:: jinja
{{ form.username(size=30, maxlength=50) }}
.. autoclass:: TelField(default field arguments)

.. autoclass:: TimeField(default field arguments, format='%H:%M')

.. autoclass:: URLField(default field arguments)

Choice Fields
-------------

.. autoclass:: Choice

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

.. code-block:: jinja
Expand All @@ -274,45 +295,43 @@ refer to a single input from the form.
Simply outputting the field without iterating its subfields will result in
a ``<ul>`` list of radio choices.

.. class:: SelectField(default field arguments, choices=[], coerce=str, option_widget=None, validate_choice=True)

.. class:: SelectField(default field arguments, choices=None, coerce=str, option_widget=None, validate_choice=True)

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

* a list of ``(value, label)`` pairs. It can also be a list of only values, in
which case the value is used as the label. The value can be of any
* a list of :class:`Choice`.
It can also be a list of only values, in which case the value is used
as the label. 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 dictionary of ``{label: list}`` pairs defining groupings of options.
* a function taking no argument, and returning either a list or a dictionary.
* a function taking no argument, and returning a list of :class:`Choice`.


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

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

Note that the `choices` keyword is only evaluated once, so if you want to make
a dynamic drop-down list, you'll want to assign the choices list to the field
after instantiation. Any submitted choices which are not in the given choices
list will cause validation on the field to fail. If this option cannot be
applied to your problem you may wish to skip choice validation (see below).
language = SelectField('Programming Language', choices=[
Choice('cpp', 'C++'),
Choice('py', 'Python'),
Choice('text', 'Plain Text'),
])

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

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

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

def edit_user(request, id):
user = User.query.get(id)
form = UserDetails(request.POST, obj=user)
form.group_id.choices = [(g.id, g.name) for g in Group.query.order_by('name')]

Note we didn't pass a `choices` to the :class:`~wtforms.fields.SelectField`
constructor, but rather created the list in the view function. Also, the
`coerce` keyword arg to :class:`~wtforms.fields.SelectField` says that we
use :func:`int()` to coerce form data. The default coerce is
:func:`str()`.
Note that the `coerce` keyword arg to :class:`~wtforms.fields.SelectField` says
that we use :func:`int()` to coerce form data. The default coerce is :func:`str()`.

**Coerce function example**::

Expand All @@ -322,7 +341,11 @@ refer to a single input from the form.
return value

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

Note when the option None is selected a 'None' str will be passed. By using a coerce
function the 'None' str will be converted to None.
Expand All @@ -347,29 +370,13 @@ refer to a single input from the form.
a list of fields each representing an option. The rendering of this can be
further controlled by specifying `option_widget=`.

.. autoclass:: SearchField(default field arguments)

.. autoclass:: SelectMultipleField(default field arguments, choices=[], coerce=str, option_widget=None)
.. autoclass:: SelectMultipleField(default field arguments, choices=None, coerce=str, option_widget=None)

The data on the SelectMultipleField is stored as a list of objects, each of
which is checked and coerced from the form input. Any submitted choices
which are not in the given choices list will cause validation on the field
to fail.

.. autoclass:: SubmitField(default field arguments)

.. autoclass:: StringField(default field arguments)

.. code-block:: jinja
{{ form.username(size=30, maxlength=50) }}
.. autoclass:: TelField(default field arguments)

.. autoclass:: TimeField(default field arguments, format='%H:%M')

.. autoclass:: URLField(default field arguments)


Convenience Fields
------------------
Expand Down Expand Up @@ -459,7 +466,7 @@ complex data structures such as lists and nested objects can be represented.
FormField::

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

class ContactForm(Form):
Expand Down
166 changes: 100 additions & 66 deletions src/wtforms/fields/choices.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,63 @@
import itertools
import warnings
from dataclasses import dataclass
from typing import Optional

from wtforms import widgets
from wtforms.fields.core import Field
from wtforms.validators import ValidationError

__all__ = (
"SelectField",
"Choice",
"SelectMultipleField",
"RadioField",
)


@dataclass
class Choice:
"""
A dataclass that represents available choices for
:class:`RadioField`, :class:`SelectField` and :class:`SelectMultipleField`
:param value:
The value that will be send in the request.
:param label:
The label of the option.
:param render_kw:
A dict containing HTML attributes that will be rendered
with the option.
:param optgroup:
The `<optgroup>` HTML tag in which the option will be
rendered.
"""

value: str
label: Optional[str] = None
render_kw: Optional[dict] = None
optgroup: Optional[str] = None
_selected: bool = False

@classmethod
def from_input(cls, input, optgroup=None):
if isinstance(input, Choice):
if optgroup:
input.optgroup = optgroup
return input

if isinstance(input, str):
return Choice(value=input, optgroup=optgroup)

if isinstance(input, tuple):
warnings.warn(
"Passing SelectField choices as tuples is deprecated and will be "
"removed in wtforms 3.3. Please use Choice instead.",
DeprecationWarning,
stacklevel=2,
)
return Choice(*input, optgroup=optgroup)


class SelectFieldBase(Field):
option_widget = widgets.Option()

Expand All @@ -30,16 +77,10 @@ def __init__(self, label=None, validators=None, option_widget=None, **kwargs):
def iter_choices(self):
"""
Provides data for choice widget rendering. Must return a sequence or
iterable of (value, label, selected) tuples.
iterable of Choice.
"""
raise NotImplementedError()

def has_groups(self):
return False

def iter_groups(self):
raise NotImplementedError()

def __iter__(self):
opts = dict(
widget=self.option_widget,
Expand All @@ -49,15 +90,47 @@ def __iter__(self):
_form=None,
_meta=self.meta,
)
for i, (value, label, checked) in enumerate(self.iter_choices()):
opt = self._Option(label=label, id="%s-%d" % (self.id, i), **opts)
opt.process(None, value)
opt.checked = checked
for i, choice in enumerate(self.iter_choices()):
opt = self._Option(
id="%s-%d" % (self.id, i), label=choice.label or choice.value, **opts
)
opt.choice = choice
opt.checked = choice._selected
opt.process(None, choice.value)
yield opt

class _Option(Field):
checked = False
def choices_from_input(self, choices):
if callable(choices):
choices = 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.3. Please pass a list of Choice objects with a "
"custom optgroup attribute instead.",
DeprecationWarning,
stacklevel=2,
)
print(
[
Choice.from_input(input, optgroup)
for optgroup, inputs in choices.items()
for input in inputs
]
)

return [
Choice.from_input(input, optgroup)
for optgroup, inputs in choices.items()
for input in inputs
]

return [Choice.from_input(input) for input in choices]

class _Option(Field):
def _value(self):
return str(self.data)

Expand All @@ -76,44 +149,14 @@ def __init__(
):
super().__init__(label, validators, **kwargs)
self.coerce = coerce
if callable(choices):
choices = choices()
if choices is not None:
self.choices = choices if isinstance(choices, dict) else list(choices)
else:
self.choices = None
self.choices = self.choices_from_input(choices)
self.validate_choice = validate_choice

def iter_choices(self):
if not self.choices:
choices = []
elif isinstance(self.choices, dict):
choices = list(itertools.chain.from_iterable(self.choices.values()))
else:
choices = self.choices

return self._choices_generator(choices)

def has_groups(self):
return isinstance(self.choices, dict)

def iter_groups(self):
if isinstance(self.choices, dict):
for label, choices in self.choices.items():
yield (label, self._choices_generator(choices))

def _choices_generator(self, choices):
if not choices:
_choices = []

elif isinstance(choices[0], (list, tuple)):
_choices = choices

else:
_choices = zip(choices, choices)

for value, label in _choices:
yield (value, label, self.coerce(value) == self.data)
choices = self.choices_from_input(self.choices) or []
for choice in choices:
choice._selected = self.coerce(choice.value) == self.data
return choices

def process_data(self, value):
try:
Expand All @@ -138,10 +181,7 @@ def pre_validate(self, form):
if not self.validate_choice:
return

for _, _, match in self.iter_choices():
if match:
break
else:
if not any(choice._selected for choice in self.iter_choices()):
raise ValidationError(self.gettext("Not a valid choice."))


Expand All @@ -154,18 +194,12 @@ class SelectMultipleField(SelectField):

widget = widgets.Select(multiple=True)

def _choices_generator(self, choices):
if choices:
if isinstance(choices[0], (list, tuple)):
_choices = choices
else:
_choices = zip(choices, choices)
else:
_choices = []

for value, label in _choices:
selected = self.data is not None and self.coerce(value) in self.data
yield (value, label, selected)
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

def process_data(self, value):
try:
Expand All @@ -190,7 +224,7 @@ def pre_validate(self, form):
if not self.validate_choice or not self.data:
return

acceptable = {c[0] for c in self.iter_choices()}
acceptable = {self.coerce(choice.value) for choice in self.iter_choices()}
if any(d not in acceptable for d in self.data):
unacceptable = [str(d) for d in set(self.data) - acceptable]
raise ValidationError(
Expand Down

0 comments on commit 5148434

Please sign in to comment.