When Should I Subclass Enummeta Instead of Enum

When should I subclass EnumMeta instead of Enum?

The best (and only) cases I have seen so far for subclassing EnumMeta comes from these four questions:

  • A more pythonic way to define an enum with dynamic members

  • Prevent invalid enum attribute assignment

  • Create an abstract Enum class

  • Invoke a function when an enum member is accessed

We'll examine the dynamic member case further here.


First, a look at the code needed when not subclassing EnumMeta:

The stdlib way

from enum import Enum
import json

class BaseCountry(Enum):
def __new__(cls, record):
member = object.__new__(cls)
member.country_name = record['name']
member.code = int(record['country-code'])
member.abbr = record['alpha-2']
member._value_ = member.abbr, member.code, member.country_name
if not hasattr(cls, '_choices'):
cls._choices = {}
cls._choices[member.code] = member.country_name
cls._choices[member.abbr] = member.country_name
return member
def __str__(self):
return self.country_name

Country = BaseCountry(
'Country',
[(rec['alpha-2'], rec) for rec in json.load(open('slim-2.json'))],
)

The aenum way 1 2

from aenum import Enum, MultiValue
import json

class Country(Enum, init='abbr code country_name', settings=MultiValue):
_ignore_ = 'country this' # do not add these names as members
# create members
this = vars()
for country in json.load(open('slim-2.json')):
this[country['alpha-2']] = (
country['alpha-2'],
int(country['country-code']),
country['name'],
)
# have str() print just the country name
def __str__(self):
return self.country_name

The above code is fine for a one-off enumeration -- but what if creating Enums from JSON files was common for you? Imagine if you could do this instead:

class Country(JSONEnum):
_init_ = 'abbr code country_name' # remove if not using aenum
_file = 'some_file.json'
_name = 'alpha-2'
_value = {
1: ('alpha-2', None),
2: ('country-code', lambda c: int(c)),
3: ('name', None),
}

As you can see:

  • _file is the name of the json file to use
  • _name is the path to whatever should be used for the name
  • _value is a dictionary mapping paths to values3
  • _init_ specifies the attribute names for the different value components (if using aenum)

The JSON data is taken from https://github.com/lukes/ISO-3166-Countries-with-Regional-Codes -- here is a short excerpt:

[{"name":"Afghanistan","alpha-2":"AF","country-code":"004"},

{"name":"Åland Islands","alpha-2":"AX","country-code":"248"},

{"name":"Albania","alpha-2":"AL","country-code":"008"},

{"name":"Algeria","alpha-2":"DZ","country-code":"012"}]

Here is the JSONEnumMeta class:

class JSONEnumMeta(EnumMeta):

@classmethod
def __prepare__(metacls, cls, bases, **kwds):
# return a standard dictionary for the initial processing
return {}

def __init__(cls, *args , **kwds):
super(JSONEnumMeta, cls).__init__(*args)

def __new__(metacls, cls, bases, clsdict, **kwds):
import json
members = []
missing = [
name
for name in ('_file', '_name', '_value')
if name not in clsdict
]
if len(missing) in (1, 2):
# all three must be present or absent
raise TypeError('missing required settings: %r' % (missing, ))
if not missing:
# process
name_spec = clsdict.pop('_name')
if not isinstance(name_spec, (tuple, list)):
name_spec = (name_spec, )
value_spec = clsdict.pop('_value')
file = clsdict.pop('_file')
with open(file) as f:
json_data = json.load(f)
for data in json_data:
values = []
name = data[name_spec[0]]
for piece in name_spec[1:]:
name = name[piece]
for order, (value_path, func) in sorted(value_spec.items()):
if not isinstance(value_path, (list, tuple)):
value_path = (value_path, )
value = data[value_path[0]]
for piece in value_path[1:]:
value = value[piece]
if func is not None:
value = func(value)
values.append(value)
values = tuple(values)
members.append(
(name, values)
)
# get the real EnumDict
enum_dict = super(JSONEnumMeta, metacls).__prepare__(cls, bases, **kwds)
# transfer the original dict content, _items first
items = list(clsdict.items())
items.sort(key=lambda p: (0 if p[0][0] == '_' else 1, p))
for name, value in items:
enum_dict[name] = value
# add the members
for name, value in members:
enum_dict[name] = value
return super(JSONEnumMeta, metacls).__new__(metacls, cls, bases, enum_dict, **kwds)

# for use with both Python 2/3
JSONEnum = JSONEnumMeta('JsonEnum', (Enum, ), {})

A few notes:

  • JSONEnumMeta.__prepare__ returns a normal dict

  • EnumMeta.__prepare__ is used to get an instance of _EnumDict -- this is the proper way to get one

  • keys with a leading underscore are passed to the real _EnumDict first as they may be needed when processing the enum members

  • Enum members are in the same order as they were in the file


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

2 This requires aenum 2.0.5+.

3 The keys are numeric to keep multiple values in order should your Enum need more than one.

How to get new Enum with members as enum using EnumMeta Python 3.6

In Python 3, the dct object is not a regular dictionary, but a subclass dedicated to helping create enums, set via the __prepare__ attribute of the metaclass. It's update() method, however, is not altered from the base dictionary.

It normally expects members to be set via it's __setitem__ method (e.g. dct[name] = value); do so in your __new__ method too:

class KeyMeta(EnumMeta):
def __new__(mcs, name, bases, dct):
for e in KeyCode:
dct[e.name] = e.value
return super(KeyMeta, mcs).__new__(mcs, name, bases, dct)

Now each of the names A, B and C are recorded as being enum member names and the EnumMeta class takes it from there. Without using the specialised __setitem__ implementation, the names are seen as some other type of attribute instead.

With the above change you get the expected output:

>>> class Key(Enum, metaclass=KeyMeta):
... pass
...
>>> Key.A
<Key.A: 1>

The same for e in KeyCode: loop will continue to work in Python 2 as well.

Overload __init__() for a subclass of Enum

You have to overload the _missing_ hook. All instances of WeekDay are created when the class is first defined; WeekDay(date(...)) is an indexing operation rather than a creation operation, and __new__ is initially looking for pre-existing values bound to the integers 0 to 6. Failing that, it calls _missing_, in which you can convert the date object into such an integer.

class WeekDay(Enum):
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6

@classmethod
def _missing_(cls, value):
if isinstance(value, date):
return cls(value.weekday())
return super()._missing_(value)

A few examples:

>>> WeekDay(date(2019,3,7))
<WeekDay.THURSDAY: 3>
>>> assert WeekDay(date(2019, 4, 1)) == WeekDay.MONDAY
>>> assert WeekDay(date(2019, 4, 3)) == WeekDay.MONDAY
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError

(Note: _missing_ is not available prior to Python 3.6.)


Prior to 3.6, it seems you can override EnumMeta.__call__ to make the same check, but I'm not sure if this will have unintended side effects. (Reasoning about __call__ always makes my head spin a little.)

# Silently convert an instance of datatime.date to a day-of-week
# integer for lookup.
class WeekDayMeta(EnumMeta):
def __call__(cls, value, *args, **kwargs):
if isinstance(value, date):
value = value.weekday())
return super().__call__(value, *args, **kwargs)

class WeekDay(Enum, metaclass=WeekDayMeta):
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6

How to extend the Python class attributes for Enum derived classes

Enum was designed to not allow extending. Depending on your use-case, though, you have a couple options:

  • build the enum dynamically from an external source (such as a json file). See When should I subclass EnumMeta instead of Enum? for the full details.
    class Country(JSONEnum):
_init_ = 'abbr code country_name' # remove if not using aenum
_file = 'some_file.json'
_name = 'alpha-2'
_value = {
1: ('alpha-2', None),
2: ('country-code', lambda c: int(c)),
3: ('name', None),
}
  • use the extend_enum function the aenum library1:
    extend_enum(ErrorCode, 'NEW_ERROR2', 'The new error 2')

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

Create an abstract Enum class

Here's how to adapt the accepted answer to the question Abstract Enum Class using ABCMeta and EnumMeta to create the kind of abstract Enum class you want:

from abc import abstractmethod, ABC, ABCMeta
from enum import auto, Flag, EnumMeta

class ABCEnumMeta(ABCMeta, EnumMeta):

def __new__(mcls, *args, **kw):
abstract_enum_cls = super().__new__(mcls, *args, **kw)
# Only check abstractions if members were defined.
if abstract_enum_cls._member_map_:
try: # Handle existence of undefined abstract methods.
absmethods = list(abstract_enum_cls.__abstractmethods__)
if absmethods:
missing = ', '.join(f'{method!r}' for method in absmethods)
plural = 's' if len(absmethods) > 1 else ''
raise TypeError(
f"cannot instantiate abstract class {abstract_enum_cls.__name__!r}"
f" with abstract method{plural} {missing}")
except AttributeError:
pass
return abstract_enum_cls

class TranslateableFlag(Flag, metaclass=ABCEnumMeta):

@classmethod
@abstractmethod
def base(cls):
pass

def translate(self):
base = self.base()
if self in base:
return base[self]
else:
ret = []
for basic in base:
if basic in self:
ret.append(base[basic])
return " | ".join(ret)

class Students1(TranslateableFlag):
ALICE = auto()
BOB = auto()
CHARLIE = auto()
ALL = ALICE | BOB | CHARLIE

@classmethod
def base(cls):
return {Students1.ALICE: "Alice", Students1.BOB: "Bob",
Students1.CHARLIE: "Charlie"}

# Abstract method not defined - should raise TypeError.
class Students2(TranslateableFlag):
ALICE = auto()
BOB = auto()
CHARLIE = auto()
ALL = ALICE | BOB | CHARLIE

# @classmethod
# def base(cls):
# ...

Result:

Traceback (most recent call last):
...
TypeError: cannot instantiate abstract class 'Students2' with abstract method 'base'

Python: overloading the `__call__` dunder method of a class that inherits Enum

The typical way to look up an enum member by name is with the __getitem__ syntax:

>>> B["BOB"]
<B.BOB: 1>

If you need to be able to do value lookups using the name, you can define _missing_:

class A(Enum):
#
@classmethod
def _missing_(cls, name):
return cls[name]

class B(A):
BOB = 1

and in use:

>>> B("BOB")
<B.BOB: 1>

How to extend Python Enum?

Subclassing an enumeration is allowed only if the enumeration does not define any members.

Allowing subclassing of enums that define members would lead to a violation of some important invariants of types and instances.

https://docs.python.org/3/library/enum.html#restricted-enum-subclassing

So no, it's not directly possible.



Related Topics



Leave a reply



Submit