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

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

274 statements  

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