Skip to content

SelectField backward compatibility and minor QoL improvements#923

Open
azmeuk wants to merge 9 commits into
pallets-eco:mainfrom
azmeuk:922-selectfield
Open

SelectField backward compatibility and minor QoL improvements#923
azmeuk wants to merge 9 commits into
pallets-eco:mainfrom
azmeuk:922-selectfield

Conversation

@azmeuk
Copy link
Copy Markdown
Member

@azmeuk azmeuk commented May 13, 2026

I addressed most of the points raised in #922

  • Add a post_process step which happens right after process
  • Add optional form and field args to SelectField and DataList choices callbacks. Callbacks are resolved during the post_process step, so it can access the other fields .data
  • Choice inherit from NamedTuple instead of Dataclass. An additional private _Choice class is needed to pass default values to the tuple members since NamedTuple forbids overriding new
  • Restore has_groups and iter_groups
  • Backward compatibility for iter_choices returning tuples, for render_options taking value, label, selected instead of choice.
  • Support choices as dict as suggested in Support dict for SelectForm.choices #886

The compatibility layer makes the code bigger and harder to read, but hopefully things goes better when we delete the deprecations in 3.4/4.0.

I have tested those modification in downstream projects (wtf-peewee, wtforms-alchemy, starlette-wtf, flask-appbuilder) and it solves all the issues related to SelectField.

/cc @Daverball

@ElLorans
Copy link
Copy Markdown

ElLorans commented May 13, 2026

Couldn't double check everything, but if we were extending a Field now we have to change from iter_choices to _iter_choices_normalized?

Copy link
Copy Markdown

@Daverball Daverball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, this looks pretty good and alleviates my concerns. I will try to take another deep dive later, to see if there is anything else you or I missed.

I did spot one small issue with the new callback, but that's an easy fix, it just adds a little bit more code.

Comment thread src/wtforms/fields/choices.py Outdated
@azmeuk
Copy link
Copy Markdown
Member Author

azmeuk commented May 13, 2026

Couldn't double check everything, but if we were extending a Field now we have to change from iter_choices to _iter_choices_normalized?

No, you keep overriding iter_choices. _iter_choices_normalized is just a private helper that normalizes the iter_choices output to keep backward compatibility (and raise deprecation warnings).

Copy link
Copy Markdown

@Daverball Daverball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon closer review I found a few additional subtle issues, but other than that I think we're good.

Comment thread src/wtforms/fields/choices.py Outdated
Comment thread src/wtforms/fields/choices.py Outdated
Comment thread src/wtforms/form.py Outdated
Comment thread src/wtforms/fields/choices.py
Copy link
Copy Markdown

@Daverball Daverball left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I stand corrected, found a couple more things in the widget code.

Comment thread src/wtforms/widgets/core.py Outdated
Comment thread src/wtforms/widgets/core.py Outdated
@azmeuk azmeuk force-pushed the 922-selectfield branch from 3170d05 to 3287fc6 Compare May 18, 2026 15:12
azmeuk added 2 commits May 18, 2026 18:11
Add Field._form and BaseForm._parent_form so fields and nested forms can
reach the enclosing form. FieldList is transparent in the chain: a
FormField nested in a FieldList points to the form that owns the list,
not the list. Also fix FieldList entries which previously got _form=None.
Add Field.post_process() and BaseForm.post_process() hooks, invoked at
the end of BaseForm.process() on the root form. FormField and FieldList
propagate the hook to their nested form or entries, so every nested
field's hook runs exactly once.
@azmeuk azmeuk force-pushed the 922-selectfield branch 2 times, most recently from ea8013a to 7de39bd Compare May 18, 2026 16:47
Comment thread src/wtforms/widgets/core.py
Comment thread src/wtforms/fields/choices.py Outdated
@azmeuk azmeuk force-pushed the 922-selectfield branch 3 times, most recently from 9009bb2 to 6f91c47 Compare May 22, 2026 21:09
@azmeuk
Copy link
Copy Markdown
Member Author

azmeuk commented May 22, 2026

I think I adressed most of your feedback. I went back to dataclasses with iter for the tuple compatibility, because this is hard to handle default values and inheritance with them. I tested on several dowstream projects and the unit test suites pass.

azmeuk added 7 commits May 22, 2026 23:19
The choices callable may optionally accept (form, field) as positional
arguments, mirroring the validator signature. Resolved from post_process
so it can read processed data from any field on the form.
DataList callable choices follow the same contract as SelectField: the
callable accepts (form, field) or no arguments and is invoked once per
form processing cycle from post_process.
SelectChoice / DataListChoice declare options for SelectField / DataList;
Choice is the shape yielded by iter_choices and iter_groups. All three
are dataclasses with __iter__ preserving the 3.2 tuple-unpacking contract.
Pipe iter_choices and iter_groups yields through _normalize_iter_choice
with a DeprecationWarning so subclasses yielding 3.2-shaped tuples keep
working until 4.0. The Select widget consumes the normalized iterators
in both flat and grouped paths.
Don't coerce choices to SelectChoice at __init__; keep the user-supplied
shape so subclasses iterating self.choices directly keep working.
iter_choices still coerces per render. Legacy dict and raw tuple shapes
emit a one-shot DeprecationWarning via _warn_legacy_choices.
Detect render_option overrides using the WTForms 3.2 signature
(cls, value, label, selected, **kwargs) via _dispatch_render_option,
adapt them to receive the new (cls, choice, **kwargs) signature, and
emit a DeprecationWarning.
{value: label} for flat options, {label: {value: label}} for optgroups,
both forms mixable at the top level. _warn_legacy_choices treats this
shorthand as non-deprecated.
@azmeuk azmeuk force-pushed the 922-selectfield branch from 6f91c47 to 08a4079 Compare May 22, 2026 21:24
@Daverball
Copy link
Copy Markdown

I think I adressed most of your feedback. I went back to dataclasses with iter for the tuple compatibility, because this is hard to handle default values and inheritance with them. I tested on several dowstream projects and the unit test suites pass.

Thanks so much, I'll take another closer look later, but there's one thing I can already say now.

Using dataclasses for SelectChoice/DataListChoice seems fine to me, however for Choice I still think a NamedTuple is the way to go. Choice doesn't need any defaults, you can just make all four components mandatory, since it's for internal use only, so you also don't need to use any subclassing tricks, you can just directly use a plain NamedTuple.

class Choice(NamedTuple):
    """
    A rendered option yielded by
    :meth:`SelectFieldBase.iter_choices` and
    :meth:`SelectFieldBase.iter_groups`.

    ``selected`` is computed against the field's current data. To
    declare options on a :class:`SelectField`, use
    :class:`SelectChoice` instead.

    :param value:
        The value that will be sent in the request.
    :param label:
        The label of the option.
    :param selected:
        Whether the option is currently selected. Set by ``iter_choices``;
        you rarely set this yourself.
    :param render_kw:
        A dict containing HTML attributes that will be rendered
        with the option.
    """

    value: str
    label: str
    selected: bool
    render_kw: dict

The main reason to use a NamedTuple for Choice is so that type checkers better understand them as structured types for the purposes of unpacking, you can't unpack a dataclass that has mixed field types and have type checkers know the type of each component. This preserves exact typing information and ergonomics for people that decide to keep their implementations the same, instead of switching to named attribute access, which can make the code longer.

I.e. compare

for value, label, selected, render_kw in field.iter_choices():
    do_something_with(value, label, selected)

to

for choice in field.iter_choices():
    do_something_with(choice.value, choice.label, choice.selected)

Neither code is obviously better than the other. Forcing people to switch their code to the latter, just to preserve good type checking experience seems like unnecessary downstream churn for no real benefit to anyone, when a NamedTuple would've worked just as well. It also avoids any potential risks for breakage in downstream code that expects a tuple, since they will still get one, since NamedTuple classes are subclasses of tuple.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants