From 051a963f6a1639a1545d3ee9c2d49ebe83d78579 Mon Sep 17 00:00:00 2001 From: shiftinv <8530778+shiftinv@users.noreply.github.com> Date: Tue, 20 Jun 2023 23:04:29 +0200 Subject: [PATCH] refactor(ext.commands): completely rewrite `Range` and `String`, require type argument (#991) Co-authored-by: arl --- changelog/991.bugfix.rst | 1 + changelog/991.deprecate.0.rst | 1 + changelog/991.deprecate.1.rst | 1 + disnake/ext/commands/params.py | 240 +++++++++++++++------------ disnake/ext/mypy_plugin/__init__.py | 61 +------ docs/ext/commands/slash_commands.rst | 48 ++---- examples/interactions/param.py | 16 +- test_bot/cogs/slash_commands.py | 6 +- tests/ext/commands/test_params.py | 152 ++++++++++++++++- 9 files changed, 313 insertions(+), 213 deletions(-) create mode 100644 changelog/991.bugfix.rst create mode 100644 changelog/991.deprecate.0.rst create mode 100644 changelog/991.deprecate.1.rst diff --git a/changelog/991.bugfix.rst b/changelog/991.bugfix.rst new file mode 100644 index 0000000000..98304b9b8f --- /dev/null +++ b/changelog/991.bugfix.rst @@ -0,0 +1 @@ +|commands| Fix type-checker support for :class:`~disnake.ext.commands.Range` and :class:`~disnake.ext.commands.String` by requiring type argument (i.e. ``Range[int, 1, 5]`` instead of ``Range[1, 5]``). diff --git a/changelog/991.deprecate.0.rst b/changelog/991.deprecate.0.rst new file mode 100644 index 0000000000..50234e7f16 --- /dev/null +++ b/changelog/991.deprecate.0.rst @@ -0,0 +1 @@ +|commands| :class:`~disnake.ext.commands.Range` and :class:`~disnake.ext.commands.String` now require a type argument (i.e. ``Range[int, 1, 5]`` instead of ``Range[1, 5]``, similarly with ``String[str, 2, 4]``). The old form is deprecated. diff --git a/changelog/991.deprecate.1.rst b/changelog/991.deprecate.1.rst new file mode 100644 index 0000000000..ae6ccfa020 --- /dev/null +++ b/changelog/991.deprecate.1.rst @@ -0,0 +1 @@ +|commands| The mypy plugin is now a no-op. It was previously used for supporting ``Range[]`` and ``String[]`` annotations. diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 0a08510063..69d9ccb085 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -10,6 +10,8 @@ import itertools import math import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass from enum import Enum, EnumMeta from typing import ( TYPE_CHECKING, @@ -22,6 +24,7 @@ Generic, List, Literal, + NoReturn, Optional, Sequence, Tuple, @@ -31,7 +34,6 @@ get_args, get_origin, get_type_hints, - overload, ) import disnake @@ -279,135 +281,163 @@ def decorator(func: CallableT) -> CallableT: return decorator -class RangeMeta(type): - """Custom Generic implementation for Range""" +@dataclass(frozen=True) +class _BaseRange(ABC): + """Internal base type for supporting ``Range[...]`` and ``String[...]``.""" - @overload - def __getitem__( - self, args: Tuple[Union[int, EllipsisType], Union[int, EllipsisType]] - ) -> Type[int]: - ... + _allowed_types: ClassVar[Tuple[Type[Any], ...]] - @overload - def __getitem__( - self, args: Tuple[Union[float, EllipsisType], Union[float, EllipsisType]] - ) -> Type[float]: - ... + underlying_type: Type[Any] + min_value: Optional[Union[int, float]] + max_value: Optional[Union[int, float]] - def __getitem__(self, args: Tuple[Any, ...]) -> Any: - a, b = [None if isinstance(x, type(Ellipsis)) else x for x in args] - return Range.create(min_value=a, max_value=b) + def __class_getitem__(cls, params: Tuple[Any, ...]) -> Self: + # deconstruct type arguments + if not isinstance(params, tuple): + params = (params,) + name = cls.__name__ -class Range(type, metaclass=RangeMeta): - """Type depicting a limited range of allowed values. + if len(params) == 2: + # backwards compatibility for `Range[1, 2]` - See :ref:`param_ranges` for more information. + # FIXME: the warning context is incorrect when used with stringified annotations, + # and points to the eval frame instead of user code + disnake.utils.warn_deprecated( + f"Using `{name}` without an explicit type argument is deprecated, " + "as this form does not work well with modern type-checkers. " + f"Use `{name}[, , ]` instead.", + stacklevel=2, + ) - .. versionadded:: 2.4 + # infer type from min/max values + params = (cls._infer_type(params),) + params - """ + if len(params) != 3: + raise TypeError( + f"`{name}` expects 3 type arguments ({name}[, , ]), got {len(params)}" + ) - min_value: Optional[float] - max_value: Optional[float] + underlying_type, min_value, max_value = params - @overload - @classmethod - def create( - cls, - min_value: Optional[int] = None, - max_value: Optional[int] = None, - *, - le: Optional[int] = None, - lt: Optional[int] = None, - ge: Optional[int] = None, - gt: Optional[int] = None, - ) -> Type[int]: - ... - - @overload - @classmethod - def create( - cls, - min_value: Optional[float] = None, - max_value: Optional[float] = None, - *, - le: Optional[float] = None, - lt: Optional[float] = None, - ge: Optional[float] = None, - gt: Optional[float] = None, - ) -> Type[float]: - ... + # validate type (argument 1) + if not isinstance(underlying_type, type): + raise TypeError(f"First `{name}` argument must be a type, not `{underlying_type!r}`") - @classmethod - def create( - cls, - min_value: Optional[float] = None, - max_value: Optional[float] = None, - *, - le: Optional[float] = None, - lt: Optional[float] = None, - ge: Optional[float] = None, - gt: Optional[float] = None, - ) -> Any: - """Construct a new range with any possible constraints""" - self = cls(cls.__name__, (), {}) - self.min_value = min_value if min_value is not None else _xt_to_xe(le, lt, -1) - self.max_value = max_value if max_value is not None else _xt_to_xe(ge, gt, 1) - return self + if not issubclass(underlying_type, cls._allowed_types): + allowed = "/".join(t.__name__ for t in cls._allowed_types) + raise TypeError(f"First `{name}` argument must be {allowed}, not `{underlying_type!r}`") - @property - def underlying_type(self) -> Union[Type[int], Type[float]]: - if isinstance(self.min_value, float) or isinstance(self.max_value, float): - return float + # validate min/max (arguments 2/3) + min_value = cls._coerce_bound(min_value, "min") + max_value = cls._coerce_bound(max_value, "max") + + if min_value is None and max_value is None: + raise ValueError(f"`{name}` bounds cannot both be empty") - return int + # n.b. this allows bounds to be equal, which doesn't really serve a purpose with numbers, + # but is still accepted by the api + if min_value is not None and max_value is not None and min_value > max_value: + raise ValueError( + f"`{name}` minimum ({min_value}) must be less than or equal to maximum ({max_value})" + ) + + return cls(underlying_type=underlying_type, min_value=min_value, max_value=max_value) + + @staticmethod + def _coerce_bound(value: Any, name: str) -> Optional[Union[int, float]]: + if value is None or isinstance(value, EllipsisType): + return None + elif isinstance(value, (int, float)): + if not math.isfinite(value): + raise ValueError(f"{name} value may not be NaN, inf, or -inf") + return value + else: + raise TypeError(f"{name} value must be int, float, None, or `...`, not `{type(value)}`") def __repr__(self) -> str: a = "..." if self.min_value is None else self.min_value b = "..." if self.max_value is None else self.max_value - return f"{type(self).__name__}[{a}, {b}]" + return f"{type(self).__name__}[{self.underlying_type.__name__}, {a}, {b}]" + @classmethod + @abstractmethod + def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]: + raise NotImplementedError -class StringMeta(type): - """Custom Generic implementation for String.""" + # hack to get `typing._type_check` to pass, e.g. when using `Range` as a generic parameter + def __call__(self) -> NoReturn: + raise NotImplementedError - def __getitem__( - self, args: Tuple[Union[int, EllipsisType], Union[int, EllipsisType]] - ) -> Type[str]: - a, b = [None if isinstance(x, EllipsisType) else x for x in args] - return String.create(min_length=a, max_length=b) + # support new union syntax for `Range[int, 1, 2] | None` + if sys.version_info >= (3, 10): + def __or__(self, other): + return Union[self, other] # type: ignore -class String(type, metaclass=StringMeta): - """Type depicting a string option with limited length. - See :ref:`string_lengths` for more information. +if TYPE_CHECKING: + # aliased import since mypy doesn't understand `Range = Annotated` + from typing_extensions import Annotated as Range, Annotated as String +else: - .. versionadded:: 2.6 + @dataclass(frozen=True, repr=False) + class Range(_BaseRange): + """Type representing a number with a limited range of allowed values. - """ + See :ref:`param_ranges` for more information. - min_length: Optional[int] - max_length: Optional[int] - underlying_type: Final[Type[str]] = str + .. versionadded:: 2.4 - @classmethod - def create( - cls, - min_length: Optional[int] = None, - max_length: Optional[int] = None, - ) -> Any: - """Construct a new String with constraints.""" - self = cls(cls.__name__, (), {}) - self.min_length = min_length - self.max_length = max_length - return self + .. versionchanged:: 2.9 + Syntax changed from ``Range[5, 10]`` to ``Range[int, 5, 10]``; + the type (:class:`int` or :class:`float`) must now be specified explicitly. + """ - def __repr__(self) -> str: - a = "..." if self.min_length is None else self.min_length - b = "..." if self.max_length is None else self.max_length - return f"{type(self).__name__}[{a}, {b}]" + _allowed_types = (int, float) + + def __post_init__(self): + for value in (self.min_value, self.max_value): + if value is None: + continue + + if self.underlying_type is int and not isinstance(value, int): + raise TypeError("Range[int, ...] bounds must be int, not float") + + @classmethod + def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]: + if any(isinstance(p, float) for p in params): + return float + return int + + @dataclass(frozen=True, repr=False) + class String(_BaseRange): + """Type representing a string option with a limited length. + + See :ref:`string_lengths` for more information. + + .. versionadded:: 2.6 + + .. versionchanged:: 2.9 + Syntax changed from ``String[5, 10]`` to ``String[str, 5, 10]``; + the type (:class:`str`) must now be specified explicitly. + """ + + _allowed_types = (str,) + + def __post_init__(self): + for value in (self.min_value, self.max_value): + if value is None: + continue + + if not isinstance(value, int): + raise TypeError("String bounds must be int, not float") + if value < 0: + raise ValueError("String bounds may not be negative") + + @classmethod + def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]: + return str class LargeInt(int): @@ -701,14 +731,14 @@ def parse_annotation(self, annotation: Any, converter_mode: bool = False) -> boo if annotation is inspect.Parameter.empty or annotation is Any: return False - # resolve type aliases + # resolve type aliases and special types if isinstance(annotation, Range): self.min_value = annotation.min_value self.max_value = annotation.max_value annotation = annotation.underlying_type if isinstance(annotation, String): - self.min_length = annotation.min_length - self.max_length = annotation.max_length + self.min_length = annotation.min_value + self.max_length = annotation.max_value annotation = annotation.underlying_type if issubclass_(annotation, LargeInt): self.large = True diff --git a/disnake/ext/mypy_plugin/__init__.py b/disnake/ext/mypy_plugin/__init__.py index f6fa437492..97b23df5da 100644 --- a/disnake/ext/mypy_plugin/__init__.py +++ b/disnake/ext/mypy_plugin/__init__.py @@ -2,67 +2,12 @@ import typing as t -from mypy.plugin import AnalyzeTypeContext, Plugin -from mypy.types import AnyType, EllipsisType, RawExpressionType, Type, TypeOfAny +from mypy.plugin import Plugin +# FIXME: properly deprecate this in the future class DisnakePlugin(Plugin): - def get_type_analyze_hook( - self, fullname: str - ) -> t.Optional[t.Callable[[AnalyzeTypeContext], Type]]: - if fullname == "disnake.ext.commands.params.Range": - return range_type_analyze_callback - if fullname == "disnake.ext.commands.params.String": - return string_type_analyze_callback - return None - - -def range_type_analyze_callback(ctx: AnalyzeTypeContext) -> Type: - args = ctx.type.args - - if len(args) != 2: - ctx.api.fail(f'"Range" expected 2 parameters, got {len(args)}', ctx.context) - return AnyType(TypeOfAny.from_error) - - for arg in args: - if isinstance(arg, EllipsisType): - continue - if not isinstance(arg, RawExpressionType): - ctx.api.fail('invalid usage of "Range"', ctx.context) - return AnyType(TypeOfAny.from_error) - - name = arg.simple_name() - # if one is a float, `Range.underlying_type` returns `float` - if name == "float": - return ctx.api.named_type("builtins.float", []) - # otherwise it should be an int; fail if it isn't - elif name != "int": - ctx.api.fail(f'"Range" parameters must be int or float, not {name}', ctx.context) - return AnyType(TypeOfAny.from_error) - - return ctx.api.named_type("builtins.int", []) - - -def string_type_analyze_callback(ctx: AnalyzeTypeContext) -> Type: - args = ctx.type.args - - if len(args) != 2: - ctx.api.fail(f'"String" expected 2 parameters, got {len(args)}', ctx.context) - return AnyType(TypeOfAny.from_error) - - for arg in args: - if isinstance(arg, EllipsisType): - continue - if not isinstance(arg, RawExpressionType): - ctx.api.fail('invalid usage of "String"', ctx.context) - return AnyType(TypeOfAny.from_error) - - name = arg.simple_name() - if name != "int": - ctx.api.fail(f'"String" parameters must be int, not {name}', ctx.context) - return AnyType(TypeOfAny.from_error) - - return ctx.api.named_type("builtins.str", []) + """Custom mypy plugin; no-op as of version 2.9.""" def plugin(version: str) -> t.Type[Plugin]: diff --git a/docs/ext/commands/slash_commands.rst b/docs/ext/commands/slash_commands.rst index d1b16e4469..603696b637 100644 --- a/docs/ext/commands/slash_commands.rst +++ b/docs/ext/commands/slash_commands.rst @@ -188,49 +188,28 @@ For instance, you could restrict an option to only accept positive integers: ... -Instead of using :func:`Param `, you can also use a :class:`~ext.commands.Range` annotation. +Instead of using :func:`~ext.commands.Param`, you can also use a :class:`~ext.commands.Range` annotation. The range bounds are both inclusive; using ``...`` as a bound indicates that this end of the range is unbounded. -The type of the option is determined by the range bounds, with the option being a -:class:`float` if at least one of the bounds is a :class:`float`, and :class:`int` otherwise. +The type of the option is specified by the first type argument, which can be either :class:`int` or :class:`float`. .. code-block:: python3 @bot.slash_command() async def ranges( inter: disnake.ApplicationCommandInteraction, - a: commands.Range[0, 10], # 0 - 10 int - b: commands.Range[0, 10.0], # 0 - 10 float - c: commands.Range[1, ...], # positive int + a: commands.Range[int, 0, 10], # 0 - 10 int + b: commands.Range[float, 0, 10.0], # 0 - 10 float + c: commands.Range[int, 1, ...], # positive int ): ... -.. _type_checker_mypy_plugin: - -.. note:: - - Type checker support for :class:`~ext.commands.Range` and :class:`~ext.commands.String` (:ref:`see below `) is limited. - Pylance/Pyright seem to handle it correctly; MyPy currently needs a plugin for it to understand :class:`~ext.commands.Range` - and :class:`~ext.commands.String` semantics, which can be added in the configuration file (``setup.cfg``, ``mypy.ini``): - - .. code-block:: ini - - [mypy] - plugins = disnake.ext.mypy_plugin - - For ``pyproject.toml`` configs, use this instead: - - .. code-block:: toml - - [tool.mypy] - plugins = "disnake.ext.mypy_plugin" - .. _string_lengths: String Lengths ++++++++++++++ -:class:`str` parameters support minimum and maximum allowed value lengths -using the ``min_length`` and ``max_length`` parameters on :func:`Param `. +:class:`str` parameters support minimum and maximum allowed lengths +using the ``min_length`` and ``max_length`` parameters on :func:`~ext.commands.Param`. For instance, you could restrict an option to only accept a single character: .. code-block:: python3 @@ -253,17 +232,18 @@ Or restrict a tag command to limit tag names to 20 characters: ): ... -Instead of using :func:`Param `, you can also use a :class:`~ext.commands.String` annotation. +Instead of using :func:`~ext.commands.Param`, you can also use a :class:`~ext.commands.String` annotation. The length bounds are both inclusive; using ``...`` as a bound indicates that this end of the string length is unbounded. +The first type argument should always be :class:`str`. .. code-block:: python3 @bot.slash_command() async def strings( inter: disnake.ApplicationCommandInteraction, - a: commands.String[0, 10], # a str no longer than 10 characters. - b: commands.String[10, 100], # a str that's at least 10 characters but not longer than 100. - c: commands.String[50, ...] # a str that's at least 50 characters. + a: commands.String[str, 0, 10], # a str no longer than 10 characters. + b: commands.String[str, 10, 100], # a str that's at least 10 characters but not longer than 100. + c: commands.String[str, 50, ...], # a str that's at least 50 characters. ): ... @@ -271,10 +251,6 @@ The length bounds are both inclusive; using ``...`` as a bound indicates that th There is a max length of 6000 characters, which is enforced by Discord. -.. note:: - - For mypy type checking support, please see the above note about the :ref:`mypy plugin `. - .. _docstrings: Docstrings diff --git a/examples/interactions/param.py b/examples/interactions/param.py index f4ff3891ef..bc12030fcf 100644 --- a/examples/interactions/param.py +++ b/examples/interactions/param.py @@ -71,19 +71,15 @@ async def defaults( # You may limit numbers to a certain range using `commands.Range`. -# "..." is impicitly infinity. Range[0, ...] therefore means any integer from 0 to infinity, -# and Range[..., 0] means anything from -inf to 0. - - -# The 1.0 in the `fraction` parameter below is important - the usage of a float specifies -# that the argument may be a float in that range, not just an integer. -# All of these bounds are inclusive, meaning `Range[1, 4]` would allow any of 1, 2, 3, or 4. +# "..." is impicitly infinity. Range[int, 0, ...] therefore means any integer from 0 to infinity, +# and Range[int, ..., 0] means anything from -inf to 0. +# All of these bounds are inclusive, meaning `Range[int, 1, 4]` would allow any of 1, 2, 3, or 4. @bot.slash_command() async def ranges( inter: disnake.CommandInteraction, - ranking: commands.Range[1, 10], - negative: commands.Range[..., 0], - fraction: commands.Range[0, 1.0], + ranking: commands.Range[int, 1, 10], + negative: commands.Range[int, ..., 0], + fraction: commands.Range[float, 0, 1.0], ): """Command with limited ranges diff --git a/test_bot/cogs/slash_commands.py b/test_bot/cogs/slash_commands.py index 4700339384..a2c7847f15 100644 --- a/test_bot/cogs/slash_commands.py +++ b/test_bot/cogs/slash_commands.py @@ -51,9 +51,9 @@ async def ranges( self, inter: disnake.CommandInteraction, a: int = commands.Param(None, lt=0), - b: Optional[commands.Range[1, ...]] = None, - c: Optional[commands.Range[0, 10]] = None, - d: Optional[commands.Range[0, 10.0]] = None, + b: Optional[commands.Range[int, 1, ...]] = None, + c: Optional[commands.Range[int, 0, 10]] = None, + d: Optional[commands.Range[float, 0, 10.0]] = None, ) -> None: """Limit slash command options to a range of values diff --git a/tests/ext/commands/test_params.py b/tests/ext/commands/test_params.py index 917f0d707f..a3b4ea4289 100644 --- a/tests/ext/commands/test_params.py +++ b/tests/ext/commands/test_params.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: MIT -from typing import Union +import math +import sys +from typing import Any, Optional, Union from unittest import mock import pytest @@ -63,3 +65,151 @@ async def test_verify_type__invalid_member(self, annotation, arg_types) -> None: arg_mock = mock.Mock(arg_type) with pytest.raises(commands.errors.MemberNotFound): await info.verify_type(mock.Mock(), arg_mock) + + +# this uses `Range` for testing `_BaseRange`, `String` should work equally +class TestBaseRange: + @pytest.mark.parametrize("args", [int, (int,), (int, 1, 2, 3)]) + def test_param_count(self, args) -> None: + with pytest.raises(TypeError, match=r"`Range` expects 3 type arguments"): + commands.Range[args] # type: ignore + + @pytest.mark.parametrize("value", ["int", 42, Optional[int], Union[int, float]]) + def test_invalid_type(self, value) -> None: + with pytest.raises(TypeError, match=r"First `Range` argument must be a type"): + commands.Range[value, 1, 10] + + @pytest.mark.parametrize("value", ["42", int]) + def test_invalid_bound(self, value) -> None: + with pytest.raises(TypeError, match=r"min value must be int, float"): + commands.Range[int, value, 1] + + with pytest.raises(TypeError, match=r"max value must be int, float"): + commands.Range[int, 1, value] + + def test_invalid_min_max(self) -> None: + with pytest.raises(ValueError, match=r"`Range` bounds cannot both be empty"): + commands.Range[int, None, ...] + + with pytest.raises(ValueError, match=r"`Range` minimum \(\d+\) must be less"): + commands.Range[int, 100, 99] + + @pytest.mark.parametrize("empty", [None, ...]) + def test_ellipsis(self, empty) -> None: + x: Any = commands.Range[int, 1, empty] + assert x.min_value == 1 + assert x.max_value is None + assert repr(x) == "Range[int, 1, ...]" + + x: Any = commands.Range[float, empty, -10] + assert x.min_value is None + assert x.max_value == -10 + assert repr(x) == "Range[float, ..., -10]" + + @pytest.mark.parametrize( + ("create", "expected"), + [ + (lambda: commands.Range[1, 2], (int, 1, 2)), # type: ignore + (lambda: commands.Range[0, 10.0], (float, 0, 10.0)), # type: ignore + (lambda: commands.Range[..., 10.0], (float, None, 10.0)), + (lambda: commands.String[5, 10], (str, 5, 10)), # type: ignore + ], + ) + def test_backwards_compatible(self, create: Any, expected) -> None: + with pytest.warns(DeprecationWarning, match=r"without an explicit type argument"): + value = create() + assert (value.underlying_type, value.min_value, value.max_value) == expected + + +class TestRange: + def test_disallowed_type(self) -> None: + with pytest.raises(TypeError, match=r"First `Range` argument must be int/float, not"): + commands.Range[str, 1, 10] + + def test_int_float_bounds(self) -> None: + with pytest.raises(TypeError, match=r"Range.* bounds must be int, not float"): + commands.Range[int, 1.0, 10] + + with pytest.raises(TypeError, match=r"Range.* bounds must be int, not float"): + commands.Range[int, 1, 10.0] + + @pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) + def test_nan(self, value) -> None: + with pytest.raises(ValueError, match=r"min value may not be NaN, inf, or -inf"): + commands.Range[float, value, 100] + + def test_valid(self) -> None: + x: Any = commands.Range[int, -1, 2] + assert x.underlying_type == int + + x: Any = commands.Range[float, ..., 23.45] + assert x.underlying_type == float + + +class TestString: + def test_disallowed_type(self) -> None: + with pytest.raises(TypeError, match=r"First `String` argument must be str, not"): + commands.String[int, 1, 10] + + def test_float_bound(self) -> None: + with pytest.raises(TypeError, match=r"String bounds must be int, not float"): + commands.String[str, 1.0, ...] + + def test_negative_bound(self) -> None: + with pytest.raises(ValueError, match=r"String bounds may not be negative"): + commands.String[str, -5, 10] + + def test_valid(self) -> None: + commands.String[str, 10, 10] + commands.String[str, 100, 1234] + commands.String[str, 100, ...] + + +class TestRangeStringParam: + @pytest.mark.parametrize( + "annotation", [commands.Range[int, 1, 2], commands.Range[float, ..., 12.3]] + ) + def test_range(self, annotation) -> None: + info = commands.ParamInfo() + info.parse_annotation(annotation) + + assert info.min_value == annotation.min_value + assert info.max_value == annotation.max_value + assert info.type == annotation.underlying_type + + def test_string(self) -> None: + annotation: Any = commands.String[str, 4, 10] + + info = commands.ParamInfo() + info.parse_annotation(annotation) + + assert info.min_length == annotation.min_value + assert info.max_length == annotation.max_value + assert info.min_value is None + assert info.max_value is None + assert info.type == annotation.underlying_type + + # uses lambdas since new union syntax isn't supported on all versions + @pytest.mark.parametrize( + "annotation_str", + [ + "Optional[commands.Range[int, 1, 2]]", + # 3.10 union syntax + pytest.param( + "commands.Range[int, 1, 2] | None", + marks=pytest.mark.skipif( + sys.version_info < (3, 10), reason="syntax requires py3.10" + ), + ), + ], + ) + def test_optional(self, annotation_str) -> None: + annotation = disnake.utils.resolve_annotation(annotation_str, globals(), None, None) + assert type(None) in annotation.__args__ + + info = commands.ParamInfo() + info.parse_annotation(annotation) + + assert info.min_value == 1 + assert info.max_value == 2 + assert info.type == int