From 47c4be9def9f38f1f14401b2ec8ba07ecc6190ab Mon Sep 17 00:00:00 2001 From: Paillat Date: Thu, 12 Dec 2024 23:08:19 +0100 Subject: [PATCH 1/8] :sparkles: Allow for `functools.partials` and such as autocomplete --- discord/commands/core.py | 4 ++-- discord/commands/options.py | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index 6dd1b0d636..846e030efc 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -1095,13 +1095,13 @@ async def invoke_autocomplete_callback(self, ctx: AutocompleteContext): ctx.value = op.get("value") ctx.options = values - if len(inspect.signature(option.autocomplete).parameters) == 2: + if option.autocomplete._is_instance_method: instance = getattr(option.autocomplete, "__self__", ctx.cog) result = option.autocomplete(instance, ctx) else: result = option.autocomplete(ctx) - if asyncio.iscoroutinefunction(option.autocomplete): + if inspect.isawaitable(result): result = await result choices = [ diff --git a/discord/commands/options.py b/discord/commands/options.py index 4b35a080d9..5fe4db2798 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -27,7 +27,7 @@ import inspect import logging from enum import Enum -from typing import TYPE_CHECKING, Literal, Optional, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, Union from ..abc import GuildChannel, Mentionable from ..channel import ( @@ -272,6 +272,7 @@ def __init__( ) self.default = kwargs.pop("default", None) + self._autocomplete: Callable[[Any], Any, Any] | None = None self.autocomplete = kwargs.pop("autocomplete", None) if len(enum_choices) > 25: self.choices: list[OptionChoice] = [] @@ -390,6 +391,27 @@ def to_dict(self) -> dict: def __repr__(self): return f"" + @property + def autocomplete(self) -> Callable[[Any], Any, Any] | None: + return self._autocomplete + + @autocomplete.setter + def autocomplete(self, value: Callable[[Any], Any, Any] | None) -> None: + self._autocomplete = value + # this is done here so it does not have to be computed every time the autocomplete is invoked + if self._autocomplete is not None: + self._autocomplete._is_instance_method = ( + sum( + 1 + for param in inspect.signature( + self.autocomplete + ).parameters.values() + if param.default == param.empty + and param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD) + ) + == 2 + ) + class OptionChoice: """ From 53036a4c61b9106fc710edf7b9fe8d82522bdee8 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 13 Dec 2024 08:57:56 +0100 Subject: [PATCH 2/8] :memo: CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bda9a0ecc..4bb501bf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ These changes are available on the `master` branch, but have not yet been releas `Permissions.use_external_sounds` and `Permissions.view_creator_monetization_analytics`. ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) +- Added the ability to use functions with any number of optional arguments, and + functions returning an awaitable as `Option.autocomplete` + ([#2669](https://github.com/Pycord-Development/pycord/pull/2669)). ### Fixed From 7308d08f2bf35fd3f3f3737741dee76444bc1d18 Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 13 Dec 2024 09:35:07 +0100 Subject: [PATCH 3/8] :label: Better typing --- discord/commands/options.py | 44 +++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/discord/commands/options.py b/discord/commands/options.py index 5fe4db2798..013e05b190 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -27,7 +27,7 @@ import inspect import logging from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Type, Union +from typing import TYPE_CHECKING, Iterable, Literal, Optional, Type, Union from ..abc import GuildChannel, Mentionable from ..channel import ( @@ -39,7 +39,7 @@ Thread, VoiceChannel, ) -from ..commands import ApplicationContext +from ..commands import ApplicationContext, AutocompleteContext from ..enums import ChannelType from ..enums import Enum as DiscordEnum from ..enums import SlashCommandOptionType @@ -111,6 +111,11 @@ def __init__(self, thread_type: Literal["public", "private", "news"]): self._type = type_map[thread_type] +AutocompleteReturnType = Union[ + Iterable["OptionChoice"], Iterable[str], Iterable[int], Iterable[float] +] + + class Option: """Represents a selectable option for a slash command. @@ -146,15 +151,6 @@ class Option: max_length: Optional[:class:`int`] The maximum length of the string that can be entered. Must be between 1 and 6000 (inclusive). Only applies to Options with an :attr:`input_type` of :class:`str`. - autocomplete: Optional[Callable[[:class:`.AutocompleteContext`], Awaitable[Union[Iterable[:class:`.OptionChoice`], Iterable[:class:`str`], Iterable[:class:`int`], Iterable[:class:`float`]]]]] - The autocomplete handler for the option. Accepts a callable (sync or async) - that takes a single argument of :class:`AutocompleteContext`. - The callable must return an iterable of :class:`str` or :class:`OptionChoice`. - Alternatively, :func:`discord.utils.basic_autocomplete` may be used in place of the callable. - - .. note:: - - Does not validate the input value against the autocomplete results. channel_types: list[:class:`discord.ChannelType`] | None A list of channel types that can be selected in this option. Only applies to Options with an :attr:`input_type` of :class:`discord.SlashCommandOptionType.channel`. @@ -272,7 +268,7 @@ def __init__( ) self.default = kwargs.pop("default", None) - self._autocomplete: Callable[[Any], Any, Any] | None = None + self._autocomplete = None self.autocomplete = kwargs.pop("autocomplete", None) if len(enum_choices) > 25: self.choices: list[OptionChoice] = [] @@ -392,11 +388,31 @@ def __repr__(self): return f"" @property - def autocomplete(self) -> Callable[[Any], Any, Any] | None: + def autocomplete(self): return self._autocomplete @autocomplete.setter - def autocomplete(self, value: Callable[[Any], Any, Any] | None) -> None: + def autocomplete(self, value) -> None: + """ + The autocomplete handler for the option. Accepts a callable (sync or async) + that takes a single required argument of :class:`AutocompleteContext`. + The callable must return an iterable of :class:`str` or :class:`OptionChoice`. + Alternatively, :func:`discord.utils.basic_autocomplete` may be used in place of the callable. + + Parameters + ---------- + value: Union[ + Callable[[Self, AutocompleteContext, Any], AutocompleteReturnType], + Callable[[AutocompleteContext, Any], AutocompleteReturnType], + Callable[[Self, AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], + Callable[[AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], + ] + + .. versionchanged:: 2.7 + + .. note:: + Does not validate the input value against the autocomplete results. + """ self._autocomplete = value # this is done here so it does not have to be computed every time the autocomplete is invoked if self._autocomplete is not None: From ebb190418b08ec8737e76a4389d3c6ec14bcf19f Mon Sep 17 00:00:00 2001 From: Paillat Date: Fri, 13 Dec 2024 09:43:18 +0100 Subject: [PATCH 4/8] :truck: Add partial autocomplete example --- .../slash_partial_autocomplete.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 examples/app_commands/slash_partial_autocomplete.py diff --git a/examples/app_commands/slash_partial_autocomplete.py b/examples/app_commands/slash_partial_autocomplete.py new file mode 100644 index 0000000000..79be4a9bf9 --- /dev/null +++ b/examples/app_commands/slash_partial_autocomplete.py @@ -0,0 +1,55 @@ +from functools import partial +from os import getenv + +from dotenv import load_dotenv + +import discord +from discord.ext import commands + +load_dotenv() + +bot = discord.Bot() + +fruits = ["Apple", "Banana", "Orange"] +vegetables = ["Carrot", "Lettuce", "Potato"] + + +async def food_autocomplete( + ctx: discord.AutocompleteContext, food_type: str +) -> list[discord.OptionChoice]: + items = fruits if food_type == "fruit" else vegetables + return [ + discord.OptionChoice(name=item) + for item in items + if ctx.value.lower() in item.lower() + ] + + +class FoodCog(commands.Cog): + @commands.slash_command(name="fruit") + async def get_fruit( + self, + ctx: discord.ApplicationContext, + choice: discord.Option( + str, + "Pick a fruit", + autocomplete=partial(food_autocomplete, food_type="fruit"), + ), + ): + await ctx.respond(f"You picked: {choice}") + + @commands.slash_command(name="vegetable") + async def get_vegetable( + self, + ctx: discord.ApplicationContext, + choice: discord.Option( + str, + "Pick a vegetable", + autocomplete=partial(food_autocomplete, food_type="vegetable"), + ), + ): + await ctx.respond(f"You picked: {choice}") + + +bot.add_cog(FoodCog()) +bot.run(getenv("TOKEN")) From 9121251c8f70084f51e249bb5a4da1b6663d3193 Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 14 Dec 2024 15:54:50 +0100 Subject: [PATCH 5/8] :adhesive_bandage: Make CI pass --- .../slash_partial_autocomplete.py | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/examples/app_commands/slash_partial_autocomplete.py b/examples/app_commands/slash_partial_autocomplete.py index 79be4a9bf9..f21499e006 100644 --- a/examples/app_commands/slash_partial_autocomplete.py +++ b/examples/app_commands/slash_partial_autocomplete.py @@ -27,27 +27,21 @@ async def food_autocomplete( class FoodCog(commands.Cog): @commands.slash_command(name="fruit") - async def get_fruit( - self, - ctx: discord.ApplicationContext, - choice: discord.Option( - str, - "Pick a fruit", - autocomplete=partial(food_autocomplete, food_type="fruit"), - ), - ): + @discord.option( + "choice", + "Pick a fruit", + autocomplete=partial(food_autocomplete, food_type="fruit"), + ) + async def get_fruit(self, ctx: discord.ApplicationContext, choice: str): await ctx.respond(f"You picked: {choice}") @commands.slash_command(name="vegetable") - async def get_vegetable( - self, - ctx: discord.ApplicationContext, - choice: discord.Option( - str, - "Pick a vegetable", - autocomplete=partial(food_autocomplete, food_type="vegetable"), - ), - ): + @discord.option( + "choice", + "Pick a vegetable", + autocomplete=partial(food_autocomplete, food_type="vegetable"), + ) + async def get_vegetable(self, ctx: discord.ApplicationContext, choice: str): await ctx.respond(f"You picked: {choice}") From c2b401124763c8bbf39f07a2c7594c19ad19995b Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 14 Dec 2024 15:56:05 +0100 Subject: [PATCH 6/8] :memo: Move docstring to getter --- discord/commands/options.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/discord/commands/options.py b/discord/commands/options.py index 013e05b190..ea0bf3e209 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -389,23 +389,20 @@ def __repr__(self): @property def autocomplete(self): - return self._autocomplete - - @autocomplete.setter - def autocomplete(self, value) -> None: """ The autocomplete handler for the option. Accepts a callable (sync or async) that takes a single required argument of :class:`AutocompleteContext`. The callable must return an iterable of :class:`str` or :class:`OptionChoice`. Alternatively, :func:`discord.utils.basic_autocomplete` may be used in place of the callable. - Parameters - ---------- - value: Union[ + Returns + ------- + Union[ Callable[[Self, AutocompleteContext, Any], AutocompleteReturnType], Callable[[AutocompleteContext, Any], AutocompleteReturnType], Callable[[Self, AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], Callable[[AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], + None ] .. versionchanged:: 2.7 @@ -413,6 +410,10 @@ def autocomplete(self, value) -> None: .. note:: Does not validate the input value against the autocomplete results. """ + return self._autocomplete + + @autocomplete.setter + def autocomplete(self, value) -> None: self._autocomplete = value # this is done here so it does not have to be computed every time the autocomplete is invoked if self._autocomplete is not None: From 1dddf13747208d23d66bd12c50610a348c6b541d Mon Sep 17 00:00:00 2001 From: Paillat Date: Sat, 14 Dec 2024 16:20:13 +0100 Subject: [PATCH 7/8] :label: Boring typing stuff --- discord/commands/options.py | 51 ++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/discord/commands/options.py b/discord/commands/options.py index ea0bf3e209..8b4649a4a5 100644 --- a/discord/commands/options.py +++ b/discord/commands/options.py @@ -26,8 +26,9 @@ import inspect import logging +from collections.abc import Awaitable, Callable, Iterable from enum import Enum -from typing import TYPE_CHECKING, Iterable, Literal, Optional, Type, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Type, TypeVar, Union from ..abc import GuildChannel, Mentionable from ..channel import ( @@ -46,6 +47,7 @@ from ..utils import MISSING, basic_autocomplete if TYPE_CHECKING: + from ..cog import Cog from ..ext.commands import Converter from ..member import Member from ..message import Attachment @@ -71,6 +73,25 @@ Type[DiscordEnum], ] + AutocompleteReturnType = Union[ + Iterable["OptionChoice"], Iterable[str], Iterable[int], Iterable[float] + ] + T = TypeVar("T", bound=AutocompleteReturnType) + MaybeAwaitable = Union[T, Awaitable[T]] + AutocompleteFunction = Union[ + Callable[[AutocompleteContext], MaybeAwaitable[AutocompleteReturnType]], + Callable[[Cog, AutocompleteContext], MaybeAwaitable[AutocompleteReturnType]], + Callable[ + [AutocompleteContext, Any], # pyright: ignore [reportExplicitAny] + MaybeAwaitable[AutocompleteReturnType], + ], + Callable[ + [Cog, AutocompleteContext, Any], # pyright: ignore [reportExplicitAny] + MaybeAwaitable[AutocompleteReturnType], + ], + ] + + __all__ = ( "ThreadOption", "Option", @@ -111,11 +132,6 @@ def __init__(self, thread_type: Literal["public", "private", "news"]): self._type = type_map[thread_type] -AutocompleteReturnType = Union[ - Iterable["OptionChoice"], Iterable[str], Iterable[int], Iterable[float] -] - - class Option: """Represents a selectable option for a slash command. @@ -268,7 +284,7 @@ def __init__( ) self.default = kwargs.pop("default", None) - self._autocomplete = None + self._autocomplete: AutocompleteFunction | None = None self.autocomplete = kwargs.pop("autocomplete", None) if len(enum_choices) > 25: self.choices: list[OptionChoice] = [] @@ -388,22 +404,17 @@ def __repr__(self): return f"" @property - def autocomplete(self): + def autocomplete(self) -> AutocompleteFunction | None: """ The autocomplete handler for the option. Accepts a callable (sync or async) - that takes a single required argument of :class:`AutocompleteContext`. + that takes a single required argument of :class:`AutocompleteContext` or two arguments + of :class:`discord.Cog` (being the command's cog) and :class:`AutocompleteContext`. The callable must return an iterable of :class:`str` or :class:`OptionChoice`. Alternatively, :func:`discord.utils.basic_autocomplete` may be used in place of the callable. Returns ------- - Union[ - Callable[[Self, AutocompleteContext, Any], AutocompleteReturnType], - Callable[[AutocompleteContext, Any], AutocompleteReturnType], - Callable[[Self, AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], - Callable[[AutocompleteContext, Any], Awaitable[AutocompleteReturnType]], - None - ] + Optional[AutocompleteFunction] .. versionchanged:: 2.7 @@ -413,17 +424,17 @@ def autocomplete(self): return self._autocomplete @autocomplete.setter - def autocomplete(self, value) -> None: + def autocomplete(self, value: AutocompleteFunction | None) -> None: self._autocomplete = value # this is done here so it does not have to be computed every time the autocomplete is invoked if self._autocomplete is not None: - self._autocomplete._is_instance_method = ( + self._autocomplete._is_instance_method = ( # pyright: ignore [reportFunctionMemberAccess] sum( 1 for param in inspect.signature( - self.autocomplete + self._autocomplete ).parameters.values() - if param.default == param.empty + if param.default == param.empty # pyright: ignore[reportAny] and param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD) ) == 2 From 1f0b69703dd959ecd0a263656bfdd9a1f3ca112b Mon Sep 17 00:00:00 2001 From: Paillat Date: Wed, 18 Dec 2024 17:35:18 +0100 Subject: [PATCH 8/8] :pencil2: Fix writing --- examples/app_commands/slash_partial_autocomplete.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/app_commands/slash_partial_autocomplete.py b/examples/app_commands/slash_partial_autocomplete.py index f21499e006..ca6cd1eab9 100644 --- a/examples/app_commands/slash_partial_autocomplete.py +++ b/examples/app_commands/slash_partial_autocomplete.py @@ -33,7 +33,7 @@ class FoodCog(commands.Cog): autocomplete=partial(food_autocomplete, food_type="fruit"), ) async def get_fruit(self, ctx: discord.ApplicationContext, choice: str): - await ctx.respond(f"You picked: {choice}") + await ctx.respond(f'You picked "{choice}"') @commands.slash_command(name="vegetable") @discord.option( @@ -42,7 +42,7 @@ async def get_fruit(self, ctx: discord.ApplicationContext, choice: str): autocomplete=partial(food_autocomplete, food_type="vegetable"), ) async def get_vegetable(self, ctx: discord.ApplicationContext, choice: str): - await ctx.respond(f"You picked: {choice}") + await ctx.respond(f'You picked "{choice}"') bot.add_cog(FoodCog())