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