diff --git a/changelog/1431.feature.rst b/changelog/1431.feature.rst new file mode 100644 index 0000000000..3a2b130ef5 --- /dev/null +++ b/changelog/1431.feature.rst @@ -0,0 +1 @@ +|commands| Support :data:`typing.Annotated` for specifying converters in prefix commands in a more type-safe way. See :ref:`Special Converters ` for details. diff --git a/disnake/ext/commands/params.py b/disnake/ext/commands/params.py index 014b3bb010..e2cdd2de8d 100644 --- a/disnake/ext/commands/params.py +++ b/disnake/ext/commands/params.py @@ -369,7 +369,7 @@ def __or__(self, other): if TYPE_CHECKING: # aliased import since mypy doesn't understand `Range = Annotated` - from typing_extensions import Annotated as Range, Annotated as String + from typing import Annotated as Range, Annotated as String else: @dataclass(frozen=True, repr=False) diff --git a/disnake/utils.py b/disnake/utils.py index c2d15ce209..0558d4944a 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -21,6 +21,7 @@ from operator import attrgetter from typing import ( TYPE_CHECKING, + Annotated, Any, AsyncIterator, Awaitable, @@ -1212,9 +1213,14 @@ def evaluate_annotation( cache[tp] = evaluated return evaluated + # Annotated[X, Y], where Y is the converter we need + if get_origin(tp) is Annotated: + return evaluate_annotation(tp.__metadata__[0], globals, locals, cache) + # GenericAlias / UnionType if hasattr(tp, "__args__"): if not hasattr(tp, "__origin__"): + # n.b. this became obsolete in Python 3.14+, as `UnionType` and `Union` are the same thing now. if tp.__class__ is UnionType: converted = Union[tp.__args__] return evaluate_annotation(converted, globals, locals, cache) diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index b78934d04e..dfc88ce928 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -356,6 +356,8 @@ This can get tedious, so an inline advanced converter is possible through a :fun else: await ctx.send("Hm you're not so new.") +.. _ext_commands_discord_converters: + Discord Converters ++++++++++++++++++ @@ -552,6 +554,34 @@ The ``buy_sell`` parameter must be either the literal string ``"buy"`` or ``"sel Note that ``typing.Literal[True]`` and ``typing.Literal[False]`` still follow the :class:`bool` converter rules. +.. _ext_commands_converters_annotated: + +typing.Annotated +^^^^^^^^^^^^^^^^ + +.. versionadded:: |vnext| + +With :data:`typing.Annotated`, you can use converters in a more type-safe way. +Taking the example from :ref:`ext_commands_basic_converters` above, ``content`` is annotated +as ``to_upper`` (i.e. a converter function), while it would naturally be a :class:`str` at runtime; +this will likely trip up type-checkers such as pyright/mypy. + +To avoid this, you can use :data:`typing.Annotated`, such that type-checkers consider the parameter +a :class:`str` while disnake will use the converter passed as the second argument to :data:`~typing.Annotated` at runtime: + +.. code-block:: python3 + + from typing import Annotated + + def to_upper(argument: str): + return argument.upper() + + @bot.command() + async def up(ctx, *, content: Annotated[str, to_upper]): + await ctx.send(content) + +This works with all types of converters mentioned on this page. + Greedy ^^^^^^ diff --git a/docs/ext/commands/slash_commands.rst b/docs/ext/commands/slash_commands.rst index 0b878aa30d..626231a7eb 100644 --- a/docs/ext/commands/slash_commands.rst +++ b/docs/ext/commands/slash_commands.rst @@ -138,7 +138,7 @@ Discord itself supports only a few built-in types which are guaranteed to be enf - :class:`disnake.Attachment` - :class:`disnake.abc.Snowflake`\*\*\* -All the other types may be converted implicitly, similarly to :ref:`ext_commands_basic_converters` +Other types may be converted implicitly, using the builtin :ref:`ext_commands_discord_converters`: .. code-block:: python3 diff --git a/tests/test_utils.py b/tests/test_utils.py index fcc1ed60e8..556ad4b955 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,6 +11,7 @@ from datetime import timedelta, timezone from typing import ( TYPE_CHECKING, + Annotated, Any, Callable, Dict, @@ -791,6 +792,8 @@ def test_normalise_optional_params(params, expected) -> None: # forward refs ("bool", bool, True), ("Tuple[dict, List[Literal[42, 99]]]", Tuple[dict, List[Literal[42, 99]]], True), + # Annotated[X, Y] -> Y + (Annotated[str, str.casefold], str.casefold, False), # 3.10 union syntax pytest.param( "int | float",