7
\$\begingroup\$

I am using enums in python3 for the first time but unsure if I am using them correctly.

I want to restrict the 'allowed values' for a class attribute ArticleMetadata.article_type to those defined within an ArticleTypes Enum class.

Here's my code:

from enum import Enum

class ArticleMetadata(object):

    ArticleTypes = Enum('ArticleTypes', 'original modified', module=__name__)

    def is_in_enum(self, value, Enum):
        """Verify that a value is contained within a defined Enum object.

        Raises:
            ValueError: If the value is not contained within the defined Enum.
        """
        if value in Enum.__members__:
            return True
        else:
            raise ValueError("Value {0} not in Enum list.".format(value))

    def __init__(self, **kwargs):
        """Set class attributes."""

        self.article_type = (kwargs['article_type'] if 'article_type' in kwargs.keys()
                              and self.is_in_enum(kwargs['article_type'], self.ArticleTypes)
                              else None
                              )

Usage

> foo = ArticleMetadata(article_type='original')
> foo.article_type
'original'
> bar = ArticleMetadata(article_type='invalid')
*** ValueError: Value invalid not in Enum list.

This code works, but having to write an is_in_enum method seems clunky, and I would have thought that this functionality would be natively part of enums somehow??

Also, no errors are raised if I later run foo.article_type = 'invalid', which doesn't seem right!

Am I missing a common usage pattern?

\$\endgroup\$

3 Answers 3

3
\$\begingroup\$
  • You get no benefit for raising an error in is_in_enum. It's actually a hindrance. I'd expect to use that function as followed:

    if foo.is_in_enum(key):
        # do something
    else:
        # do something
    

    However, to correctly use your function I'd have to use:

    try:
        foo.is_in_enum(key)
    except ValueError:
        # do something
    else:
        # do something
    

    Instead just change it to return your if statement.

  • You can use **kwargs with keyword arguments. Use that instead.

And so I'd use:

from enum import Enum


class ArticleMetadata(object):
    ArticleTypes = Enum('ArticleTypes', 'original modified', module=__name__)

    def in_enum(self, value, Enum):
        """Verify that a value is contained within a defined Enum object."""
        return value in Enum.__members__

    def __init__(self, article_type=None, **kwargs):
        """Set class attributes."""
        if article_type is None or not self.in_enum(article_type, self.ArticleTypes):
            raise ValueError("Value {} not in Enum list.".format(article_type))
        self.article_type = article_type
\$\endgroup\$
3
\$\begingroup\$

I think for it to be completely safe (even against re-assigning later), you should make AtricleMetadata.article_type a property:

from enum import Enum

class ArticleMetadata(object):

    ArticleTypes = Enum('ArticleTypes', 'original modified', module=__name__)

    def __init__(self, **kwargs):
        """Set class attributes."""
        self.article_type = kwargs.get('article_type', None)

    @property
    def article_type(self):
        return self._article_type

    @article_type.setter
    def article_type(self, value):
        if value in self.ArticleTypes.__members__:
            self._article_type = value
        else:
            raise ValueError("Value {} not in ArticleType list.".format(value))

In this case you don't need your helper function any more (at the price of losing this general function and having to write a getter and setter for every property for which you want this kind of protection). Note that I used dict.get to make your initialization easier.

Also note that this can still be tricked, because Python has no real private members. So you can still do this:

>>> a = ArticleMetadata(article_type="original")
>>> a._article_type = "invalid"
>>> a.article_type
'invalid'
\$\endgroup\$
2
  • \$\begingroup\$ Overall this is the better answer as it responds to the entire question. Regarding the direct access to the _article attribute: while it cannot be prevented, some code could be added to the getter to check self._article_type and raise an error if it is not valid. Not perfect, but would help catch the problem sooner. \$\endgroup\$ Commented May 4, 2017 at 18:21
  • \$\begingroup\$ It seems you could still make this more general by using a descriptor that takes the attribute's associated Enum as an argument. \$\endgroup\$ Commented Nov 12, 2021 at 23:51
2
\$\begingroup\$

In any event, I would avoid the inline logic at the initialization of your attributes and abstract that out to a validator funtion. There are several approaches to class attribute validations you can follow, some of which are mentioned here in other answers. This article describes several fairly well.

Following the example presented by @Graipher's answer, here is a general approach that solves the problems you mentioned (namely handling the case in which an incorrect attribute is set after init), along with avoiding the declarations of multiple getter/setter descriptors for each attribute.

Here I also am passing the Enum member's as the parameter values, so as to make it a bit easier for a user to navigate available options. It is best to validate at input, and in the case of setting an attribute after init, I feel the best way to do this is by forcing the use of the Enum to eliminate erroneous possibilites.

from enum import Enum, IntEnum
from datetime import datetime
from typing import Any


class EnumAttribute:
    """General descriptor for validated Enum attributes"""

    __slots__ = ("_attr", "_Enum")

    def __init__(self, attr: str, enum: Enum):
        self._attr = attr
        self._Enum = enum

    def __get__(self, obj: object, objtype: type):
        return obj.__dict__[self._attr]

    def __set__(self, obj: object, enum_member: Any):
        enum_members = self._Enum.__members__.values()
        if enum_member not in enum_members:
            value_strings = [
                f"{str(v)} ({v.value})" for v in enum_members
            ]
            raise ValueError(
                f"Invalid value '{str(enum_member)}' for attribute `{self._attr}` "
                f"must be one of: {', '.join(value_strings)}"
            )
        obj.__dict__[self._attr] = enum_member.value


class Strings(Enum):
    A = "first option"
    B = "second option"


class Integers(IntEnum):
    A = 10
    B = 20

class Datetimes(Enum):
    A = datetime(2021,1,1)
    B = datetime(2021,2,2)


class Example:
    """Example class using the validating descriptor"""

    str_attr = EnumAttribute("str_attr", Strings)
    int_attr = EnumAttribute("int_attr", Integers)
    datetime_attr = EnumAttribute("datetime_attr", Datetimes)

    def __init__(self, str_attr: Strings, int_attr: Integers, datetime_attr: Floats):
        self.str_attr = str_attr
        self.int_attr = int_attr
        self.datetime_attr = datetime_attr

Usage:

>>> example = Example(Strings.A, Integers.B, Datetimes.A)
>>> example.str_attr
'first option'

>>> example.datetime_attr
datetime.datetime(2021, 1, 1, 0, 0)

>>> example = Example(Strings.A, Integers.B, "C")
ValueError: Invalid value 'C' for attribute `datetime_attr` must be one of: Datetimes.A (2021-01-01 00:00:00), Datetimes.B (2021-02-02 00:00:00)

>>> example = Example(Strings.A, Integers.B, Datetimes.C)
AttributeError: C
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.