Как расширить возможности стандартного Enum

ad1343785e72005e02d5be6462e5c373.webp

А может всё-таки есть способ сделать такой Enum, используя стандартную библиотеку Python?!

Иногда очень хочется, чтобы у констант были дополнительные параметры, хранящие прочие характеристики. Первое, что приходит на ум — это описать Enum, хранящий простые значения, и маппинг.

from dataclasses import dataclass
from enum import Enum

class Color(Enum):
    BLACK = 'black'
    WHITE = 'white'
    PURPLE = 'purple'
    

@dataclass(frozen=True)
class RGB:
    red: int
    green: int
    blue: int


COLOR_TO_RGB = {
    Color.BLACK: RGB(0, 0, 0),
    Color.WHITE: RGB(255, 255, 255),
    Color.PURPLE: RGB(128, 0, 128),
}

В таком случае получается, что константы и характеристики располагаются сами по себе, к тому же они могут находится в разных частях системы. Это может привести к тому что при появлении новой константы в Color, никто не обновит маппинг, т.к. нет жёсткой и явной связи.

Давайте разбираться как же можно хранить всё необходимое в единой структуре.

Вариант 1.

from enum import Enum
from typing import Union

class RelatedEnum(str, Enum):
    related_value: Union[int, str]

    def __new__(
            cls, 
            value: Union[int, str], 
            related_value: Union[int, str]
    ) -> 'RelatedEnum':
        obj = str.__new__(cls, value)
        obj._value_ = value
        obj.related_value = related_value
        return obj

class SomeEnum(RelatedEnum):
    CONST1 = ('value1', 'related_value1')
    CONST2 = ('value2', 'related_value2')
>>> SomeEnum.CONST1.value
'value1'
>>> SomeEnum.CONST1.related_value
'related_value1'
>>> SomeEnum('value1')

>>> SomeEnum('value1').related_value
'related_value1'

Кажется, что выглядит неплохо, но в таком варианте есть ограничение по кол-ву дополнительных параметров. Давайте попробуем еще немного улучшить.

Вариант 2.

Раз в прошлом варианте у нас получилось сделать с использованием tuple, то значит получится и с typing.NamedTuple. К тому же будут именованные параметры, что повысит читабельность.

В качестве члена перечисления будем хранить целиком объект typing.NamedTuple. Теперь чтобы у нас происходило корректное сравнение объектов нам нужно переопределить методы __hash__ и __eq__. Сравниваться объекты будут по одному полю — value.

from enum import Enum
from types import DynamicClassAttribute
from typing import Any, NamedTuple, Union


class RGB(NamedTuple):
    red: int
    green: int
    blue: int


class ColorInfo(NamedTuple):
    value: Union[int, str]
    rgb: RGB = None
    ru: str = None

    def __hash__(self) -> int:
        return hash(self.value)

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, type(self)):
            return hash(self) == hash(other)
        return False


class Color(Enum):
    BLACK = ColorInfo('black', rgb=RGB(0, 0, 0), ru='черный')
    WHITE = ColorInfo('white', rgb=RGB(255, 255, 255), ru='белый')
    RED = ColorInfo('red', rgb=RGB(255, 0, 0), ru='красный')
    GREEN = ColorInfo('green', rgb=RGB(0, 255, 0), ru='зеленый')
    BLUE = ColorInfo('blue', rgb=RGB(0, 0, 255), ru='голубой')
    PURPLE = ColorInfo('purple', rgb=RGB(128, 0, 128), ru='пурпурный')
    OLIVE = ColorInfo('olive', rgb=RGB(128, 128, 0), ru='оливковый')
    TEAL = ColorInfo('teal', rgb=RGB(0, 128, 128), ru='бирюзовый')

    _value_: ColorInfo

    @DynamicClassAttribute
    def value(self) -> str:
        return self._value_.value

    @DynamicClassAttribute
    def info(self) -> ColorInfo:
        return self._value_

    @classmethod
    def _missing_(cls, value: Any) -> 'Color':
        if isinstance(value, (str, int)):
            return cls._value2member_map_[ColorInfo(value)]
        raise ValueError(f'Unknown color: {value}')
>>> Color.PURPLE.value
'purple'
>>> Color.PURPLE.info
ColorInfo(value='purple', rgb=RGB(red=128, green=0, blue=128), ru='пурпурный')
>>> Color('black')

Получился в принципе рабочий вариант. Конечно у него есть свои ограничения за счет использования typing.NamedTuple. Плюс ко всему решение не универсальное. После некоторых раздумий появился следующий вариант.

Вариант 3.

Что если вместо typing.NamedTuple использовать dataclass? Вроде идея здравая. Появляется возможность наследования классов, хранящих доп. параметры. Плюс вспомогательные функции из dataclasses.

В качестве члена перечисления, как и в прошлый раз, будем хранить объект целиком, только теперь это dataclass.

import enum
from dataclasses import dataclass
from types import DynamicClassAttribute
from typing import Union, TypeVar, Any
from uuid import UUID

SimpleValueType = Union[UUID, int, str]
ExtendedEnumValueType = TypeVar('ExtendedEnumValueType', bound='BaseExtendedEnumValue')
ExtendedEnumType = TypeVar('ExtendedEnumType', bound='ExtendedEnum')

@dataclass(frozen=True)
class BaseExtendedEnumValue:
    value: SimpleValueType
    
class ExtendedEnum(enum.Enum):
    value: SimpleValueType
    _value_: ExtendedEnumValueType

    @DynamicClassAttribute
    def value(self) -> SimpleValueType:
        return self._value_.value

    @DynamicClassAttribute
    def extended_value(self) -> ExtendedEnumValueType:
        return self._value_

    @classmethod
    def _missing_(cls, value: Any) -> ExtendedEnumType:  # noqa: WPS120
        if isinstance(value, (UUID, int, str)):
            simple_value2member = {member.value: member for member in cls.__members__.values()}
            try:
                return simple_value2member[value]
            except KeyError:
                pass  # noqa: WPS420
        raise ValueError(f'{value!r} is not a valid {cls.__qualname__}')

Теперь чтобы всё красиво заработало нам понадобится функция EnumField, которая упростит инициализацию (вдохновлено Pydantic).

def EnumField(value: Union[SimpleValueType, ExtendedEnumValueType]) -> BaseExtendedEnumValue:
    if isinstance(value, (UUID, int, str)):
        return BaseExtendedEnumValue(value=value)
    return value

Теперь можно приступать к объявлению и проверке работы.

from dataclasses import field

@dataclass(frozen=True)
class RGB:
    red: int
    green: int
    blue: int

@dataclass(frozen=True)
class ColorInfo(BaseExtendedEnumValue):
    rgb: RGB = field(compare=False)
    ru: str = field(compare=False)

class Color(ExtendedEnum):
    BLACK = EnumField(ColorInfo('black', rgb=RGB(0, 0, 0), ru='черный'))
    WHITE = EnumField(ColorInfo('white', rgb=RGB(255, 255, 255), ru='белый'))
    RED = EnumField(ColorInfo('red', rgb=RGB(255, 0, 0), ru='красный'))
    GREEN = EnumField(ColorInfo('green', rgb=RGB(0, 255, 0), ru='зеленый'))
    BLUE = EnumField(ColorInfo('blue', rgb=RGB(0, 0, 255), ru='голубой'))
    PURPLE = EnumField(ColorInfo('purple', rgb=RGB(128, 0, 128), ru='пурпурный'))
    OLIVE = EnumField(ColorInfo('olive', rgb=RGB(128, 128, 0), ru='оливковый'))
    TEAL = EnumField(ColorInfo('teal', rgb=RGB(0, 128, 128), ru='бирюзовый'))
>>> Color.PURPLE

>>> Color.PURPLE.value
'purple'
>>> Color.PURPLE.extended_value
ColorInfo(value='purple', rgb=RGB(red=128, green=0, blue=128), ru='пурпурный')
>>> Color.PURPLE.extended_value.rgb
RGB(red=128, green=0, blue=128)
>>> Color.PURPLE.extended_value.ru
'пурпурный'
>>> Color('purple')

Так родился Python пакет extended-enum. В репозитории я описал основные возможности, а также процесс миграции со стандартного Enum на ExtendedEnum.

Надеюсь, что материал был полезен! Успехов вам в любых начинаниях!

P.S. Мне будет очень приятно, если поставите звёздочку на github

© Habrahabr.ru