Repository: Pycord-Development/pycord

Issue:
Allow typing.Annotated hints for defining options.
### Summary

Allow typing.Annotated hints for defining options.

### What is the feature request for?

The core library

### The Problem

Pycord allows commands to have options defined through parameter annotations, as in:
```python
async def echo(ctx, txt: Option(str, description="Text to echo")):
  await ctx.respond(txt)
```
however, this use of annotations precludes a static type checker from being able to be used on the associated parameter (that is, mypy for example gives a warning: "Invalid type comment or annotation  [valid-type]". The standard library has introduced with PEP 593 a way for an annotation to provide both a type hint and metadata for that parameter with `typing.Annotated`. However, this library's command parsing methods are not able to interpret an annotation that is a `typing.Annotated`.

(As an aside, using decorators to define options is not a work around for this because while then the option's associated parameter in the callback function can have a type hint, the decorator hides from statis type analysis the callback function's parameter types/ return when called)

### The Ideal Solution

Add to option parsing the ability to read annotations of the form, 
```python
Annotated[<type>, Option(<args>)
```
(ie. something like this would be valid.
```python
async def square(ctx, num: Annotated[int, Option(description="Text to echo")]):
  await ctx.respond(num**2)
```
) by for instance, by checking if `option` (as in commands/core.py:731) is an instance of `typing.Annotated`, picking the input type for the option from `option.__args__[0]`, and iterating through `option.__metadata__` trying to find an instance of `discord.Option`.

### The Current Solution

As far as I'm aware, the only way to avoid static type checker warning in a command function definition is ignoring them.

### Additional Context

A brief test for the above ideal solution is
```   
import discord

def test_typing_annotated_decorator():
    bot = discord.Bot()
    @bot.slash_command()
    async def echo(ctx, txt: Annotated[str, discord.Option(description='Some text')]):
        await ctx.respond(txt)
```
which fails with
```
============================= test session starts ==============================
platform linux -- Python 3.11.3, pytest-7.3.2, pluggy-1.0.0
rootdir: /home/amelia/pycord
configfile: pyproject.toml
collected 1 item

tests/test_typing_annotated.py F                                         [100%]

=================================== FAILURES ===================================
____________________________ test_typing_annotated _____________________________

    def test_typing_annotated():
        async def echo(ctx, txt: Annotated[str, discord.Option()]):
            await ctx.respond(txt)
        cmd = SlashCommand(echo)
        bot = discord.Bot()
>       bot.add_application_command(cmd)

tests/test_typing_annotated.py:14: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
discord/bot.py:127: in add_application_command
    command._set_cog(None)
discord/commands/core.py:603: in _set_cog
    self.cog = cog
discord/commands/core.py:830: in cog
    self._validate_parameters()
discord/commands/core.py:708: in _validate_parameters
    self.options: list[Option] = self._parse_options(params)
discord/commands/core.py:748: in _parse_options
    option = Option(option)
discord/commands/options.py:231: in __init__
    raise exc
discord/commands/options.py:226: in __init__
    self.input_type = SlashCommandOptionType.from_datatype(input_type)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

cls = <enum SlashCommandOptionType>
datatype = typing.Annotated[str, <discord.commands.Option name=None>]

    @classmethod
    def from_datatype(cls, datatype):
        if isinstance(datatype, tuple):  # typing.Union has been used
            datatypes = [cls.from_datatype(op) for op in datatype]
            if all(x == cls.channel for x in datatypes):
                return cls.channel
            elif set(datatypes) <= {cls.role, cls.user}:
                return cls.mentionable
            else:
                raise TypeError("Invalid usage of typing.Union")
    
        py_3_10_union_type = hasattr(types, "UnionType") and isinstance(
            datatype, types.UnionType
        )
    
        if py_3_10_union_type or getattr(datatype, "__origin__", None) is Union:
            # Python 3.10+ "|" operator or typing.Union has been used. The __args__ attribute is a tuple of the types.
            # Type checking fails for this case, so ignore it.
            return cls.from_datatype(datatype.__args__)  # type: ignore
    
        if datatype.__name__ in ["Member", "User"]:
            return cls.user
        if datatype.__name__ in [
            "GuildChannel",
            "TextChannel",
            "VoiceChannel",
            "StageChannel",
            "CategoryChannel",
            "ThreadOption",
            "Thread",
            "ForumChannel",
            "DMChannel",
        ]:
            return cls.channel
        if datatype.__name__ == "Role":
            return cls.role
        if datatype.__name__ == "Attachment":
            return cls.attachment
        if datatype.__name__ == "Mentionable":
            return cls.mentionable
    
>       if issubclass(datatype, str):
E       TypeError: issubclass() arg 1 must be a class

discord/enums.py:796: TypeError
=============================== warnings summary ===============================
.venv/lib64/python3.11/site-packages/_pytest/config/__init__.py:1314
  /home/amelia/pycord/.venv/lib64/python3.11/site-packages/_pytest/config/__init__.py:1314: PytestConfigWarning: Unknown config option: asyncio_mode
  
    self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")

discord/player.py:28
  /home/amelia/pycord/discord/player.py:28: DeprecationWarning: 'audioop' is deprecated and slated for removal in Python 3.13
    import audioop

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_typing_annotated.py::test_typing_annotated - TypeError: iss...
======================== 1 failed, 2 warnings in 0.18s =========================
```


Discussion:
Feel free to open up a pull request

Resolving patch:
diff --git a/discord/commands/core.py b/discord/commands/core.py
--- a/discord/commands/core.py
+++ b/discord/commands/core.py
@@ -44,6 +44,8 @@
     Union,
 )
 
+from typing_extensions import Annotated, get_args, get_origin
+
 from ..channel import _threaded_guild_channel_factory
 from ..enums import Enum as DiscordEnum
 from ..enums import MessageType, SlashCommandOptionType, try_enum
@@ -732,6 +734,19 @@ def _parse_options(self, params, *, check_params: bool = True) -> list[Option]:
             if option == inspect.Parameter.empty:
                 option = str
 
+            if self._is_typing_annotated(option):
+                type_hint = get_args(option)[0]
+                metadata = option.__metadata__
+                # If multiple Options in metadata, the first will be used.
+                option_gen = (elem for elem in metadata if isinstance(elem, Option))
+                option = next(option_gen, Option())
+                # Handle Optional
+                if self._is_typing_optional(type_hint):
+                    option.input_type = get_args(type_hint)[0]
+                    option.default = None
+                else:
+                    option.input_type = type_hint
+
             if self._is_typing_union(option):
                 if self._is_typing_optional(option):
                     option = Option(option.__args__[0], default=None)
@@ -820,6 +835,9 @@ def _is_typing_union(self, annotation):
     def _is_typing_optional(self, annotation):
         return self._is_typing_union(annotation) and type(None) in annotation.__args__  # type: ignore
 
+    def _is_typing_annotated(self, annotation):
+        return get_origin(annotation) is Annotated
+
     @property
     def cog(self):
         return getattr(self, "_cog", MISSING)


Test patch:
diff --git a/tests/test_typing_annotated.py b/tests/test_typing_annotated.py
new file mode 100644
--- /dev/null
+++ b/tests/test_typing_annotated.py
@@ -0,0 +1,86 @@
+from typing import Optional
+
+import pytest
+from typing_extensions import Annotated
+
+import discord
+from discord import ApplicationContext
+from discord.commands.core import SlashCommand, slash_command
+
+
+def test_typing_annotated():
+    async def echo(ctx, txt: Annotated[str, discord.Option()]):
+        await ctx.respond(txt)
+
+    cmd = SlashCommand(echo)
+    bot = discord.Bot()
+    bot.add_application_command(cmd)
+
+
+def test_typing_annotated_decorator():
+    bot = discord.Bot()
+
+    @bot.slash_command()
+    async def echo(ctx, txt: Annotated[str, discord.Option(description="Some text")]):
+        await ctx.respond(txt)
+
+
+def test_typing_annotated_cog():
+    class echoCog(discord.Cog):
+        def __init__(self, bot_) -> None:
+            self.bot = bot_
+            super().__init__()
+
+        @slash_command()
+        async def echo(
+            self, ctx, txt: Annotated[str, discord.Option(description="Some text")]
+        ):
+            await ctx.respond(txt)
+
+    bot = discord.Bot()
+    bot.add_cog(echoCog(bot))
+
+
+def test_typing_annotated_cog_slashgroup():
+    class echoCog(discord.Cog):
+        grp = discord.commands.SlashCommandGroup("echo")
+
+        def __init__(self, bot_) -> None:
+            self.bot = bot_
+            super().__init__()
+
+        @grp.command()
+        async def echo(
+            self, ctx, txt: Annotated[str, discord.Option(description="Some text")]
+        ):
+            await ctx.respond(txt)
+
+    bot = discord.Bot()
+    bot.add_cog(echoCog(bot))
+
+
+def test_typing_annotated_optional():
+    async def echo(ctx, txt: Annotated[Optional[str], discord.Option()]):
+        await ctx.respond(txt)
+
+    cmd = SlashCommand(echo)
+    bot = discord.Bot()
+    bot.add_application_command(cmd)
+
+
+def test_no_annotation():
+    async def echo(ctx, txt: str):
+        await ctx.respond(txt)
+
+    cmd = SlashCommand(echo)
+    bot = discord.Bot()
+    bot.add_application_command(cmd)
+
+
+def test_annotated_no_option():
+    async def echo(ctx, txt: Annotated[str, "..."]):
+        await ctx.respond(txt)
+
+    cmd = SlashCommand(echo)
+    bot = discord.Bot()
+    bot.add_application_command(cmd)