From 3cf3065c02bc8ab7fd61f3ce357d9f679ba8e0f0 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 28 Feb 2022 08:06:32 -0500 Subject: [PATCH] Add support for annotation transformers This facilitates the "converter-like" API of the app_commands submodule. As a consequence of this refactor, more types are supported like channels and attachment. --- discord/app_commands/__init__.py | 2 + discord/app_commands/commands.py | 206 +---------- discord/app_commands/errors.py | 50 ++- discord/app_commands/transformers.py | 496 +++++++++++++++++++++++++++ 4 files changed, 565 insertions(+), 189 deletions(-) create mode 100644 discord/app_commands/transformers.py diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py index acc98fd41..011002e37 100644 --- a/discord/app_commands/__init__.py +++ b/discord/app_commands/__init__.py @@ -14,3 +14,5 @@ from .enums import * from .errors import * from .models import * from .tree import * +from .namespace import Namespace +from .transformers import * diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index a8ad52f3e..7026d4c9b 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -41,7 +41,6 @@ from typing import ( TypeVar, Union, ) -from dataclasses import dataclass from textwrap import TextWrapper import sys @@ -51,6 +50,7 @@ from .enums import AppCommandOptionType, AppCommandType from ..interactions import Interaction from ..enums import ChannelType, try_enum from .models import AppCommandChannel, AppCommandThread, Choice +from .transformers import annotation_to_parameter, CommandParameter, NoneType from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered from ..utils import resolve_annotation, MISSING, is_inside_class from ..user import User @@ -72,7 +72,6 @@ if TYPE_CHECKING: from .namespace import Namespace __all__ = ( - 'CommandParameter', 'Command', 'ContextMenu', 'Group', @@ -130,158 +129,6 @@ def _to_kebab_case(text: str) -> str: return CAMEL_CASE_REGEX.sub('-', text).lower() -@dataclass -class CommandParameter: - """Represents a application command parameter. - - Attributes - ----------- - name: :class:`str` - The name of the parameter. - description: :class:`str` - The description of the parameter - required: :class:`bool` - Whether the parameter is required - choices: List[:class:`~discord.app_commands.Choice`] - A list of choices this parameter takes - type: :class:`~discord.app_commands.AppCommandOptionType` - The underlying type of this parameter. - channel_types: List[:class:`~discord.ChannelType`] - The channel types that are allowed for this parameter. - min_value: Optional[:class:`int`] - The minimum supported value for this parameter. - max_value: Optional[:class:`int`] - The maximum supported value for this parameter. - autocomplete: :class:`bool` - Whether this parameter enables autocomplete. - """ - - name: str = MISSING - description: str = MISSING - required: bool = MISSING - default: Any = MISSING - choices: List[Choice] = MISSING - type: AppCommandOptionType = MISSING - channel_types: List[ChannelType] = MISSING - min_value: Optional[int] = None - max_value: Optional[int] = None - autocomplete: bool = MISSING - _annotation: Any = MISSING - - def to_dict(self) -> Dict[str, Any]: - base = { - 'type': self.type.value, - 'name': self.name, - 'description': self.description, - 'required': self.required, - } - - if self.choices: - base['choices'] = [choice.to_dict() for choice in self.choices] - if self.channel_types: - base['channel_types'] = [t.value for t in self.channel_types] - if self.autocomplete: - base['autocomplete'] = True - if self.min_value is not None: - base['min_value'] = self.min_value - if self.max_value is not None: - base['max_value'] = self.max_value - - return base - - -annotation_to_option_type: Dict[Any, AppCommandOptionType] = { - str: AppCommandOptionType.string, - int: AppCommandOptionType.integer, - float: AppCommandOptionType.number, - bool: AppCommandOptionType.boolean, - User: AppCommandOptionType.user, - Member: AppCommandOptionType.user, - Role: AppCommandOptionType.role, - AppCommandChannel: AppCommandOptionType.channel, - AppCommandThread: AppCommandOptionType.channel, - # StageChannel: AppCommandOptionType.channel, - # StoreChannel: AppCommandOptionType.channel, - # VoiceChannel: AppCommandOptionType.channel, - # TextChannel: AppCommandOptionType.channel, -} - -NoneType = type(None) -allowed_default_types: Dict[AppCommandOptionType, Tuple[Type[Any], ...]] = { - AppCommandOptionType.string: (str, NoneType), - AppCommandOptionType.integer: (int, NoneType), - AppCommandOptionType.boolean: (bool, NoneType), -} - - -# Some sanity checks: -# str => string -# int => int -# User => user -# etc ... -# Optional[str] => string, required: false, default: None -# Optional[int] => integer, required: false, default: None -# Optional[Model] = None => resolved, required: false, default: None -# Optional[Model] can only have (CommandParameter, None) as default -# Optional[int | str | bool] can have (CommandParameter, None, int | str | bool) as a default -# Union[str, Member] => disallowed -# Union[int, str] => disallowed -# Union[Member, User] => user -# Optional[Union[Member, User]] => user, required: false, default: None -# Union[Member, User, Object] => mentionable -# Union[Models] => mentionable -# Optional[Union[Models]] => mentionable, required: false, default: None - - -def _annotation_to_type( - annotation: Any, - *, - mapping=annotation_to_option_type, - _none=NoneType, -) -> Tuple[AppCommandOptionType, Any]: - # Straight simple case, a regular ol' parameter - try: - option_type = mapping[annotation] - except KeyError: - pass - else: - return (option_type, MISSING) - - # Check if there's an origin - origin = getattr(annotation, '__origin__', None) - if origin is not Union: - # Only Union/Optional is supported so bail early - raise TypeError(f'unsupported type annotation {annotation!r}') - - default = MISSING - if annotation.__args__[-1] is _none: - if len(annotation.__args__) == 2: - underlying = annotation.__args__[0] - option_type = mapping.get(underlying) - if option_type is None: - raise TypeError(f'unsupported inner optional type {underlying!r}') - return (option_type, None) - else: - args = annotation.__args__[:-1] - default = None - else: - args = annotation.__args__ - - # At this point only models are allowed - # Since Optional[int | bool | str] will be taken care of above - # The only valid transformations here are: - # [Member, User] => user - # [Member, User, Role] => mentionable - # [Member | User, Role] => mentionable - supported_types: Set[Any] = {Role, Member, User} - if not all(arg in supported_types for arg in args): - raise TypeError(f'unsupported types given inside {annotation!r}') - if args == (User, Member) or args == (Member, User): - return (AppCommandOptionType.user, default) - - return (AppCommandOptionType.mentionable, default) - - def _context_menu_annotation(annotation: Any, *, _none=NoneType) -> AppCommandType: if annotation is Message: return AppCommandType.message @@ -324,33 +171,6 @@ def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Di raise TypeError(f'unknown parameter given: {first}') -def _get_parameter(annotation: Any, parameter: inspect.Parameter) -> CommandParameter: - (type, default) = _annotation_to_type(annotation) - if default is MISSING: - default = parameter.default - if default is parameter.empty: - default = MISSING - - result = CommandParameter( - type=type, - default=default, - required=default is MISSING, - name=parameter.name, - ) - - if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL): - raise TypeError(f'unsupported parameter kind in callback: {parameter.kind!s}') - - # Verify validity of the default parameter - if result.default is not MISSING: - valid_types: Tuple[Any, ...] = allowed_default_types.get(result.type, (NoneType,)) - if not isinstance(result.default, valid_types): - raise TypeError(f'invalid default parameter type given ({result.default.__class__}), expected {valid_types}') - - result._annotation = annotation - return result - - def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]: params = inspect.signature(func).parameters cache = {} @@ -368,7 +188,7 @@ def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[s raise TypeError(f'annotation for {parameter.name} must be given') resolved = resolve_annotation(parameter.annotation, globalns, globalns, cache) - param = _get_parameter(resolved, parameter) + param = annotation_to_parameter(resolved, parameter) parameters.append(param) values = sorted(parameters, key=lambda a: a.required, reverse=True) @@ -377,7 +197,9 @@ def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[s try: descriptions = func.__discord_app_commands_param_description__ except AttributeError: - pass + for param in values: + if param.description is MISSING: + param.description = '...' else: _populate_descriptions(result, descriptions) @@ -489,14 +311,24 @@ class Command(Generic[GroupT, P, T]): await parent.parent.on_error(interaction, self, error) async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T: - defaults = ((name, param.default) for name, param in self._params.items() if not param.required) - namespace._update_with_defaults(defaults) + values = namespace.__dict__ + for name, param in self._params.items(): + if not param.required: + values.setdefault(name, param.default) + else: + try: + value = values[name] + except KeyError: + raise CommandSignatureMismatch(self) from None + else: + values[name] = await param.transform(interaction, value) + # These type ignores are because the type checker doesn't quite understand the narrowing here # Likewise, it thinks we're missing positional arguments when there aren't any. try: if self.binding is not None: - return await self._callback(self.binding, interaction, **namespace.__dict__) # type: ignore - return await self._callback(interaction, **namespace.__dict__) # type: ignore + return await self._callback(self.binding, interaction, **values) # type: ignore + return await self._callback(interaction, **values) # type: ignore except TypeError as e: # In order to detect mismatch from the provided signature and the Discord data, # there are many ways it can go wrong yet all of them eventually lead to a TypeError diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py index 377b99ecd..e175d7084 100644 --- a/discord/app_commands/errors.py +++ b/discord/app_commands/errors.py @@ -24,14 +24,16 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional, Union +from typing import Any, TYPE_CHECKING, List, Optional, Type, Union -from .enums import AppCommandType + +from .enums import AppCommandOptionType, AppCommandType from ..errors import DiscordException __all__ = ( 'AppCommandError', 'CommandInvokeError', + 'TransformerError', 'CommandAlreadyRegistered', 'CommandSignatureMismatch', 'CommandNotFound', @@ -39,6 +41,7 @@ __all__ = ( if TYPE_CHECKING: from .commands import Command, Group, ContextMenu + from .transformers import Transformer class AppCommandError(DiscordException): @@ -82,6 +85,49 @@ class CommandInvokeError(AppCommandError): super().__init__(f'Command {command.name!r} raised an exception: {e.__class__.__name__}: {e}') +class TransformerError(AppCommandError): + """An exception raised when a :class:`Transformer` or type annotation fails to + convert to its target type. + + This inherits from :exc:`~discord.app_commands.AppCommandError`. + + .. note:: + + If the transformer raises a custom :exc:`AppCommandError` then it will + be propagated rather than wrapped into this exception. + + .. versionadded:: 2.0 + + Attributes + ----------- + value: Any + The value that failed to convert. + type: :class:`AppCommandOptionType` + The type of argument that failed to convert. + transformer: Type[:class:`Transformer`] + The transformer that failed the conversion. + """ + + def __init__(self, value: Any, opt_type: AppCommandOptionType, transformer: Type[Transformer]): + self.value: Any = value + self.type: AppCommandOptionType = opt_type + self.transformer: Type[Transformer] = transformer + + try: + result_type = transformer.transform.__annotations__['return'] + except KeyError: + name = transformer.__name__ + if name.endswith('Transformer'): + result_type = name[:-11] + else: + result_type = name + else: + if isinstance(result_type, type): + result_type = result_type.__name__ + + super().__init__(f'Failed to convert {value} to {result_type!s}') + + class CommandAlreadyRegistered(AppCommandError): """An exception raised when a command is already registered. diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py new file mode 100644 index 000000000..0b0ee6af0 --- /dev/null +++ b/discord/app_commands/transformers.py @@ -0,0 +1,496 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present 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. +""" + +from __future__ import annotations +import inspect + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union + +from .enums import AppCommandOptionType +from .errors import TransformerError +from .models import AppCommandChannel, AppCommandThread, Choice +from ..channel import StageChannel, StoreChannel, VoiceChannel, TextChannel, CategoryChannel +from ..enums import ChannelType +from ..utils import MISSING +from ..user import User +from ..role import Role +from ..member import Member +from ..message import Attachment + +__all__ = ( + 'Transformer', + 'Transform', +) + +T = TypeVar('T') +NoneType = type(None) + +if TYPE_CHECKING: + from ..interactions import Interaction + + +@dataclass +class CommandParameter: + """Represents a application command parameter. + + Attributes + ----------- + name: :class:`str` + The name of the parameter. + description: :class:`str` + The description of the parameter + required: :class:`bool` + Whether the parameter is required + choices: List[:class:`~discord.app_commands.Choice`] + A list of choices this parameter takes + type: :class:`~discord.app_commands.AppCommandOptionType` + The underlying type of this parameter. + channel_types: List[:class:`~discord.ChannelType`] + The channel types that are allowed for this parameter. + min_value: Optional[Union[:class:`int`, :class:`float`]] + The minimum supported value for this parameter. + max_value: Optional[Union[:class:`int`, :class:`float`]] + The maximum supported value for this parameter. + autocomplete: :class:`bool` + Whether this parameter enables autocomplete. + """ + + name: str = MISSING + description: str = MISSING + required: bool = MISSING + default: Any = MISSING + choices: List[Choice] = MISSING + type: AppCommandOptionType = MISSING + channel_types: List[ChannelType] = MISSING + min_value: Optional[Union[int, float]] = None + max_value: Optional[Union[int, float]] = None + autocomplete: bool = MISSING + _annotation: Any = MISSING + + def to_dict(self) -> Dict[str, Any]: + base = { + 'type': self.type.value, + 'name': self.name, + 'description': self.description, + 'required': self.required, + } + + if self.choices: + base['choices'] = [choice.to_dict() for choice in self.choices] + if self.channel_types: + base['channel_types'] = [t.value for t in self.channel_types] + if self.autocomplete: + base['autocomplete'] = True + if self.min_value is not None: + base['min_value'] = self.min_value + if self.max_value is not None: + base['max_value'] = self.max_value + + return base + + async def transform(self, interaction: Interaction, value: Any) -> Any: + if hasattr(self._annotation, '__discord_app_commands_transformer__'): + return await self._annotation.transform(interaction, value) + return value + + +class Transformer: + """The base class that allows a type annotation in an application command parameter + to map into a :class:`AppCommandOptionType` and transform the raw value into one from + this type. + + This class is customisable through the overriding of :obj:`classmethod`s in the class + and by using it as the second type parameter of the :class:`~discord.app_commands.Transform` + class. For example, to convert a string into a custom pair type: + + .. code-block:: python3 + + class Point(typing.NamedTuple): + x: int + y: int + + class PointTransformer(app_commands.Transformer): + @classmethod + async def transform(cls, interaction: discord.Interaction, value: str) -> Point: + (x, _, y) = value.partition(',') + return Point(x=int(x.strip()), y=int(y.strip())) + + @app_commands.command() + async def graph( + interaction: discord.Interaction, + point: app_commands.Transform[Point, PointTransformer], + ): + await interaction.response.send_message(str(point)) + + .. versionadded:: 2.0 + """ + + __discord_app_commands_transformer__: ClassVar[bool] = True + + @classmethod + def type(cls) -> AppCommandOptionType: + """:class:`AppCommandOptionType`: The option type associated with this transformer. + + This must be a :obj:`classmethod`. + + Defaults to :attr:`AppCommandOptionType.string`. + """ + return AppCommandOptionType.string + + @classmethod + def channel_types(cls) -> List[ChannelType]: + """List[:class:`~discord.ChannelType`]: A list of channel types that are allowed to this parameter. + + Only valid if the :meth:`type` returns :attr:`AppCommandOptionType.channel`. + + Defaults to an empty list. + """ + return [] + + @classmethod + def min_value(cls) -> Optional[Union[int, float]]: + """Optional[:class:`int`]: The minimum supported value for this parameter. + + Only valid if the :meth:`type` returns :attr:`AppCommandOptionType.number` or + :attr:`AppCommandOptionType.integer`. + + Defaults to ``None``. + """ + return None + + @classmethod + def max_value(cls) -> Optional[Union[int, float]]: + """Optional[:class:`int`]: The maximum supported value for this parameter. + + Only valid if the :meth:`type` returns :attr:`AppCommandOptionType.number` or + :attr:`AppCommandOptionType.integer`. + + Defaults to ``None``. + """ + return None + + @classmethod + async def transform(cls, interaction: Interaction, value: Any) -> Any: + """|coro| + + Transforms the converted option value into another value. + + The value passed into this transform function is the same as the + one in the :class:`conversion table `. + + Parameters + ----------- + interaction: :class:`~discord.Interaction` + The interaction being handled. + value: Any + The value of the given argument after being resolved. + See the :class:`conversion table ` + for how certain option types correspond to certain values. + """ + raise NotImplementedError('Derived classes need to implement this.') + + +class _TransformMetadata: + __discord_app_commands_transform__: ClassVar[bool] = True + __slots__ = ('metadata',) + + def __init__(self, metadata: Type[Transformer]): + self.metadata: Type[Transformer] = metadata + + +def _dynamic_transformer( + opt_type: AppCommandOptionType, + *, + channel_types: List[ChannelType] = MISSING, + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, +) -> Type[Transformer]: + types = channel_types or [] + + async def transform(cls, interaction: Interaction, value: Any) -> Any: + return value + + ns = { + 'type': classmethod(lambda _: opt_type), + 'channel_types': classmethod(lambda _: types), + 'min_value': classmethod(lambda _: min), + 'max_value': classmethod(lambda _: max), + 'transform': classmethod(transform), + } + return type('_DynamicTransformer', (Transformer,), ns) + + +if TYPE_CHECKING: + from typing_extensions import Annotated as Transform +else: + + class Transform: + """A type annotation that can be applied to a parameter to customise the behaviour of + an option type by transforming with the given :class:`Transformer`. This requires + the usage of two generic parameters, the first one is the type you're converting to and the second + one is the type of the :class:`Transformer` actually doing the transformation. + + During type checking time this is equivalent to :obj:`py:Annotated` so type checkers understand + the intent of the code. + + For example usage, check :class:`Transformer`. + + .. versionadded:: 2.0 + """ + + def __class_getitem__(cls, items) -> _TransformMetadata: + if not isinstance(items, tuple): + raise TypeError(f'expected tuple for arguments, received {items.__class__!r} instead') + + if len(items) != 2: + raise TypeError(f'Transform only accepts exactly two arguments') + + _, transformer = items + + is_valid = inspect.isclass(transformer) and issubclass(transformer, Transformer) + if not is_valid: + raise TypeError(f'second argument of Transform must be a Transformer class not {transformer!r}') + + return _TransformMetadata(transformer) + + +def passthrough_transformer(opt_type: AppCommandOptionType) -> Type[Transformer]: + class _Generated(Transformer): + @classmethod + def type(cls) -> AppCommandOptionType: + return opt_type + + @classmethod + async def transform(cls, interaction: Interaction, value: Any) -> Any: + return value + + return _Generated + + +class MemberTransformer(Transformer): + @classmethod + def type(cls) -> AppCommandOptionType: + return AppCommandOptionType.user + + @classmethod + async def transform(cls, interaction: Interaction, value: Any) -> Member: + if not isinstance(value, Member): + raise TransformerError(value, cls.type(), cls) + return value + + +def channel_transformer(*channel_types: Type[Any], raw: Optional[bool] = False) -> Type[Transformer]: + if raw: + + async def transform(cls, interaction: Interaction, value: Any): + if not isinstance(value, channel_types): + raise TransformerError(value, AppCommandOptionType.channel, cls) + return value + + elif raw is False: + + async def transform(cls, interaction: Interaction, value: Any): + resolved = value.resolve() + if resolved is None or not isinstance(resolved, channel_types): + raise TransformerError(value, AppCommandOptionType.channel, cls) + return resolved + + else: + + async def transform(cls, interaction: Interaction, value: Any): + if isinstance(value, channel_types): + return value + + resolved = value.resolve() + if resolved is None or not isinstance(resolved, channel_types): + raise TransformerError(value, AppCommandOptionType.channel, cls) + return resolved + + if len(channel_types) == 1: + name = channel_types[0].__name__ + types = CHANNEL_TO_TYPES[channel_types[0]] + else: + name = 'MultiChannel' + types = [] + + for t in channel_types: + try: + types.extend(CHANNEL_TO_TYPES[t]) + except KeyError: + raise TypeError(f'Union type of channels must be entirely made up of channels') from None + + return type( + f'{name}Transformer', + (Transformer,), + { + 'type': classmethod(lambda cls: AppCommandOptionType.channel), + 'transform': classmethod(transform), + 'channel_types': classmethod(lambda cls: types), + }, + ) + + +CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = { + AppCommandChannel: [ + ChannelType.stage_voice, + ChannelType.store, + ChannelType.voice, + ChannelType.text, + ChannelType.category, + ], + AppCommandThread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread], + StageChannel: [ChannelType.stage_voice], + StoreChannel: [ChannelType.store], + VoiceChannel: [ChannelType.voice], + TextChannel: [ChannelType.text], + CategoryChannel: [ChannelType.category], +} + +BUILT_IN_TRANSFORMERS: Dict[Any, Type[Transformer]] = { + str: passthrough_transformer(AppCommandOptionType.string), + int: passthrough_transformer(AppCommandOptionType.integer), + float: passthrough_transformer(AppCommandOptionType.number), + bool: passthrough_transformer(AppCommandOptionType.boolean), + User: passthrough_transformer(AppCommandOptionType.user), + Member: MemberTransformer, + Role: passthrough_transformer(AppCommandOptionType.role), + AppCommandChannel: channel_transformer(AppCommandChannel, raw=True), + AppCommandThread: channel_transformer(AppCommandThread, raw=True), + StageChannel: channel_transformer(StageChannel), + StoreChannel: channel_transformer(StoreChannel), + VoiceChannel: channel_transformer(VoiceChannel), + TextChannel: channel_transformer(TextChannel), + CategoryChannel: channel_transformer(CategoryChannel), + Attachment: passthrough_transformer(AppCommandOptionType.attachment), +} + +ALLOWED_DEFAULTS: Dict[AppCommandOptionType, Tuple[Type[Any], ...]] = { + AppCommandOptionType.string: (str, NoneType), + AppCommandOptionType.integer: (int, NoneType), + AppCommandOptionType.boolean: (bool, NoneType), +} + + +def get_supported_annotation( + annotation: Any, + *, + _none=NoneType, + _mapping: Dict[Any, Type[Transformer]] = BUILT_IN_TRANSFORMERS, +) -> Tuple[Any, Any]: + """Returns an appropriate, yet supported, annotation along with an optional default value. + + This differs from the built in mapping by supporting a few more things. + Likewise, this returns a "transformed" annotation that is ready to use with CommandParameter.transform. + """ + + try: + return (_mapping[annotation], MISSING) + except KeyError: + pass + + if hasattr(annotation, '__discord_app_commands_transform__'): + return (annotation.metadata, MISSING) + + if inspect.isclass(annotation) and issubclass(annotation, Transformer): + return (annotation, MISSING) + + # Check if there's an origin + origin = getattr(annotation, '__origin__', None) + if origin is not Union: + # Only Union/Optional is supported right now so bail early + raise TypeError(f'unsupported type annotation {annotation!r}') + + default = MISSING + args = annotation.__args__ # type: ignore + if args[-1] is _none: + if len(args) == 2: + underlying = args[0] + inner, _ = get_supported_annotation(underlying) + if inner is None: + raise TypeError(f'unsupported inner optional type {underlying!r}') + return (inner, None) + else: + args = args[:-1] + default = None + + # Check for channel union types + if any(arg in CHANNEL_TO_TYPES for arg in args): + # If any channel type is given, then *all* must be channel types + return (channel_transformer(*args, raw=None), default) + + # The only valid transformations here are: + # [Member, User] => user + # [Member, User, Role] => mentionable + # [Member | User, Role] => mentionable + supported_types: Set[Any] = {Role, Member, User} + if not all(arg in supported_types for arg in args): + raise TypeError(f'unsupported types given inside {annotation!r}') + if args == (User, Member) or args == (Member, User): + return (passthrough_transformer(AppCommandOptionType.user), default) + + return (passthrough_transformer(AppCommandOptionType.mentionable), default) + + +def annotation_to_parameter(annotation: Any, parameter: inspect.Parameter) -> CommandParameter: + """Returns the appropriate :class:`CommandParameter` for the given annotation. + + The resulting ``_annotation`` attribute might not match the one given here and might + be transformed in order to be easier to call from the ``transform`` asynchronous function + of a command parameter. + """ + + (inner, default) = get_supported_annotation(annotation) + type = inner.type() + if default is MISSING: + default = parameter.default + if default is parameter.empty: + default = MISSING + + # Verify validity of the default parameter + if default is not MISSING: + valid_types: Tuple[Any, ...] = ALLOWED_DEFAULTS.get(type, (NoneType,)) + if not isinstance(default, valid_types): + raise TypeError(f'invalid default parameter type given ({default.__class__}), expected {valid_types}') + + result = CommandParameter( + type=type, + _annotation=inner, + default=default, + required=default is MISSING, + name=parameter.name, + ) + + # These methods should be duck typed + if type in (AppCommandOptionType.number, AppCommandOptionType.integer): + result.min_value = inner.min_value() + result.max_value = inner.max_value() + + if type is AppCommandOptionType.channel: + result.channel_types = inner.channel_types() + + if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL): + raise TypeError(f'unsupported parameter kind in callback: {parameter.kind!s}') + + return result