1import itertools
2
3from wtforms import widgets
4from wtforms.fields.core import Field
5from wtforms.validators import ValidationError
6
7__all__ = (
8 "SelectField",
9 "SelectMultipleField",
10 "RadioField",
11)
12
13
14class SelectFieldBase(Field):
15 option_widget = widgets.Option()
16
17 """
18 Base class for fields which can be iterated to produce options.
19
20 This isn't a field, but an abstract base class for fields which want to
21 provide this functionality.
22 """
23
24 def __init__(self, label=None, validators=None, option_widget=None, **kwargs):
25 super().__init__(label, validators, **kwargs)
26
27 if option_widget is not None:
28 self.option_widget = option_widget
29
30 def iter_choices(self):
31 """
32 Provides data for choice widget rendering. Must return a sequence or
33 iterable of (value, label, selected, render_kw) tuples.
34 """
35 raise NotImplementedError()
36
37 def has_groups(self):
38 return False
39
40 def iter_groups(self):
41 raise NotImplementedError()
42
43 def __iter__(self):
44 opts = dict(
45 widget=self.option_widget,
46 validators=self.validators,
47 name=self.name,
48 render_kw=self.render_kw,
49 _form=None,
50 _meta=self.meta,
51 )
52 for i, choice in enumerate(self.iter_choices()):
53 if len(choice) == 4:
54 value, label, checked, render_kw = choice
55 else:
56 value, label, checked = choice
57 render_kw = {}
58
59 opt = self._Option(label=label, id=f"{self.id}-{i}", **opts, **render_kw)
60 opt.process(None, value)
61 opt.checked = checked
62 yield opt
63
64 class _Option(Field):
65 checked = False
66
67 def _value(self):
68 return str(self.data)
69
70
71class SelectField(SelectFieldBase):
72 widget = widgets.Select()
73
74 def __init__(
75 self,
76 label=None,
77 validators=None,
78 coerce=str,
79 choices=None,
80 validate_choice=True,
81 **kwargs,
82 ):
83 super().__init__(label, validators, **kwargs)
84 self.coerce = coerce
85 if callable(choices):
86 choices = choices()
87 if choices is not None:
88 self.choices = choices if isinstance(choices, dict) else list(choices)
89 else:
90 self.choices = None
91 self.validate_choice = validate_choice
92
93 def iter_choices(self):
94 if not self.choices:
95 choices = []
96 elif isinstance(self.choices, dict):
97 choices = list(itertools.chain.from_iterable(self.choices.values()))
98 else:
99 choices = self.choices
100
101 return self._choices_generator(choices)
102
103 def has_groups(self):
104 return isinstance(self.choices, dict)
105
106 def iter_groups(self):
107 if isinstance(self.choices, dict):
108 for label, choices in self.choices.items():
109 yield (label, self._choices_generator(choices))
110
111 def _choices_generator(self, choices):
112 if not choices:
113 _choices = []
114
115 elif isinstance(choices[0], list | tuple):
116 _choices = choices
117
118 else:
119 _choices = zip(choices, choices, strict=False)
120
121 for value, label, *other_args in _choices:
122 selected = self.coerce(value) == self.data
123 render_kw = other_args[0] if len(other_args) else {}
124 yield (value, label, selected, render_kw)
125
126 def process_data(self, value):
127 try:
128 # If value is None, don't coerce to a value
129 self.data = self.coerce(value) if value is not None else None
130 except (ValueError, TypeError):
131 self.data = None
132
133 def process_formdata(self, valuelist):
134 if not valuelist:
135 return
136
137 try:
138 self.data = self.coerce(valuelist[0])
139 except ValueError as exc:
140 raise ValueError(self.gettext("Invalid Choice: could not coerce.")) from exc
141
142 def pre_validate(self, form):
143 if not self.validate_choice:
144 return
145
146 if self.choices is None:
147 raise TypeError(self.gettext("Choices cannot be None."))
148
149 for _, _, match, *_ in self.iter_choices():
150 if match:
151 break
152 else:
153 raise ValidationError(self.gettext("Not a valid choice."))
154
155
156class SelectMultipleField(SelectField):
157 """
158 No different from a normal select field, except this one can take (and
159 validate) multiple choices. You'll need to specify the HTML `size`
160 attribute to the select field when rendering.
161 """
162
163 widget = widgets.Select(multiple=True)
164
165 def _choices_generator(self, choices):
166 if not choices:
167 _choices = []
168
169 elif isinstance(choices[0], list | tuple):
170 _choices = choices
171
172 else:
173 _choices = zip(choices, choices, strict=False)
174
175 for value, label, *other_args in _choices:
176 selected = self.data is not None and self.coerce(value) in self.data
177 render_kw = other_args[0] if len(other_args) else {}
178 yield (value, label, selected, render_kw)
179
180 def process_data(self, value):
181 try:
182 self.data = list(self.coerce(v) for v in value)
183 except (ValueError, TypeError):
184 self.data = None
185
186 def process_formdata(self, valuelist):
187 try:
188 self.data = list(self.coerce(x) for x in valuelist)
189 except ValueError as exc:
190 raise ValueError(
191 self.gettext(
192 "Invalid choice(s): one or more data inputs could not be coerced."
193 )
194 ) from exc
195
196 def pre_validate(self, form):
197 if not self.validate_choice or not self.data:
198 return
199
200 if self.choices is None:
201 raise TypeError(self.gettext("Choices cannot be None."))
202
203 acceptable = [self.coerce(choice[0]) for choice in self.iter_choices()]
204 if any(data not in acceptable for data in self.data):
205 unacceptable = [
206 str(data) for data in set(self.data) if data not in acceptable
207 ]
208 raise ValidationError(
209 self.ngettext(
210 "'%(value)s' is not a valid choice for this field.",
211 "'%(value)s' are not valid choices for this field.",
212 len(unacceptable),
213 )
214 % dict(value="', '".join(unacceptable))
215 )
216
217
218class RadioField(SelectField):
219 """
220 Like a SelectField, except displays a list of radio buttons.
221
222 Iterating the field will produce subfields (each containing a label as
223 well) in order to allow custom rendering of the individual radio fields.
224 """
225
226 widget = widgets.ListWidget(prefix_label=False)
227 option_widget = widgets.RadioInput()