Source code for discord.ext.paginators.base_paginator

from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional, Union, overload

import logging
from collections.abc import Sequence, Coroutine
from math import ceil

import discord

from ._types import PageT
from . import utils as _utils

if TYPE_CHECKING:
    from typing_extensions import Self

    from ._types import PaginatorCheck, BaseKwargs, Destination
else:
    PaginatorCheck = Callable[[Any, discord.Interaction[Any]], Union[bool, Coroutine[Any, Any, bool]]]


__all__ = ("BaseClassPaginator",)

_log = logging.getLogger(__name__)


[docs] class BaseClassPaginator(discord.ui.View, Generic[PageT]): """Base class for all paginators. Parameters ----------- pages: Sequence[Any] A sequence of pages to paginate. Supported types for pages: - :class:`str`: Will be set as the content of the message. - :class:`.discord.Embed`: Will be appended to the embeds of the message. - :class:`.discord.File`: Will be appended to the files of the message. - :class:`.discord.Attachment`: Calls :meth:`~discord.Attachment.to_file()` and appends it to the files of the message. - :class:`dict`: Will be updated with the kwargs of the message. - Sequence[Any]: Will be flattened and each entry will be handled as above. Sequence = List, Tuple, etc. Any other types will probably be ignored. This attribute *should* be able to be set after the paginator is created. Aka, hotswapping the pages. per_page: :class:`int` The amount of pages to display per page. Defaults to ``1``. E,g: If ``per_page`` is ``2`` and ``pages`` is ``["1", "2", "3", "4"]``, then the message will show ``["1", "2"]`` on the first page and ``["3", "4"]`` on the second page. author_id: Optional[:class:`int`] The id of the user who can interact with the paginator. Defaults to ``None``. check: Optional[Callable[[:class:`.BaseClassPaginator`, :class:`discord.Interaction`], Union[:class:`bool`, Coroutine[Any, Any, :class:`bool`]]]] A callable that checks if the interaction is valid. This must be a callable that takes 2 or 3 parameters. The last two parameters represent the interaction and paginator respectively. It CAN be a coroutine. This is called in :meth:`~discord.ui.View.interaction_check`. If ``author_id`` is not ``None``, this won't be called. Defaults to ``None``. always_allow_bot_owner: :class:`bool` Whether to always allow the bot owner to interact with the paginator. Defaults to ``True``. delete_after: :class:`bool` Whether to delete the message after the paginator stops. Only works if ``message`` is not ``None``. Defaults to ``False``. disable_after: :class:`bool` Whether to disable the paginator after the paginator stops. Only works if ``message`` is not ``None``. Defaults to ``False``. clear_buttons_after: :class:`bool` Whether to clear the buttons after the paginator stops. Only works if ``message`` is not ``None``. Defaults to ``False``. message: Optional[:class:`discord.Message`] The message to use for the paginator. This is set automatically when ``_send`` is called. Defaults to ``None``. add_page_string: :class:`bool` Whether to add the page string to the page. Defaults to ``True``. This is a string that represents the current page and the max pages. E,g: ``"Page 1 of 2"``. If the page is an embed, it will be appended to the footer text. If the page is a string, it will be appended to the string. else, it will be set as the content of the message. timeout: Optional[Union[:class:`int`, :class:`float`]] The timeout for the paginator. Defaults to ``180.0``. """ def __init__( self, pages: Sequence[PageT], *, per_page: int = 1, author_id: Optional[int] = None, check: Optional[PaginatorCheck[Self]] = None, always_allow_bot_owner: bool = True, delete_after: bool = False, disable_after: bool = False, clear_buttons_after: bool = False, message: Optional[discord.Message] = None, add_page_string: bool = True, timeout: Optional[Union[int, float]] = 180.0, ) -> None: super().__init__(timeout=timeout) self.pages = pages self.per_page = per_page self._current_page: int = 0 self.author_id: Optional[int] = author_id self._check: Optional[PaginatorCheck[Self]] = None if check is not None: if not callable(check) or not _utils._check_parameters_amount(check, (2, 3)): raise TypeError( ( "check must be a callable with exactly 2 or 3 parameters. Last two " "representing the interaction and paginator. `(async) def check(self, interaction, " "paginator):` or `(async) def check(interaction, paginator):`." ) ) self.always_allow_bot_owner: bool = always_allow_bot_owner self.delete_after: bool = delete_after self.disable_after: bool = disable_after self.clear_buttons_after: bool = clear_buttons_after self.add_page_string: bool = add_page_string self.message: Optional[discord.Message] = message self.__owner_ids: Optional[set[int]] = None self._reset_base_kwargs() async def __is_bot_owner(self, interaction: discord.Interaction[Any]) -> bool: """Checks if the interaction's user is one of the bot owners.""" if self.__owner_ids is None: self.__owner_ids = await _utils._fetch_bot_owner_ids(interaction.client) return interaction.user.id in self.__owner_ids def _reset_base_kwargs(self) -> None: """Resets the base kwargs. This sets the base kwargs to ``{"content": None, "embeds": [], "view": self}``. """ self.__base_kwargs: BaseKwargs = {"content": None, "embeds": [], "view": self} @property def current_page(self) -> int: """:class:`int`: The current page. Starts from ``0``.""" if self._current_page < 0: self._current_page = 0 elif self._current_page >= self.max_pages: self._current_page = self.max_pages - 1 elif self.per_page == 0: self._current_page = 0 elif self.per_page == 1: self._current_page = self._current_page % len(self.pages) else: self._current_page = self._current_page % self.max_pages return self._current_page @current_page.setter def current_page(self, value: int) -> None: """:class:`int`: Sets the current page to the given value.""" self._current_page = max(0, min(value, self.max_pages - 1)) @property def page_string(self) -> str: """:class:`str`: A string representing the current page and the max pages.""" return f"Page {self.current_page + 1} of {self.max_pages}" @property def pages(self) -> Sequence[PageT]: """Sequence[Any]: The pages of the paginator.""" return self._pages @property def per_page(self) -> int: """:class:`int`: The amount of pages to display per page.""" return self._per_page @per_page.setter def per_page(self, value: int) -> None: """Sets the amount of pages to display per page.""" if not isinstance(value, int): raise TypeError("per_page must be an int.") if value < 1: raise ValueError("per_page must be greater than 0.") if value > len(self.pages): raise ValueError("per_page cannot be greater than the amount of pages.") self._per_page = value @pages.setter def pages(self, value: Sequence[PageT]) -> None: """Sets the pages of the paginator.""" if not value: raise ValueError("pages cannot be empty.") if not isinstance(value, (list, tuple)): raise TypeError("pages must be a list or tuple.") self._pages = value @property def max_pages(self) -> int: """int: The max pages of the paginator.""" if self.per_page == 0: return 0 return ceil(len(self.pages) / self.per_page)
[docs] def stop(self) -> None: """Stops the view and resets the base kwargs.""" self._reset_base_kwargs() self.message = None return super().stop()
[docs] async def on_timeout(self) -> None: """This method is called when the paginator times out. This method does the following checks (in order): - Calls :meth:`.BaseClassPaginator.stop_paginator`. - Calls :meth:`discord.ui.View.on_timeout`. """ await self.stop_paginator() await super().on_timeout()
async def _handle_checks(self, interaction: discord.Interaction[Any]) -> bool: """Handles the checks for the paginator. This is called in :meth:`~discord.ui.View.interaction_check`. Parameters ---------- interaction: :class:`discord.Interaction` The interaction to check. Returns ------- :class:`bool` Whether the interaction is valid or not. """ _log.debug("Checking interaction %s", interaction) if self.always_allow_bot_owner and await self.__is_bot_owner(interaction): _log.debug("Allowing bot owner %s to interact with the paginator since always_allow_bot_owner is True", interaction.user) return True elif self.author_id is not None: _log.debug("Checking if %s equals %s", interaction.user, self.author_id) return interaction.user.id == self.author_id elif self._check: _log.debug("Calling check %s", self._check) return await discord.utils.maybe_coroutine(self._check, self, interaction) _log.debug("No checks to run, allowing interaction") return True
[docs] async def interaction_check(self, interaction: discord.Interaction[Any]) -> bool: """This method is called by the library when the paginator receives an interaction. This method does the following checks (in order): - If ``always_allow_bot_owner`` is ``True``, it checks if the interaction's author id is one of the bot owners. - If ``author_id`` is not ``None``, it checks if the interaction's author id is the same as the one set. - If ``check`` is not ``None``, it calls it and checks if it returns ``True``. - If none of the above checks are ``True``, it returns ``False``. Parameters ---------- interaction: :class:`discord.Interaction` The interaction received. """ return await self._handle_checks(interaction)
def _do_format_page(self, page: Union[PageT, Sequence[PageT]]) -> Coroutine[Any, Any, Union[PageT, Sequence[PageT]]]: return discord.utils.maybe_coroutine(self.format_page, page)
[docs] async def format_page(self, page: Union[PageT, Sequence[PageT]]) -> Union[PageT, Sequence[PageT]]: """This method can be overridden to format the page before sending it. By default, it returns the page as is. Parameters ---------- page: Union[Any], Sequence[Any]] The page to format. Returns ------- Union[Any], Sequence[Any]] The formatted page(s). """ return page
[docs] async def stop_paginator(self, interaction: Optional[discord.Interaction[Any]] = None) -> None: """Stops the paginator. This method does handles deleting the message, disabling the paginator and clearing the buttons. Parameters ---------- interaction: Optional[:class:`discord.Interaction`] Optionally, the last interaction to edit. If ``None``, ``.message`` is used. """ if self.delete_after: if interaction: if not interaction.response.is_done(): await interaction.response.defer() await interaction.delete_original_response() elif self.message: await self.message.delete() self.stop() return if self.disable_after or self.clear_buttons_after: if self.clear_buttons_after: self.clear_items() else: self._disable_all_children() if interaction: await interaction.response.defer() await interaction.edit_original_response(view=self) elif self.message: await self.message.edit(view=self) self.stop() return
[docs] def get_page(self, page_number: int) -> Union[PageT, Sequence[PageT]]: """Gets the page with the given page number. Parameters ---------- page_number: :class:`int` The page number to get. Returns ------- Union[Any], Sequence[Any]] The page(s) with the given page number. """ # handle per_page if page_number < 0 or page_number >= self.max_pages: self.current_page = 0 return self.pages[self.current_page] if self.per_page == 1: return self.pages[page_number] else: base = page_number * self.per_page return self.pages[base : base + self.per_page]
def _handle_page_string(self) -> None: if not self.add_page_string: return embeds = self.__base_kwargs.get("embeds", []) content = self.__base_kwargs.get("content") if embeds: for embed in embeds: to_set = self.page_string if footer_text := embed.footer.text: if "|" in footer_text: footer_text = footer_text.split("|")[0].strip() to_set = f"{footer_text} | {self.page_string}" embed.set_footer(text=to_set) elif content: self.__base_kwargs["content"] = f"{content}\n{self.page_string}" else: self.__base_kwargs["content"] = self.page_string
[docs] async def on_page(self, interaction: discord.Interaction[Any], before: int, after: int) -> None: """Called when the paginator switches pages. This method is called after the page is switched and does nothing by default. .. versionadded:: 0.3.0 Parameters ---------- interaction: :class:`discord.Interaction` The interaction that triggered the event. before: :class:`int` The page number before. after: :class:`int` The page number after. """ pass
[docs] async def get_page_kwargs(self, page: Union[PageT, Sequence[PageT]], /, skip_formatting: bool = False) -> BaseKwargs: """Gets the kwargs to send the page with. Parameters ---------- page: Union[Any, Sequence[Any]] The page to get the kwargs for. skip_formatting: bool Whether to not call :meth:`.BaseClassPaginator.format_page` with the given page. Defaults to ``False``. Returns ------- :class:`.BaseKwargs` The kwargs to send the page with. """ if not skip_formatting: self._reset_base_kwargs() _page = await self._do_format_page(page) return await self.get_page_kwargs(_page, skip_formatting=True) # Sequence if isinstance(page, (list, tuple)): inner_page: Any for inner_page in page: # type: ignore # handles the page kwargs await self.get_page_kwargs(inner_page, skip_formatting=True) # type: ignore if isinstance(page, (int, str)): if self.__base_kwargs["content"]: self.__base_kwargs["content"] += str(page) else: self.__base_kwargs["content"] = str(page) elif isinstance(page, discord.Embed): self.__base_kwargs["embeds"].append(page) elif isinstance(page, (discord.File, discord.Attachment)): file = await _utils._new_file(page) try: self.__base_kwargs["files"].append(file) # type: ignore # yeah no except KeyError: self.__base_kwargs["files"] = [file] elif isinstance(page, dict): # kinda the same thing as above but it didn't appricate that it # didn't know the type of the key&value so it was "dict[Unknown, Unknown]" data: dict[Any, Any] = page.copy() # type: ignore self.__base_kwargs.update(data) return self.__base_kwargs
def _disable_all_children(self) -> None: for child in self.children: if hasattr(child, "disabled"): child.disabled = True # type: ignore # not all children have disabled attr. async def _edit_message(self, interaction: Optional[discord.Interaction[Any]] = None, /, **kwargs: Any) -> None: """Edits the message with the given kwargs. Parameters ---------- interaction: Optional[:class:`discord.Interaction`] The interaction to edit. If available. **kwargs: Any The kwargs to edit the message with. Raises ------ ValueError If ``interaction`` is ``None`` and :attr:`.BaseClassPaginator.message` is ``None``. """ kwargs.pop("ephemeral", None) files_to_edit: list[discord.File] = [] atachments_or_Files = kwargs.pop("files", []) + kwargs.pop("attachments", []) if atachments_or_Files: for file in atachments_or_Files: files_to_edit.append(await _utils._new_file(file)) kwargs["attachments"] = files_to_edit if interaction: if interaction.response.is_done(): await interaction.edit_original_response(**kwargs) else: await interaction.response.edit_message(**kwargs) elif self.message: await self.message.edit(**kwargs) if self.is_finished(): await self.stop_paginator()
[docs] async def switch_page(self, interaction: Optional[discord.Interaction[Any]], page_number: int) -> None: """Switches the page to the given page number. Parameters ---------- interaction: Optional[:class:`discord.Interaction`] The interaction to edit. If ``None``, ``.message`` is used. page_number: :class:`int` The page number to switch to. """ current_page_number = int(self.current_page) self.current_page = page_number page = self.get_page(self.current_page) page_kwargs = await self.get_page_kwargs(page) self._handle_page_string() await self._edit_message(interaction, **page_kwargs) if interaction: await self.on_page(interaction, current_page_number, self.current_page)
@overload async def send( self, destination: Destination, *, override_page_kwargs: bool = ..., edit_message: Literal[True] = ..., **send_kwargs: Any, ) -> None: ... @overload async def send( self, destination: Destination, *, override_page_kwargs: bool = ..., edit_message: Literal[False] = ..., **send_kwargs: Any, ) -> discord.Message: ... @overload async def send( self, destination: Destination, *, override_page_kwargs: Literal[False] = ..., edit_message: bool = ..., ) -> Optional[discord.Message]: ... @overload async def send( self, destination: Destination, *, override_page_kwargs: Literal[True] = ..., edit_message: bool = ..., **send_kwargs: Any, ) -> Optional[discord.Message]: ...
[docs] async def send( self, destination: Destination, *, override_page_kwargs: bool = False, edit_message: bool = False, **send_kwargs: Any, ) -> Optional[discord.Message]: """Sends the message to the given destination. Parameters ---------- destination: Union[:class:`discord.abc.Messageable`, :class:`discord.Interaction`] The destination to send the message to. Handles responding to the interaction if given. override_page_kwargs: :class:`bool` Whether to override the page kwargs with the given kwargs to ``send_kwargs``. Defaults to ``False``. edit_message: :class:`bool` Whether to edit the message instead of sending a new one. Defaults to ``False``. **send_kwargs: Any The kwargs to pass to the destination's send method. Only used if ``override_page_kwargs`` is ``True``. Returns ------- Optional[:class:`discord.Message`] The message or response sent. """ return await self._send( destination, override_page_kwargs=override_page_kwargs, edit_message=edit_message, **send_kwargs )
async def _send( self, destination: Destination, *, override_page_kwargs: bool = False, edit_message: bool = False, **send_kwargs: Any, ) -> Optional[discord.Message]: if not self.pages: raise ValueError("No pages to send.") page = self.get_page(self.current_page) page_kwargs: dict[str, Any] = await self.get_page_kwargs(page) # type: ignore # TypedDict don't go well with overloads self._handle_page_string() if override_page_kwargs: page_kwargs |= send_kwargs if edit_message: return await self._edit_message( destination if isinstance(destination, discord.Interaction) else None, **page_kwargs ) elif isinstance(destination, discord.Interaction): if destination.response.is_done(): self.message = await destination.followup.send(**page_kwargs, wait=True) else: await destination.response.send_message(**page_kwargs) if not self.message: self.message = await destination.original_response() else: self.message = await destination.send(**page_kwargs) return self.message