Repository: Rapptz/discord.py

Issue:
Multiple selects error
### Summary

master commit breaks multiple selects

### Reproduction Steps

this [commit](https://github.com/Rapptz/discord.py/tree/d826f4f3a8d4d97d24499f5de5c51e11e640e98f) provides global state for `Select` object 

### Minimal Reproducible Code

_No response_

### Expected Results

No global state

### Actual Results

global state

### Intents

...

### System Information

...

### Checklist

- [X] I have searched the open issues for duplicates.
- [X] I have shown the entire traceback, if possible.
- [X] I have removed my token from display, if visible.

### Additional Context

_No response_

Discussion:
Rapptz: Have we checked the memory impact of this at all on a long running bot
sgtlaggy: I have not, though there shouldn't be long-term lingering values as ContextVars *should* clean up after themselves in an async environment as implied by the [example in the docs](https://docs.python.org/3.10/library/contextvars.html#asyncio-support).

While writing this, I realized I missed a case when testing this outside modals and reintroduced the race condition with regular views, so back to the drawing board.
z03h: > While writing this, I realized I missed a case when testing this outside modals and reintroduced the race condition with regular views, so back to the drawing board.

The contxtvar gets set in item/modal refresh which is not in the callback task so the contextvar value gets overwritten.

Moving `item._refresh_state`/`modal._refresh` inside `view/modal._scheduled_task` seems to fix this.
sgtlaggy: I think best course of action would be to close this PR and revert the original commit if you care for a quick fix to modals, despite it not being actually supported by Discord.
I accidentally scuffed this PR with a rebase, and really have no ideas on how to fix this.
sgtlaggy: Unscuffed commits, moved the refresh calls as early as possible in `_scheduled_task` to avoid a breaking change if someone were to access the component(s) in `interaction_check`. Tested, works for both modals and views (didn't miss that same case this time).
Rapptz: One fundamental issue with this approach is the same as the other one, except this one kind of exacerbates the problem IMO. It's the fact that more select types are coming soon, and I wanted to commit to a solution that would work with those without resorting to repetition. As it stands right now the footprint for those other selects is going to end up creating N contextvars for every select type there is going to be in the future (channel, user, role, mentionable, text) which just does not work very well.
sgtlaggy: I'm not sure I understand? This solution uses a single contextvar with a new token per interaction context, as the original commit does. It doesn't make the contextvar an attribute of `Select`, as was my initial thought for solving this. 
If Discord adds more types of selects, it should just need changing the typehint of `List[str]` to `List[Union[str, User, ...]]` and maybe mix in a typevar for `choices` and `values`.
Rapptz: I was talking about this PR vs just reverting the commit.

Patch:
diff --git a/discord/app_commands/errors.py b/discord/app_commands/errors.py
index 1b8020a6..19c9f736 100644
--- a/discord/app_commands/errors.py
+++ b/discord/app_commands/errors.py
@@ -26,9 +26,8 @@ from __future__ import annotations
 
 from typing import Any, TYPE_CHECKING, List, Optional, Union
 
-
 from ..enums import AppCommandOptionType, AppCommandType, Locale
-from ..errors import DiscordException
+from ..errors import DiscordException, HTTPException, _flatten_error_dict
 
 __all__ = (
     'AppCommandError',
@@ -47,6 +46,7 @@ __all__ = (
     'BotMissingPermissions',
     'CommandOnCooldown',
     'MissingApplicationID',
+    'CommandSyncFailure',
 )
 
 if TYPE_CHECKING:
@@ -56,6 +56,8 @@ if TYPE_CHECKING:
     from ..types.snowflake import Snowflake, SnowflakeList
     from .checks import Cooldown
 
+    CommandTypes = Union[Command[Any, ..., Any], Group, ContextMenu]
+
 APP_ID_NOT_FOUND = (
     'Client does not have an application_id set. Either the function was called before on_ready '
     'was called or application_id was not passed to the Client constructor.'
@@ -444,3 +446,58 @@ class MissingApplicationID(AppCommandError):
 
     def __init__(self, message: Optional[str] = None):
         super().__init__(message or APP_ID_NOT_FOUND)
+
+
+def _get_command_error(index: str, inner: Any, commands: List[CommandTypes], messages: List[str]) -> None:
+    # Top level errors are:
+    # <number>: { <key>: <error> }
+    # The dicts could be nested, e.g.
+    # <number>: { <key>: { <second>: <error> } }
+    # Luckily, this is already handled by the flatten_error_dict utility
+    if not index.isdigit():
+        errors = _flatten_error_dict(inner, index)
+        messages.extend(f'In {k}: {v}' for k, v in errors.items())
+        return
+
+    idx = int(index)
+    try:
+        command = commands[idx]
+    except IndexError:
+        errors = _flatten_error_dict(inner, index)
+        messages.extend(f'In {k}: {v}' for k, v in errors.items())
+        return
+
+    callback = getattr(command, 'callback', None)
+    class_name = command.__class__.__name__
+    if callback:
+        messages.append(f'In {class_name} {command.qualified_name!r} defined in {callback.__qualname__!r}')
+    else:
+        messages.append(f'In {class_name} {command.qualified_name!r} defined in module {command.module!r}')
+
+    errors = _flatten_error_dict(inner)
+    messages.extend(f'  {k}: {v}' for k, v in errors.items())
+
+
+class CommandSyncFailure(AppCommandError, HTTPException):
+    """An exception raised when :meth:`CommandTree.sync` failed.
+
+    This provides syncing failures in a slightly more readable format.
+
+    This inherits from :exc:`~discord.app_commands.AppCommandError`
+    and :exc:`~discord.HTTPException`.
+
+    .. versionadded:: 2.0
+    """
+
+    def __init__(self, child: HTTPException, commands: List[CommandTypes]) -> None:
+        # Consume the child exception and make it seem as if we are that exception
+        self.__dict__.update(child.__dict__)
+
+        messages = [f'Failed to upload commands to Discord (HTTP status {self.status}, error code {self.code})']
+
+        if self._errors:
+            for index, inner in self._errors.items():
+                _get_command_error(index, inner, commands, messages)
+
+        # Equivalent to super().__init__(...) but skips other constructors
+        self.args = ('\n'.join(messages),)
diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py
index bd6b0f83..d7b6eb8c 100644
--- a/discord/app_commands/namespace.py
+++ b/discord/app_commands/namespace.py
@@ -142,7 +142,8 @@ class Namespace:
                 self.__dict__[name] = value
             elif opt_type == 10:  # number
                 value = option['value']  # type: ignore # Key is there
-                if value is None:
+                # This condition is written this way because 0 can be a valid float
+                if value is None or value == '':
                     self.__dict__[name] = float('nan')
                 else:
                     self.__dict__[name] = float(value)
diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py
index c8e2134e..571626b3 100644
--- a/discord/app_commands/tree.py
+++ b/discord/app_commands/tree.py
@@ -56,10 +56,11 @@ from .errors import (
     CommandNotFound,
     CommandSignatureMismatch,
     CommandLimitReached,
+    CommandSyncFailure,
     MissingApplicationID,
 )
 from .translator import Translator, locale_str
-from ..errors import ClientException
+from ..errors import ClientException, HTTPException
 from ..enums import AppCommandType, InteractionType
 from ..utils import MISSING, _get_as_snowflake, _is_submodule
 
@@ -1034,6 +1035,10 @@ class CommandTree(Generic[ClientT]):
         -------
         HTTPException
             Syncing the commands failed.
+        CommandSyncFailure
+            Syncing the commands failed due to a user related error, typically because
+            the command has invalid data. This is equivalent to an HTTP status code of
+            400.
         Forbidden
             The client does not have the ``applications.commands`` scope in the guild.
         MissingApplicationID
@@ -1058,10 +1063,15 @@ class CommandTree(Generic[ClientT]):
         else:
             payload = [command.to_dict() for command in commands]
 
-        if guild is None:
-            data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload)
-        else:
-            data = await self._http.bulk_upsert_guild_commands(self.client.application_id, guild.id, payload=payload)
+        try:
+            if guild is None:
+                data = await self._http.bulk_upsert_global_commands(self.client.application_id, payload=payload)
+            else:
+                data = await self._http.bulk_upsert_guild_commands(self.client.application_id, guild.id, payload=payload)
+        except HTTPException as e:
+            if e.status == 400:
+                raise CommandSyncFailure(e, commands) from None
+            raise
 
         return [AppCommand(data=d, state=self._state) for d in data]
 
@@ -1075,7 +1085,7 @@ class CommandTree(Generic[ClientT]):
     def _from_interaction(self, interaction: Interaction) -> None:
         async def wrapper():
             try:
-                await self.call(interaction)
+                await self._call(interaction)
             except AppCommandError as e:
                 await self._dispatch_error(interaction, e)
 
@@ -1191,29 +1201,7 @@ class CommandTree(Generic[ClientT]):
         """
         return True
 
-    async def call(self, interaction: Interaction) -> None:
-        """|coro|
-
-        Given an :class:`~discord.Interaction`, calls the matching
-        application command that's being invoked.
-
-        This is usually called automatically by the library.
-
-        Parameters
-        -----------
-        interaction: :class:`~discord.Interaction`
-            The interaction to dispatch from.
-
-        Raises
-        --------
-        CommandNotFound
-            The application command referred to could not be found.
-        CommandSignatureMismatch
-            The interaction data referred to a parameter that was not found in the
-            application command definition.
-        AppCommandError
-            An error occurred while calling the command.
-        """
+    async def _call(self, interaction: Interaction) -> None:
         if not await self.interaction_check(interaction):
             return
 
diff --git a/discord/client.py b/discord/client.py
index 517e4d97..e52862ba 100644
--- a/discord/client.py
+++ b/discord/client.py
@@ -630,7 +630,11 @@ class Client:
         if self.loop is _loop:
             await self._async_setup_hook()
 
-        data = await self.http.static_login(token.strip())
+        if not isinstance(token, str):
+            raise TypeError(f'expected token to be a str, received {token.__class__!r} instead')
+        token = token.strip()
+
+        data = await self.http.static_login(token)
         self._connection.user = ClientUser(state=self._connection, data=data)
         self._application = await self.application_info()
         if self._connection.application_id is None:
diff --git a/discord/errors.py b/discord/errors.py
index b8b32931..6035ace7 100644
--- a/discord/errors.py
+++ b/discord/errors.py
@@ -121,6 +121,7 @@ class HTTPException(DiscordException):
             self.code = message.get('code', 0)
             base = message.get('message', '')
             errors = message.get('errors')
+            self._errors: Optional[Dict[str, Any]] = errors
             if errors:
                 errors = _flatten_error_dict(errors)
                 helpful = '\n'.join('In %s: %s' % t for t in errors.items())
diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py
index 5d7d1be3..6112a229 100644
--- a/discord/ext/commands/cog.py
+++ b/discord/ext/commands/cog.py
@@ -138,6 +138,12 @@ class CogMeta(type):
         Whether the application command group is NSFW. This is only applicable for :class:`GroupCog` instances.
         By default, it's ``False``.
 
+        .. versionadded:: 2.0
+    group_auto_locale_strings: :class:`bool`
+        If this is set to ``True``, then all translatable strings will implicitly
+        be wrapped into :class:`~discord.app_commands.locale_str` rather
+        than :class:`str`. Defaults to ``True``.
+
         .. versionadded:: 2.0
     """
 
@@ -146,6 +152,7 @@ class CogMeta(type):
     __cog_group_name__: Union[str, app_commands.locale_str]
     __cog_group_description__: Union[str, app_commands.locale_str]
     __cog_group_nsfw__: bool
+    __cog_group_auto_locale_strings__: bool
     __cog_settings__: Dict[str, Any]
     __cog_commands__: List[Command[Any, ..., Any]]
     __cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]]
@@ -175,6 +182,7 @@ class CogMeta(type):
         attrs['__cog_name__'] = cog_name
         attrs['__cog_group_name__'] = group_name
         attrs['__cog_group_nsfw__'] = kwargs.pop('group_nsfw', False)
+        attrs['__cog_group_auto_locale_strings__'] = kwargs.pop('group_auto_locale_strings', True)
 
         description = kwargs.pop('description', None)
         if description is None:
@@ -293,6 +301,7 @@ class Cog(metaclass=CogMeta):
                 name=cls.__cog_group_name__,
                 description=cls.__cog_group_description__,
                 nsfw=cls.__cog_group_nsfw__,
+                auto_locale_strings=cls.__cog_group_auto_locale_strings__,
                 parent=None,
                 guild_ids=getattr(cls, '__discord_app_commands_default_guilds__', None),
                 guild_only=getattr(cls, '__discord_app_commands_guild_only__', False),
diff --git a/discord/ui/modal.py b/discord/ui/modal.py
index 7de5431e..d2624bb8 100644
--- a/discord/ui/modal.py
+++ b/discord/ui/modal.py
@@ -174,9 +174,11 @@ class Modal(View):
                     continue
                 item._refresh_state(component)  # type: ignore
 
-    async def _scheduled_task(self, interaction: Interaction):
+    async def _scheduled_task(self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]):
         try:
             self._refresh_timeout()
+            self._refresh(components)
+
             allow = await self.interaction_check(interaction)
             if not allow:
                 return
@@ -189,8 +191,10 @@ class Modal(View):
             # In the future, maybe this will require checking if we set an error response.
             self.stop()
 
-    def _dispatch_submit(self, interaction: Interaction) -> None:
-        asyncio.create_task(self._scheduled_task(interaction), name=f'discord-ui-modal-dispatch-{self.id}')
+    def _dispatch_submit(
+        self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]
+    ) -> None:
+        asyncio.create_task(self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}')
 
     def to_dict(self) -> Dict[str, Any]:
         payload = {
diff --git a/discord/ui/select.py b/discord/ui/select.py
index 8eb6f98b..3f997cdb 100644
--- a/discord/ui/select.py
+++ b/discord/ui/select.py
@@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE.
 """
 
 from __future__ import annotations
-from typing import List, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Callable, Union
+from typing import List, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Callable, Union, Dict
 from contextvars import ContextVar
 import inspect
 import os
@@ -54,7 +54,7 @@ if TYPE_CHECKING:
 
 V = TypeVar('V', bound='View', covariant=True)
 
-selected_values: ContextVar[Optional[List[str]]] = ContextVar('selected_values', default=None)
+selected_values: ContextVar[Dict[str, List[str]]] = ContextVar('selected_values')
 
 
 class Select(Item[V]):
@@ -126,6 +126,7 @@ class Select(Item[V]):
             disabled=disabled,
         )
         self.row = row
+        self._values: List[str] = []
 
     @property
     def custom_id(self) -> str:
@@ -262,8 +263,8 @@ class Select(Item[V]):
     @property
     def values(self) -> List[str]:
         """List[:class:`str`]: A list of values that have been selected by the user."""
-        values = selected_values.get()
-        return values if values is not None else []
+        values = selected_values.get(None)
+        return self._values if values is None else values.get(self.custom_id, [])
 
     @property
     def width(self) -> int:
@@ -276,7 +277,9 @@ class Select(Item[V]):
         self._underlying = component
 
     def _refresh_state(self, data: MessageComponentInteractionData) -> None:
-        selected_values.set(data.get('values', []))
+        values = selected_values.get({})
+        self._values = values[self.custom_id] = data.get('values', [])
+        selected_values.set(values)
 
     @classmethod
     def from_component(cls, component: SelectMenu) -> Self:
diff --git a/discord/ui/view.py b/discord/ui/view.py
index c3621c23..0f708769 100644
--- a/discord/ui/view.py
+++ b/discord/ui/view.py
@@ -416,6 +416,8 @@ class View:
             if self.timeout:
                 self.__timeout_expiry = time.monotonic() + self.timeout
 
+            item._refresh_state(interaction.data)  # type: ignore
+
             allow = await self.interaction_check(interaction)
             if not allow:
                 return
@@ -615,7 +617,6 @@ class ViewStore:
         if item is None:
             return
 
-        item._refresh_state(interaction.data)  # type: ignore
         # Note, at this point the View is *not* None
         item.view._dispatch_item(item, interaction)  # type: ignore
 
@@ -630,8 +631,7 @@ class ViewStore:
             _log.debug("Modal interaction referencing unknown custom_id %s. Discarding", custom_id)
             return
 
-        modal._refresh(components)
-        modal._dispatch_submit(interaction)
+        modal._dispatch_submit(interaction, components)
 
     def remove_interaction_mapping(self, interaction_id: int) -> None:
         # This is called before re-adding the view
diff --git a/docs/api.rst b/docs/api.rst
index 22833b60..50aa2169 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -4724,4 +4724,5 @@ Exception Hierarchy
                 - :exc:`Forbidden`
                 - :exc:`NotFound`
                 - :exc:`DiscordServerError`
+                - :exc:`app_commands.CommandSyncFailure`
             - :exc:`RateLimited`
diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst
index 722b242b..199d0e2a 100644
--- a/docs/interactions/api.rst
+++ b/docs/interactions/api.rst
@@ -721,6 +721,9 @@ Exceptions
 .. autoexception:: discord.app_commands.MissingApplicationID
     :members:
 
+.. autoexception:: discord.app_commands.CommandSyncFailure
+    :members:
+
 Exception Hierarchy
 ~~~~~~~~~~~~~~~~~~~~
 
@@ -743,3 +746,6 @@ Exception Hierarchy
             - :exc:`~discord.app_commands.CommandSignatureMismatch`
             - :exc:`~discord.app_commands.CommandNotFound`
             - :exc:`~discord.app_commands.MissingApplicationID`
+            - :exc:`~discord.app_commands.CommandSyncFailure`
+        - :exc:`~discord.HTTPException`
+            - :exc:`~discord.app_commands.CommandSyncFailure`


Test patch:
diff --git a/tests/test_app_commands_invoke.py b/tests/test_app_commands_invoke.py
index 38ead3e5..35915c19 100644
--- a/tests/test_app_commands_invoke.py
+++ b/tests/test_app_commands_invoke.py
@@ -100,9 +100,9 @@ client = discord.Client(intents=discord.Intents.default())
 class MockTree(discord.app_commands.CommandTree):
     last_exception: Optional[discord.app_commands.AppCommandError]
 
-    async def call(self, interaction: discord.Interaction) -> None:
+    async def _call(self, interaction: discord.Interaction) -> None:
         self.last_exception = None
-        return await super().call(interaction)
+        return await super()._call(interaction)
 
     async def on_error(self, interaction: discord.Interaction, error: discord.app_commands.AppCommandError) -> None:
         self.last_exception = error
@@ -167,7 +167,7 @@ async def test_valid_command_invoke(
     command: discord.app_commands.Command[Any, ..., Any], raises: Optional[Type[BaseException]]
 ):
     interaction = MockCommandInteraction(client, command, foo='foo')
-    await tree.call(interaction)
+    await tree._call(interaction)
 
     if raises is None:
         assert tree.last_exception is None
@@ -192,6 +192,6 @@ async def test_valid_command_invoke(
 @pytest.mark.asyncio
 async def test_invalid_command_invoke(command: discord.app_commands.Command[Any, ..., Any]):
     interaction = MockCommandInteraction(client, command, bar='bar')
-    await tree.call(interaction)
+    await tree._call(interaction)
 
     assert isinstance(tree.last_exception, discord.app_commands.CommandSignatureMismatch)