Skip to content

Commit 051a963

Browse files
refactor(ext.commands): completely rewrite Range and String, require type argument (#991)
Co-authored-by: arl <me@arielle.codes>
1 parent dcc1b83 commit 051a963

File tree

9 files changed

+313
-213
lines changed

9 files changed

+313
-213
lines changed

changelog/991.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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]``).

changelog/991.deprecate.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

changelog/991.deprecate.1.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
|commands| The mypy plugin is now a no-op. It was previously used for supporting ``Range[]`` and ``String[]`` annotations.

disnake/ext/commands/params.py

Lines changed: 135 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import itertools
1111
import math
1212
import sys
13+
from abc import ABC, abstractmethod
14+
from dataclasses import dataclass
1315
from enum import Enum, EnumMeta
1416
from typing import (
1517
TYPE_CHECKING,
@@ -22,6 +24,7 @@
2224
Generic,
2325
List,
2426
Literal,
27+
NoReturn,
2528
Optional,
2629
Sequence,
2730
Tuple,
@@ -31,7 +34,6 @@
3134
get_args,
3235
get_origin,
3336
get_type_hints,
34-
overload,
3537
)
3638

3739
import disnake
@@ -279,135 +281,163 @@ def decorator(func: CallableT) -> CallableT:
279281
return decorator
280282

281283

282-
class RangeMeta(type):
283-
"""Custom Generic implementation for Range"""
284+
@dataclass(frozen=True)
285+
class _BaseRange(ABC):
286+
"""Internal base type for supporting ``Range[...]`` and ``String[...]``."""
284287

285-
@overload
286-
def __getitem__(
287-
self, args: Tuple[Union[int, EllipsisType], Union[int, EllipsisType]]
288-
) -> Type[int]:
289-
...
288+
_allowed_types: ClassVar[Tuple[Type[Any], ...]]
290289

291-
@overload
292-
def __getitem__(
293-
self, args: Tuple[Union[float, EllipsisType], Union[float, EllipsisType]]
294-
) -> Type[float]:
295-
...
290+
underlying_type: Type[Any]
291+
min_value: Optional[Union[int, float]]
292+
max_value: Optional[Union[int, float]]
296293

297-
def __getitem__(self, args: Tuple[Any, ...]) -> Any:
298-
a, b = [None if isinstance(x, type(Ellipsis)) else x for x in args]
299-
return Range.create(min_value=a, max_value=b)
294+
def __class_getitem__(cls, params: Tuple[Any, ...]) -> Self:
295+
# deconstruct type arguments
296+
if not isinstance(params, tuple):
297+
params = (params,)
300298

299+
name = cls.__name__
301300

302-
class Range(type, metaclass=RangeMeta):
303-
"""Type depicting a limited range of allowed values.
301+
if len(params) == 2:
302+
# backwards compatibility for `Range[1, 2]`
304303

305-
See :ref:`param_ranges` for more information.
304+
# FIXME: the warning context is incorrect when used with stringified annotations,
305+
# and points to the eval frame instead of user code
306+
disnake.utils.warn_deprecated(
307+
f"Using `{name}` without an explicit type argument is deprecated, "
308+
"as this form does not work well with modern type-checkers. "
309+
f"Use `{name}[<type>, <min>, <max>]` instead.",
310+
stacklevel=2,
311+
)
306312

307-
.. versionadded:: 2.4
313+
# infer type from min/max values
314+
params = (cls._infer_type(params),) + params
308315

309-
"""
316+
if len(params) != 3:
317+
raise TypeError(
318+
f"`{name}` expects 3 type arguments ({name}[<type>, <min>, <max>]), got {len(params)}"
319+
)
310320

311-
min_value: Optional[float]
312-
max_value: Optional[float]
321+
underlying_type, min_value, max_value = params
313322

314-
@overload
315-
@classmethod
316-
def create(
317-
cls,
318-
min_value: Optional[int] = None,
319-
max_value: Optional[int] = None,
320-
*,
321-
le: Optional[int] = None,
322-
lt: Optional[int] = None,
323-
ge: Optional[int] = None,
324-
gt: Optional[int] = None,
325-
) -> Type[int]:
326-
...
327-
328-
@overload
329-
@classmethod
330-
def create(
331-
cls,
332-
min_value: Optional[float] = None,
333-
max_value: Optional[float] = None,
334-
*,
335-
le: Optional[float] = None,
336-
lt: Optional[float] = None,
337-
ge: Optional[float] = None,
338-
gt: Optional[float] = None,
339-
) -> Type[float]:
340-
...
323+
# validate type (argument 1)
324+
if not isinstance(underlying_type, type):
325+
raise TypeError(f"First `{name}` argument must be a type, not `{underlying_type!r}`")
341326

342-
@classmethod
343-
def create(
344-
cls,
345-
min_value: Optional[float] = None,
346-
max_value: Optional[float] = None,
347-
*,
348-
le: Optional[float] = None,
349-
lt: Optional[float] = None,
350-
ge: Optional[float] = None,
351-
gt: Optional[float] = None,
352-
) -> Any:
353-
"""Construct a new range with any possible constraints"""
354-
self = cls(cls.__name__, (), {})
355-
self.min_value = min_value if min_value is not None else _xt_to_xe(le, lt, -1)
356-
self.max_value = max_value if max_value is not None else _xt_to_xe(ge, gt, 1)
357-
return self
327+
if not issubclass(underlying_type, cls._allowed_types):
328+
allowed = "/".join(t.__name__ for t in cls._allowed_types)
329+
raise TypeError(f"First `{name}` argument must be {allowed}, not `{underlying_type!r}`")
358330

359-
@property
360-
def underlying_type(self) -> Union[Type[int], Type[float]]:
361-
if isinstance(self.min_value, float) or isinstance(self.max_value, float):
362-
return float
331+
# validate min/max (arguments 2/3)
332+
min_value = cls._coerce_bound(min_value, "min")
333+
max_value = cls._coerce_bound(max_value, "max")
334+
335+
if min_value is None and max_value is None:
336+
raise ValueError(f"`{name}` bounds cannot both be empty")
363337

364-
return int
338+
# n.b. this allows bounds to be equal, which doesn't really serve a purpose with numbers,
339+
# but is still accepted by the api
340+
if min_value is not None and max_value is not None and min_value > max_value:
341+
raise ValueError(
342+
f"`{name}` minimum ({min_value}) must be less than or equal to maximum ({max_value})"
343+
)
344+
345+
return cls(underlying_type=underlying_type, min_value=min_value, max_value=max_value)
346+
347+
@staticmethod
348+
def _coerce_bound(value: Any, name: str) -> Optional[Union[int, float]]:
349+
if value is None or isinstance(value, EllipsisType):
350+
return None
351+
elif isinstance(value, (int, float)):
352+
if not math.isfinite(value):
353+
raise ValueError(f"{name} value may not be NaN, inf, or -inf")
354+
return value
355+
else:
356+
raise TypeError(f"{name} value must be int, float, None, or `...`, not `{type(value)}`")
365357

366358
def __repr__(self) -> str:
367359
a = "..." if self.min_value is None else self.min_value
368360
b = "..." if self.max_value is None else self.max_value
369-
return f"{type(self).__name__}[{a}, {b}]"
361+
return f"{type(self).__name__}[{self.underlying_type.__name__}, {a}, {b}]"
370362

363+
@classmethod
364+
@abstractmethod
365+
def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]:
366+
raise NotImplementedError
371367

372-
class StringMeta(type):
373-
"""Custom Generic implementation for String."""
368+
# hack to get `typing._type_check` to pass, e.g. when using `Range` as a generic parameter
369+
def __call__(self) -> NoReturn:
370+
raise NotImplementedError
374371

375-
def __getitem__(
376-
self, args: Tuple[Union[int, EllipsisType], Union[int, EllipsisType]]
377-
) -> Type[str]:
378-
a, b = [None if isinstance(x, EllipsisType) else x for x in args]
379-
return String.create(min_length=a, max_length=b)
372+
# support new union syntax for `Range[int, 1, 2] | None`
373+
if sys.version_info >= (3, 10):
380374

375+
def __or__(self, other):
376+
return Union[self, other] # type: ignore
381377

382-
class String(type, metaclass=StringMeta):
383-
"""Type depicting a string option with limited length.
384378

385-
See :ref:`string_lengths` for more information.
379+
if TYPE_CHECKING:
380+
# aliased import since mypy doesn't understand `Range = Annotated`
381+
from typing_extensions import Annotated as Range, Annotated as String
382+
else:
386383

387-
.. versionadded:: 2.6
384+
@dataclass(frozen=True, repr=False)
385+
class Range(_BaseRange):
386+
"""Type representing a number with a limited range of allowed values.
388387
389-
"""
388+
See :ref:`param_ranges` for more information.
390389
391-
min_length: Optional[int]
392-
max_length: Optional[int]
393-
underlying_type: Final[Type[str]] = str
390+
.. versionadded:: 2.4
394391
395-
@classmethod
396-
def create(
397-
cls,
398-
min_length: Optional[int] = None,
399-
max_length: Optional[int] = None,
400-
) -> Any:
401-
"""Construct a new String with constraints."""
402-
self = cls(cls.__name__, (), {})
403-
self.min_length = min_length
404-
self.max_length = max_length
405-
return self
392+
.. versionchanged:: 2.9
393+
Syntax changed from ``Range[5, 10]`` to ``Range[int, 5, 10]``;
394+
the type (:class:`int` or :class:`float`) must now be specified explicitly.
395+
"""
406396

407-
def __repr__(self) -> str:
408-
a = "..." if self.min_length is None else self.min_length
409-
b = "..." if self.max_length is None else self.max_length
410-
return f"{type(self).__name__}[{a}, {b}]"
397+
_allowed_types = (int, float)
398+
399+
def __post_init__(self):
400+
for value in (self.min_value, self.max_value):
401+
if value is None:
402+
continue
403+
404+
if self.underlying_type is int and not isinstance(value, int):
405+
raise TypeError("Range[int, ...] bounds must be int, not float")
406+
407+
@classmethod
408+
def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]:
409+
if any(isinstance(p, float) for p in params):
410+
return float
411+
return int
412+
413+
@dataclass(frozen=True, repr=False)
414+
class String(_BaseRange):
415+
"""Type representing a string option with a limited length.
416+
417+
See :ref:`string_lengths` for more information.
418+
419+
.. versionadded:: 2.6
420+
421+
.. versionchanged:: 2.9
422+
Syntax changed from ``String[5, 10]`` to ``String[str, 5, 10]``;
423+
the type (:class:`str`) must now be specified explicitly.
424+
"""
425+
426+
_allowed_types = (str,)
427+
428+
def __post_init__(self):
429+
for value in (self.min_value, self.max_value):
430+
if value is None:
431+
continue
432+
433+
if not isinstance(value, int):
434+
raise TypeError("String bounds must be int, not float")
435+
if value < 0:
436+
raise ValueError("String bounds may not be negative")
437+
438+
@classmethod
439+
def _infer_type(cls, params: Tuple[Any, ...]) -> Type[Any]:
440+
return str
411441

412442

413443
class LargeInt(int):
@@ -701,14 +731,14 @@ def parse_annotation(self, annotation: Any, converter_mode: bool = False) -> boo
701731
if annotation is inspect.Parameter.empty or annotation is Any:
702732
return False
703733

704-
# resolve type aliases
734+
# resolve type aliases and special types
705735
if isinstance(annotation, Range):
706736
self.min_value = annotation.min_value
707737
self.max_value = annotation.max_value
708738
annotation = annotation.underlying_type
709739
if isinstance(annotation, String):
710-
self.min_length = annotation.min_length
711-
self.max_length = annotation.max_length
740+
self.min_length = annotation.min_value
741+
self.max_length = annotation.max_value
712742
annotation = annotation.underlying_type
713743
if issubclass_(annotation, LargeInt):
714744
self.large = True

disnake/ext/mypy_plugin/__init__.py

Lines changed: 3 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,67 +2,12 @@
22

33
import typing as t
44

5-
from mypy.plugin import AnalyzeTypeContext, Plugin
6-
from mypy.types import AnyType, EllipsisType, RawExpressionType, Type, TypeOfAny
5+
from mypy.plugin import Plugin
76

87

8+
# FIXME: properly deprecate this in the future
99
class DisnakePlugin(Plugin):
10-
def get_type_analyze_hook(
11-
self, fullname: str
12-
) -> t.Optional[t.Callable[[AnalyzeTypeContext], Type]]:
13-
if fullname == "disnake.ext.commands.params.Range":
14-
return range_type_analyze_callback
15-
if fullname == "disnake.ext.commands.params.String":
16-
return string_type_analyze_callback
17-
return None
18-
19-
20-
def range_type_analyze_callback(ctx: AnalyzeTypeContext) -> Type:
21-
args = ctx.type.args
22-
23-
if len(args) != 2:
24-
ctx.api.fail(f'"Range" expected 2 parameters, got {len(args)}', ctx.context)
25-
return AnyType(TypeOfAny.from_error)
26-
27-
for arg in args:
28-
if isinstance(arg, EllipsisType):
29-
continue
30-
if not isinstance(arg, RawExpressionType):
31-
ctx.api.fail('invalid usage of "Range"', ctx.context)
32-
return AnyType(TypeOfAny.from_error)
33-
34-
name = arg.simple_name()
35-
# if one is a float, `Range.underlying_type` returns `float`
36-
if name == "float":
37-
return ctx.api.named_type("builtins.float", [])
38-
# otherwise it should be an int; fail if it isn't
39-
elif name != "int":
40-
ctx.api.fail(f'"Range" parameters must be int or float, not {name}', ctx.context)
41-
return AnyType(TypeOfAny.from_error)
42-
43-
return ctx.api.named_type("builtins.int", [])
44-
45-
46-
def string_type_analyze_callback(ctx: AnalyzeTypeContext) -> Type:
47-
args = ctx.type.args
48-
49-
if len(args) != 2:
50-
ctx.api.fail(f'"String" expected 2 parameters, got {len(args)}', ctx.context)
51-
return AnyType(TypeOfAny.from_error)
52-
53-
for arg in args:
54-
if isinstance(arg, EllipsisType):
55-
continue
56-
if not isinstance(arg, RawExpressionType):
57-
ctx.api.fail('invalid usage of "String"', ctx.context)
58-
return AnyType(TypeOfAny.from_error)
59-
60-
name = arg.simple_name()
61-
if name != "int":
62-
ctx.api.fail(f'"String" parameters must be int, not {name}', ctx.context)
63-
return AnyType(TypeOfAny.from_error)
64-
65-
return ctx.api.named_type("builtins.str", [])
10+
"""Custom mypy plugin; no-op as of version 2.9."""
6611

6712

6813
def plugin(version: str) -> t.Type[Plugin]:

0 commit comments

Comments
 (0)