From b1e2ff9d28ba425ad9b140f50103fbd268d19f82 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 8 Oct 2025 14:14:08 +0200 Subject: [PATCH 1/8] feat(commands): support `Annotated[tp, converter]` in prefix commands --- disnake/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/disnake/utils.py b/disnake/utils.py index c2d15ce209..287a71c16e 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -1212,6 +1212,11 @@ def evaluate_annotation( cache[tp] = evaluated return evaluated + # Annotated[X, Y], where Y is the converter we need + if hasattr(tp, "__metadata__"): + tp = tp.__metadata__[0] + return evaluate_annotation(tp, globals, locals, cache) + # GenericAlias / UnionType if hasattr(tp, "__args__"): if not hasattr(tp, "__origin__"): From 798c4dc5808855c58900cfcb23dc506e9d58c367 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Wed, 8 Oct 2025 14:14:32 +0200 Subject: [PATCH 2/8] docs: fix reference to converters in slash cmd docs --- docs/ext/commands/commands.rst | 4 ++-- docs/ext/commands/slash_commands.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index b78934d04e..f61bb32e05 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -196,8 +196,6 @@ Converters come in a few flavours: - A custom class that inherits from :class:`~ext.commands.Converter`. -.. _ext_commands_basic_converters: - Basic Converters ++++++++++++++++ @@ -356,6 +354,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 ++++++++++++++++++ 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 From e2dacec9d457d82a1b71be12838d97987a68fc52 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 11:41:13 +0200 Subject: [PATCH 3/8] docs: add `typing.Annotated` to prefix commands docs --- docs/ext/commands/commands.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index f61bb32e05..afa85f001e 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -196,6 +196,8 @@ Converters come in a few flavours: - A custom class that inherits from :class:`~ext.commands.Converter`. +.. _ext_commands_basic_converters: + Basic Converters ++++++++++++++++ @@ -552,6 +554,32 @@ 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. +typing.Annotated +^^^^^^^^^^^^^^^^ + +.. versionadded:: |vnext| + +With :data:`typing.Annotated`, you can use converters in a 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 ^^^^^^ From 3344e0e598d8faaaaad7ad12e7516e6183cab340 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 11:46:06 +0200 Subject: [PATCH 4/8] docs: add changelog entry --- changelog/1431.feature.rst | 1 + disnake/utils.py | 1 + docs/ext/commands/commands.rst | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/1431.feature.rst diff --git a/changelog/1431.feature.rst b/changelog/1431.feature.rst new file mode 100644 index 0000000000..5b6cbf460b --- /dev/null +++ b/changelog/1431.feature.rst @@ -0,0 +1 @@ +|commands| Support :data:`typing.Annotated` for specifying converters in prefix commands in a type-safe way. See :ref:`Special Converters ` for details. diff --git a/disnake/utils.py b/disnake/utils.py index 287a71c16e..89a9131e8c 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -1220,6 +1220,7 @@ def evaluate_annotation( # 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 afa85f001e..c6c3ef3424 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -554,6 +554,8 @@ 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 ^^^^^^^^^^^^^^^^ @@ -565,7 +567,7 @@ as ``to_upper`` (i.e. a converter function), while it would naturally be a :clas 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: +a :class:`str` while disnake will use the converter passed as the second argument to :data:`~typing.Annotated` at runtime: .. code-block:: python3 From 238e9d6dac6cb6bf4c3e67ac4e1f1ba49d7e10d3 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 13:49:10 +0200 Subject: [PATCH 5/8] docs: *more* type-safe --- changelog/1431.feature.rst | 2 +- docs/ext/commands/commands.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/1431.feature.rst b/changelog/1431.feature.rst index 5b6cbf460b..3a2b130ef5 100644 --- a/changelog/1431.feature.rst +++ b/changelog/1431.feature.rst @@ -1 +1 @@ -|commands| Support :data:`typing.Annotated` for specifying converters in prefix commands in a type-safe way. See :ref:`Special Converters ` for details. +|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/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index c6c3ef3424..dfc88ce928 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -561,7 +561,7 @@ typing.Annotated .. versionadded:: |vnext| -With :data:`typing.Annotated`, you can use converters in a type-safe way. +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. From 8c948737c1866fad9e9ca31fa0982c90cb19da2d Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 13:49:31 +0200 Subject: [PATCH 6/8] refactor: avoid `hasattr` for `Annotated` check --- disnake/ext/commands/params.py | 2 +- disnake/utils.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 89a9131e8c..3cac4e58f5 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, @@ -1213,7 +1214,7 @@ def evaluate_annotation( return evaluated # Annotated[X, Y], where Y is the converter we need - if hasattr(tp, "__metadata__"): + if get_origin(tp) is Annotated: tp = tp.__metadata__[0] return evaluate_annotation(tp, globals, locals, cache) From 8366f0e95c094d136d169b8c18898132104e5653 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 13:55:49 +0200 Subject: [PATCH 7/8] test: add `Annotated` test case --- tests/test_utils.py | 3 +++ 1 file changed, 3 insertions(+) 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", From fd4b4488dd6fbe542b9a5bacf5f230667b947181 Mon Sep 17 00:00:00 2001 From: shiftinv Date: Sun, 12 Oct 2025 14:06:34 +0200 Subject: [PATCH 8/8] chore: remove useless assignment from testing --- disnake/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/disnake/utils.py b/disnake/utils.py index 3cac4e58f5..0558d4944a 100644 --- a/disnake/utils.py +++ b/disnake/utils.py @@ -1215,8 +1215,7 @@ def evaluate_annotation( # Annotated[X, Y], where Y is the converter we need if get_origin(tp) is Annotated: - tp = tp.__metadata__[0] - return evaluate_annotation(tp, globals, locals, cache) + return evaluate_annotation(tp.__metadata__[0], globals, locals, cache) # GenericAlias / UnionType if hasattr(tp, "__args__"):