8

I would like to extend Python Enums to support non-defined values.

My use case: I want to benefit from Enums (e.g., being able to address/compare known entries with speaking names) but I also want it to support unknown values. If there's no name behind the value, str should simply print the str representation of the value and comparisons should fail.

Let me give you a short example what I'd like to have:

from enum import Enum

class Foo(Enum):
    A = 1
    B = 2
    C = 3

    def __str__(self):
        return self.name


print(Foo(1)) # prints 'A'
print(Foo(2)) # print 'B'
print(Foo(3)) # prints 'C'
print(Foo(1) == Foo.A) # prints 'true'

print(Foo(4)) # I'd expect '4'
print(Foo(123)) # I'd expect '123'
print(Foo(123) == Foo.A) # I'd expect False

Of course the last lines fail.

Is there a way to extend Enums, or is there maybe another easy pythonic way? (No third party libraries, please.)

rralf
  • 1,202
  • 15
  • 27
  • 5
    This is counter to the use of enums, which are supposed to *enum*erate a set of *known* values. – chepner May 05 '19 at 21:35
  • Ok, got this -- is there another simple pythonic way to achieve my goal? – rralf May 05 '19 at 21:40
  • 3
    [Please explain what you need this for](http://xyproblem.info/) -- it's very likely there's a more fitting solution. – ivan_pozdeev May 05 '19 at 22:26
  • 2
    Let me try. I parse magic constants (integers). Some of them are well-defined, and there are speaking names for them, some aren't. I need to check for some of those well-defined integers, and I want to use their speaking names during the comparison (for the sake of readability of code). Later on, I want to use type-safe representations of those values to generate config files. If the instance has a speaking name, print it. If not, simply use its raw integer value. Enums fulfil these requirements, as long as I don't have undefined values. But there are some. Hope that helps. – rralf May 05 '19 at 22:49
  • I can see that happening. Check my answer for a solution. – Ethan Furman May 06 '19 at 08:27

5 Answers5

4

To make this work across multiple values of Python, particularly Python 2, you are going to need an external library: aenum1.

from aenum import Enum, extend_enum

class Foo(Enum):
    _order_ = 'A B C'   # because Python 2 does not keep order
    A = 1
    B = 2
    C = 3

    def __str__(self):
        return self.name

    @classmethod
    def _missing_(cls, value):
        extend_enum(cls, str(value), (value, ))
        return list(cls)[-1]

This dynamically adds the unknown value as an enum member and returns it.


1 Disclosure: I am the author of the Python stdlib Enum, the enum34 backport, and the Advanced Enumeration (aenum) library.

Ethan Furman
  • 63,992
  • 20
  • 159
  • 237
  • 1
    works perfect, but as noted in my initial questions I'd like to avoid third party libraries. – rralf May 06 '19 at 10:41
  • @rralf: And as I noted in my answer, if you're going to support both Python 2 & 3 then you'll need a third-party library... unless you use a non-Enum solution or rewrite the whole thing yourself, of course. – Ethan Furman May 06 '19 at 16:53
3

Here's another answer that works in Python 3.6, 3.7 and 3.8.

It involves weird metaclass hackery, so be wary of that.

import enum

class TestMeta(enum.EnumMeta):
    def __call__(cls, value, names=None, *, module=None, qualname=None, type=None, start=1):
        if names is not None:
            # the enum is being constructed via the functional syntax
            return super().__call__(value, names=names, module=module, qualname=qualname, type=type, start=start)

        try:
            # attempt to get an enum member
            return super().__call__(value, names=names, module=module, qualname=qualname, type=type, start=start)
        except ValueError:
            # no such member exists, but we don't care
            return value

class Test(enum.Enum, metaclass=TestMeta):
    A = 5
    B = 6    

print(Test(5), Test(6), Test(7))

This version works in Python 2.7, but requires a third-party library for enum (see comments):

class TestMeta(enum.EnumMeta):
    def __call__(cls, value, names=None, module=None, type=None, start=1):
        if names is not None:
            return enum.EnumMeta.__call__(cls, value, names, module, type, start)

        try:    
            return enum.EnumMeta.__call__(cls, value, names, module, type, start)
        except ValueError:
            return value
ForceBru
  • 43,482
  • 10
  • 63
  • 98
  • Looks like a promising solution. In my question I forgot to mention that I also need to support python2, but I will accept this if there are no further solutions. – rralf May 06 '19 at 10:48
  • @rralf, I added a version that seems to work in Python 2 – ForceBru May 06 '19 at 11:25
  • @rralf: There is no `enum` in Python 2.7, so which third-party library is being used? – Ethan Furman May 06 '19 at 16:57
  • @EthanFurman, yeah, it actually doesn't... Yet importing _some kind of_ `enum` somehow works. Running `help(enum)` on the site doesn't give any info about the version of the library or any links. – ForceBru May 06 '19 at 17:09
  • @ForceBru: since `EnumMeta` is there, it's probably `enum34`, which is a bare-bones version of the `Enum` included in Python3.4. The `aenum` library actually has all the features of `Enum` present in the latest Python, plus a few extra things (such as `extend_enum()`). – Ethan Furman May 06 '19 at 18:56
  • @ForceBru: You can have one version to support both 2.7 and 3+ if you change the signature to `def __call__(cls, value, names=None, module=None, *args, **kwds)`. Also, could you add a disclaimer at the top of your answer that says the `enum34` or `aenum` libraries are needed to work with Python 2.7? – Ethan Furman May 07 '19 at 21:37
  • @rralf: The non-Enum values returned are still not enum members, so you have no type safety. It also uses a third-party library (one of mine, actually). While my answer does use a third-party library (also mine), the dynamic values returned have been transformed into enum members. – Ethan Furman Jun 27 '19 at 21:12
2

For your use case, a more natural solution would be to:

  • Get a complete list of number values present in the input (from parsing it)
  • Autogenerate a Enum class with Functional API using those values and known identifiers

E.g.:

known_values = {'a':1,'b':2,'c':1}    # only one of 'a' and 'c' will be used
                                      # 'a' guaranteed in 3.7+, implementation-specific before
                                      # (https://stackoverflow.com/q/39980323)
extracted_values = (1,2,3,4,5)

known_values_reverse = {}
for k,v in known_values.items():
    if v not in known_values_reverse:
        known_values_reverse[v]=k
    del k,v    #if placed outside the loop, will error if known_values is empty

for v in extracted_values:
    if v not in known_values_reverse:
        known_values_reverse[v]=str(v)
    del v

AutoFooEnum = enum.Enum(value='AutoFooEnum',names=(k,v for v,k in known_values_reverse.items()))

When producing the output, you'll need to use AutoFooEnum(value).name to output the textual ID, if any, or a number.

Naturally, you won't be able to refer to numbered members with AutoFooEnum.<id> in code since a number is not a valid identifier. But it doesn't look like you need that.

ivan_pozdeev
  • 33,874
  • 19
  • 107
  • 152
2

how about ?

from enum import Enum


class CraftTypes(Enum):
    wood_work = 0
    welding = 1
    mechanics = 2
    unknown = 3
    # 3 and above is unknown


    @classmethod
    def _missing_(cls, number):
        return cls(cls.unknown)

simple and pythonic...

2

One could try to extend the enum at runtime with a new member following the example in enum.Flag._create_pseudo_member_:

from enum import Enum

class Foo(Enum):
    A = 1
    B = 2
    C = 3

    def __str__(self):
        return self.name

    @classmethod
    def _missing_(cls, value):
        if cls._member_type_ is object:
            # construct a new singleton enum member
            new_member = object.__new__(cls)
            # include the value representation in _name_ because the Enum hash implementation relies on _name_
            new_member._name_ = '%r' % value
            new_member._value_ = value
            # use setdefault in case another thread already created a composite with this value
            new_member = cls._value2member_map_.setdefault(value, new_member)
            return new_member

This simplistic implementation will break the possibility to mix Enum with other types in class definitions, but this could certainly be fixed following the machinery in enum.EnumMeta.__new__.

As already commented by @chepner, this breaks the promise

an Enum class is final once enumeration items have been defined

(also found in enum.EnumMeta.__new__) and expectedly breaks the behavior of some Enum methods such as __len__.

jpeg
  • 2,372
  • 4
  • 18
  • 31