Skip to content

Commit afee478

Browse files
authored
refactor(typing): replace **kwargs: Any with Unpack[T] in (app) command decorators (#1539)
1 parent 49ddf39 commit afee478

7 files changed

Lines changed: 163 additions & 138 deletions

File tree

changelog/1539.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
|commands| Improve typing of :func:`@command <ext.commands.command>`, :func:`@slash_command <ext.commands.command>`, and similar command decorators, making type-checkers warn about unknown/misspelled parameters.

disnake/ext/commands/base_core.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
TYPE_CHECKING,
1313
Any,
1414
TypeAlias,
15+
TypedDict,
1516
TypeVar,
1617
cast,
1718
overload,
@@ -30,7 +31,7 @@
3031
if TYPE_CHECKING:
3132
from typing import Concatenate
3233

33-
from typing_extensions import ParamSpec, Self
34+
from typing_extensions import ParamSpec, Self, Unpack
3435

3536
from disnake.interactions import ApplicationCommandInteraction
3637

@@ -50,6 +51,13 @@
5051
| Callable[Concatenate[ApplicationCommandInteractionT, P], Coro[Any]]
5152
)
5253

54+
class _AppCommandArgs(TypedDict, total=False):
55+
guild_only: bool
56+
extras: dict[str, Any] | None
57+
checks: list[AppCheck]
58+
cooldown: CooldownMapping | None
59+
max_concurrency: MaxConcurrency | None
60+
5361

5462
__all__ = (
5563
"InvokableApplicationCommand",
@@ -137,7 +145,9 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self:
137145
self.__original_kwargs__ = {k: v for k, v in kwargs.items() if v is not None}
138146
return self
139147

140-
def __init__(self, func: CommandCallback, *, name: str | None = None, **kwargs: Any) -> None:
148+
def __init__(
149+
self, func: CommandCallback, *, name: str | None = None, **kwargs: Unpack[_AppCommandArgs]
150+
) -> None:
141151
self.__command_flag__ = None
142152
self._callback: CommandCallback = func
143153
self.name: str = name or func.__name__

disnake/ext/commands/core.py

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Literal,
1515
Protocol,
1616
TypeAlias,
17+
TypedDict,
1718
TypeVar,
1819
Union,
1920
cast,
@@ -59,12 +60,34 @@
5960
if TYPE_CHECKING:
6061
from typing import Concatenate
6162

62-
from typing_extensions import ParamSpec, Self
63+
from typing_extensions import ParamSpec, Self, Unpack
6364

6465
from disnake.message import Message
6566

6667
from ._types import AppCheck, Check, Coro, CoroFunc, Error, Hook
6768

69+
class _CommandArgs(TypedDict, total=False):
70+
enabled: bool
71+
help: str | None
72+
brief: str | None
73+
usage: str | None
74+
rest_is_raw: bool
75+
aliases: list[str] | tuple[str, ...]
76+
extras: dict[str, Any]
77+
description: str
78+
hidden: bool
79+
checks: list[Check]
80+
cooldown: CooldownMapping | None
81+
max_concurrency: MaxConcurrency | None
82+
require_var_positional: bool
83+
ignore_extra: bool
84+
cooldown_after_parsing: bool
85+
parent: GroupMixin[Any] | None
86+
87+
class _GroupArgs(_CommandArgs, total=False):
88+
invoke_without_command: bool
89+
case_insensitive: bool
90+
6891

6992
__all__ = (
7093
"Command",
@@ -201,7 +224,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]):
201224
The short help text for the command.
202225
usage: :class:`str` | :data:`None`
203226
A replacement for arguments in the default help text.
204-
aliases: :class:`list`\[:class:`str`] | :class:`tuple`\[:class:`str`]
227+
aliases: :class:`list`\[:class:`str`] | :class:`tuple`\[:class:`str`, ...]
205228
The list of aliases the command can be invoked under.
206229
enabled: :class:`bool`
207230
Whether the command is currently enabled.
@@ -277,13 +300,15 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self:
277300
def __init__(
278301
self,
279302
func: CommandCallback[CogT, ContextT, P, T],
280-
**kwargs: Any,
303+
*,
304+
name: str | None = None,
305+
**kwargs: Unpack[_CommandArgs],
281306
) -> None:
282307
if not inspect.iscoroutinefunction(func):
283308
msg = "Callback must be a coroutine function."
284309
raise TypeError(msg)
285310

286-
name = kwargs.get("name") or func.__name__
311+
name = name or func.__name__
287312
if not isinstance(name, str):
288313
msg = "Name of a command must be a string."
289314
raise TypeError(msg)
@@ -305,7 +330,7 @@ def __init__(
305330
self.brief: str | None = kwargs.get("brief")
306331
self.usage: str | None = kwargs.get("usage")
307332
self.rest_is_raw: bool = kwargs.get("rest_is_raw", False)
308-
self.aliases: list[str] | tuple[str] = kwargs.get("aliases", [])
333+
self.aliases: list[str] | tuple[str, ...] = kwargs.get("aliases", [])
309334
self.extras: dict[str, Any] = kwargs.get("extras", {})
310335

311336
if not isinstance(self.aliases, (list, tuple)):
@@ -351,7 +376,7 @@ def __init__(
351376

352377
# bandaid for the fact that sometimes parent can be the bot instance
353378
parent = kwargs.get("parent")
354-
self.parent: GroupMixin | None = parent if isinstance(parent, _BaseCommand) else None # pyright: ignore[reportAttributeAccessIssue]
379+
self.parent: GroupMixin | None = parent if isinstance(parent, _BaseCommand) else None
355380

356381
self._before_invoke: Hook | None = None
357382
try:
@@ -433,6 +458,8 @@ def remove_check(self, func: Check) -> None:
433458
except ValueError:
434459
pass
435460

461+
# n.b. this continues to use `**kwargs: Any` instead of _CommandArgs,
462+
# since custom command subclasses may accept additional parameters
436463
def update(self, **kwargs: Any) -> None:
437464
"""Updates :class:`Command` instance with updated attribute.
438465
@@ -1283,18 +1310,16 @@ def get_command(self, name: str) -> Command[CogT, Any, Any] | None:
12831310
@overload
12841311
def command(
12851312
self,
1286-
name: str,
1287-
cls: type[CommandT],
1288-
*args: Any,
1289-
**kwargs: Any,
1290-
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], CommandT]: ...
1313+
name: str = ...,
1314+
**attrs: Unpack[_CommandArgs],
1315+
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], Command[CogT, P, T]]: ...
12911316

12921317
@overload
12931318
def command(
12941319
self,
1295-
name: str = ...,
1296-
*args: Any,
1320+
name: str,
12971321
cls: type[CommandT],
1322+
*args: Any,
12981323
**kwargs: Any,
12991324
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], CommandT]: ...
13001325

@@ -1303,8 +1328,9 @@ def command(
13031328
self,
13041329
name: str = ...,
13051330
*args: Any,
1331+
cls: type[CommandT],
13061332
**kwargs: Any,
1307-
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], Command[CogT, P, T]]: ...
1333+
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], CommandT]: ...
13081334

13091335
def command(
13101336
self,
@@ -1333,18 +1359,16 @@ def decorator(func: CommandCallback[CogT, ContextT, P, T]) -> Command[Any, Any,
13331359
@overload
13341360
def group(
13351361
self,
1336-
name: str,
1337-
cls: type[GroupT],
1338-
*args: Any,
1339-
**kwargs: Any,
1340-
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
1362+
name: str = ...,
1363+
**attrs: Unpack[_GroupArgs],
1364+
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], Group[CogT, P, T]]: ...
13411365

13421366
@overload
13431367
def group(
13441368
self,
1345-
name: str = ...,
1346-
*args: Any,
1369+
name: str,
13471370
cls: type[GroupT],
1371+
*args: Any,
13481372
**kwargs: Any,
13491373
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
13501374

@@ -1353,8 +1377,9 @@ def group(
13531377
self,
13541378
name: str = ...,
13551379
*args: Any,
1380+
cls: type[GroupT],
13561381
**kwargs: Any,
1357-
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], Group[CogT, P, T]]: ...
1382+
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
13581383

13591384
def group(
13601385
self,
@@ -1404,9 +1429,9 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]):
14041429
Defaults to ``False``.
14051430
"""
14061431

1407-
def __init__(self, *args: Any, **attrs: Any) -> None:
1432+
def __init__(self, *args: Any, name: str | None = None, **attrs: Unpack[_GroupArgs]) -> None:
14081433
self.invoke_without_command: bool = attrs.pop("invoke_without_command", False)
1409-
super().__init__(*args, **attrs)
1434+
super().__init__(*args, name=name, **attrs)
14101435

14111436
def copy(self: GroupT) -> GroupT:
14121437
"""Creates a copy of this :class:`Group`.
@@ -1522,15 +1547,17 @@ def __call__(
15221547
) -> Group[CogT, P, T]: ...
15231548

15241549

1525-
# Small explanation regarding these overloads:
1526-
# The overloads with the `cls` parameter need to be first,
1527-
# as the other overload would otherwise match first even if `cls` is given.
1528-
# To prevent the overloads with `cls` from matching everything, the parameter
1529-
# cannot have a default value, which in turn means it has to be split into two
1530-
# overloads, one with a positional `cls` parameter and one with a kwarg parameter,
1531-
# as `name` should still be optional.
1550+
@overload
1551+
def command(
1552+
name: str = ...,
1553+
**attrs: Unpack[_CommandArgs],
1554+
) -> CommandDecorator: ...
15321555

15331556

1557+
# Typing **attrs correctly here is not possible with current ParamSpec/Concatenate features,
1558+
# as it does not support adding kw-only arguments.
1559+
# This overload is split into two, since `cls` cannot have a default without implicitly
1560+
# becoming a fallback when the previous overload didn't match (due to typo'd parameters, etc.)
15341561
@overload
15351562
def command(
15361563
name: str,
@@ -1548,19 +1575,12 @@ def command(
15481575
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], CommandT]: ...
15491576

15501577

1551-
@overload
1552-
def command(
1553-
name: str = ...,
1554-
**attrs: Any,
1555-
) -> CommandDecorator: ...
1556-
1557-
15581578
def command(
15591579
name: str = MISSING,
15601580
cls: type[Command[Any, Any, Any]] = MISSING,
15611581
**attrs: Any,
15621582
) -> Any:
1563-
"""A decorator that transforms a function into a :class:`.Command`
1583+
r"""A decorator that transforms a function into a :class:`.Command`
15641584
or if called with :func:`.group`, :class:`.Group`.
15651585
15661586
By default the ``help`` attribute is received automatically from the
@@ -1580,7 +1600,7 @@ def command(
15801600
cls
15811601
The class to construct with. By default this is :class:`.Command`.
15821602
You usually do not change this.
1583-
attrs
1603+
\*\*attrs
15841604
Keyword arguments to pass into the construction of the class denoted
15851605
by ``cls``.
15861606
@@ -1603,16 +1623,14 @@ def decorator(func: CommandCallback[CogT, ContextT, P, T]) -> Command[Any, Any,
16031623

16041624
@overload
16051625
def group(
1606-
name: str,
1607-
cls: type[GroupT],
1608-
**attrs: Any,
1609-
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
1626+
name: str = ...,
1627+
**attrs: Unpack[_GroupArgs],
1628+
) -> GroupDecorator: ...
16101629

16111630

16121631
@overload
16131632
def group(
1614-
name: str = ...,
1615-
*,
1633+
name: str,
16161634
cls: type[GroupT],
16171635
**attrs: Any,
16181636
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
@@ -1621,8 +1639,10 @@ def group(
16211639
@overload
16221640
def group(
16231641
name: str = ...,
1642+
*,
1643+
cls: type[GroupT],
16241644
**attrs: Any,
1625-
) -> GroupDecorator: ...
1645+
) -> Callable[[CommandCallback[CogT, ContextT, P, T]], GroupT]: ...
16261646

16271647

16281648
def group(

0 commit comments

Comments
 (0)