Browse Source
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.pull/7492/head
4 changed files with 565 additions and 189 deletions
@ -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…
Reference in new issue