Using WTForms with Enum
Asked Answered
B

7

5

I have the following code:

class Company(enum.Enum):
    EnterMedia = 'EnterMedia'
    WhalesMedia = 'WhalesMedia'

    @classmethod
    def choices(cls):
        return [(choice.name, choice.name) for choice in cls]

    @classmethod
    def coerce(cls, item):
        print "Coerce", item, type(item)
        if item == 'WhalesMedia':
            return Company.WhalesMedia
        elif item == 'EnterMedia':
            return Company.EnterMedia
        else:
            raise ValueError

And this is my wtform field:

company = SelectField("Company", choices=Company.choices(), coerce=Company.coerce)

This is the html generated in my form:

<select class="" id="company" name="company" with_label="">
    <option value="EnterMedia">EnterMedia</option>
    <option value="WhalesMedia">WhalesMedia</option>
</select>

Somehow, when I click submit, I keep getting "Not a Valid Choice".

Any ideas why?

This is my terminal output:

When I look at my terminal I see the following:

Coerce None <type 'NoneType'>
Coerce EnterMedia <type 'unicode'>
Coerce EnterMedia <type 'str'>
Coerce WhalesMedia <type 'str'>
Boccherini answered 19/5, 2017 at 20:54 Comment(1)
You can map string names to objects (coerce) simply by indexing the enum: Company["EnterMedia"] produces Company.EnterMedia. You can use coerce=Company.__getitem__. Calling works too, for values, so coerce=Company. See See Programmatic access. Your names and values match, so you can use either here.Boggers
B
-3
class Company(enum.Enum):
  WhalesMedia = 'WhalesMedia'
  EnterMedia = 'EnterMedia'

  @classmethod
  def choices(cls):
    return [(choice, choice.value) for choice in cls]

  @classmethod
  def coerce(cls, item):
    """item will be both type(enum) AND type(unicode).
    """
    if item == 'Company.EnterMedia' or item == Company.EnterMedia:
      return Company.EnterMedia
    elif item == 'Company.WhalesMedia' or item == Company.WhalesMedia:
      return Company.WhalesMedia
    else:
      print "Can't coerce", item, type(item)

So I hacked around and this works.

It seems to me that coerce will be applied to both (x,y) for (x,y) in choices.

I can't seem to understand why I keep seeing: Can't coerce None <type 'NoneType'> though

Boccherini answered 19/5, 2017 at 22:48 Comment(1)
See my solution, it's considerably cleanerContaminant
B
5

WTForm will either pass in strings, None, or already coerced data to coerce; this is a little annoying but easily handled by testing if the data to coerce is already an instance:

isinstance(someobject, Company)

The coerce function must otherwise raise a ValueError or TypeError when coercing.

You want to use the enum names as the values in the select box; these are always going to be strings. If your enum values are suitable as labels, then that's great, you can use those for the option readable text, but don't confuse those with the option values, which must be unique, enum values do not need to be.

Enum classes let you map a string containing an enum name to the Enum instance by using subscription:

enum_instance = Company[enum_name]

See Programmatic access to enumeration members and their attributes in the enum module documentation.

Next, we could leave conversion of the enum objects to unique strings (for the value="..." attribute of <option> tags) and to label strings (to show to the users) to standard hook methods on the enum class, such as __str__ and __html__.

Together, for your specific setup, use:

from markupsafe import escape

class Company(enum.Enum):
    EnterMedia = 'Enter Media'
    WhalesMedia = 'Whales Media'

    def __str__(self):
        return self.name  # value string

    def __html__(self):
        return self.value  # label string

def coerce_for_enum(enum):
    def coerce(name):
        if isinstance(name, enum):
            return name
        try:
            return enum[name]
        except KeyError:
            raise ValueError(name)
    return coerce

company = SelectField(
    "Company",
    # (unique value, human-readable label)
    # the escape() call can be dropped when using wtforms 3.0 or newer
    choices=[(v, escape(v)) for v in Company],
    coerce=coerce_for_enum(Company)
)

The above keeps the Enum class implementation separate from the presentation; the cource_for_enum() function takes care of mapping KeyErrors to ValueErrors. The (v, escape(v)) pairs provide the value and label for each option; str(v) is used for the <option value="..."> attribute value, and that same string is then used via Company[__html__result] to coerce back to enum instances. WTForms 3.0 will start using MarkupSafe for labels, but until then, we can directly provide the same functionality with escape(v), which in turn uses __html__ to provide a suitable rendering.

If having to remember about what to put in the list comprehension, and to use coerce_for_enum() is becoming tedious, you can generate the choices and coerce options with a helper function; you could even have it verify that there are suitable __str__ and __html__ methods available:

def enum_field_options(enum):
    """Produce WTForm Field instance configuration options for an Enum

    Returns a dictionary with 'choices' and 'coerce' keys, use this as
    **enum_fields_options(EnumClass) when constructing a field:

    enum_selection = SelectField("Enum Selection", **enum_field_options(EnumClass))

    Labels are produced from str(enum_instance.value) or 
    str(eum_instance), value strings with str(enum_instance).

    """
    assert not {'__str__', '__html__'}.isdisjoint(vars(enum)), (
        "The {!r} enum class does not implement __str__ and __html__ methods")

    def coerce(name):
        if isinstance(name, enum):
            # already coerced to instance of this enum
            return name
        try:
            return enum[name]
        except KeyError:
            raise ValueError(name)

    return {'choices': [(v, escape(v)) for v in enum], 'coerce': coerce}

and for your example, then use

company = SelectField("Company", **enum_field_options(Company))

Note that once WTForm 3.0 is released, you can use a __html__ method on enum objects without having to use markdownsafe.escape(), because the project is switching to using MarkupSafe for the label values.

Boggers answered 15/8, 2018 at 12:8 Comment(0)
I
4

I've just been down the same rabbit hole. Not sure why, but coerce gets called with None when the form is initialised. After wasting a lot of time, I decided it's not worth coercing, and instead I just used:

field = SelectField("Label", choices=[(choice.name, choice.value) for choice in MyEnum])

and to get the value:

selected_value = MyEnum[field.data]
Icily answered 15/11, 2017 at 16:29 Comment(1)
Works and validates too.Dot
C
4

This is a lot cleaner than the accepted solution, as you don't need to put the options more than once.

By default Python will convert objects to strings using their path, which is why you end up with Company.EnterMedia and so on. In the below solution, I use __str__ to tell python that the name should be used instead, and then use the [] notation to look up the enum object by the name.

class Company(enum.Enum):
    EnterMedia = 'EnterMedia'
    WhalesMedia = 'WhalesMedia'

    def __str__(self):
        return self.name

    @classmethod
    def choices(cls):
        return [(choice, choice.value) for choice in cls]

    @classmethod
    def coerce(cls, item):
        return item if isinstance(item, Company) else Company[item]
Contaminant answered 20/3, 2018 at 4:15 Comment(2)
Don't use type(...) == ..., ever. You want isinstance(item, Company), or if subclasses are not permissable, use type(item) is Company (note the identity test, not equality). Concrete Enum classes are not sub-classable, so isinstance() is more than fine.Boggers
And coerce() will never be called with an enum instance, because WTF deals with strings, exclusively!Boggers
M
3

I think you need to convert the argument passed to coerce method into an instance of the enum.

import enum

class Company(enum.Enum):
    EnterMedia = 'EnterMedia'
    WhalesMedia = 'WhalesMedia'

    @classmethod
    def choices(cls):
        return [(choice.name, choice.value) for choice in cls]

    @classmethod
    def coerce(cls, item):
        item = cls(item) \
               if not isinstance(item, cls) \
               else item  # a ValueError thrown if item is not defined in cls.
        return item.value
        # if item.value == 'WhalesMedia':
        #     return Company.WhalesMedia.value
        # elif item.value == 'EnterMedia':
        #     return Company.EnterMedia.value
        # else:
        #     raise ValueError
Memorialize answered 19/5, 2017 at 22:35 Comment(4)
I gave this a try and it doesn't work, getting a "Not a valid choice". This is what's being rendered: <select id="company" name="company"><option selected value="EnterMedia">EnterMedia</option><option value="WhalesMedia">WhalesMedia</option></select>. And this is what form.company.data = Company.WhalesMedia (type:<enum 'Company'>)Boccherini
I believe the error arise from Enum.coerce mapping form.data to an enum but WTForm's SelectField is looking for a string since that's what's given in choicesBoccherini
@Sparrowcide I updated the answer to return the value in the enum field.Memorialize
Mapping string names to enum objects is done by using [...]: Company['EnterMedia']. See Programmatic access. Calling can work too, for values.Boggers
K
1

The function pointed to by the coerce parameter needs to convert the string delivered by the browser (the value of the <select>ed <option>) into the type of the values you specified in your choices:

Select fields keep a choices property which is a sequence of (value, label) pairs. The value portion can be any type in theory, but as form data is sent by the browser as strings, you will need to provide a function which can coerce the string representation back to a comparable object.

https://wtforms.readthedocs.io/en/2.2.1/fields.html#wtforms.fields.SelectField

That way the coerced provided value can be compared with the configured ones.

Since you are already using the name strings of your enum items as values (choices=[(choice.name, choice.name) for choice in Company]), there is no coercing for you to do.

If you decided to use integer Enum::values as the values for the <option>s, you'd have to coerce the returned strings back into ints for comparison.

choices=[(choice.value, choice.name) for choice in Company],
coerce=int

If you wanted to get enum items out of your form, you'd have to configure those in your choices ([(choice, choice.name) for choice in Company]) and coerce their string serialization (e.g. Company.EnterMedia) back into Enum instances, dealing with the issues mentioned in the other answers such as None and coerced enum instances being passed into your function:

Given you return the Company::name in Company::__str__ and using EnterMedia as a default:

coerce=lambda value: value if isinstance(value, Company) else Company[value or Company.EnterMedia.name]

Hth, dtk

Kingsbury answered 16/7, 2019 at 0:49 Comment(0)
C
0

Here a different method that just creates a new WTF EnumField and does some class manipulation on the enum-type to make it seamlessly usable with these function:

import enum

@enum.unique
class MyEnum(enum.Enum):
    foo = 0
    bar = 10

then create the EnumField definition somewhere, which just extends SelectField to use Enum-types:

import enum
from markupsafe import escape
from wtforms import SelectField

from typing import Union, Callable


class EnumField(SelectField):
    def coerce(enum_type: enum.Enum) -> Callable[[Union[enum.Enum, str]], enum.Enum]:
        def coerce(name: Union[enum.Enum, str]) -> enum.Enum:
            if isinstance(name, enum_type):
                return name
            try:
                return enum_type[name]
            except KeyError:
                raise ValueError(name)
        return coerce

    def __init__(self, enum_type: enum.Enum, *args, **kwargs):
        def attach_functions(enum_type: enum.Enum) -> enum.Enum:
            enum_type.__str__ = lambda self: self.name
            enum_type.__html__ = lambda self: self.name
            return enum_type

        _enum_type = attach_functions(enum_type)
        super().__init__(_enum_type.__name__,
            choices=[(v, escape(v)) for v in _enum_type],
            coerce=EnumField.coerce(_enum_type), *args, **kwargs)

now in your code, you can use stuff naively:

class MyForm(FlaskForm):
    field__myenum = EnumField(MyEnum)
    submit = SubmitField('Submit')

@app.route("/action", methods=['GET', 'POST'])
def action():
    form = MyForm()
    if form.validate_on_submit():
        print('Enum value is: ', form.field__myenum)  #<MyEnum.foo: 0>
        return redirect(url_for('.action'))
    elif request.method == 'GET':  # display the information on record
        form.field__myenum.data = MyEnum.foo
        form.field__myenum.default = MyEnum.foo
    return render_template('action.html', form=form)
Cowling answered 13/3, 2020 at 14:20 Comment(0)
B
-3
class Company(enum.Enum):
  WhalesMedia = 'WhalesMedia'
  EnterMedia = 'EnterMedia'

  @classmethod
  def choices(cls):
    return [(choice, choice.value) for choice in cls]

  @classmethod
  def coerce(cls, item):
    """item will be both type(enum) AND type(unicode).
    """
    if item == 'Company.EnterMedia' or item == Company.EnterMedia:
      return Company.EnterMedia
    elif item == 'Company.WhalesMedia' or item == Company.WhalesMedia:
      return Company.WhalesMedia
    else:
      print "Can't coerce", item, type(item)

So I hacked around and this works.

It seems to me that coerce will be applied to both (x,y) for (x,y) in choices.

I can't seem to understand why I keep seeing: Can't coerce None <type 'NoneType'> though

Boccherini answered 19/5, 2017 at 22:48 Comment(1)
See my solution, it's considerably cleanerContaminant

© 2022 - 2024 — McMap. All rights reserved.