Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import enum
  4from types import DynamicClassAttribute
  5from typing import Any
  6
  7from plain.utils.functional import Promise
  8
  9__all__ = ["Choices", "IntegerChoices", "TextChoices"]
 10
 11
 12class ChoicesMeta(enum.EnumMeta):
 13    """A metaclass for creating a enum choices."""
 14
 15    def __new__(
 16        metacls: type,
 17        classname: str,
 18        bases: tuple[type, ...],
 19        classdict: Any,
 20        **kwds: Any,
 21    ) -> type:
 22        labels = []
 23        for key in classdict._member_names:
 24            value = classdict[key]
 25            if (
 26                isinstance(value, list | tuple)
 27                and len(value) > 1
 28                and isinstance(value[-1], Promise | str)
 29            ):
 30                *value, label = value
 31                value = tuple(value)
 32            else:
 33                label = key.replace("_", " ").title()
 34            labels.append(label)
 35            # Use dict.__setitem__() to suppress defenses against double
 36            # assignment in enum's classdict.
 37            dict.__setitem__(classdict, key, value)
 38        cls = super().__new__(metacls, classname, bases, classdict, **kwds)  # type: ignore[invalid-super-argument]
 39        for member, label in zip(cls.__members__.values(), labels):
 40            member._label_ = label
 41        return enum.unique(cls)
 42
 43    def __contains__(cls, member: object) -> bool:  # type: ignore[override]
 44        if not isinstance(member, enum.Enum):
 45            # Allow non-enums to match against member values.
 46            return any(x.value == member for x in cls)  # type: ignore[attr-defined]
 47        return super().__contains__(member)
 48
 49    @property
 50    def names(cls) -> list[str]:
 51        empty = ["__empty__"] if hasattr(cls, "__empty__") else []
 52        return empty + [member.name for member in cls]  # type: ignore[attr-defined]
 53
 54    @property
 55    def choices(cls) -> list[tuple[Any, str]]:
 56        empty = [(None, cls.__empty__)] if hasattr(cls, "__empty__") else []
 57        return empty + [(member.value, member.label) for member in cls]  # type: ignore[attr-defined]
 58
 59    @property
 60    def labels(cls) -> list[str]:
 61        return [label for _, label in cls.choices]
 62
 63    @property
 64    def values(cls) -> list[Any]:
 65        return [value for value, _ in cls.choices]
 66
 67
 68class Choices(enum.Enum, metaclass=ChoicesMeta):
 69    """Class for creating enumerated choices."""
 70
 71    # Dynamically set by metaclass
 72    _label_: str
 73
 74    @DynamicClassAttribute
 75    def label(self) -> str:
 76        return self._label_
 77
 78    def __str__(self) -> str:
 79        """
 80        Use value when cast to str, so that Choices set as model instance
 81        attributes are rendered as expected in templates and similar contexts.
 82        """
 83        return str(self.value)
 84
 85    # A similar format was proposed for Python 3.10.
 86    def __repr__(self) -> str:
 87        return f"{self.__class__.__qualname__}.{self._name_}"
 88
 89
 90class IntegerChoices(int, Choices):
 91    """Class for creating enumerated integer choices."""
 92
 93    pass
 94
 95
 96class TextChoices(str, Choices):
 97    """Class for creating enumerated string choices."""
 98
 99    @staticmethod
100    def _generate_next_value_(
101        name: str, start: int, count: int, last_values: list[str]
102    ) -> str:
103        return name