|
|
Log in / Subscribe / Register

Extending Python's enums

By Jake Edge
March 24, 2021

Enumerated types or "enums" are a feature of many languages, including Python; enums provide a convenient way to collect up a bunch of related symbols that (typically) evaluate to integer values. The canonical example would seem to be for colors, at least for demonstration purposes, but there are others, especially for handling "magic" constants from source likes POSIX or the host operating system. A recent thread on the python-ideas mailing list discusses different ways to add a new feature to enums—seven years after they were added to the standard library as part of Python 3.4.

Background

We covered some of the discussion around PEP 435 ("Adding an Enum type to the Python standard library") back in 2013 when it was approved. For those who are unfamiliar with the enum feature, a mini-introduction may be in order; following the documentation for the enum module, the examples will use upper case for the names, though it is not required (and the PEP does not).

    >>> from enum import Enum
    >>> class Color(Enum):
    ...     RED = 1
    ...     GREEN = 2
    ...     BLUE = 3
    ... 
    >>> Color.GREEN
    <Color.GREEN: 2>
    >>> Color.GREEN == 2
    False
    >>> Color.GREEN.value == 2
    True

    >>> Color(3)
    <Color.BLUE: 3>
    >>> Color.RED != Color.BLUE
    True
    >>> print(Color['RED'])
    Color.RED
Here we define Color as an Enum with three name-value pairs as "members" of the enum that correspond to colors with consecutive integer values. There is no requirement that the integers be consecutive or, even, integers. As the PEP puts it:
In the vast majority of use-cases, one doesn't care what the actual value of an enumeration is. But if the value is important, enumerations can have arbitrary values.

Beyond that, enums can be iterated over in the order the members were defined and enum members are hashable, so they can be used as keys in dictionaries. While each member name must be unique, values do not have to be, so aliases are allowed:

    >>> class Color(Enum):
    ...     RED = 1
    ...     GREEN = 2
    ...     BLUE = 3
    ...     ROJO = 1
    ...     VERDE = 2
    ...     AZUL = 3
    ... 
    >>> Color.AZUL
    <Color.BLUE: 3>
    >>> Color.VERDE is Color.GREEN
    True

    >>> for i in Color:
    ...     print(i)
    ... 
    Color.RED
    Color.GREEN
    Color.BLUE

    >>> Color.__members__
    mappingproxy({'RED': <Color.RED: 1>, 'GREEN': <Color.GREEN: 2>, 'BLUE': <Color.BLUE: 3>,
    'ROJO': <Color.RED: 1>, 'VERDE': <Color.GREEN: 2>, 'AZUL': <Color.BLUE: 3>})
As can be seen, the __members__ attribute stores all of the members defined, though there are only three members created in the example. The aliases can be used, but the first member with a given value is the "true" member that gets created.

Testing for an enum member

Ethan Furman, who is one of the authors of the PEP and maintainers of the enum module, posted a question about a common Stack Overflow query on March 12: is there a way, other than using try and except, to determine whether a given value is a member of an enum? There was a way to do so in an earlier version of Python, he said, but that was fixed as a bug along the way. He suggested adding a get() to Enum with a default to be returned if the value is not found, similar to dict.get(), adding a recipe to the documentation for enum, or to "do nothing".

Since it was a python-ideas thread, "do nothing" was probably not going to win out, though Guido van Rossum acknowledged it as a possibility. He also said that get() did not make sense and had an alternative to suggest:

But the way to convert a raw value to an enum value is Color(1), not Color[1], so Color.get(1) seems inconsistent.

Maybe you can just change the constructor so you can spell this as Color(1, default=None) (and then check whether that's None)?

Furman agreed that get() was not the right interface and liked the idea of changing the constructor, though he wanted a slightly different approach:

I think I like your constructor change idea, with a small twist:
    Color(value=<sentinel>, name=<sentinel>, default=<sentinal>)
This would make it possible to search for an enum by value or by name, and also specify a default return value (raising an exception if the default is not set and a member cannot be found).

In that message, Furman also answered Van Rossum's question about the earlier removal of a way that worked to determine if a value was a member of an enum:

    if 1 in Color:
       ...
The __contains__() special method for Enum had formerly worked for values, but that was removed because of the ambiguity over whether it should work for values or names. Van Rossum pointed out that since the sets of names and values do not overlap, both could be supported with in (which uses __contains__()). He also seemed reasonably happy with Furman's elaboration of the constructor idea, though he suggested that getattr() might make a better fit for name lookup (with an optional default) to eliminate the need for the default= keyword argument.

But Van Rossum also wondered about a corner case: "I'm not sure what Color(value=1, name='RED') would do -- insist that both value and name match? Would that have a use case?" Furman said that he did not really see a good use case, but that requiring both to match seemed like the right approach. He also noted that in some strange cases, names and values could overlap, "although having the name of one member match the value of a different member seems odd". He seemed inclined to ignore that possibility and concluded:

Everything considered, I think I like allowing `__contains__` to verify both names and values, adding `default=<sentinel>` to the constructor for the value-based "gimme an Enum or None" case, and recommending `getattr` for the name-based "gimme an Enum or None" case.

With an explicit "+1" from Van Rossum, it seemed like this relatively minor detail for the enum module had been resolved. But perhaps both Furman and Van Rossum got caught up in the discussion and did not fully consider the nature of a class constructor returning None (or whatever other random object was given as the default), rather than an object of the type being constructed. That did not strike Matt Wozniski as particularly Pythonic:

I find the idea of having the constructor potentially return something other than an instance of the class to be very... off-putting. Maybe it's the best option, but my first impression of it isn't favorable, and I can't think of any similar case that exists in the stdlib today off the top of my head. It seems like we should be able to do better.

He suggested adding from_value() and from_name() class methods, each of which would take an optional default value as a better approach. Van Rossum agreed that the constructor idea was not the right one ("and as static typing proponent I should have thought of that"), but he thought there might not really be a need for an arbitrary default, so changing __contains__() might be best. "The ergonomics of that seem better for the dominant use case ('is this a valid value for that enum?')."

There was some discussion of whether aliases (i.e. multiple names with the same value) would cause a problem for "by value" lookups, but Furman made it clear that there actually is no ambiguity. Values are associated with the first name that uses them, which is why "Color.AZUL" displays as "Color.BLUE" in the example above.

Furman has not circled back with a final decision on his plans, but it seems clear that the constructor-based idea is a non-starter even though it seemed like the right way to go from early on. Overall, the short thread gave a nice little peek into the kinds of thinking that go on in language and library design. Sometimes, the obvious solution is really not quite right, even when it seems to tick all of the right boxes. And, once again, this kind of discussion shows the value of FOSS communities openly working to arrive at a better solution; while it is not perfect by any means, the results generally speak for themselves.


Index entries for this article
PythonEnum


to post comments

Extending Python's enums

Posted Mar 26, 2021 5:54 UTC (Fri) by milesrout (subscriber, #126894) [Link] (9 responses)

The obvious solution is surely __contains__. When I saw the problem my very first thought was 'I hope the solution is "if x in Colour"'.

Extending Python's enums

Posted Mar 26, 2021 8:19 UTC (Fri) by pgdx (guest, #119243) [Link] (8 responses)

It's not exactly obvious, but it is one of the things discussed in the mailing list.

What should __contains__ do? Look for keys or values? Does it work for aliases?

Extending Python's enums

Posted Mar 26, 2021 9:18 UTC (Fri) by rschroev (subscriber, #4164) [Link] (7 responses)

Why not provide both?
if x in Colour.names():
    ...

if x in Colour.values():
    ...
That provides both use cases, and because it's explicit it's always immediately obvious what is meant, even if you're not familiar with enums. It's a bit longer but I think that's a small price to pay.

Extending Python's enums

Posted Mar 27, 2021 3:29 UTC (Sat) by NYKevin (subscriber, #129325) [Link] (6 responses)

Half of that API already exists, and is even O(1), but it's spelled very oddly: https://docs.python.org/3/library/enum.html#iteration

TL;DR:

>>> 'BLUE' in color.__members__.keys()
True
>>> 'AZUL' in color.__members__.keys()
True

The other half would be nice to have, obviously.

Extending Python's enums

Posted Mar 27, 2021 9:34 UTC (Sat) by rschroev (subscriber, #4164) [Link] (5 responses)

Thanks, I didn't know that. Now that I read that documentation more thoroughly than I did in the past: apparently __members__ is a read-only mapping object, and as such it already supports the other half. It tested it; values() works too.

So everything (at least everything I think I would ever need) is already there, it's just a bit ugly.

Thinking about it some more, my proposal was not a good one. We can't use names and values as attributes, since that would make it impossible to use those as enum names.

Extending Python's enums

Posted Mar 27, 2021 23:23 UTC (Sat) by NYKevin (subscriber, #129325) [Link] (4 responses)

Unfortunately, Color.__members__.values() consists of the objects Color.RED, Color.GREEN, and Color.BLUE. Those objects are not integers; they are basically opaque sentinel objects. You can use Color.RED.value to get back 1. There is no built-in collection of all the values, however, so you would have to build it yourself with a set comprehension or something like that.

Extending Python's enums

Posted Mar 27, 2021 23:43 UTC (Sat) by hummassa (guest, #307) [Link] (3 responses)

(x.value for x in Color.__members__.values())
is not the end of the world

Extending Python's enums

Posted Apr 16, 2021 6:12 UTC (Fri) by cpitrat (subscriber, #116459) [Link] (2 responses)

The idea is to call it with x being an integer, not a Color. Otherwise you already now it will be there :)

Extending Python's enums

Posted Apr 16, 2021 12:12 UTC (Fri) by hummassa (guest, #307) [Link] (1 responses)

You seem to have overlooked the
x.value
above...

Extending Python's enums

Posted Apr 16, 2021 17:23 UTC (Fri) by cpitrat (subscriber, #116459) [Link]

No, I missed the for x.
I read:
x.value in Color.__members__.values()

In the end the full check is a mouthful:
v in (x.value for x in Color.__members__.values())

Extending Python's enums

Posted Mar 26, 2021 23:36 UTC (Fri) by NYKevin (subscriber, #129325) [Link]

> There was some discussion of whether aliases (i.e. multiple names with the same value) would cause a problem for "by value" lookups, but Furman made it clear that there actually is no ambiguity. Values are associated with the first name that uses them, which is why "Color.AZUL" displays as "Color.BLUE" in the example above.

Another way of thinking about it: The example enum (from the article) only has three members. There's no such thing as Color.AZUL, it's really just another name for Color.BLUE. This is also why the enum module provides the enum.unique() decorator (it raises an error instead of creating an alias).

> Furman has not circled back with a final decision on his plans, but it seems clear that the constructor-based idea is a non-starter even though it seemed like the right way to go from early on. Overall, the short thread gave a nice little peek into the kinds of thinking that go on in language and library design. Sometimes, the obvious solution is really not quite right, even when it seems to tick all of the right boxes. And, once again, this kind of discussion shows the value of FOSS communities openly working to arrive at a better solution; while it is not perfect by any means, the results generally speak for themselves.

classmethods are obviously superior to using a constructor that sometimes returns None. In regular Python, you can't even attempt to return an object of the wrong type unless you override __new__(), which is rarely done (or, hypothetically, you could override __call__() on the metaclass, but that's nearly unheard of). I'm moderately surprised that the constructor approach was even discussed, but I'm not that surprised that this happened in the context of the enum module (which already does all sorts of weird stuff internally to provide a nice and simple API - for example, it really does override __call__() on the metaclass).


Copyright © 2021, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds