1import warnings
2from dataclasses import dataclass
3from dataclasses import field
4from dataclasses import replace
5from itertools import groupby
6from operator import attrgetter
7from typing import NamedTuple
8
9from wtforms import widgets
10from wtforms._compat import get_signature
11from wtforms.fields.core import Field
12from wtforms.validators import ValidationError
13
14__all__ = (
15 "SelectField",
16 "Choice",
17 "SelectChoice",
18 "SelectMultipleField",
19 "RadioField",
20)
21
22
23class Choice(NamedTuple):
24 """
25 A rendered option yielded by
26 :meth:`SelectFieldBase.iter_choices` and
27 :meth:`SelectFieldBase.iter_groups`.
28
29 ``selected`` is computed against the field's current data. To
30 declare options on a :class:`SelectField`, use
31 :class:`SelectChoice` instead.
32
33 :param value:
34 The value that will be sent in the request.
35 :param label:
36 The label of the option.
37 :param selected:
38 Whether the option is currently selected. Set by ``iter_choices``;
39 you rarely set this yourself.
40 :param render_kw:
41 A dict containing HTML attributes that will be rendered
42 with the option.
43 """
44
45 value: str
46 label: str
47 selected: bool
48 render_kw: dict
49
50
51@dataclass
52class SelectChoice:
53 """
54 An option declared via :class:`SelectField` and
55 :class:`SelectMultipleField`'s ``choices=`` parameter.
56
57 :param value:
58 The value that will be sent in the request.
59 :param label:
60 The label of the option. Defaults to ``value`` when omitted.
61 :param render_kw:
62 A dict containing HTML attributes that will be rendered
63 with the option. Defaults to an empty dict when omitted.
64 :param optgroup:
65 The ``<optgroup>`` HTML tag in which the option will be rendered.
66 """
67
68 value: str
69 label: str = None # type: ignore[assignment]
70 render_kw: dict = field(default_factory=dict)
71 optgroup: str | None = None
72
73 def __post_init__(self):
74 if self.label is None:
75 self.label = self.value
76
77 def __iter__(self):
78 return iter((self.value, self.label, self.render_kw, self.optgroup))
79
80 @classmethod
81 def from_input(cls, input, optgroup=None):
82 """Coerce a value passed by the user via ``choices=...`` into a
83 :class:`SelectChoice`.
84 """
85 if isinstance(input, SelectChoice):
86 if optgroup:
87 return replace(input, optgroup=optgroup)
88 return input
89
90 if isinstance(input, Choice):
91 warnings.warn(
92 "Passing Choice to a SelectField is deprecated; Choice is the "
93 "output type returned by iter_choices(). Use SelectChoice "
94 "instead. Support for Choice as input will be removed in "
95 "WTForms 4.0.",
96 DeprecationWarning,
97 stacklevel=4,
98 )
99 return cls(
100 value=input.value,
101 label=input.label,
102 render_kw=input.render_kw,
103 optgroup=optgroup,
104 )
105
106 if isinstance(input, str):
107 return cls(value=input, optgroup=optgroup)
108
109 if isinstance(input, tuple):
110 if len(input) not in (2, 3):
111 raise ValueError(
112 f"SelectField choice tuple must have 2 or 3 elements, "
113 f"got {len(input)}"
114 )
115 return cls(*input, optgroup=optgroup)
116
117
118def _enum_options(enum_cls, by, label, factory):
119 if by not in ("value", "name"):
120 raise ValueError(f"by must be 'value' or 'name', got {by!r}")
121 if label is None:
122 label = str if "__str__" in enum_cls.__dict__ else lambda m: m.name
123 return [factory(value=getattr(m, by), label=label(m)) for m in enum_cls]
124
125
126def enum_choices(enum_cls, *, by="value", label=None):
127 """Build a list of :class:`SelectChoice` from an :class:`enum.Enum` class.
128
129 ``by`` selects which member attribute becomes the HTML ``value=``:
130 ``"value"`` (the default) emits ``member.value``, ``"name"`` emits
131 ``member.name``. Use ``"name"`` for enums whose values are not good
132 transport identifiers (non-string, non-unique or non-serialisable).
133
134 The label defaults to ``str(member)`` when the Enum defines its own
135 ``__str__``, otherwise to ``member.name``. Pass ``label=`` (a callable
136 taking a member) to override.
137
138 Pair with :func:`enum_coerce` using the same ``by`` to round-trip the
139 submitted string back into a member.
140 """
141 return _enum_options(enum_cls, by, label, SelectChoice)
142
143
144def enum_coerce(enum_cls, *, by="value"):
145 """Return a ``coerce`` callable mapping a submitted string back to an
146 :class:`enum.Enum` member.
147
148 ``by`` must match the one passed to :func:`enum_choices`: ``"value"``
149 (the default) resolves ``str(member.value)`` — so ``IntEnum`` values
150 round-trip too — and ``"name"`` resolves ``member.name``. Already-coerced
151 members pass through unchanged.
152 """
153 if by not in ("value", "name"):
154 raise ValueError(f"by must be 'value' or 'name', got {by!r}")
155
156 def coerce(v):
157 if isinstance(v, enum_cls):
158 return v
159
160 try:
161 if by == "value":
162 by_value = {str(m.value): m for m in enum_cls}
163 return by_value[str(v)]
164 else:
165 return enum_cls[v]
166 except KeyError as e:
167 raise ValueError(str(e)) from e
168
169 return coerce
170
171
172def _normalize_iter_choice(choice):
173 """Coerce a value yielded by :meth:`SelectFieldBase.iter_choices` or
174 :meth:`SelectFieldBase.iter_groups` into a :class:`Choice`.
175 """
176 if isinstance(choice, Choice):
177 return choice
178 if isinstance(choice, tuple):
179 warnings.warn(
180 "Yielding raw tuples from iter_choices() or iter_groups() is "
181 "deprecated; yield Choice instances instead. Will be removed "
182 "in WTForms 4.0.",
183 DeprecationWarning,
184 stacklevel=3,
185 )
186 if len(choice) == 4:
187 value, label, selected, render_kw = choice
188 elif len(choice) == 3:
189 value, label, selected = choice
190 render_kw = {}
191 else:
192 raise TypeError(
193 f"iter_choices()/iter_groups() yielded a tuple of unsupported "
194 f"length: {len(choice)}"
195 )
196 return Choice(value=value, label=label, selected=selected, render_kw=render_kw)
197 raise TypeError(
198 f"iter_choices()/iter_groups() yielded an unsupported type: "
199 f"{type(choice).__name__}"
200 )
201
202
203class SelectFieldBase(Field):
204 option_widget = widgets.Option()
205
206 """
207 Base class for fields which can be iterated to produce options.
208
209 This isn't a field, but an abstract base class for fields which want to
210 provide this functionality.
211 """
212
213 def __init__(self, label=None, validators=None, option_widget=None, **kwargs):
214 super().__init__(label, validators, **kwargs)
215
216 if option_widget is not None:
217 self.option_widget = option_widget
218
219 def iter_choices(self):
220 """Provide data for choice widget rendering.
221
222 Should yield :class:`Choice` instances.
223 """
224 raise NotImplementedError()
225
226 def _iter_choices_normalized(self):
227 """Wrap :meth:`iter_choices` to always yield :class:`Choice`."""
228 for choice in self.iter_choices():
229 yield _normalize_iter_choice(choice)
230
231 def has_groups(self):
232 """Whether the field's choices include any ``optgroup`` hint."""
233 return False
234
235 def iter_groups(self):
236 """Yield ``(group_label, [Choice, ...])`` pairs for grouped rendering."""
237 raise NotImplementedError()
238
239 def _iter_groups_normalized(self):
240 """Wrap :meth:`iter_groups` to always yield ``(name, [Choice, ...])``."""
241 for name, group in self.iter_groups():
242 yield name, [_normalize_iter_choice(c) for c in group]
243
244 def __iter__(self):
245 opts = dict(
246 widget=self.option_widget,
247 validators=self.validators,
248 name=self.name,
249 render_kw=self.render_kw,
250 _form=self._form,
251 _meta=self.meta,
252 )
253 for i, choice in enumerate(self._iter_choices_normalized()):
254 opt = self._Option(
255 id=f"{self.id}-{i}",
256 label=choice.label or choice.value,
257 **opts,
258 )
259 opt.choice = choice
260 opt.checked = choice.selected
261 opt.process(None, choice.value)
262 yield opt
263
264 def _choices_from_input(self, choices):
265 """Parse the user-supplied ``choices`` into a list of :class:`SelectChoice`."""
266 if callable(choices):
267 choices = self._invoke_choices_callback(choices)
268
269 if choices is None:
270 return None
271
272 if isinstance(choices, dict):
273 if self._is_shorthand_dict(choices):
274 result = []
275 for key, value in choices.items():
276 if isinstance(value, dict):
277 for inner_value, inner_label in value.items():
278 result.append(
279 SelectChoice(
280 value=inner_value, label=inner_label, optgroup=key
281 )
282 )
283 else:
284 result.append(SelectChoice(value=key, label=value))
285 return result
286 return [
287 SelectChoice.from_input(input, optgroup)
288 for optgroup, inputs in choices.items()
289 for input in inputs
290 ]
291
292 return [SelectChoice.from_input(input) for input in choices]
293
294 @staticmethod
295 def _is_shorthand_dict(choices):
296 """``True`` if ``choices`` matches the shorthand dict syntax.
297
298 Shorthand is ``dict[str, str | dict[str, str]]``.
299 """
300 return all(isinstance(v, (str, dict)) for v in choices.values())
301
302 @staticmethod
303 def _warn_legacy_choices(choices):
304 """Emit a one-shot ``DeprecationWarning`` for legacy ``choices`` shapes.
305
306 Legacy shapes are raw tuples or ``dict``.
307 """
308 if isinstance(choices, dict):
309 if SelectFieldBase._is_shorthand_dict(choices):
310 return
311 warnings.warn(
312 "Passing SelectField choices in a dict is deprecated and will be "
313 "removed in wtforms 3.4. Please pass a list of SelectChoice "
314 "objects with a custom optgroup attribute instead.",
315 DeprecationWarning,
316 stacklevel=3,
317 )
318 items = (i for v in choices.values() for i in v)
319 else:
320 items = choices
321 for item in items:
322 if isinstance(item, (SelectChoice, Choice)):
323 continue
324 if isinstance(item, tuple):
325 warnings.warn(
326 "Passing SelectField choices as tuples is deprecated and "
327 "will be removed in wtforms 3.4. Please use SelectChoice "
328 "instead.",
329 DeprecationWarning,
330 stacklevel=3,
331 )
332 return
333
334 def _invoke_choices_callback(self, cb):
335 try:
336 sig = get_signature(cb)
337 except (ValueError, TypeError):
338 return cb()
339 try:
340 sig.bind(self._form, self)
341 except TypeError:
342 return cb()
343 return cb(self._form, self)
344
345 class _Option(Field):
346 def _value(self):
347 return str(self.data)
348
349
350class SelectField(SelectFieldBase):
351 widget = widgets.Select()
352
353 def __init__(
354 self,
355 label=None,
356 validators=None,
357 coerce=str,
358 choices=None,
359 validate_choice=True,
360 invalid_value_message=None,
361 invalid_choice_message=None,
362 **kwargs,
363 ):
364 super().__init__(label, validators, **kwargs)
365 self.coerce = coerce
366 if callable(choices):
367 self._choices_callable = choices
368 self.choices = None
369 else:
370 self._choices_callable = None
371 if choices is None:
372 self.choices = None
373 else:
374 self._warn_legacy_choices(choices)
375 self.choices = (
376 dict(choices) if isinstance(choices, dict) else list(choices)
377 )
378 self.validate_choice = validate_choice
379 self.invalid_value_message = invalid_value_message or self.gettext(
380 "Invalid Choice: could not coerce."
381 )
382 self.invalid_choice_message = invalid_choice_message or self.gettext(
383 "Not a valid choice."
384 )
385
386 def iter_choices(self):
387 choices = self._choices_from_input(self.choices) or []
388 return [
389 Choice(
390 value=c.value,
391 label=c.label,
392 selected=self.coerce(c.value) == self.data,
393 render_kw=c.render_kw,
394 )
395 for c in choices
396 ]
397
398 def has_groups(self):
399 choices = self._choices_from_input(self.choices) or []
400 return any(c.optgroup is not None for c in choices)
401
402 def iter_groups(self):
403 choices = self._choices_from_input(self.choices) or []
404 for optgroup, group in groupby(choices, key=attrgetter("optgroup")):
405 yield (
406 optgroup,
407 [
408 Choice(
409 value=c.value,
410 label=c.label,
411 selected=self.coerce(c.value) == self.data,
412 render_kw=c.render_kw,
413 )
414 for c in group
415 ],
416 )
417
418 def post_process(self, formdata=None):
419 super().post_process(formdata)
420 if self._choices_callable is not None:
421 self.choices = self._invoke_choices_callback(self._choices_callable)
422
423 def process_data(self, value):
424 try:
425 # If value is None, don't coerce to a value
426 self.data = self.coerce(value) if value is not None else None
427 except (ValueError, TypeError):
428 self.data = None
429
430 def process_formdata(self, valuelist):
431 if not valuelist:
432 return
433
434 try:
435 self.data = self.coerce(valuelist[0])
436 except ValueError as exc:
437 raise ValueError(self.invalid_value_message) from exc
438
439 def pre_validate(self, form):
440 if self.process_errors:
441 return
442
443 if not self.validate_choice:
444 return
445
446 if self.choices is None:
447 raise TypeError(self.gettext("Choices cannot be None."))
448
449 if not any(choice.selected for choice in self._iter_choices_normalized()):
450 raise ValidationError(self.invalid_choice_message)
451
452
453class SelectMultipleField(SelectField):
454 """
455 No different from a normal select field, except this one can take (and
456 validate) multiple choices. You'll need to specify the HTML
457 :mdn-attr:`size` attribute on the :mdn-tag:`select` field when rendering.
458
459 ``invalid_choice_message`` may be a string, or a callable taking the
460 number of invalid submitted values and returning the message. The
461 returned message must contain ``%(value)s``, which is replaced with the
462 comma-separated list of unacceptable values.
463 """
464
465 widget = widgets.Select(multiple=True)
466
467 def __init__(
468 self,
469 label=None,
470 validators=None,
471 coerce=str,
472 choices=None,
473 validate_choice=True,
474 invalid_value_message=None,
475 invalid_choice_message=None,
476 **kwargs,
477 ):
478 super().__init__(
479 label,
480 validators,
481 coerce=coerce,
482 choices=choices,
483 validate_choice=validate_choice,
484 **kwargs,
485 )
486 self.invalid_value_message = invalid_value_message or self.gettext(
487 "Invalid choice(s): one or more data inputs could not be coerced."
488 )
489 self.invalid_choice_message = invalid_choice_message
490
491 def iter_choices(self):
492 choices = self._choices_from_input(self.choices) or []
493 data = self.data or ()
494 return [
495 Choice(
496 value=c.value,
497 label=c.label,
498 selected=self.coerce(c.value) in data,
499 render_kw=c.render_kw,
500 )
501 for c in choices
502 ]
503
504 def iter_groups(self):
505 choices = self._choices_from_input(self.choices) or []
506 data = self.data or ()
507 for optgroup, group in groupby(choices, key=attrgetter("optgroup")):
508 yield (
509 optgroup,
510 [
511 Choice(
512 value=c.value,
513 label=c.label,
514 selected=self.coerce(c.value) in data,
515 render_kw=c.render_kw,
516 )
517 for c in group
518 ],
519 )
520
521 def process_data(self, value):
522 try:
523 self.data = list(self.coerce(v) for v in value)
524 except (ValueError, TypeError):
525 self.data = None
526
527 def process_formdata(self, valuelist):
528 try:
529 self.data = list(self.coerce(x) for x in valuelist)
530 except ValueError as exc:
531 raise ValueError(self.invalid_value_message) from exc
532
533 def pre_validate(self, form):
534 if self.process_errors:
535 return
536
537 if not self.validate_choice or not self.data:
538 return
539
540 if self.choices is None:
541 raise TypeError(self.gettext("Choices cannot be None."))
542
543 acceptable = {
544 self.coerce(choice.value) for choice in self._iter_choices_normalized()
545 }
546 if any(data not in acceptable for data in self.data):
547 unacceptable = [
548 str(data) for data in set(self.data) if data not in acceptable
549 ]
550 if callable(self.invalid_choice_message):
551 message = self.invalid_choice_message(len(unacceptable))
552 elif self.invalid_choice_message is not None:
553 message = self.invalid_choice_message
554 else:
555 message = self.ngettext(
556 "'%(value)s' is not a valid choice for this field.",
557 "'%(value)s' are not valid choices for this field.",
558 len(unacceptable),
559 )
560 raise ValidationError(message % dict(value="', '".join(unacceptable)))
561
562
563class RadioField(SelectField):
564 """
565 Like a SelectField, except displays a list of :mdn-input:`radio` buttons.
566
567 Iterating the field will produce subfields (each containing a label as
568 well) in order to allow custom rendering of the individual radio fields.
569 """
570
571 widget = widgets.ListWidget(prefix_label=False)
572 option_widget = widgets.RadioInput()
573
574 def __init__(self, label=None, validators=None, **kwargs):
575 super().__init__(label, validators, **kwargs)
576 self.label.field_id = False