Repository: hikari-py/hikari
Pull request #1473 (2023-01-14 00:20:21)

Add utilities to aid with enum member deprecation
### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.



Patch:
diff --git a/hikari/internal/deprecation.py b/hikari/internal/deprecation.py
index 29be9819..ba2f555e 100644
--- a/hikari/internal/deprecation.py
+++ b/hikari/internal/deprecation.py
@@ -24,7 +24,7 @@
 
 from __future__ import annotations
 
-__all__: typing.Sequence[str] = ("warn_deprecated",)
+__all__: typing.Sequence[str] = ("warn_deprecated", "check_if_past_removal")
 
 import typing
 import warnings
@@ -33,12 +33,36 @@
 from hikari.internal import ux
 
 
-def warn_deprecated(name: str, /, *, removal_version: str, additional_info: str, stack_level: int = 3) -> None:
+def check_if_past_removal(what: str, /, *, removal_version: str) -> None:
+    """Check if a deprecation is passed its removal version.
+
+    Parameters
+    ----------
+    what : str
+        What is being deprecated.
+
+    Other Parameters
+    ----------------
+    removal_version : str
+        The version it will be removed in.
+
+    Raises
+    ------
+    DeprecationWarning
+        If the deprecated item is past its removal version.
+    """
+    if ux.HikariVersion(hikari_about.__version__) >= ux.HikariVersion(removal_version):
+        raise DeprecationWarning(f"{what!r} is passed its removal version ({removal_version})")
+
+
+def warn_deprecated(what: str, /, *, removal_version: str, additional_info: str, stack_level: int = 3) -> None:
     """Issue a deprecation warning.
 
+    If the item is past its deprecation version, an error will be raised instead.
+
     Parameters
     ----------
-    name : str
+    what : str
         What is being deprecated.
 
     Other Parameters
@@ -49,12 +73,16 @@ def warn_deprecated(name: str, /, *, removal_version: str, additional_info: str,
         Additional information on the deprecation for the user.
     stack_level : int
         The stack level to issue the warning in.
+
+    Raises
+    ------
+    DeprecationWarning
+        If the deprecated item is past its removal version.
     """
-    if ux.HikariVersion(hikari_about.__version__) >= ux.HikariVersion(removal_version):
-        raise DeprecationWarning(f"{name!r} is passed its removal version ({removal_version})")
+    check_if_past_removal(what, removal_version=removal_version)
 
     warnings.warn(
-        f"{name!r} is deprecated and will be removed in `{removal_version}`. {additional_info}",
+        f"{what!r} is deprecated and will be removed in `{removal_version}`. {additional_info}",
         category=DeprecationWarning,
         stacklevel=stack_level,
     )
diff --git a/hikari/internal/enums.py b/hikari/internal/enums.py
index 4af0454a..caa96e63 100644
--- a/hikari/internal/enums.py
+++ b/hikari/internal/enums.py
@@ -23,7 +23,7 @@
 """Implementation of parts of Python's `enum` protocol to be more performant."""
 from __future__ import annotations
 
-__all__: typing.Sequence[str] = ("Enum", "Flag")
+__all__: typing.Sequence[str] = ("deprecated", "Enum", "Flag")
 
 import functools
 import operator
@@ -35,6 +35,42 @@
 _MAX_CACHED_MEMBERS: typing.Final[int] = 1 << 12
 
 
+class _DeprecatedAlias(typing.Generic[_T]):
+    __slots__ = ("_name", "_alias", "_removal_version")
+
+    def __init__(self, name: str, alias: str, removal_version: str) -> None:
+        self._name = name
+        self._alias = alias
+        self._removal_version = removal_version
+
+        # Import kept in-line due to circular import issues
+        from hikari.internal import deprecation
+
+        deprecation.check_if_past_removal(self._name, removal_version=removal_version)
+
+    def __get__(self, instance: typing.Optional[_T], owner_enum: _T) -> _T:
+        # Import kept in-line due to circular import issues
+        from hikari.internal import deprecation
+
+        deprecation.warn_deprecated(
+            self._name,
+            removal_version=self._removal_version,
+            additional_info=f"Use '{self._alias}' instead.",
+        )
+
+        return owner_enum[self._alias]
+
+
+class deprecated:
+    """Used to denote that an enum member is a deprecated alias of another."""
+
+    __slots__ = ("value", "removal_version")
+
+    def __init__(self, value: typing.Any, /, *, removal_version: str) -> None:
+        self.value = value
+        self.removal_version = removal_version
+
+
 class _EnumNamespace(typing.Dict[str, typing.Any]):
     __slots__: typing.Sequence[str] = ("base", "names_to_values", "values_to_names")
 
@@ -42,7 +78,7 @@ def __init__(self, base: typing.Type[typing.Any]) -> None:
         super().__init__()
         self.base = base
         self.names_to_values: typing.Dict[str, typing.Any] = {}
-        self.values_to_names: typing.Dict[str, typing.Any] = {}
+        self.values_to_names: typing.Dict[typing.Any, str] = {}
         self["__doc__"] = "An enumeration."
 
     def __getitem__(self, name: str) -> typing.Any:
@@ -58,16 +94,28 @@ def __setitem__(self, name: str, value: typing.Any) -> None:
         if name == "" or name == "mro":
             raise TypeError(f"Invalid enum member name: {name!r}")
 
-        if name.startswith("_"):
+        if isinstance(value, deprecated):
+            real_value = value.value
+
+            if (alias := self.values_to_names.get(real_value)) is None:
+                raise ValueError("`deprecated` must be used on an existing value")
+
+            member = _DeprecatedAlias(name, alias, value.removal_version)
+            super().__setitem__(name, member)
+
+            # Unpack for down below
+            value = real_value
+
+        elif name.startswith("_"):
             # Dunder/sunder, so skip.
             super().__setitem__(name, value)
             return
 
-        if hasattr(value, "__get__") or hasattr(value, "__set__") or hasattr(value, "__del__"):
+        elif hasattr(value, "__get__") or hasattr(value, "__set__") or hasattr(value, "__del__"):
             super().__setitem__(name, value)
             return
 
-        if not isinstance(value, self.base):
+        elif not isinstance(value, self.base):
             raise TypeError(f"Expected member {name} to be of type {self.base.__name__} but was {type(value).__name__}")
 
         name = sys.intern(name)
@@ -83,13 +131,9 @@ def __setitem__(self, name: str, value: typing.Any) -> None:
 
         if name in self.names_to_values:
             raise TypeError(f"Cannot define {name!r} name multiple times")
-        if value in self.values_to_names:
-            # We must have defined some alias, so just register the name
-            self.names_to_values[name] = value
-            return
 
         self.names_to_values[name] = value
-        self.values_to_names[value] = name
+        self.values_to_names.setdefault(value, name)
 
 
 # We refer to these from the metaclasses, but obviously this won't work
@@ -102,14 +146,13 @@ def __setitem__(self, name: str, value: typing.Any) -> None:
 class _EnumMeta(type):
     def __call__(cls, value: typing.Any) -> typing.Any:
         """Cast a value to the enum, returning the raw value that was passed if value not found."""
-        try:
-            return cls._value_to_member_map_[value]
-        except KeyError:
-            # If we can't find the value, just return what got casted in
-            return value
+        return cls._value_to_member_map_.get(value, value)
 
     def __getitem__(cls, name: str) -> typing.Any:
-        return cls._name_to_member_map_[name]
+        if member := getattr(cls, name, None):
+            return member
+
+        raise KeyError(name)
 
     def __contains__(cls, item: typing.Any) -> bool:
         return item in cls._value_to_member_map_
@@ -158,18 +201,21 @@ def __new__(
         cls = super().__new__(mcs, name, bases, new_namespace)
 
         for name, value in namespace.names_to_values.items():
-            # Patching the member init call is around 100ns faster per call than
-            # using the default type.__call__ which would make us do the lookup
-            # in cls.__new__. Reason for this is that python will also always
-            # invoke cls.__init__ if we do this, so we end up with two function
-            # calls.
-            member = cls.__new__(cls, value)
-            member._name_ = name
-            member._value_ = value
+            member = new_namespace.get(name)
+            if not isinstance(member, _DeprecatedAlias):
+                # Patching the member init call is around 100ns faster per call than
+                # using the default type.__call__ which would make us do the lookup
+                # in cls.__new__. Reason for this is that python will also always
+                # invoke cls.__init__ if we do this, so we end up with two function
+                # calls.
+                member = cls.__new__(cls, value)
+                member._name_ = name
+                member._value_ = value
+                setattr(cls, name, member)
+
             name_to_member[name] = member
-            value_to_member[value] = member
+            value_to_member.setdefault(value, member)
             member_names.append(name)
-            setattr(cls, name, member)
 
         return cls
 
diff --git a/hikari/internal/enums.pyi b/hikari/internal/enums.pyi
index 526460de..9832676b 100644
--- a/hikari/internal/enums.pyi
+++ b/hikari/internal/enums.pyi
@@ -33,6 +33,8 @@
 __all__ = ["Enum", "Flag"]
 
 import enum as __enum
+from typing import Any as __Any
+from typing import Generic as __Generic
 from typing import Iterator as __Iterator
 from typing import Sequence as __Sequence
 from typing import TypeVar as __TypeVar
@@ -64,3 +66,7 @@ class Flag(__enum.IntFlag):
     def issubset(self: __FlagT, other: __Union[int, __FlagT]) -> bool: ...  # is_subset
     # '__invert__' is explicitly defined as a special case because it is typed as returning 'int' in typeshed
     def __invert__(self: __FlagT) -> __FlagT: ...  # invert
+
+_V = __TypeVar("_V")
+
+def deprecated(value: _V, /, *, removal_version: str) -> _V: ...


Test patch:
diff --git a/tests/hikari/internal/test_enums.py b/tests/hikari/internal/test_enums.py
index ee001965..7db56e7f 100644
--- a/tests/hikari/internal/test_enums.py
+++ b/tests/hikari/internal/test_enums.py
@@ -21,10 +21,12 @@
 # SOFTWARE.
 import builtins
 import operator
+import warnings
 
 import mock
 import pytest
 
+from hikari.internal import deprecation
 from hikari.internal import enums
 
 
@@ -1285,3 +1287,31 @@ def __int__(self):
                 return 855555
 
         assert int(TestFlag.FOO | TestFlag.BAR) == 855555
+
+
+def test_deprecated():
+    with mock.patch.object(deprecation, "check_if_past_removal"):
+
+        class Enum(int, enums.Enum):
+            OK_VALUE = 1
+            DEPRECATED = enums.deprecated(OK_VALUE, removal_version="4.0.0")
+
+        with mock.patch.object(warnings, "warn") as warn:
+            assert Enum.DEPRECATED == Enum.OK_VALUE
+            warn.assert_called_once()
+            warn.reset_mock()
+
+            assert Enum["DEPRECATED"] == Enum.OK_VALUE
+            warn.assert_called_once()
+            warn.reset_mock()
+
+            assert Enum.DEPRECATED.value == Enum.OK_VALUE.value
+            warn.assert_called_once()
+            warn.reset_mock()
+
+            # Ensure we didn't break any other attributes
+            assert Enum(1) == Enum.OK_VALUE
+            warn.assert_not_called()
+
+            Enum.OK_VALUE
+            warn.assert_not_called()