Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wtforms/fields/choices.py: 29%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

249 statements  

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