From 80c8b321f6dd89f679c438e99ee7de06f1c843d4 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Tue, 5 Sep 2023 17:15:55 +0200 Subject: [PATCH 1/3] feat(typing): make `Interaction` and subclasses generic (#1037) Signed-off-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- changelog/1036.feature.rst | 1 + disnake/ext/commands/params.py | 21 ++++++++++++++------- disnake/interactions/application_command.py | 10 +++++----- disnake/interactions/base.py | 16 +++++++--------- disnake/interactions/message.py | 4 ++-- disnake/interactions/modal.py | 4 ++-- disnake/ui/item.py | 5 ++++- disnake/ui/modal.py | 9 ++++++--- disnake/utils.py | 6 +++--- examples/interactions/converters.py | 2 +- examples/interactions/param.py | 2 +- test_bot/cogs/guild_scheduled_events.py | 12 +++++++++--- test_bot/cogs/injections.py | 8 ++++---- test_bot/cogs/localization.py | 12 ++++++------ test_bot/cogs/message_commands.py | 2 +- test_bot/cogs/misc.py | 8 ++++++-- test_bot/cogs/modals.py | 6 +++--- test_bot/cogs/slash_commands.py | 14 +++++++------- test_bot/cogs/user_commands.py | 4 +++- 19 files changed, 85 insertions(+), 61 deletions(-) create mode 100644 changelog/1036.feature.rst diff --git a/changelog/1036.feature.rst b/changelog/1036.feature.rst new file mode 100644 index 0000000000..1294ba4044 --- /dev/null +++ b/changelog/1036.feature.rst @@ -0,0 +1 @@ +Make :class:`Interaction` and subtypes accept the bot type as a generic parameter to denote the type returned by the :attr:`~Interaction.bot` and :attr:`~Interaction.client` properties. diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 69d9ccb085..2ab93359d2 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -91,6 +91,7 @@ T = TypeVar("T", bound=Any) TypeT = TypeVar("TypeT", bound=Type[Any]) CallableT = TypeVar("CallableT", bound=Callable[..., Any]) +BotT = TypeVar("BotT", bound="disnake.Client", covariant=True) __all__ = ( "Range", @@ -520,11 +521,11 @@ class ParamInfo: def __init__( self, - default: Union[Any, Callable[[ApplicationCommandInteraction], Any]] = ..., + default: Union[Any, Callable[[ApplicationCommandInteraction[BotT]], Any]] = ..., *, name: LocalizedOptional = None, description: LocalizedOptional = None, - converter: Optional[Callable[[ApplicationCommandInteraction, Any], Any]] = None, + converter: Optional[Callable[[ApplicationCommandInteraction[BotT], Any], Any]] = None, convert_default: bool = False, autocomplete: Optional[AnyAutocompleter] = None, choices: Optional[Choices] = None, @@ -911,6 +912,7 @@ def isolate_self( parametersl.pop(0) if parametersl: annot = parametersl[0].annotation + annot = get_origin(annot) or annot if issubclass_(annot, ApplicationCommandInteraction) or annot is inspect.Parameter.empty: inter_param = parameters.pop(parametersl[0].name) @@ -982,7 +984,9 @@ def collect_params( injections[parameter.name] = default elif parameter.annotation in Injection._registered: injections[parameter.name] = Injection._registered[parameter.annotation] - elif issubclass_(parameter.annotation, ApplicationCommandInteraction): + elif issubclass_( + get_origin(parameter.annotation) or parameter.annotation, ApplicationCommandInteraction + ): if inter_param is None: inter_param = parameter else: @@ -1116,21 +1120,24 @@ def expand_params(command: AnySlashCommand) -> List[Option]: if param.autocomplete: command.autocompleters[param.name] = param.autocomplete - if issubclass_(sig.parameters[inter_param].annotation, disnake.GuildCommandInteraction): + if issubclass_( + get_origin(annot := sig.parameters[inter_param].annotation) or annot, + disnake.GuildCommandInteraction, + ): command._guild_only = True return [param.to_option() for param in params] def Param( - default: Union[Any, Callable[[ApplicationCommandInteraction], Any]] = ..., + default: Union[Any, Callable[[ApplicationCommandInteraction[BotT]], Any]] = ..., *, name: LocalizedOptional = None, description: LocalizedOptional = None, choices: Optional[Choices] = None, - converter: Optional[Callable[[ApplicationCommandInteraction, Any], Any]] = None, + converter: Optional[Callable[[ApplicationCommandInteraction[BotT], Any], Any]] = None, convert_defaults: bool = False, - autocomplete: Optional[Callable[[ApplicationCommandInteraction, str], Any]] = None, + autocomplete: Optional[Callable[[ApplicationCommandInteraction[BotT], str], Any]] = None, channel_types: Optional[List[ChannelType]] = None, lt: Optional[float] = None, le: Optional[float] = None, diff --git a/disnake/interactions/application_command.py b/disnake/interactions/application_command.py index 309f286c74..13c96c02de 100644 --- a/disnake/interactions/application_command.py +++ b/disnake/interactions/application_command.py @@ -10,7 +10,7 @@ from ..member import Member from ..message import Message from ..user import User -from .base import Interaction, InteractionDataResolved +from .base import ClientT, Interaction, InteractionDataResolved __all__ = ( "ApplicationCommandInteraction", @@ -41,7 +41,7 @@ ) -class ApplicationCommandInteraction(Interaction): +class ApplicationCommandInteraction(Interaction[ClientT]): """Represents an interaction with an application command. Current examples are slash commands, user commands and message commands. @@ -119,7 +119,7 @@ def filled_options(self) -> Dict[str, Any]: return kwargs -class GuildCommandInteraction(ApplicationCommandInteraction): +class GuildCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass, primarily meant for annotations. This prevents the command from being invoked in DMs by automatically setting @@ -137,7 +137,7 @@ class GuildCommandInteraction(ApplicationCommandInteraction): me: Member -class UserCommandInteraction(ApplicationCommandInteraction): +class UserCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass meant for annotations. No runtime behavior is changed but annotations are modified @@ -147,7 +147,7 @@ class UserCommandInteraction(ApplicationCommandInteraction): target: Union[User, Member] -class MessageCommandInteraction(ApplicationCommandInteraction): +class MessageCommandInteraction(ApplicationCommandInteraction[ClientT]): """An :class:`ApplicationCommandInteraction` subclass meant for annotations. No runtime behavior is changed but annotations are modified diff --git a/disnake/interactions/base.py b/disnake/interactions/base.py index aff49c6a33..bdcbe3cae2 100644 --- a/disnake/interactions/base.py +++ b/disnake/interactions/base.py @@ -8,6 +8,7 @@ TYPE_CHECKING, Any, Dict, + Generic, List, Mapping, Optional, @@ -95,9 +96,10 @@ MISSING: Any = utils.MISSING T = TypeVar("T") +ClientT = TypeVar("ClientT", bound="Client", covariant=True) -class Interaction: +class Interaction(Generic[ClientT]): """A base class representing a user-initiated Discord interaction. An interaction happens when a user performs an action that the client needs to @@ -175,7 +177,7 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: self._state: ConnectionState = state # TODO: Maybe use a unique session self._session: ClientSession = state.http._HTTPClient__session # type: ignore - self.client: Client = state._get_client() + self.client: ClientT = cast(ClientT, state._get_client()) self._original_response: Optional[InteractionMessage] = None self.id: int = int(data["id"]) @@ -208,13 +210,9 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState) -> None: self.author = self._state.store_user(user) @property - def bot(self) -> AnyBot: - """:class:`~disnake.ext.commands.Bot`: The bot handling the interaction. - - Only applicable when used with :class:`~disnake.ext.commands.Bot`. - This is an alias for :attr:`.client`. - """ - return self.client # type: ignore + def bot(self) -> ClientT: + """:class:`~disnake.ext.commands.Bot`: An alias for :attr:`.client`.""" + return self.client @property def created_at(self) -> datetime: diff --git a/disnake/interactions/message.py b/disnake/interactions/message.py index 21effdd40f..c48213df1c 100644 --- a/disnake/interactions/message.py +++ b/disnake/interactions/message.py @@ -8,7 +8,7 @@ from ..enums import ComponentType, try_enum from ..message import Message from ..utils import cached_slot_property -from .base import Interaction, InteractionDataResolved +from .base import ClientT, Interaction, InteractionDataResolved __all__ = ( "MessageInteraction", @@ -28,7 +28,7 @@ from .base import InteractionChannel -class MessageInteraction(Interaction): +class MessageInteraction(Interaction[ClientT]): """Represents an interaction with a message component. Current examples are buttons and dropdowns. diff --git a/disnake/interactions/modal.py b/disnake/interactions/modal.py index 741130e58f..8c9945ab46 100644 --- a/disnake/interactions/modal.py +++ b/disnake/interactions/modal.py @@ -7,7 +7,7 @@ from ..enums import ComponentType from ..message import Message from ..utils import cached_slot_property -from .base import Interaction +from .base import ClientT, Interaction if TYPE_CHECKING: from ..state import ConnectionState @@ -21,7 +21,7 @@ __all__ = ("ModalInteraction", "ModalInteractionData") -class ModalInteraction(Interaction): +class ModalInteraction(Interaction[ClientT]): """Represents an interaction with a modal. .. versionadded:: 2.4 diff --git a/disnake/ui/item.py b/disnake/ui/item.py index b1723feab0..971ca8dcb3 100644 --- a/disnake/ui/item.py +++ b/disnake/ui/item.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from typing_extensions import ParamSpec, Self + from ..client import Client from ..components import NestedComponent from ..enums import ComponentType from ..interactions import MessageInteraction @@ -35,6 +36,8 @@ else: ParamSpec = TypeVar +ClientT = TypeVar("ClientT", bound="Client") + class WrappedComponent(ABC): """Represents the base UI component that all UI components inherit from. @@ -142,7 +145,7 @@ def view(self) -> V_co: """Optional[:class:`View`]: The underlying view for this item.""" return self._view - async def callback(self, interaction: MessageInteraction, /) -> None: + async def callback(self, interaction: MessageInteraction[ClientT], /) -> None: """|coro| The callback associated with this UI item. diff --git a/disnake/ui/modal.py b/disnake/ui/modal.py index 02ffb34493..a7a5503a28 100644 --- a/disnake/ui/modal.py +++ b/disnake/ui/modal.py @@ -6,7 +6,7 @@ import os import sys import traceback -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypeVar, Union from ..enums import TextInputStyle from ..utils import MISSING @@ -14,6 +14,7 @@ from .text_input import TextInput if TYPE_CHECKING: + from ..client import Client from ..interactions.modal import ModalInteraction from ..state import ConnectionState from ..types.components import Modal as ModalPayload @@ -22,6 +23,8 @@ __all__ = ("Modal",) +ClientT = TypeVar("ClientT", bound="Client") + class Modal: """Represents a UI Modal. @@ -156,7 +159,7 @@ def add_text_input( ) ) - async def callback(self, interaction: ModalInteraction, /) -> None: + async def callback(self, interaction: ModalInteraction[ClientT], /) -> None: """|coro| The callback associated with this modal. @@ -170,7 +173,7 @@ async def callback(self, interaction: ModalInteraction, /) -> None: """ pass - async def on_error(self, error: Exception, interaction: ModalInteraction) -> None: + async def on_error(self, error: Exception, interaction: ModalInteraction[ClientT]) -> None: """|coro| A callback that is called when an error occurs. diff --git a/disnake/utils.py b/disnake/utils.py index 54781da834..15b2f53ee0 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -141,14 +141,14 @@ def __init__(self, name: str, function: Callable[[T], T_co]) -> None: self.__doc__ = function.__doc__ @overload - def __get__(self, instance: None, owner: Type[T]) -> Self: + def __get__(self, instance: None, owner: Type[Any]) -> Self: ... @overload - def __get__(self, instance: T, owner: Type[T]) -> T_co: + def __get__(self, instance: T, owner: Type[Any]) -> T_co: ... - def __get__(self, instance: Optional[T], owner: Type[T]) -> Any: + def __get__(self, instance: Optional[T], owner: Type[Any]) -> Any: if instance is None: return self diff --git a/examples/interactions/converters.py b/examples/interactions/converters.py index 7ef2f99a09..99c89821da 100644 --- a/examples/interactions/converters.py +++ b/examples/interactions/converters.py @@ -14,7 +14,7 @@ # which can be set using `Param` and the `converter` argument. @bot.slash_command() async def clean_command( - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], text: str = commands.Param(converter=lambda inter, text: text.replace("@", "\\@")), ): ... diff --git a/examples/interactions/param.py b/examples/interactions/param.py index bc12030fcf..cd235f75e5 100644 --- a/examples/interactions/param.py +++ b/examples/interactions/param.py @@ -63,7 +63,7 @@ async def description( # by using `Param` and passing a callable. @bot.slash_command() async def defaults( - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], string: str = "this is a default value", user: disnake.User = commands.Param(lambda inter: inter.author), ): diff --git a/test_bot/cogs/guild_scheduled_events.py b/test_bot/cogs/guild_scheduled_events.py index dac3b6171c..1ffcb92295 100644 --- a/test_bot/cogs/guild_scheduled_events.py +++ b/test_bot/cogs/guild_scheduled_events.py @@ -13,14 +13,17 @@ def __init__(self, bot: commands.Bot) -> None: @commands.slash_command() async def fetch_event( - self, inter: disnake.GuildCommandInteraction, id: commands.LargeInt + self, inter: disnake.GuildCommandInteraction[commands.Bot], id: commands.LargeInt ) -> None: gse = await inter.guild.fetch_scheduled_event(id) await inter.response.send_message(str(gse.image)) @commands.slash_command() async def edit_event( - self, inter: disnake.GuildCommandInteraction, id: commands.LargeInt, new_image: bool + self, + inter: disnake.GuildCommandInteraction[commands.Bot], + id: commands.LargeInt, + new_image: bool, ) -> None: await inter.response.defer() gse = await inter.guild.fetch_scheduled_event(id) @@ -33,7 +36,10 @@ async def edit_event( @commands.slash_command() async def create_event( - self, inter: disnake.GuildCommandInteraction, name: str, channel: disnake.VoiceChannel + self, + inter: disnake.GuildCommandInteraction[commands.Bot], + name: str, + channel: disnake.VoiceChannel, ) -> None: image = disnake.File("./assets/banner.png") gse = await inter.guild.create_scheduled_event( diff --git a/test_bot/cogs/injections.py b/test_bot/cogs/injections.py index 2621c69b08..192ca10137 100644 --- a/test_bot/cogs/injections.py +++ b/test_bot/cogs/injections.py @@ -31,7 +31,7 @@ def __init__(self, prefix: str, suffix: str = "") -> None: self.prefix = prefix self.suffix = suffix - def __call__(self, inter: disnake.CommandInteraction, a: str = "init"): + def __call__(self, inter: disnake.CommandInteraction[commands.Bot], a: str = "init"): return self.prefix + a + self.suffix @@ -41,7 +41,7 @@ def __init__(self, username: str, discriminator: str) -> None: self.discriminator = discriminator @commands.converter_method - async def convert(cls, inter: disnake.CommandInteraction, user: disnake.User): + async def convert(cls, inter: disnake.CommandInteraction[commands.Bot], user: disnake.User): return cls(user.name, user.discriminator) def __repr__(self) -> str: @@ -89,7 +89,7 @@ async def injected_method(self, number: int = 3): @commands.slash_command() async def injection_command( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], sqrt: Optional[float] = commands.Param(None, converter=lambda i, x: x**0.5), prefixed: str = commands.Param(converter=PrefixConverter("__", "__")), other: Tuple[int, str] = commands.inject(injected), @@ -109,7 +109,7 @@ async def injection_command( @commands.slash_command() async def discerned_injections( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], perhaps: PerhapsThis, god: Optional[HopeToGod] = None, ) -> None: diff --git a/test_bot/cogs/localization.py b/test_bot/cogs/localization.py index ae93d5b770..9bba8d1495 100644 --- a/test_bot/cogs/localization.py +++ b/test_bot/cogs/localization.py @@ -16,7 +16,7 @@ def __init__(self, bot) -> None: @commands.slash_command() async def localized_command( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], auto: str, choice: str = commands.Param( choices=[ @@ -45,7 +45,7 @@ async def localized_command( @localized_command.autocomplete("auto") async def autocomp( - self, inter: disnake.AppCmdInter, value: str + self, inter: disnake.AppCmdInter[commands.Bot], value: str ) -> "disnake.app_commands.Choices": # not really autocomplete, only used for showing autocomplete localization x = list(map(str, range(1, 6))) @@ -53,11 +53,11 @@ async def autocomp( return [Localized(v, key=f"AUTOCOMP_{v}") for v in x] @commands.slash_command() - async def localized_top_level(self, inter: disnake.AppCmdInter) -> None: + async def localized_top_level(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: pass @localized_top_level.sub_command_group() - async def second(self, inter: disnake.AppCmdInter) -> None: + async def second(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: pass @second.sub_command( @@ -66,7 +66,7 @@ async def second(self, inter: disnake.AppCmdInter) -> None: ) async def third( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], value: str = commands.Param(name=Localized("a_string", key="A_VERY_COOL_PARAM_NAME")), ) -> None: await inter.response.send_message(f"```py\n{pformat(locals())}\n```") @@ -75,7 +75,7 @@ async def third( @commands.message_command( name=Localized("Localized Reverse", key="MSG_REVERSE"), ) - async def cmd_msg(self, inter: disnake.AppCmdInter, msg: disnake.Message) -> None: + async def cmd_msg(self, inter: disnake.AppCmdInter[commands.Bot], msg: disnake.Message) -> None: await inter.response.send_message(msg.content[::-1]) diff --git a/test_bot/cogs/message_commands.py b/test_bot/cogs/message_commands.py index f322584090..d4101b8e41 100644 --- a/test_bot/cogs/message_commands.py +++ b/test_bot/cogs/message_commands.py @@ -9,7 +9,7 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.message_command(name="Reverse") - async def reverse(self, inter: disnake.MessageCommandInteraction) -> None: + async def reverse(self, inter: disnake.MessageCommandInteraction[commands.Bot]) -> None: await inter.response.send_message(inter.target.content[::-1]) diff --git a/test_bot/cogs/misc.py b/test_bot/cogs/misc.py index 5784a65496..c10081e967 100644 --- a/test_bot/cogs/misc.py +++ b/test_bot/cogs/misc.py @@ -19,7 +19,9 @@ def _get_file(self, description: str) -> disnake.File: return disnake.File(io.BytesIO(data), "image.png", description=description) @commands.slash_command() - async def attachment_desc(self, inter: disnake.AppCmdInter, desc: str = "test") -> None: + async def attachment_desc( + self, inter: disnake.AppCmdInter[commands.Bot], desc: str = "test" + ) -> None: """Send an attachment with the given description (or the default) Parameters @@ -29,7 +31,9 @@ async def attachment_desc(self, inter: disnake.AppCmdInter, desc: str = "test") await inter.response.send_message(file=self._get_file(desc)) @commands.slash_command() - async def attachment_desc_edit(self, inter: disnake.AppCmdInter, desc: str = "test") -> None: + async def attachment_desc_edit( + self, inter: disnake.AppCmdInter[commands.Bot], desc: str = "test" + ) -> None: """Send a message with a button, which sends an attachment with the given description (or the default) Parameters diff --git a/test_bot/cogs/modals.py b/test_bot/cogs/modals.py index 64c095839f..13c84bddf2 100644 --- a/test_bot/cogs/modals.py +++ b/test_bot/cogs/modals.py @@ -24,7 +24,7 @@ def __init__(self) -> None: ] super().__init__(title="Create Tag", custom_id="create_tag", components=components) - async def callback(self, inter: disnake.ModalInteraction) -> None: + async def callback(self, inter: disnake.ModalInteraction[commands.Bot]) -> None: embed = disnake.Embed(title="Tag Creation") for key, value in inter.text_values.items(): embed.add_field(name=key.capitalize(), value=value, inline=False) @@ -36,12 +36,12 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = bot @commands.slash_command() - async def create_tag(self, inter: disnake.AppCmdInter) -> None: + async def create_tag(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: """Sends a Modal to create a tag.""" await inter.response.send_modal(modal=MyModal()) @commands.slash_command() - async def create_tag_low(self, inter: disnake.AppCmdInter) -> None: + async def create_tag_low(self, inter: disnake.AppCmdInter[commands.Bot]) -> None: """Sends a Modal to create a tag but with a low-level implementation.""" await inter.response.send_modal( title="Create Tag", diff --git a/test_bot/cogs/slash_commands.py b/test_bot/cogs/slash_commands.py index a2c7847f15..e7a2437d56 100644 --- a/test_bot/cogs/slash_commands.py +++ b/test_bot/cogs/slash_commands.py @@ -15,11 +15,11 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.slash_command() - async def hello(self, inter: disnake.CommandInteraction) -> None: + async def hello(self, inter: disnake.CommandInteraction[commands.Bot]) -> None: await inter.response.send_message("Hello world!") @commands.slash_command() - async def auto(self, inter: disnake.CommandInteraction, mood: str) -> None: + async def auto(self, inter: disnake.CommandInteraction[commands.Bot], mood: str) -> None: """Has an autocomplete option. Parameters @@ -29,27 +29,27 @@ async def auto(self, inter: disnake.CommandInteraction, mood: str) -> None: await inter.send(mood) @auto.autocomplete("mood") - async def test_autocomp(self, inter: disnake.CommandInteraction, string: str): + async def test_autocomp(self, inter: disnake.CommandInteraction[commands.Bot], string: str): return ["XD", ":D", ":)", ":|", ":("] @commands.slash_command() async def alt_auto( self, - inter: disnake.AppCmdInter, + inter: disnake.AppCmdInter[commands.Bot], mood: str = commands.Param(autocomplete=test_autocomp), ) -> None: await inter.send(mood) @commands.slash_command() async def guild_only( - self, inter: disnake.GuildCommandInteraction, option: Optional[str] = None + self, inter: disnake.GuildCommandInteraction[commands.Bot], option: Optional[str] = None ) -> None: await inter.send(f"guild: {inter.guild} | option: {option!r}") @commands.slash_command() async def ranges( self, - inter: disnake.CommandInteraction, + inter: disnake.CommandInteraction[commands.Bot], a: int = commands.Param(None, lt=0), b: Optional[commands.Range[int, 1, ...]] = None, c: Optional[commands.Range[int, 0, 10]] = None, @@ -68,7 +68,7 @@ async def ranges( @commands.slash_command() async def largenumber( - self, inter: disnake.CommandInteraction, largenum: commands.LargeInt + self, inter: disnake.CommandInteraction[commands.Bot], largenum: commands.LargeInt ) -> None: await inter.send(f"Is int: {isinstance(largenum, int)}") diff --git a/test_bot/cogs/user_commands.py b/test_bot/cogs/user_commands.py index 318f3127a3..e8c67efdca 100644 --- a/test_bot/cogs/user_commands.py +++ b/test_bot/cogs/user_commands.py @@ -9,7 +9,9 @@ def __init__(self, bot) -> None: self.bot: commands.Bot = bot @commands.user_command(name="Avatar") - async def avatar(self, inter: disnake.UserCommandInteraction, user: disnake.User) -> None: + async def avatar( + self, inter: disnake.UserCommandInteraction[commands.Bot], user: disnake.User + ) -> None: await inter.response.send_message(user.display_avatar.url, ephemeral=True) From 6689bb6c243f0b2bacb88645a29460848b9f70fb Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Tue, 5 Sep 2023 17:17:53 +0200 Subject: [PATCH 2/3] docs: rewrite CONTRIBUTING.md (#1098) A general overhaul of the entire contribution guide, hopefully making it easier to follow and understand. Also includes the documentation side of #196, i.e. outlining the commit/PR naming convention. This removes the detailed example which, while insightful, was way too lengthy for new contributors. We could maybe re-add it on a separate page in the future, but for now it's too much for this guide. --- CONTRIBUTING.md | 232 ++++++++++++---------------------------- changelog/1098.misc.rst | 1 + 2 files changed, 68 insertions(+), 165 deletions(-) create mode 100644 changelog/1098.misc.rst diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5054de13ca..e1151d59b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,220 +2,122 @@ # Contributing to disnake -- [Bug Reports](#good-bug-reports) -- [Creating Pull Requests](#creating-a-pull-request) +First off, thanks for taking the time to contribute! It makes the library substantially better. :tada: + +The following is a set of guidelines for contributing to the repository. These are not necessarily hard rules, but they streamline the process for everyone involved. -First off, thanks for taking the time to contribute. It makes the library substantially better. :+1: +### Table of Contents -The following is a set of guidelines for contributing to the repository. These are mostly guidelines, not hard rules. +- [Bug Reports](#good-bug-reports) +- [Creating Pull Requests](#creating-a-pull-request) + - [Overview](#overview) + - [Initial setup](#initial-setup) + - [Commit/PR Naming Guidelines](#commitpr-naming-guidelines) ## This is too much to read! I want to ask a question! -Generally speaking questions are better suited in our resources below. +> [!IMPORTANT] +> Please try your best not to create new issues in the issue tracker just to ask questions, unless they provide value to a larger audience. + +Generally speaking, questions are better suited in our resources below. -- The official support server: https://discord.gg/disnake +- The official Discord server: https://discord.gg/disnake - The [FAQ in the documentation](https://docs.disnake.dev/en/latest/faq.html) - The project's [discussions section](https://github.com/DisnakeDev/disnake/discussions) -Please try your best not to create new issues in the issue tracker just to ask questions. Most of them don't belong there unless they provide value to a larger audience. - --- ## Good Bug Reports -Please be aware of the following things when filing bug reports. +To report bugs (or to suggest new features), visit our [issue tracker](https://github.com/DisnakeDev/disnake/issues). +The issue templates will generally walk you through the steps, but please be aware of the following things: -1. Don't open duplicate issues. Please search your issue to see if it has been asked already. Duplicate issues will be closed. -2. When filing a bug about exceptions or tracebacks, please include the *complete* traceback. Without the complete traceback the issue might be **unsolvable** and you will be asked to provide more information. -3. Make sure to provide enough information to make the issue workable. The issue template will generally walk you through the process but they are enumerated here as well: - - A **summary** of your bug report. This is generally a quick sentence or two to describe the issue in human terms. - - Guidance on **how to reproduce the issue**. Ideally, this should have a small code sample that allows us to run and see the issue for ourselves to debug. **Please make sure that the token is not displayed**. If you cannot provide a code snippet, then let us know what the steps were, how often it happens, etc. - - Tell us **what you expected to happen**. That way we can meet that expectation. - - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? - - Tell us **information about your environment**. What version of disnake are you using? How was it installed? What operating system are you running on? These are valuable questions and information that we use. +1. **Don't open duplicate issues**. Before you submit an issue, search the issue tracker to see if an issue for your problem already exists. If you find a similar issue, you can add a comment with additional information or context to help us understand the problem better. +2. **Include the *complete* traceback** when filing a bug report about exceptions or tracebacks. Without the complete traceback, it will be much more difficult for others to understand (and perhaps fix) your issue. +3. **Add a minimal reproducible code snippet** that results in the behavior you're seeing. This helps us quickly confirm a bug or point out a solution to your problem. We cannot reliably investigate bugs without a way to reproduce them. -If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. +If the bug report is missing this information, it'll take us longer to fix the issue. We may ask for clarification, and if no response was given, the issue will be closed. --- ## Creating a Pull Request -Creating a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. - -### Formatting +Creating a pull request is fairly straightforward. Make sure it focuses on a single aspect and avoids scope creep, then it's probably good to go. -We would greatly appreciate the code submitted to be of a consistent style with other code in disnake. This project follows PEP-8 guidelines (mostly) with a column limit of 100 characters. +If you're unsure about some aspect of development, feel free to use existing files as a guide or reach out via the Discord server. +### Overview -We use [`PDM`](https://pdm.fming.dev/) for development. If PDM is not already installed on your system, you can follow their [installation steps here](https://pdm.fming.dev/latest/#installation) to get started. +The general workflow can be summarized as follows: -Once PDM is installed and avaliable, use the following command to initialise a virtual environment, install the necessary development dependencies, and install the [`pre-commit`](https://pre-commit.com/#quick-start) hooks. -. -``` -pdm run setup_env -``` +1. Fork + clone the repository. +2. Initialize the development environment: `pdm run setup_env`. +3. Create a new branch. +4. Commit your changes, update documentation if required. +5. Add a changelog entry (e.g. `changelog/1234.feature.rst`). +6. Push the branch to your fork, and [submit a pull request!](https://github.com/DisnakeDev/disnake/compare) -The installed `pre-commit` hooks will automatically run before every commit, which will format/lint the code -to match the project's style. Note that you will have to stage and commit again if anything was updated! +Specific development aspects are further explained below. -Most of the time, running pre-commit will automatically fix any issues that arise, but this is not always the case. -We have a few hooks that *don't* resolve their issues automatically, and must be fixed manually. -One of these is the license header, which must exist in all files unless comments are not supported in those files, or they -are not text files, in which case exceptions can be made. These headers must exist following the format -documented at [https://spdx.dev/ids/](https://spdx.dev/ids/). +### Initial setup -### Scripts +We use [`PDM`](https://pdm.fming.dev/) as our dependency manager. If it isn't already installed on your system, you can follow the installation steps [here](https://pdm.fming.dev/latest/#installation) to get started. -To run all important checks and tests, use `pdm run nox`: -```sh -pdm run nox -R +Once PDM is installed, use the following command to initialize a virtual environment, install the necessary development dependencies, and install the [`pre-commit`](#pre-commit) hooks. ``` - -You can also choose to only run a single task; run `pdm run --list` to view all available scripts and use `pdm run ` to run them. - -Some notes (all of the mentioned scripts are automatically run by `pdm run nox -R`, see above): -- If `pre-commit` hooks aren't installed, run `pdm run lint` manually to check and fix the formatting in all files. - **Note**: If the code is formatted incorrectly, `pre-commit` will apply fixes and exit without committing the changes - just stage and commit again. -- For type-checking, run `pdm run pyright`. You can use `pdm run pyright -w` to automatically re-check on every file change. - **Note**: If you're using VSCode and pylance, it will use the same type-checking settings, which generally means that you don't necessarily have to run `pyright` separately. However, there can be version differences which may lead to different results when later run in CI on GitHub. -- Tests can be run using `pdm run test`. If you changed some functionality, you may have to adjust a few tests - if you added new features, it would be great if you added new tests for them as well. - -A PR cannot be merged as long as there are any failing checks. - -### Changelogs - -We use [towncrier](https://github.com/twisted/towncrier) for managing our changelogs. Each change is required to have at least one file in the [`changelog/`](changelog/README.rst) directory. There is more documentation in that directory on how to create a changelog entry. - -### Git Commit Guidelines - -- Use present tense (e.g. "Add feature" not "Added feature") -- Reference issues or pull requests outside of the first line. - - Please use the shorthand `#123` and not the full URL. - -If you do not meet any of these guidelines, don't fret. Chances are they will be fixed upon rebasing but please do try to meet them to remove some of the workload. - ---- - -## How do I add a new feature? - -Welcome! If you've made it to this point you are likely a new contributor! This section will go through how to add a new feature to disnake. - -Most attributes and data structures are broken up in to a file for each related class. For example, `disnake.Guild` is defined in [disnake/guild.py](disnake/guild.py), and `disnake.GuildPreview` is defined in [disnake/guild_preview.py](disnake/guild_preview.py). For example, writing a new feature to `disnake.Guild` would go in [disnake/guild.py](disnake/guild.py), as part of the `disnake.Guild` class. - -### Adding a new API Feature - -However, adding a new feature that interfaces with the API requires also updating the [disnake/types](disnake/types) directory to match the relevant [API specifications](https://discord.com/developers/docs). We ask that when making or receiving payloads from the API, they are typed and typehints are used on the functions that are processing said data. For example, take a look at `disnake.abc.Messageable.pins` (defined in [disnake/abc.py](disnake/abc.py)). - - -```py - async def pins(self) -> List[Message]: - channel = await self._get_channel() - state = self._state - data = await state.http.pins_from(channel.id) - return [state.create_message(channel=channel, data=m) for m in data] -``` -*docstring removed for brevity* - -Here we have several things occuring. First, we have annotated the return type of this method to return a list of `Message`s. As disnake supports Python 3.8, we use typing imports instead of subscripting built-ins — hence the capital ``List``. - -The next interesting thing is `self._state`. The library uses a state-centric design, which means the state is passed around to most objects. -Every Discord model that makes requests uses that internal state and its `http` attribute to make requests to the Discord API. Each endpoint is processed and defined in [disnake/http.py](disnake/http.py) — and it's where `http.pins_from` is defined too, which looks like this: - -```py - def pins_from(self, channel_id: Snowflake) -> Response[List[message.Message]]: - return self.request(Route("GET", "/channels/{channel_id}/pins", channel_id=channel_id)) +$ pdm run setup_env ``` -This is the basic model that all API request methods follow. Define the `Route`, provide the major parameters (in this example `channel_id`), then return a call to `self.request()`. - -The `Response[]` part in the typehint is referring to `self.request`, as the important thing here is that `pins_from` is **not** a coroutine. Rather, `pins_from` does preprocessing and `self.request` does the actual work. The result from `pins_from` is awaited by `disnake.abc.Messageable.pins`. - -The Route class is how all routes are processed internally. Along with `self.request`, this makes it possible to properly handle all ratelimits. This is why `channel_id` is provided as a kwarg to `Route`, as it is considered a major parameter for ratelimit handling. - -#### Writing Documentation +Other tools used in this project include [black](https://black.readthedocs.io/en/stable/) + [isort](https://pycqa.github.io/isort/) (formatters), [ruff](https://beta.ruff.rs/docs/) (linter), and [pyright](https://microsoft.github.io/pyright/#/) (type-checker). For the most part, these automatically run on every commit with no additional action required - see below for details. -While a new feature can be useful, it requires documentation to be usable by everyone. When updating a class or method, we ask that you use -[Sphinx directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-versionadded) in the docstring to note when it was added or updated, and what about it was updated. +All of the following checks also automatically run for every PR on GitHub, so don't worry if you're not sure whether you missed anything. A PR cannot be merged as long as there are any failing checks. -For example, here is the docstring for `pins()`: -```py - """|coro| +### Commit/PR Naming Guidelines - Retrieves all messages that are currently pinned in the channel. +This project uses the commonly known [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). +While not necessarily required (but appreciated) for individual commit messages, please make sure to title your PR according to this schema: - .. note:: - - Due to a limitation with the Discord API, the :class:`.Message` - objects returned by this method do not contain complete - :attr:`.Message.reactions` data. - - Raises - ------ - HTTPException - Retrieving the pinned messages failed. - - Returns - ------- - List[:class:`.Message`] - The messages that are currently pinned. - """ +``` +(): + │ │ │ │ + │ │ │ └─⫸ Summary in present tense, not capitalized, no period at the end + │ │ │ + │ │ └─⫸ [optional] `!` indicates a breaking change + │ │ + │ └─⫸ [optional] Commit Scope: The affected area, e.g. gateway, user, ... + │ + └─⫸ Commit Type: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert ``` -If we were to add a new parameter to this method, a few things would need to be added to this docstring. Lets pretend we're adding a parameter, ``oldest_first``. - -We use NumPy style docstrings parsed with Sphinx's Napoleon extension — the primary documentation for these docstrings can be found [here](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html). - -```py - """ - ... - - Parameters - ---------- - oldest_first: bool - Whether to order the result by the oldest or newest pins first. - - .. versionadded:: 2.9 +Examples: `feat: support new avatar format` or `fix(gateway): use correct url for resuming connection`. +Details about the specific commit types can be found [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json). - ... - """ -``` -It is important that the section header comes **after** any description and admonitions that exist, as it will stop the parsing of the description. +### Formatting -The end result of these changes would be as follows: +This project follows PEP-8 guidelines (mostly) with a column limit of 100 characters, and uses the tools mentioned above to enforce a consistent coding style. -```py - """|coro| +The installed [`pre-commit`](https://pre-commit.com/) hooks will automatically run before every commit, which will format/lint the code +to match the project's style. Note that you will have to stage and commit again if anything was updated! +Most of the time, running pre-commit will automatically fix any issues that arise. - Retrieves all messages that are currently pinned in the channel. - .. note:: +### Pyright - Due to a limitation with the Discord API, the :class:`.Message` - objects returned by this method do not contain complete - :attr:`.Message.reactions` data. +For type-checking, run `pdm run pyright` (append `-w` to have it automatically re-check on every file change). +> [!NOTE] +> If you're using VSCode and pylance, it will use the same type-checking settings, which generally means that you don't necessarily have to run `pyright` separately. +> However, since we use a specific version of `pyright` (which may not match pylance's version), there can be version differences which may lead to different results. - Parameters - ---------- - oldest_first: bool - Whether to order the result by the oldest or newest pins first. - .. versionadded:: 2.9 +### Changelogs - Raises - ------ - HTTPException - Retrieving the pinned messages failed. +We use [towncrier](https://github.com/twisted/towncrier) for managing our changelogs. Each change is required to have at least one file in the [`changelog/`](changelog/README.rst) directory, unless it's a trivial change. There is more documentation in that directory on how to create a changelog entry. - Returns - ------- - List[:class:`.Message`] - The messages that are currently pinned. - """ - ``` -*If you're having trouble with adding or modifying documentation, don't be afraid to reach out! -We understand that the documentation can be intimidating, and there are quite a few quirks and limitations to be aware of.* +### Documentation +We use Sphinx to build the project's documentation, which includes [automatically generating](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) the API Reference from docstrings using the [NumPy style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html). +To build the documentation locally, use `pdm run docs` and visit http://127.0.0.1:8009/ once built. diff --git a/changelog/1098.misc.rst b/changelog/1098.misc.rst new file mode 100644 index 0000000000..accde4a92b --- /dev/null +++ b/changelog/1098.misc.rst @@ -0,0 +1 @@ +Overhaul and simplify `contribution guide `__. From 1f871ff51f9583881b2ad51a6a9aa0d70dafe9dd Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Tue, 5 Sep 2023 17:35:27 +0200 Subject: [PATCH 3/3] feat(auditlog): support `integration_type` field (#1096) --- changelog/1096.feature.rst | 1 + disnake/audit_logs.py | 18 ++++++++++++++---- disnake/types/audit_log.py | 3 +++ docs/api/audit_logs.rst | 10 ++++++++++ 4 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 changelog/1096.feature.rst diff --git a/changelog/1096.feature.rst b/changelog/1096.feature.rst new file mode 100644 index 0000000000..5cddd33cc8 --- /dev/null +++ b/changelog/1096.feature.rst @@ -0,0 +1 @@ +Support ``integration_type`` field in :attr:`AuditLogEntry.extra` (for :attr:`~AuditLogAction.kick` and :attr:`~AuditLogAction.member_role_update` actions). diff --git a/disnake/audit_logs.py b/disnake/audit_logs.py index d1e2760bb9..02aad706ee 100644 --- a/disnake/audit_logs.py +++ b/disnake/audit_logs.py @@ -507,6 +507,10 @@ class _AuditLogProxyAutoModAction: rule_trigger_type: enums.AutoModTriggerType +class _AuditLogProxyKickOrMemberRoleAction: + integration_type: Optional[str] + + class AuditLogEntry(Hashable): """Represents an Audit Log entry. @@ -589,7 +593,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: if isinstance(self.action, enums.AuditLogAction) and extra: if self.action is enums.AuditLogAction.member_prune: - # member prune has two keys with useful information elems = { "delete_member_days": utils._get_as_snowflake(extra, "delete_member_days"), "members_removed": utils._get_as_snowflake(extra, "members_removed"), @@ -607,13 +610,11 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: } self.extra = type("_AuditLogProxy", (), elems)() elif self.action is enums.AuditLogAction.member_disconnect: - # The member disconnect action has a dict with some information elems = { "count": int(extra["count"]), } self.extra = type("_AuditLogProxy", (), elems)() elif self.action.name.endswith("pin"): - # the pin actions have a dict with some information elems = { "channel": self._get_channel_or_thread( utils._get_as_snowflake(extra, "channel_id") @@ -622,7 +623,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: } self.extra = type("_AuditLogProxy", (), elems)() elif self.action.name.startswith("overwrite_"): - # the overwrite_ actions have a dict with some information instance_id = int(extra["id"]) the_type = extra.get("type") if the_type == "1": @@ -662,6 +662,15 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: ), } self.extra = type("_AuditLogProxy", (), elems)() + elif self.action in ( + enums.AuditLogAction.kick, + enums.AuditLogAction.member_role_update, + ): + elems = { + # unlike other extras, this key isn't always provided + "integration_type": extra.get("integration_type"), + } + self.extra = type("_AuditLogProxy", (), elems)() self.extra: Any # actually this but there's no reason to annoy users with this: @@ -672,6 +681,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: # _AuditLogProxyPinAction, # _AuditLogProxyStageInstanceAction, # _AuditLogProxyAutoModAction, + # _AuditLogProxyKickOrMemberRoleAction, # Member, User, None, # Role, # ] diff --git a/disnake/types/audit_log.py b/disnake/types/audit_log.py index cca0c0fec0..d3b3a5484f 100644 --- a/disnake/types/audit_log.py +++ b/disnake/types/audit_log.py @@ -300,6 +300,8 @@ class _AuditLogChange_AutoModTriggerMetadata(TypedDict): ] +# All of these are technically only required for matching event types, +# but they're typed as required keys for simplicity class AuditEntryInfo(TypedDict): delete_member_days: str members_removed: str @@ -312,6 +314,7 @@ class AuditEntryInfo(TypedDict): application_id: Snowflake auto_moderation_rule_name: str auto_moderation_rule_trigger_type: str + integration_type: str class AuditLogEntry(TypedDict): diff --git a/docs/api/audit_logs.rst b/docs/api/audit_logs.rst index ba1f735587..8e98685c24 100644 --- a/docs/api/audit_logs.rst +++ b/docs/api/audit_logs.rst @@ -919,6 +919,11 @@ AuditLogAction the :class:`User` who got kicked. If the user is not found then it is a :class:`Object` with the user's ID. + When this is the action, the type of :attr:`~AuditLogEntry.extra` may be + set to an unspecified proxy object with one attribute: + + - ``integration_type``: A string representing the type of the integration which performed the action, if any. + When this is the action, :attr:`~AuditLogEntry.changes` is empty. .. attribute:: member_prune @@ -984,6 +989,11 @@ AuditLogAction the :class:`Member` or :class:`User` who got the role. If the user is not found then it is a :class:`Object` with the user's ID. + When this is the action, the type of :attr:`~AuditLogEntry.extra` may be + set to an unspecified proxy object with one attribute: + + - ``integration_type``: A string representing the type of the integration which performed the action, if any. + Possible attributes for :class:`AuditLogDiff`: - :attr:`~AuditLogDiff.roles`