From 31bb43ca38f12765abaf258af0686d1b4550f4b3 Mon Sep 17 00:00:00 2001 From: Draper <27962761+Drapersniper@users.noreply.github.com> Date: Mon, 6 Jul 2020 16:37:52 +0100 Subject: [PATCH] Vendor `discord.ext.menus` (#4039) Vendor `discord.ext.menus` from commit `cc108bed812d0e481a628ca573c2eeeca9226b42` at https://github.com/Rapptz/discord-ext-menus Co-authored-by: jack1142 <6032823+jack1142@users.noreply.github.com> --- LICENSE | 3 + README.md | 3 + docs/version_guarantees.rst | 2 +- pyproject.toml | 1 + redbot/vendored/discord-ext-menus.LICENSE | 21 + redbot/vendored/discord/ext/menus/__init__.py | 1194 +++++++++++++++++ 6 files changed, 1223 insertions(+), 1 deletion(-) create mode 100644 redbot/vendored/discord-ext-menus.LICENSE create mode 100644 redbot/vendored/discord/ext/menus/__init__.py diff --git a/LICENSE b/LICENSE index 733715286..4c7dd316c 100644 --- a/LICENSE +++ b/LICENSE @@ -700,3 +700,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This project vendors discord.ext.menus package (https://github.com/Rapptz/discord-ext-menus) made by Danny Y. (Rapptz) which is distributed under MIT License. +Copy of this license can be found in discord-ext-menus.LICENSE file in redbot/vendored folder of this repository. diff --git a/README.md b/README.md index 9bc2e6c0a..b93c11d5b 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,6 @@ Red is named after the main character of "Transistor", a video game by Artwork created by [Sinlaire](https://sinlaire.deviantart.com/) on Deviant Art for the Red Discord Bot Project. + +This project vendors [discord.ext.menus](https://github.com/Rapptz/discord-ext-menus) package made by Danny Y. (Rapptz) which is distributed under MIT License. +Copy of this license can be found in [discord-ext-menus.LICENSE](redbot/vendored/discord-ext-menus.LICENSE) file in [redbot/vendored](redbot/vendored) folder of this repository. diff --git a/docs/version_guarantees.rst b/docs/version_guarantees.rst index 5d5258e50..2e216f259 100644 --- a/docs/version_guarantees.rst +++ b/docs/version_guarantees.rst @@ -17,7 +17,7 @@ Guarantees Anything in the ``redbot.core`` module or any of its submodules which is not private (even if not documented) should not break without notice. -Anything in the ``redbot.cogs`` module or any of it's submodules is specifically +Anything in the ``redbot.cogs`` and ``redbot.vendored`` modules or any of their submodules is specifically excluded from being guaranteed. Any RPC method exposed by Red may break without notice. diff --git a/pyproject.toml b/pyproject.toml index fabe9bd5d..65b45bd84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,5 +14,6 @@ | buck-out | build | dist + | redbot\/vendored )/ ''' diff --git a/redbot/vendored/discord-ext-menus.LICENSE b/redbot/vendored/discord-ext-menus.LICENSE new file mode 100644 index 000000000..b0d778919 --- /dev/null +++ b/redbot/vendored/discord-ext-menus.LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2019 Danny Y. (Rapptz) + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/redbot/vendored/discord/ext/menus/__init__.py b/redbot/vendored/discord/ext/menus/__init__.py new file mode 100644 index 000000000..64c8980ae --- /dev/null +++ b/redbot/vendored/discord/ext/menus/__init__.py @@ -0,0 +1,1194 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2019 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import asyncio +import discord + +import itertools +import inspect +import bisect +import re +from collections import OrderedDict, namedtuple + +# Needed for the setup.py script +__version__ = '1.0.0-a' + +class MenuError(Exception): + pass + +class CannotEmbedLinks(MenuError): + def __init__(self): + super().__init__('Bot does not have embed links permission in this channel.') + +class CannotSendMessages(MenuError): + def __init__(self): + super().__init__('Bot cannot send messages in this channel.') + +class CannotAddReactions(MenuError): + def __init__(self): + super().__init__('Bot cannot add reactions in this channel.') + +class CannotReadMessageHistory(MenuError): + def __init__(self): + super().__init__('Bot does not have Read Message History permissions in this channel.') + +class Position: + __slots__ = ('number', 'bucket') + + def __init__(self, number, *, bucket=1): + self.bucket = bucket + self.number = number + + def __lt__(self, other): + if not isinstance(other, Position) or not isinstance(self, Position): + return NotImplemented + + return (self.bucket, self.number) < (other.bucket, other.number) + + def __eq__(self, other): + return isinstance(other, Position) and other.bucket == self.bucket and other.number == self.number + + def __le__(self, other): + r = Position.__lt__(other, self) + if r is NotImplemented: + return NotImplemented + return not r + + def __gt__(self, other): + return Position.__lt__(other, self) + + def __ge__(self, other): + r = Position.__lt__(self, other) + if r is NotImplemented: + return NotImplemented + return not r + + def __repr__(self): + return '<{0.__class__.__name__}: {0.number}>'.format(self) + +class Last(Position): + __slots__ = () + def __init__(self, number=0): + super().__init__(number, bucket=2) + +class First(Position): + __slots__ = () + def __init__(self, number=0): + super().__init__(number, bucket=0) + +_custom_emoji = re.compile(r'a)?:?(?P[A-Za-z0-9\_]+):(?P[0-9]{13,21})>?') + +def _cast_emoji(obj, *, _custom_emoji=_custom_emoji): + if isinstance(obj, discord.PartialEmoji): + return obj + + obj = str(obj) + match = _custom_emoji.match(obj) + if match is not None: + groups = match.groupdict() + animated = bool(groups['animated']) + emoji_id = int(groups['id']) + name = groups['name'] + return discord.PartialEmoji(name=name, animated=animated, id=emoji_id) + return discord.PartialEmoji(name=obj, id=None, animated=False) + +class Button: + """Represents a reaction-style button for the :class:`Menu`. + + There are two ways to create this, the first being through explicitly + creating this class and the second being through the decorator interface, + :func:`button`. + + The action must have both a ``self`` and a ``payload`` parameter + of type :class:`discord.RawReactionActionEvent`. + + Attributes + ------------ + emoji: :class:`discord.PartialEmoji` + The emoji to use as the button. Note that passing a string will + transform it into a :class:`discord.PartialEmoji`. + action + A coroutine that is called when the button is pressed. + skip_if: Optional[Callable[[:class:`Menu`], :class:`bool`]] + A callable that detects whether it should be skipped. + A skipped button does not show up in the reaction list + and will not be processed. + position: :class:`Position` + The position the button should have in the initial order. + Note that since Discord does not actually maintain reaction + order, this is a best effort attempt to have an order until + the user restarts their client. Defaults to ``Position(0)``. + lock: :class:`bool` + Whether the button should lock all other buttons from being processed + until this button is done. Defaults to ``True``. + """ + __slots__ = ('emoji', '_action', '_skip_if', 'position', 'lock') + + def __init__(self, emoji, action, *, skip_if=None, position=None, lock=True): + self.emoji = _cast_emoji(emoji) + self.action = action + self.skip_if = skip_if + self.position = position or Position(0) + self.lock = lock + + @property + def skip_if(self): + return self._skip_if + + @skip_if.setter + def skip_if(self, value): + if value is None: + self._skip_if = lambda x: False + return + + try: + menu_self = value.__self__ + except AttributeError: + self._skip_if = value + else: + # Unfurl the method to not be bound + if not isinstance(menu_self, Menu): + raise TypeError('skip_if bound method must be from Menu not %r' % menu_self) + + self._skip_if = value.__func__ + + @property + def action(self): + return self._action + + @action.setter + def action(self, value): + try: + menu_self = value.__self__ + except AttributeError: + pass + else: + # Unfurl the method to not be bound + if not isinstance(menu_self, Menu): + raise TypeError('action bound method must be from Menu not %r' % menu_self) + + value = value.__func__ + + if not inspect.iscoroutinefunction(value): + raise TypeError('action must be a coroutine not %r' % value) + + self._action = value + + def __call__(self, menu, payload): + if self.skip_if(menu): + return + return self._action(menu, payload) + + def __str__(self): + return str(self.emoji) + + def is_valid(self, menu): + return not self.skip_if(menu) + +def button(emoji, **kwargs): + """Denotes a method to be button for the :class:`Menu`. + + The methods being wrapped must have both a ``self`` and a ``payload`` + parameter of type :class:`discord.RawReactionActionEvent`. + + The keyword arguments are forwarded to the :class:`Button` constructor. + + Example + --------- + + .. code-block:: python3 + + class MyMenu(Menu): + async def send_initial_message(self, ctx, channel): + return await channel.send(f'Hello {ctx.author}') + + @button('\\N{THUMBS UP SIGN}') + async def on_thumbs_up(self, payload): + await self.message.edit(content=f'Thanks {self.ctx.author}!') + + @button('\\N{THUMBS DOWN SIGN}') + async def on_thumbs_down(self, payload): + await self.message.edit(content=f"That's not nice {self.ctx.author}...") + + Parameters + ------------ + emoji: Union[:class:`str`, :class:`discord.PartialEmoji`] + The emoji to use for the button. + """ + def decorator(func): + func.__menu_button__ = _cast_emoji(emoji) + func.__menu_button_kwargs__ = kwargs + return func + return decorator + +class _MenuMeta(type): + @classmethod + def __prepare__(cls, name, bases, **kwargs): + # This is needed to maintain member order for the buttons + return OrderedDict() + + def __new__(cls, name, bases, attrs, **kwargs): + buttons = [] + new_cls = super().__new__(cls, name, bases, attrs) + + inherit_buttons = kwargs.pop('inherit_buttons', True) + if inherit_buttons: + # walk MRO to get all buttons even in subclasses + for base in reversed(new_cls.__mro__): + for elem, value in base.__dict__.items(): + try: + value.__menu_button__ + except AttributeError: + continue + else: + buttons.append(value) + else: + for elem, value in attrs.items(): + try: + value.__menu_button__ + except AttributeError: + continue + else: + buttons.append(value) + + new_cls.__menu_buttons__ = buttons + return new_cls + + def get_buttons(cls): + buttons = OrderedDict() + for func in cls.__menu_buttons__: + emoji = func.__menu_button__ + buttons[emoji] = Button(emoji, func, **func.__menu_button_kwargs__) + return buttons + +class Menu(metaclass=_MenuMeta): + r"""An interface that allows handling menus by using reactions as buttons. + + Buttons should be marked with the :func:`button` decorator. Please note that + this expects the methods to have a single parameter, the ``payload``. This + ``payload`` is of type :class:`discord.RawReactionActionEvent`. + + Attributes + ------------ + timeout: :class:`float` + The timeout to wait between button inputs. + delete_message_after: :class:`bool` + Whether to delete the message after the menu interaction is done. + clear_reactions_after: :class:`bool` + Whether to clear reactions after the menu interaction is done. + Note that :attr:`delete_message_after` takes priority over this attribute. + If the bot does not have permissions to clear the reactions then it will + delete the reactions one by one. + check_embeds: :class:`bool` + Whether to verify embed permissions as well. + ctx: Optional[:class:`commands.Context`] + The context that started this pagination session or ``None`` if it hasn't + been started yet. + bot: Optional[:class:`commands.Bot`] + The bot that is running this pagination session or ``None`` if it hasn't + been started yet. + message: Optional[:class:`discord.Message`] + The message that has been sent for handling the menu. This is the returned + message of :meth:`send_initial_message`. You can set it in order to avoid + calling :meth:`send_initial_message`\, if for example you have a pre-existing + message you want to attach a menu to. + """ + def __init__(self, *, timeout=180.0, delete_message_after=False, + clear_reactions_after=False, check_embeds=False, message=None): + + self.timeout = timeout + self.delete_message_after = delete_message_after + self.clear_reactions_after = clear_reactions_after + self.check_embeds = check_embeds + self._can_remove_reactions = False + self.__tasks = [] + self._running = True + self.message = message + self.ctx = None + self.bot = None + self._author_id = None + self._buttons = self.__class__.get_buttons() + self._lock = asyncio.Lock() + self._event = asyncio.Event() + + @discord.utils.cached_property + def buttons(self): + """Retrieves the buttons that are to be used for this menu session. + + Skipped buttons are not in the resulting dictionary. + + Returns + --------- + Mapping[:class:`str`, :class:`Button`] + A mapping of button emoji to the actual button class. + """ + buttons = sorted(self._buttons.values(), key=lambda b: b.position) + return { + button.emoji: button + for button in buttons + if button.is_valid(self) + } + + def add_button(self, button, *, react=False): + """|maybecoro| + + Adds a button to the list of buttons. + + If the menu has already been started then the button will + not be added unless the ``react`` keyword-only argument is + set to ``True``. Note that when this happens this function + will need to be awaited. + + If a button with the same emoji is added then it is overridden. + + .. warning:: + + If the menu has started and the reaction is added, the order + property of the newly added button is ignored due to an API + limitation with Discord and the fact that reaction ordering + is not guaranteed. + + Parameters + ------------ + button: :class:`Button` + The button to add. + react: :class:`bool` + Whether to add a reaction if the menu has been started. + Note this turns the method into a coroutine. + + Raises + --------- + MenuError + Tried to use ``react`` when the menu had not been started. + discord.HTTPException + Adding the reaction failed. + """ + + self._buttons[button.emoji] = button + + if react: + if self.__tasks: + async def wrapped(): + # Add the reaction + try: + await self.message.add_reaction(button.emoji) + except discord.HTTPException: + raise + else: + # Update the cache to have the value + self.buttons[button.emoji] = button + + return wrapped() + + async def dummy(): + raise MenuError('Menu has not been started yet') + return dummy() + + def remove_button(self, emoji, *, react=False): + """|maybecoro| + + Removes a button from the list of buttons. + + This operates similar to :meth:`add_button`. + + Parameters + ------------ + emoji: Union[:class:`Button`, :class:`str`] + The emoji or the button to remove. + react: :class:`bool` + Whether to remove the reaction if the menu has been started. + Note this turns the method into a coroutine. + + Raises + --------- + MenuError + Tried to use ``react`` when the menu had not been started. + discord.HTTPException + Removing the reaction failed. + """ + + if isinstance(emoji, Button): + emoji = emoji.emoji + else: + emoji = _cast_emoji(emoji) + + self._buttons.pop(emoji, None) + + if react: + if self.__tasks: + async def wrapped(): + # Remove the reaction from being processable + # Removing it from the cache first makes it so the check + # doesn't get triggered. + self.buttons.pop(emoji, None) + await self.message.remove_reaction(emoji, self.__me) + return wrapped() + + async def dummy(): + raise MenuError('Menu has not been started yet') + return dummy() + + def clear_buttons(self, *, react=False): + """|maybecoro| + + Removes all buttons from the list of buttons. + + If the menu has already been started then the buttons will + not be removed unless the ``react`` keyword-only argument is + set to ``True``. Note that when this happens this function + will need to be awaited. + + Parameters + ------------ + react: :class:`bool` + Whether to clear the reactions if the menu has been started. + Note this turns the method into a coroutine. + + Raises + --------- + MenuError + Tried to use ``react`` when the menu had not been started. + discord.HTTPException + Clearing the reactions failed. + """ + + self._buttons.clear() + + if react: + if self.__tasks: + async def wrapped(): + # A fast path if we have permissions + if self._can_remove_reactions: + try: + del self.buttons + except AttributeError: + pass + finally: + await self.message.clear_reactions() + return + + # Remove the cache (the next call will have the updated buttons) + reactions = list(self.buttons.keys()) + try: + del self.buttons + except AttributeError: + pass + + for reaction in reactions: + await self.message.remove_reaction(reaction, self.__me) + + return wrapped() + async def dummy(): + raise MenuError('Menu has not been started yet') + return dummy() + + def should_add_reactions(self): + """:class:`bool`: Whether to add reactions to this menu session.""" + return len(self.buttons) + + def _verify_permissions(self, ctx, channel, permissions): + if not permissions.send_messages: + raise CannotSendMessages() + + if self.check_embeds and not permissions.embed_links: + raise CannotEmbedLinks() + + self._can_remove_reactions = permissions.manage_messages + if self.should_add_reactions(): + if not permissions.add_reactions: + raise CannotAddReactions() + if not permissions.read_message_history: + raise CannotReadMessageHistory() + + def reaction_check(self, payload): + """The function that is used to check whether the payload should be processed. + This is passed to :meth:`discord.ext.commands.Bot.wait_for `. + + There should be no reason to override this function for most users. + + Parameters + ------------ + payload: :class:`discord.RawReactionActionEvent` + The payload to check. + + Returns + --------- + :class:`bool` + Whether the payload should be processed. + """ + if payload.message_id != self.message.id: + return False + if payload.user_id not in (self.bot.owner_id, self._author_id): + return False + + return payload.emoji in self.buttons + + async def _internal_loop(self): + try: + loop = self.bot.loop + # Ensure the name exists for the cancellation handling + tasks = [] + while self._running: + tasks = [ + asyncio.ensure_future(self.bot.wait_for('raw_reaction_add', check=self.reaction_check)), + asyncio.ensure_future(self.bot.wait_for('raw_reaction_remove', check=self.reaction_check)) + ] + done, pending = await asyncio.wait(tasks, timeout=self.timeout, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + + if len(done) == 0: + raise asyncio.TimeoutError() + + # Exception will propagate if e.g. cancelled or timed out + payload = done.pop().result() + loop.create_task(self.update(payload)) + + # NOTE: Removing the reaction ourselves after it's been done when + # mixed with the checks above is incredibly racy. + # There is no guarantee when the MESSAGE_REACTION_REMOVE event will + # be called, and chances are when it does happen it'll always be + # after the remove_reaction HTTP call has returned back to the caller + # which means that the stuff above will catch the reaction that we + # just removed. + + # For the future sake of myself and to save myself the hours in the future + # consider this my warning. + + except asyncio.TimeoutError: + pass + finally: + self._event.set() + + # Cancel any outstanding tasks (if any) + for task in tasks: + task.cancel() + + try: + await self.finalize() + except Exception: + pass + + # Can't do any requests if the bot is closed + if self.bot.is_closed(): + return + + # Wrap it in another block anyway just to ensure + # nothing leaks out during clean-up + try: + if self.delete_message_after: + return await self.message.delete() + + if self.clear_reactions_after: + if self._can_remove_reactions: + return await self.message.clear_reactions() + + for button_emoji in self.buttons: + try: + await self.message.remove_reaction(button_emoji, self.__me) + except discord.HTTPException: + continue + except Exception: + pass + + async def update(self, payload): + """|coro| + + Updates the menu after an event has been received. + + Parameters + ----------- + payload: :class:`discord.RawReactionActionEvent` + The reaction event that triggered this update. + """ + button = self.buttons[payload.emoji] + if not self._running: + return + + try: + if button.lock: + async with self._lock: + if self._running: + await button(self, payload) + else: + await button(self, payload) + except Exception: + # TODO: logging? + import traceback + traceback.print_exc() + + async def start(self, ctx, *, channel=None, wait=False): + """|coro| + + Starts the interactive menu session. + + Parameters + ----------- + ctx: :class:`Context` + The invocation context to use. + channel: :class:`discord.abc.Messageable` + The messageable to send the message to. If not given + then it defaults to the channel in the context. + wait: :class:`bool` + Whether to wait until the menu is completed before + returning back to the caller. + + Raises + ------- + MenuError + An error happened when verifying permissions. + discord.HTTPException + Adding a reaction failed. + """ + + # Clear the buttons cache and re-compute if possible. + try: + del self.buttons + except AttributeError: + pass + + self.bot = bot = ctx.bot + self.ctx = ctx + self._author_id = ctx.author.id + channel = channel or ctx.channel + is_guild = isinstance(channel, discord.abc.GuildChannel) + me = ctx.guild.me if is_guild else ctx.bot.user + permissions = channel.permissions_for(me) + self.__me = discord.Object(id=me.id) + self._verify_permissions(ctx, channel, permissions) + self._event.clear() + msg = self.message + if msg is None: + self.message = msg = await self.send_initial_message(ctx, channel) + + if self.should_add_reactions(): + # Start the task first so we can listen to reactions before doing anything + for task in self.__tasks: + task.cancel() + self.__tasks.clear() + + self._running = True + self.__tasks.append(bot.loop.create_task(self._internal_loop())) + + async def add_reactions_task(): + for emoji in self.buttons: + await msg.add_reaction(emoji) + self.__tasks.append(bot.loop.create_task(add_reactions_task())) + + if wait: + await self._event.wait() + + async def finalize(self): + """|coro| + + A coroutine that is called when the menu loop has completed + its run. This is useful if some asynchronous clean-up is + required after the fact. + """ + return + + async def send_initial_message(self, ctx, channel): + """|coro| + + Sends the initial message for the menu session. + + This is internally assigned to the :attr:`message` attribute. + + Subclasses must implement this if they don't set the + :attr:`message` attribute themselves before starting the + menu via :meth:`start`. + + Parameters + ------------ + ctx: :class:`Context` + The invocation context to use. + channel: :class:`discord.abc.Messageable` + The messageable to send the message to. + + Returns + -------- + :class:`discord.Message` + The message that has been sent. + """ + raise NotImplementedError + + def stop(self): + """Stops the internal loop.""" + self._running = False + for task in self.__tasks: + task.cancel() + self.__tasks.clear() + +class PageSource: + """An interface representing a menu page's data source for the actual menu page. + + Subclasses must implement the backing resource along with the following methods: + + - :meth:`get_page` + - :meth:`is_paginating` + - :meth:`format_page` + """ + async def _prepare_once(self): + try: + # Don't feel like formatting hasattr with + # the proper mangling + # read this as follows: + # if hasattr(self, '__prepare') + # except that it works as you expect + self.__prepare + except AttributeError: + await self.prepare() + self.__prepare = True + + async def prepare(self): + """|coro| + + A coroutine that is called after initialisation + but before anything else to do some asynchronous set up + as well as the one provided in ``__init__``. + + By default this does nothing. + + This coroutine will only be called once. + """ + return + + def is_paginating(self): + """An abstract method that notifies the :class:`MenuPages` whether or not + to start paginating. This signals whether to add reactions or not. + + Subclasses must implement this. + + Returns + -------- + :class:`bool` + Whether to trigger pagination. + """ + raise NotImplementedError + + def get_max_pages(self): + """An optional abstract method that retrieves the maximum number of pages + this page source has. Useful for UX purposes. + + The default implementation returns ``None``. + + Returns + -------- + Optional[:class:`int`] + The maximum number of pages required to properly + paginate the elements, if given. + """ + return None + + async def get_page(self, page_number): + """|coro| + + An abstract method that retrieves an object representing the object to format. + + Subclasses must implement this. + + .. note:: + + The page_number is zero-indexed between [0, :meth:`get_max_pages`), + if there is a maximum number of pages. + + Parameters + ----------- + page_number: :class:`int` + The page number to access. + + Returns + --------- + Any + The object represented by that page. + This is passed into :meth:`format_page`. + """ + raise NotImplementedError + + async def format_page(self, menu, page): + """|maybecoro| + + An abstract method to format the page. + + This method must return one of the following types. + + If this method returns a ``str`` then it is interpreted as returning + the ``content`` keyword argument in :meth:`discord.Message.edit` + and :meth:`discord.abc.Messageable.send`. + + If this method returns a :class:`discord.Embed` then it is interpreted + as returning the ``embed`` keyword argument in :meth:`discord.Message.edit` + and :meth:`discord.abc.Messageable.send`. + + If this method returns a ``dict`` then it is interpreted as the + keyword-arguments that are used in both :meth:`discord.Message.edit` + and :meth:`discord.abc.Messageable.send`. The two of interest are + ``embed`` and ``content``. + + Parameters + ------------ + menu: :class:`Menu` + The menu that wants to format this page. + page: Any + The page returned by :meth:`PageSource.get_page`. + + Returns + --------- + Union[:class:`str`, :class:`discord.Embed`, :class:`dict`] + See above. + """ + raise NotImplementedError + +class MenuPages(Menu): + """A special type of Menu dedicated to pagination. + + Attributes + ------------ + current_page: :class:`int` + The current page that we are in. Zero-indexed + between [0, :attr:`PageSource.max_pages`). + """ + def __init__(self, source, **kwargs): + self._source = source + self.current_page = 0 + super().__init__(**kwargs) + + @property + def source(self): + """:class:`PageSource`: The source where the data comes from.""" + return self._source + + async def change_source(self, source): + """|coro| + + Changes the :class:`PageSource` to a different one at runtime. + + Once the change has been set, the menu is moved to the first + page of the new source if it was started. This effectively + changes the :attr:`current_page` to 0. + + Raises + -------- + TypeError + A :class:`PageSource` was not passed. + """ + + if not isinstance(source, PageSource): + raise TypeError('Expected {0!r} not {1.__class__!r}.'.format(PageSource, source)) + + self._source = source + self.current_page = 0 + if self.message is not None: + await source._prepare_once() + await self.show_page(0) + + def should_add_reactions(self): + return self._source.is_paginating() + + async def _get_kwargs_from_page(self, page): + value = await discord.utils.maybe_coroutine(self._source.format_page, self, page) + if isinstance(value, dict): + return value + elif isinstance(value, str): + return { 'content': value, 'embed': None } + elif isinstance(value, discord.Embed): + return { 'embed': value, 'content': None } + + async def show_page(self, page_number): + page = await self._source.get_page(page_number) + self.current_page = page_number + kwargs = await self._get_kwargs_from_page(page) + await self.message.edit(**kwargs) + + async def send_initial_message(self, ctx, channel): + """|coro| + + The default implementation of :meth:`Menu.send_initial_message` + for the interactive pagination session. + + This implementation shows the first page of the source. + """ + page = await self._source.get_page(0) + kwargs = await self._get_kwargs_from_page(page) + return await channel.send(**kwargs) + + async def start(self, ctx, *, channel=None, wait=False): + await self._source._prepare_once() + await super().start(ctx, channel=channel, wait=wait) + + async def show_checked_page(self, page_number): + max_pages = self._source.get_max_pages() + try: + if max_pages is None: + # If it doesn't give maximum pages, it cannot be checked + await self.show_page(page_number) + elif max_pages > page_number >= 0: + await self.show_page(page_number) + except IndexError: + # An error happened that can be handled, so ignore it. + pass + + async def show_current_page(self): + if self._source.paginating: + await self.show_page(self.current_page) + + def _skip_double_triangle_buttons(self): + max_pages = self._source.get_max_pages() + if max_pages is None: + return True + return max_pages <= 2 + + @button('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f', + position=First(0), skip_if=_skip_double_triangle_buttons) + async def go_to_first_page(self, payload): + """go to the first page""" + await self.show_page(0) + + @button('\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f', position=First(1)) + async def go_to_previous_page(self, payload): + """go to the previous page""" + await self.show_checked_page(self.current_page - 1) + + @button('\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f', position=Last(0)) + async def go_to_next_page(self, payload): + """go to the next page""" + await self.show_checked_page(self.current_page + 1) + + @button('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}\ufe0f', + position=Last(1), skip_if=_skip_double_triangle_buttons) + async def go_to_last_page(self, payload): + """go to the last page""" + # The call here is safe because it's guarded by skip_if + await self.show_page(self._source.get_max_pages() - 1) + + @button('\N{BLACK SQUARE FOR STOP}\ufe0f', position=Last(2)) + async def stop_pages(self, payload): + """stops the pagination session.""" + self.stop() + +class ListPageSource(PageSource): + """A data source for a sequence of items. + + This page source does not handle any sort of formatting, leaving it up + to the user. To do so, implement the :meth:`format_page` method. + + Attributes + ------------ + entries: Sequence[Any] + The sequence of items to paginate. + per_page: :class:`int` + How many elements are in a page. + """ + + def __init__(self, entries, *, per_page): + self.entries = entries + self.per_page = per_page + + pages, left_over = divmod(len(entries), per_page) + if left_over: + pages += 1 + + self._max_pages = pages + + def is_paginating(self): + """:class:`bool`: Whether pagination is required.""" + return len(self.entries) > self.per_page + + def get_max_pages(self): + """:class:`int`: The maximum number of pages required to paginate this sequence.""" + return self._max_pages + + async def get_page(self, page_number): + """Returns either a single element of the sequence or + a slice of the sequence. + + If :attr:`per_page` is set to ``1`` then this returns a single + element. Otherwise it returns at most :attr:`per_page` elements. + + Returns + --------- + Union[Any, List[Any]] + The data returned. + """ + if self.per_page == 1: + return self.entries[page_number] + else: + base = page_number * self.per_page + return self.entries[base:base + self.per_page] + +_GroupByEntry = namedtuple('_GroupByEntry', 'key items') + +class GroupByPageSource(ListPageSource): + """A data source for grouped by sequence of items. + + This inherits from :class:`ListPageSource`. + + This page source does not handle any sort of formatting, leaving it up + to the user. To do so, implement the :meth:`format_page` method. + + Parameters + ------------ + entries: Sequence[Any] + The sequence of items to paginate and group. + key: Callable[[Any], Any] + A key function to do the grouping with. + sort: :class:`bool` + Whether to sort the sequence before grouping it. + The elements are sorted according to the ``key`` function passed. + per_page: :class:`int` + How many elements to have per page of the group. + """ + def __init__(self, entries, *, key, per_page, sort=True): + self.__entries = entries if not sort else sorted(entries, key=key) + nested = [] + self.nested_per_page = per_page + for k, g in itertools.groupby(self.__entries, key=key): + g = list(g) + if not g: + continue + size = len(g) + + # Chunk the nested pages + nested.extend(_GroupByEntry(key=k, items=g[i:i+per_page]) for i in range(0, size, per_page)) + + super().__init__(nested, per_page=1) + + async def get_page(self, page_number): + return self.entries[page_number] + + async def format_page(self, menu, entry): + """An abstract method to format the page. + + This works similar to the :meth:`ListPageSource.format_page` except + the return type of the ``entry`` parameter is documented. + + Parameters + ------------ + menu: :class:`Menu` + The menu that wants to format this page. + entry + A namedtuple with ``(key, items)`` representing the key of the + group by function and a sequence of paginated items within that + group. + + Returns + --------- + :class:`dict` + A dictionary representing keyword-arguments to pass to + the message related calls. + """ + raise NotImplementedError + +def _aiter(obj, *, _isasync=inspect.iscoroutinefunction): + cls = obj.__class__ + try: + async_iter = cls.__aiter__ + except AttributeError: + raise TypeError('{0.__name__!r} object is not an async iterable'.format(cls)) + + async_iter = async_iter(obj) + if _isasync(async_iter): + raise TypeError('{0.__name__!r} object is not an async iterable'.format(cls)) + return async_iter + +class AsyncIteratorPageSource(PageSource): + """A data source for data backed by an asynchronous iterator. + + This page source does not handle any sort of formatting, leaving it up + to the user. To do so, implement the :meth:`format_page` method. + + Parameters + ------------ + iter: AsyncIterator[Any] + The asynchronous iterator to paginate. + per_page: :class:`int` + How many elements to have per page. + """ + + def __init__(self, iterator, *, per_page): + self.iterator = _aiter(iterator) + self.per_page = per_page + self._exhausted = False + self._cache = [] + + async def _iterate(self, n): + it = self.iterator + cache = self._cache + for i in range(0, n): + try: + elem = await it.__anext__() + except StopAsyncIteration: + self._exhausted = True + break + else: + cache.append(elem) + + async def prepare(self, *, _aiter=_aiter): + # Iterate until we have at least a bit more single page + await self._iterate(self.per_page + 1) + + def is_paginating(self): + """:class:`bool`: Whether pagination is required.""" + return len(self._cache) > self.per_page + + async def _get_single_page(self, page_number): + if page_number < 0: + raise IndexError('Negative page number.') + + if not self._exhausted and len(self._cache) <= page_number: + await self._iterate((page_number + 1) - len(self._cache)) + return self._cache[page_number] + + async def _get_page_range(self, page_number): + if page_number < 0: + raise IndexError('Negative page number.') + + base = page_number * self.per_page + max_base = base + self.per_page + if not self._exhausted and len(self._cache) <= max_base: + await self._iterate((max_base + 1) - len(self._cache)) + + entries = self._cache[base:max_base] + if not entries and max_base > len(self._cache): + raise IndexError('Went too far') + return entries + + async def get_page(self, page_number): + """Returns either a single element of the sequence or + a slice of the sequence. + + If :attr:`per_page` is set to ``1`` then this returns a single + element. Otherwise it returns at most :attr:`per_page` elements. + + Returns + --------- + Union[Any, List[Any]] + The data returned. + """ + if self.per_page == 1: + return await self._get_single_page(page_number) + else: + return await self._get_page_range(page_number)