Repository: Cog-Creators/Red-DiscordBot

Issue:
Make DEFAULT_CONTROLS and ReactionPredicate.*_EMOJIS immutable
This ensures that developers don't accidentally mutate these while working with them.


Discussion:

Resolving patch:
diff --git a/redbot/core/utils/menus.py b/redbot/core/utils/menus.py
--- a/redbot/core/utils/menus.py
+++ b/redbot/core/utils/menus.py
@@ -5,23 +5,28 @@
 import asyncio
 import contextlib
 import functools
-from typing import Iterable, List, Union
+from types import MappingProxyType
+from typing import Callable, Iterable, List, Mapping, TypeVar, Union
+
 import discord
 
 from .. import commands
 from .predicates import ReactionPredicate
 
+_T = TypeVar("_T")
+_PageList = TypeVar("_PageList", List[str], List[discord.Embed])
 _ReactableEmoji = Union[str, discord.Emoji]
+_ControlCallable = Callable[[commands.Context, _PageList, discord.Message, int, float, str], _T]
 
 
 async def menu(
     ctx: commands.Context,
-    pages: Union[List[str], List[discord.Embed]],
-    controls: dict,
+    pages: _PageList,
+    controls: Mapping[str, _ControlCallable],
     message: discord.Message = None,
     page: int = 0,
     timeout: float = 30.0,
-):
+) -> _T:
     """
     An emoji-based menu
 
@@ -40,9 +45,10 @@ async def menu(
         The command context
     pages: `list` of `str` or `discord.Embed`
         The pages of the menu.
-    controls: dict
+    controls: Mapping[str, Callable],
         A mapping of emoji to the function which handles the action for the
-        emoji.
+        emoji. The signature of the function should be the same as of this function
+        and should additionally accept an ``emoji`` parameter of type `str`.
     message: discord.Message
         The message representing the menu. Usually :code:`None` when first opening
         the menu
@@ -132,12 +138,12 @@ async def menu(
 async def next_page(
     ctx: commands.Context,
     pages: list,
-    controls: dict,
+    controls: Mapping[str, _ControlCallable],
     message: discord.Message,
     page: int,
     timeout: float,
     emoji: str,
-):
+) -> _T:
     if page == len(pages) - 1:
         page = 0  # Loop around to the first item
     else:
@@ -148,12 +154,12 @@ async def next_page(
 async def prev_page(
     ctx: commands.Context,
     pages: list,
-    controls: dict,
+    controls: Mapping[str, _ControlCallable],
     message: discord.Message,
     page: int,
     timeout: float,
     emoji: str,
-):
+) -> _T:
     if page == 0:
         page = len(pages) - 1  # Loop around to the last item
     else:
@@ -164,12 +170,12 @@ async def prev_page(
 async def close_menu(
     ctx: commands.Context,
     pages: list,
-    controls: dict,
+    controls: Mapping[str, _ControlCallable],
     message: discord.Message,
     page: int,
     timeout: float,
     emoji: str,
-):
+) -> None:
     with contextlib.suppress(discord.NotFound):
         await message.delete()
 
@@ -210,8 +216,10 @@ async def task():
     return asyncio.create_task(task())
 
 
-DEFAULT_CONTROLS = {
-    "\N{LEFTWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}": prev_page,
-    "\N{CROSS MARK}": close_menu,
-    "\N{BLACK RIGHTWARDS ARROW}\N{VARIATION SELECTOR-16}": next_page,
-}
+DEFAULT_CONTROLS: Mapping[str, _ControlCallable] = MappingProxyType(
+    {
+        "\N{LEFTWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}": prev_page,
+        "\N{CROSS MARK}": close_menu,
+        "\N{BLACK RIGHTWARDS ARROW}\N{VARIATION SELECTOR-16}": next_page,
+    }
+)
diff --git a/redbot/core/utils/predicates.py b/redbot/core/utils/predicates.py
--- a/redbot/core/utils/predicates.py
+++ b/redbot/core/utils/predicates.py
@@ -881,19 +881,19 @@ class ReactionPredicate(Callable[[discord.Reaction, discord.abc.User], bool]):
     )
     """Tuple[str, str] : A tuple containing the tick emoji and cross emoji, in that order."""
 
-    ALPHABET_EMOJIS: ClassVar[List[str]] = [
+    ALPHABET_EMOJIS: ClassVar[Tuple[str, ...]] = tuple(
         chr(code)
         for code in range(
             ord("\N{REGIONAL INDICATOR SYMBOL LETTER A}"),
             ord("\N{REGIONAL INDICATOR SYMBOL LETTER Z}") + 1,
         )
-    ]
-    """List[str] : A list of all 26 alphabetical letter emojis."""
+    )
+    """Tuple[str, ...] : A tuple of all 26 alphabetical letter emojis."""
 
-    NUMBER_EMOJIS: ClassVar[List[str]] = [
+    NUMBER_EMOJIS: ClassVar[Tuple[str, ...]] = tuple(
         chr(code) + "\N{COMBINING ENCLOSING KEYCAP}" for code in range(ord("0"), ord("9") + 1)
-    ]
-    """List[str] : A list of all single-digit number emojis, 0 through 9."""
+    )
+    """Tuple[str, ...] : A tuple of all single-digit number emojis, 0 through 9."""
 
     def __init__(
         self, predicate: Callable[["ReactionPredicate", discord.Reaction, discord.abc.User], bool]


Test patch: