4

I am trying to create a FractionEnum similar to StrEnum or IntEnum. My first attempt resulted in a metaclass conflict:

class FractionEnum(fractions.Fraction, Enum):
    VALUE_1 = 1, 1
    VALUE_2 = 8, 9
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

I followed the suggestion from this answer Multiple inheritance metaclass conflict involving Enum and created a new metaclass:

class FractionEnumMeta(type(Enum), type(fractions.Fraction)):
    pass

class FractionEnum(fractions.Fraction, Enum, metaclass=FractionEnumMeta):
    VALUE_1 = 1, 1
    VALUE_2 = 8, 9

This solved the above error but now I get:

  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/enum.py", line 289, in __new__
    enum_member = __new__(enum_class, *args)
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/fractions.py", line 93, in __new__
    self = super(Fraction, cls).__new__(cls)

TypeError: Enum.__new__() missing 1 required positional argument: 'value'

The issue seems to be that the __new__ call inside Fraction is trying to create an enum, from the call inside the EnumMeta metaclass:

            else:
                enum_member = __new__(enum_class, *args)

I'm misunderstanding how the metaclasses can work together to create an object that is both a fraction and an Enum - it seems to work out of the box with int or str or classes that don't define a metaclass.

Update:

I was able to use the code below to have the enumeration replace the Fraction's new method, but I am getting an error if I try deepcopy a class that has the enum as a member:

/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/enum.py:497: in _create_
    _, first_enum = cls._get_mixins_(cls, bases)

in the Enum code:

        # ensure final parent class is an Enum derivative, find any concrete
        # data type, and check that Enum has no members
        first_enum = bases[-1]
        if not issubclass(first_enum, Enum):
            raise TypeError("new enumerations should be created as "
                    "`EnumName([mixin_type, ...] [data_type,] enum_type)`")
        member_type = _find_data_type(bases) or object
        if first_enum._member_names_:
>           raise TypeError("Cannot extend enumerations")
E           TypeError: Cannot extend enumerations

Sample to reproduce:

class TestFractionEnum(FractionEnum):
    VALUE_1 = 1, 1
    VALUE_2 = 8, 9

class C:
    def __init__(self):
        self.fraction_enum = TestFractionEnum.VALUE_1

c = C()
print(c)
print(c.fraction_enum)
d = copy.copy(c)
print(d)
e = copy.deepcopy(c)
print(e)

Update 2:

Overriding deepcopy on the enum seems to work:

def __deepcopy__(self, memo):
        if type(self) == Fraction:
            return self
        for item in self.__class__:
            if self == item:
                return item
        assert f'Invalid enum: {self}'
Paul
  • 189
  • 1
  • 6
  • 2
    The "metaclass" part of it is resolved by the derived metaclass you create. It is the inner workings of `enum` which won't work with a Fraction object due to sheer incompatibility. It is possible that creating one (or two) intermediate classes with custom `__new__` methods can resolve this, but it is complicated. – jsbueno Jul 06 '23 at 17:53
  • You might want to look at the [definition](https://github.com/python/cpython/blob/3.11/Lib/enum.py#L1261) of `StrEnum` to see how it inherits from both `str` and `Enum` (well, `ReprEnum`, but that's a whole other issue). `Enum` is quite different from other classes, because how `EnumType` generates a class is significantly different from what you are probably used to with `type`. – chepner Jul 06 '23 at 20:06
  • [`__deepcopy__` issue created](https://github.com/python/cpython/issues/106602). – Ethan Furman Jul 10 '23 at 18:03
  • 1
    I think your '__deepcopy__` method should always just `return self`. – Ethan Furman Jul 10 '23 at 18:30

1 Answers1

3

As @jsbueno said, you have to write your own __new__:

from enum import EnumType, Enum
from fractions import Fraction
import math

class FractionEnumMeta(type(Fraction), EnumType):
    pass

class FractionEnum(Fraction, Enum, metaclass=FractionEnumMeta):
    def __new__(cls, numerator=0, denominator=None):
        #
        # this is the only line different from Fraction.__new__
        self = object.__new__(cls)
        #
        #
        if denominator is None:
            if type(numerator) is int:
                self._numerator = numerator
                self._denominator = 1
                return self
            elif isinstance(numerator, numbers.Rational):
                self._numerator = numerator.numerator
                self._denominator = numerator.denominator
                return self
            elif isinstance(numerator, (float, Decimal)):
                # Exact conversion
                self._numerator, self._denominator = numerator.as_integer_ratio()
                return self
            elif isinstance(numerator, str):
                # Handle construction from strings.
                m = _RATIONAL_FORMAT.match(numerator)
                if m is None:
                    raise ValueError('Invalid literal for Fraction: %r' %
                                     numerator)
                numerator = int(m.group('num') or '0')
                denom = m.group('denom')
                if denom:
                    denominator = int(denom)
                else:
                    denominator = 1
                    decimal = m.group('decimal')
                    if decimal:
                        decimal = decimal.replace('_', '')
                        scale = 10**len(decimal)
                        numerator = numerator * scale + int(decimal)
                        denominator *= scale
                    exp = m.group('exp')
                    if exp:
                        exp = int(exp)
                        if exp >= 0:
                            numerator *= 10**exp
                        else:
                            denominator *= 10**-exp
                if m.group('sign') == '-':
                    numerator = -numerator
            else:
                raise TypeError("argument should be a string "
                                "or a Rational instance")
        elif type(numerator) is int is type(denominator):
            pass # *very* normal case
        elif (isinstance(numerator, numbers.Rational) and
            isinstance(denominator, numbers.Rational)):
            numerator, denominator = (
                numerator.numerator * denominator.denominator,
                denominator.numerator * numerator.denominator
                )
        else:
            raise TypeError("both arguments should be "
                            "Rational instances")
        if denominator == 0:
            raise ZeroDivisionError('Fraction(%s, 0)' % numerator)
        g = math.gcd(numerator, denominator)
        if denominator < 0:
            g = -g
        numerator //= g
        denominator //= g
        self._numerator = numerator
        self._denominator = denominator
        return self
    

class TestFractionEnum(FractionEnum):
    VALUE_1 = 1, 1
    VALUE_2 = 8, 9

In this case, we just reuse Fraction.__new__, but remove the super() call and create the new fraction instance directly.


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
  • :-) Copy + paste + replace that one line sometimes work better. I confess I tried to automate that with inspect.getsource + exec - and at one point, you don't want it anymore! :-) But fundamentally, I didn't know `Enum.__new__` could simply be skipped - trying to get a suitable value for it is what ultimately prevented me from posting that code. – jsbueno Jul 07 '23 at 19:24
  • 1
    @jsbueno: `Enum.__new__` is more akin to `return_existing_member` -- as such, it should not be called during member creation, and `Fraction.__new__` calling it (via `super()`) was the problem. – Ethan Furman Jul 07 '23 at 19:46
  • Exactly what I needed! I did have to change `EnumType` to `EnumMeta` to get it to work in Python 3.10. – Paul Jul 09 '23 at 18:11
  • Unfortunately I'm running into an issue with being able to deepcopy this class, I put the error and code to reproduce as an addendum to my original question. – Paul Jul 09 '23 at 18:49
  • I suspect I'll need to override `__deepcopy__` from `Fraction` as well but not really that sure how to return the correct type. I suppose since it's an enum, a deepcopy will just be the same instance so I can either find it in the member map or throw an error. – Paul Jul 09 '23 at 19:02
  • I think I figured it out - added to my original question. – Paul Jul 09 '23 at 19:13