mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-04-19 15:36:02 +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 .models import *
|
||||
from .tree import *
|
||||
from .namespace import Namespace
|
||||
from .transformers import *
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
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