mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-06-07 20:28:38 +00:00
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.
This commit is contained in:
parent
c10ed93cef
commit
3cf3065c02
@ -14,3 +14,5 @@ from .enums import *
|
|||||||
from .errors import *
|
from .errors import *
|
||||||
from .models import *
|
from .models import *
|
||||||
from .tree import *
|
from .tree import *
|
||||||
|
from .namespace import Namespace
|
||||||
|
from .transformers import *
|
||||||
|
@ -41,7 +41,6 @@ from typing import (
|
|||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
from dataclasses import dataclass
|
|
||||||
from textwrap import TextWrapper
|
from textwrap import TextWrapper
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@ -51,6 +50,7 @@ from .enums import AppCommandOptionType, AppCommandType
|
|||||||
from ..interactions import Interaction
|
from ..interactions import Interaction
|
||||||
from ..enums import ChannelType, try_enum
|
from ..enums import ChannelType, try_enum
|
||||||
from .models import AppCommandChannel, AppCommandThread, Choice
|
from .models import AppCommandChannel, AppCommandThread, Choice
|
||||||
|
from .transformers import annotation_to_parameter, CommandParameter, NoneType
|
||||||
from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered
|
from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered
|
||||||
from ..utils import resolve_annotation, MISSING, is_inside_class
|
from ..utils import resolve_annotation, MISSING, is_inside_class
|
||||||
from ..user import User
|
from ..user import User
|
||||||
@ -72,7 +72,6 @@ if TYPE_CHECKING:
|
|||||||
from .namespace import Namespace
|
from .namespace import Namespace
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CommandParameter',
|
|
||||||
'Command',
|
'Command',
|
||||||
'ContextMenu',
|
'ContextMenu',
|
||||||
'Group',
|
'Group',
|
||||||
@ -130,158 +129,6 @@ def _to_kebab_case(text: str) -> str:
|
|||||||
return CAMEL_CASE_REGEX.sub('-', text).lower()
|
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:
|
def _context_menu_annotation(annotation: Any, *, _none=NoneType) -> AppCommandType:
|
||||||
if annotation is Message:
|
if annotation is Message:
|
||||||
return AppCommandType.message
|
return AppCommandType.message
|
||||||
@ -324,33 +171,6 @@ def _populate_descriptions(params: Dict[str, CommandParameter], descriptions: Di
|
|||||||
raise TypeError(f'unknown parameter given: {first}')
|
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]:
|
def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]:
|
||||||
params = inspect.signature(func).parameters
|
params = inspect.signature(func).parameters
|
||||||
cache = {}
|
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')
|
raise TypeError(f'annotation for {parameter.name} must be given')
|
||||||
|
|
||||||
resolved = resolve_annotation(parameter.annotation, globalns, globalns, cache)
|
resolved = resolve_annotation(parameter.annotation, globalns, globalns, cache)
|
||||||
param = _get_parameter(resolved, parameter)
|
param = annotation_to_parameter(resolved, parameter)
|
||||||
parameters.append(param)
|
parameters.append(param)
|
||||||
|
|
||||||
values = sorted(parameters, key=lambda a: a.required, reverse=True)
|
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:
|
try:
|
||||||
descriptions = func.__discord_app_commands_param_description__
|
descriptions = func.__discord_app_commands_param_description__
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
for param in values:
|
||||||
|
if param.description is MISSING:
|
||||||
|
param.description = '...'
|
||||||
else:
|
else:
|
||||||
_populate_descriptions(result, descriptions)
|
_populate_descriptions(result, descriptions)
|
||||||
|
|
||||||
@ -489,14 +311,24 @@ class Command(Generic[GroupT, P, T]):
|
|||||||
await parent.parent.on_error(interaction, self, error)
|
await parent.parent.on_error(interaction, self, error)
|
||||||
|
|
||||||
async def _invoke_with_namespace(self, interaction: Interaction, namespace: Namespace) -> T:
|
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)
|
values = namespace.__dict__
|
||||||
namespace._update_with_defaults(defaults)
|
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
|
# 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.
|
# Likewise, it thinks we're missing positional arguments when there aren't any.
|
||||||
try:
|
try:
|
||||||
if self.binding is not None:
|
if self.binding is not None:
|
||||||
return await self._callback(self.binding, interaction, **namespace.__dict__) # type: ignore
|
return await self._callback(self.binding, interaction, **values) # type: ignore
|
||||||
return await self._callback(interaction, **namespace.__dict__) # type: ignore
|
return await self._callback(interaction, **values) # type: ignore
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
# In order to detect mismatch from the provided signature and the Discord data,
|
# 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
|
# there are many ways it can go wrong yet all of them eventually lead to a TypeError
|
||||||
|
@ -24,14 +24,16 @@ DEALINGS IN THE SOFTWARE.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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
|
from ..errors import DiscordException
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'AppCommandError',
|
'AppCommandError',
|
||||||
'CommandInvokeError',
|
'CommandInvokeError',
|
||||||
|
'TransformerError',
|
||||||
'CommandAlreadyRegistered',
|
'CommandAlreadyRegistered',
|
||||||
'CommandSignatureMismatch',
|
'CommandSignatureMismatch',
|
||||||
'CommandNotFound',
|
'CommandNotFound',
|
||||||
@ -39,6 +41,7 @@ __all__ = (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .commands import Command, Group, ContextMenu
|
from .commands import Command, Group, ContextMenu
|
||||||
|
from .transformers import Transformer
|
||||||
|
|
||||||
|
|
||||||
class AppCommandError(DiscordException):
|
class AppCommandError(DiscordException):
|
||||||
@ -82,6 +85,49 @@ class CommandInvokeError(AppCommandError):
|
|||||||
super().__init__(f'Command {command.name!r} raised an exception: {e.__class__.__name__}: {e}')
|
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):
|
class CommandAlreadyRegistered(AppCommandError):
|
||||||
"""An exception raised when a command is already registered.
|
"""An exception raised when a command is already registered.
|
||||||
|
|
||||||
|
496
discord/app_commands/transformers.py
Normal file
496
discord/app_commands/transformers.py
Normal file
@ -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 <discord.app_commands.Namespace>`.
|
||||||
|
|
||||||
|
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 <discord.app_commands.Namespace>`
|
||||||
|
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
|
Loading…
x
Reference in New Issue
Block a user