diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 7bf29412d..0e5f73cc6 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -81,6 +81,7 @@ __all__ = ( 'ScheduledEventConverter', 'clean_content', 'Greedy', + 'Range', 'run_converters', ) @@ -1053,6 +1054,84 @@ class Greedy(List[T]): return cls(converter=converter) +if TYPE_CHECKING: + from typing_extensions import Annotated as Range +else: + + class Range: + """A special converter that can be applied to a parameter to require a numeric type + to fit within the range provided. + + During type checking time this is equivalent to :obj:`typing.Annotated` so type checkers understand + the intent of the code. + + Some example ranges: + + - ``Range[int, 10]`` means the minimum is 10 with no maximum. + - ``Range[int, None, 10]`` means the maximum is 10 with no minimum. + - ``Range[int, 1, 10]`` means the minimum is 1 and the maximum is 10. + + Inside a :class:`HybridCommand` this functions equivalently to :class:`discord.app_commands.Range`. + + .. versionadded:: 2.0 + + Examples + ---------- + + .. code-block:: python3 + + @bot.command() + async def range(ctx: commands.Context, value: commands.Range[int, 10, 12]): + await ctx.send(f'Your value is {value}') + """ + + def __init__( + self, + *, + annotation: Any, + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + ) -> None: + self.annotation: Any = annotation + self.min: Optional[Union[int, float]] = min + self.max: Optional[Union[int, float]] = max + + async def convert(self, ctx: Context[BotT], value: str) -> Union[int, float]: + converted = self.annotation(value) + if (self.min is not None and converted < self.min) or (self.max is not None and converted > self.max): + raise RangeError(converted, minimum=self.min, maximum=self.max) + + return converted + + def __class_getitem__(cls, obj) -> Range: + if not isinstance(obj, tuple): + raise TypeError(f'expected tuple for arguments, received {obj.__class__!r} instead') + + if len(obj) == 2: + obj = (*obj, None) + elif len(obj) != 3: + raise TypeError('Range accepts either two or three arguments with the first being the type of range.') + + annotation, min, max = obj + + if min is None and max is None: + raise TypeError('Range must not be empty') + + if min is not None and max is not None: + # At this point max and min are both not none + if type(min) != type(max): + raise TypeError('Both min and max in Range must be the same type') + + if annotation not in (int, float): + raise TypeError(f'expected int or float as range type, received {annotation!r} instead') + + return cls( + annotation=annotation, + min=annotation(min) if min is not None else None, + max=annotation(max) if max is not None else None, + ) + + def _convert_to_bool(argument: str) -> bool: lowered = argument.lower() if lowered in ('yes', 'y', 'true', 't', '1', 'enable', 'on'): diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index aa9d5950d..a55088545 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -102,6 +102,7 @@ __all__ = ( 'TooManyFlags', 'MissingRequiredFlag', 'HybridCommandError', + 'RangeError', ) @@ -555,6 +556,44 @@ class BadBoolArgument(BadArgument): super().__init__(f'{argument} is not a recognised boolean option') +class RangeError(BadArgument): + """Exception raised when an argument is out of range. + + This inherits from :exc:`BadArgument` + + .. versionadded:: 2.0 + + Attributes + ----------- + minimum: Optional[Union[:class:`int`, :class:`float`]] + The minimum value expected or ``None`` if there wasn't one + maximum: Optional[Union[:class:`int`, :class:`float`]] + The maximum value expected or ``None`` if there wasn't one + value: Union[:class:`int`, :class:`float`] + The value that was out of range. + """ + + def __init__( + self, + value: Union[int, float], + minimum: Optional[Union[int, float]], + maximum: Optional[Union[int, float]], + ) -> None: + self.value: Union[int, float] = value + self.minimum: Optional[Union[int, float]] = minimum + self.maximum: Optional[Union[int, float]] = maximum + + label: str = '' + if minimum is None and maximum is not None: + label = f'no more than {maximum}' + elif minimum is not None and maximum is None: + label = f'not less than {minimum}' + elif maximum is not None and minimum is not None: + label = f'between {minimum} and {maximum}' + + super().__init__(f'value must be {label} but received {value}') + + class DisabledCommand(CommandError): """Exception raised when the command being invoked is disabled. diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 67ce752e1..8ed2b27bc 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -43,7 +43,7 @@ from discord import app_commands from discord.utils import MISSING, maybe_coroutine, async_all from .core import Command, Group from .errors import BadArgument, CommandRegistrationError, CommandError, HybridCommandError, ConversionError -from .converter import Converter +from .converter import Converter, Range from .parameters import Parameter from .cog import Cog @@ -143,16 +143,17 @@ def replace_parameters(parameters: Dict[str, Parameter], signature: inspect.Sign for name, parameter in parameters.items(): _is_transformer = is_transformer(parameter.converter) origin = getattr(parameter.converter, '__origin__', None) - if is_converter(parameter.converter) and not _is_transformer: + if isinstance(parameter.converter, Range): + r = parameter.converter + params[name] = params[name].replace(annotation=app_commands.Range[r.annotation, r.min, r.max]) # type: ignore + elif is_converter(parameter.converter) and not _is_transformer: params[name] = params[name].replace(annotation=make_converter_transformer(parameter.converter)) - - # Special case Optional[X] where X is a single type that can optionally be a converter elif origin is Union and len(parameter.converter.__args__) == 2 and parameter.converter.__args__[-1] is _NoneType: + # Special case Optional[X] where X is a single type that can optionally be a converter inner = parameter.converter.__args__[0] is_inner_tranformer = is_transformer(inner) if is_converter(inner) and not is_inner_tranformer: params[name] = params[name].replace(annotation=Optional[make_converter_transformer(inner)]) # type: ignore - elif callable(parameter.converter) and not inspect.isclass(parameter.converter) and not _is_transformer: params[name] = params[name].replace(annotation=make_callable_transformer(parameter.converter)) diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 1700359a1..20ac07663 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -468,6 +468,8 @@ Converters .. autoclass:: discord.ext.commands.Greedy() +.. autoclass:: discord.ext.commands.Range() + .. autofunction:: discord.ext.commands.run_converters Flag Converter @@ -626,6 +628,9 @@ Exceptions .. autoexception:: discord.ext.commands.BadBoolArgument :members: +.. autoexception:: discord.ext.commands.RangeError + :members: + .. autoexception:: discord.ext.commands.MissingPermissions :members: @@ -713,6 +718,7 @@ Exception Hierarchy - :exc:`~.commands.ScheduledEventNotFound` - :exc:`~.commands.PartialEmojiConversionFailure` - :exc:`~.commands.BadBoolArgument` + - :exc:`~.commands.RangeError` - :exc:`~.commands.ThreadNotFound` - :exc:`~.commands.FlagError` - :exc:`~.commands.BadFlagArgument`