Repository: hikari-py/hikari
Pull request #710 (2021-07-30 06:15:59)

CommandInteractionOption typing fix
### Summary
Command options only have 1 value

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [x] I have run `nox` and all the pipelines have passed.
- [x] I have made unittests according to the code I have added/modified/deleted.

### Related issues
<!--
To mention an issue use `#issue-id` and to mention a merge request use `!merge-request-id`
To close/fix an issue use `Close #issue-id` or `Fix #issue-id` (depending on the merge request)
-->



Patch:
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 23444aa1..ddd75240 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -12,7 +12,7 @@ pytest-cov==2.12.1
 pytest-randomly==3.8.0
 
 # Coverage testing.
-coverage==5.5
+coverage==6.0b1
 
 # Other stuff
 async-timeout==3.0.1  # Used for timeouts in some test cases.
diff --git a/examples/image_resources/image_resources.py b/examples/image_resources/image_resources.py
index 048925b7..3a0bc95c 100644
--- a/examples/image_resources/image_resources.py
+++ b/examples/image_resources/image_resources.py
@@ -69,10 +69,10 @@ async def inspect_image(event: hikari.GuildMessageCreateEvent, what: str) -> Non
             await event.message.respond("Guild is missing from the cache :(")
             return
 
-        if guild.icon_url is None:
+        if (icon_url := guild.icon_url) is None:
             await event.message.respond("This guild doesn't have an icon")
         else:
-            await event.message.respond("Guild icon", attachment=guild.icon_url)
+            await event.message.respond("Guild icon", attachment=icon_url)
 
     # Show the image for the given emoji if there is some content present:
     elif what:
diff --git a/hikari/__init__.pyi b/hikari/__init__.pyi
index 66e4a9e6..566b8da5 100644
--- a/hikari/__init__.pyi
+++ b/hikari/__init__.pyi
@@ -1,5 +1,33 @@
-# This file was generated by `nox -s generate-stubs`. Do not manually edit this file!
+# DO NOT MANUALLY EDIT THIS FILE!
+# This file was automatically generated by `nox -s generate-stubs`
 
+from typing import Any
+
+from hikari._about import __author__ as __author__
+from hikari._about import __ci__ as __ci__
+from hikari._about import __copyright__ as __copyright__
+from hikari._about import __discord_invite__ as __discord_invite__
+from hikari._about import __docs__ as __docs__
+from hikari._about import __email__ as __email__
+from hikari._about import __issue_tracker__ as __issue_tracker__
+from hikari._about import __license__ as __license__
+from hikari._about import __url__ as __url__
+from hikari._about import __version__ as __version__
+from hikari.applications import Application as Application
+from hikari.applications import ApplicationFlags as ApplicationFlags
+from hikari.applications import AuthorizationApplication as AuthorizationApplication
+from hikari.applications import AuthorizationInformation as AuthorizationInformation
+from hikari.applications import ConnectionVisibility as ConnectionVisibility
+from hikari.applications import OAuth2AuthorizationToken as OAuth2AuthorizationToken
+from hikari.applications import OAuth2ImplicitToken as OAuth2ImplicitToken
+from hikari.applications import OAuth2Scope as OAuth2Scope
+from hikari.applications import OwnConnection as OwnConnection
+from hikari.applications import OwnGuild as OwnGuild
+from hikari.applications import PartialOAuth2Token as PartialOAuth2Token
+from hikari.applications import Team as Team
+from hikari.applications import TeamMember as TeamMember
+from hikari.applications import TeamMembershipState as TeamMembershipState
+from hikari.applications import TokenType as TokenType
 from hikari.audit_logs import *
 from hikari.channels import *
 from hikari.colors import *
@@ -9,7 +37,19 @@ from hikari.embeds import *
 from hikari.emojis import *
 from hikari.errors import *
 from hikari.events import *
+from hikari.files import URL as URL
+from hikari.files import Bytes as Bytes
+from hikari.files import File as File
+from hikari.files import LazyByteIteratorish as LazyByteIteratorish
+from hikari.files import Pathish as Pathish
+from hikari.files import Rawish as Rawish
+from hikari.files import Resourceish as Resourceish
 from hikari.guilds import *
+from hikari.impl.bot import BotApp as BotApp
+from hikari.impl.bot import GatewayBot as GatewayBot
+from hikari.impl.rest import ClientCredentialsStrategy as ClientCredentialsStrategy
+from hikari.impl.rest import RESTApp as RESTApp
+from hikari.impl.rest_bot import RESTBot as RESTBot
 from hikari.intents import *
 from hikari.interactions import *
 from hikari.invites import *
@@ -18,19 +58,21 @@ from hikari.messages import *
 from hikari.permissions import *
 from hikari.presences import *
 from hikari.sessions import *
+from hikari.snowflakes import SearchableSnowflakeish as SearchableSnowflakeish
+from hikari.snowflakes import SearchableSnowflakeishOr as SearchableSnowflakeishOr
+from hikari.snowflakes import Snowflake as Snowflake
+from hikari.snowflakes import Snowflakeish as Snowflakeish
+from hikari.snowflakes import SnowflakeishOr as SnowflakeishOr
+from hikari.snowflakes import SnowflakeishSequence as SnowflakeishSequence
+from hikari.snowflakes import Unique as Unique
 from hikari.templates import *
 from hikari.traits import *
+from hikari.undefined import UNDEFINED as UNDEFINED
+from hikari.undefined import UndefinedNoneOr as UndefinedNoneOr
+from hikari.undefined import UndefinedOr as UndefinedOr
+from hikari.undefined import UndefinedType as UndefinedType
 from hikari.users import *
 from hikari.voices import *
 from hikari.webhooks import *
-from hikari._about import __author__ as __author__, __ci__ as __ci__, __copyright__ as __copyright__, __discord_invite__ as __discord_invite__, __docs__ as __docs__, __email__ as __email__, __issue_tracker__ as __issue_tracker__, __license__ as __license__, __url__ as __url__, __version__ as __version__
-from hikari.applications import Application as Application, ApplicationFlags as ApplicationFlags, AuthorizationApplication as AuthorizationApplication, AuthorizationInformation as AuthorizationInformation, ConnectionVisibility as ConnectionVisibility, OAuth2AuthorizationToken as OAuth2AuthorizationToken, OAuth2ImplicitToken as OAuth2ImplicitToken, OAuth2Scope as OAuth2Scope, OwnConnection as OwnConnection, OwnGuild as OwnGuild, PartialOAuth2Token as PartialOAuth2Token, Team as Team, TeamMember as TeamMember, TeamMembershipState as TeamMembershipState, TokenType as TokenType
-from hikari.files import Bytes as Bytes, File as File, LazyByteIteratorish as LazyByteIteratorish, Pathish as Pathish, Rawish as Rawish, Resourceish as Resourceish, URL as URL
-from hikari.impl.bot import BotApp as BotApp, GatewayBot as GatewayBot
-from hikari.impl.rest import ClientCredentialsStrategy as ClientCredentialsStrategy, RESTApp as RESTApp
-from hikari.impl.rest_bot import RESTBot as RESTBot
-from hikari.snowflakes import SearchableSnowflakeish as SearchableSnowflakeish, SearchableSnowflakeishOr as SearchableSnowflakeishOr, Snowflake as Snowflake, Snowflakeish as Snowflakeish, SnowflakeishOr as SnowflakeishOr, SnowflakeishSequence as SnowflakeishSequence, Unique as Unique
-from hikari.undefined import UNDEFINED as UNDEFINED, UndefinedNoneOr as UndefinedNoneOr, UndefinedOr as UndefinedOr, UndefinedType as UndefinedType
-from typing import Any
 
 __all__: Any
diff --git a/hikari/errors.py b/hikari/errors.py
index 864660ee..feee3ddf 100644
--- a/hikari/errors.py
+++ b/hikari/errors.py
@@ -57,13 +57,13 @@
 import attr
 
 from hikari.internal import attr_extensions
+from hikari.internal import data_binding
 from hikari.internal import enums
 
 if typing.TYPE_CHECKING:
     from hikari import intents as intents_
     from hikari import messages
     from hikari import snowflakes
-    from hikari.internal import data_binding
     from hikari.internal import routes
 
 
@@ -460,6 +460,27 @@ class BadRequestError(ClientHTTPResponseError):
     status: http.HTTPStatus = attr.field(default=http.HTTPStatus.BAD_REQUEST, init=False)
     """The HTTP status code for the response."""
 
+    errors: typing.Optional[typing.Dict[str, data_binding.JSONObject]] = attr.field(default=None, kw_only=True)
+    """Dict of top level field names to field specific error paths.
+
+    For more information, this error format is loosely defined at
+    https://discord.com/developers/docs/reference#error-messages and is commonly
+    returned for 50035 errors.
+    """
+
+    _cached_str: str = attr.field(default=None, init=False)
+
+    def __str__(self) -> str:
+        if self._cached_str:
+            return self._cached_str
+
+        value = super().__str__()
+        if self.errors:
+            value += "\n" + data_binding.dump_json(self.errors, indent=2)
+
+        self._cached_str = value
+        return value
+
 
 @attr.define(auto_exc=True, repr=False, weakref_slot=False)
 class UnauthorizedError(ClientHTTPResponseError):
diff --git a/hikari/events/__init__.pyi b/hikari/events/__init__.pyi
index ffe4c7f3..81c9e2d6 100644
--- a/hikari/events/__init__.pyi
+++ b/hikari/events/__init__.pyi
@@ -1,5 +1,8 @@
-# This file was generated by `nox -s generate-stubs`. Do not manually edit this file!
+# DO NOT MANUALLY EDIT THIS FILE!
+# This file was automatically generated by `nox -s generate-stubs`
 
+from hikari.events.base_events import Event as Event
+from hikari.events.base_events import ExceptionEvent as ExceptionEvent
 from hikari.events.channel_events import *
 from hikari.events.guild_events import *
 from hikari.events.interaction_events import *
@@ -12,4 +15,3 @@ from hikari.events.shard_events import *
 from hikari.events.typing_events import *
 from hikari.events.user_events import *
 from hikari.events.voice_events import *
-from hikari.events.base_events import Event as Event, ExceptionEvent as ExceptionEvent
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index 2af2f9d5..7125d6de 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -2194,8 +2194,8 @@ async def edit_guild(
         route = routes.PATCH_GUILD.compile(guild=guild)
         body = data_binding.JSONObjectBuilder()
         body.put("name", name)
-        body.put("verification", verification_level)
-        body.put("notifications", default_message_notifications)
+        body.put("verification_level", verification_level)
+        body.put("default_message_notifications", default_message_notifications)
         body.put("explicit_content_filter", explicit_content_filter_level)
         body.put("afk_timeout", afk_timeout, conversion=time.timespan_to_int)
         body.put("preferred_locale", preferred_locale, conversion=str)
diff --git a/hikari/interactions/__init__.pyi b/hikari/interactions/__init__.pyi
index 73889985..3d1171ed 100644
--- a/hikari/interactions/__init__.pyi
+++ b/hikari/interactions/__init__.pyi
@@ -1,4 +1,5 @@
-# This file was generated by `nox -s generate-stubs`. Do not manually edit this file!
+# DO NOT MANUALLY EDIT THIS FILE!
+# This file was automatically generated by `nox -s generate-stubs`
 
 from hikari.interactions.bases import *
 from hikari.interactions.commands import *
diff --git a/hikari/interactions/commands.py b/hikari/interactions/commands.py
index 5df39f43..42812a85 100644
--- a/hikari/interactions/commands.py
+++ b/hikari/interactions/commands.py
@@ -367,7 +367,7 @@ class CommandInteractionOption:
     type: typing.Union[OptionType, int] = attr.field(repr=True)
     """Type of this option."""
 
-    value: typing.Optional[typing.Sequence[typing.Union[str, int, bool]]] = attr.field(repr=True)
+    value: typing.Union[str, int, bool, None] = attr.field(repr=True)
     """Value provided for this option.
 
     Either `CommandInteractionOption.value` or `CommandInteractionOption.options`
diff --git a/hikari/internal/data_binding.py b/hikari/internal/data_binding.py
index 762e75ce..b78d72be 100644
--- a/hikari/internal/data_binding.py
+++ b/hikari/internal/data_binding.py
@@ -83,7 +83,7 @@
     JSONDecodeError: typing.Type[Exception] = Exception
     """Exception raised when loading an invalid JSON string"""
 
-    def dump_json(_: typing.Union[JSONArray, JSONObject], /) -> str:
+    def dump_json(_: typing.Union[JSONArray, JSONObject], /, *, indent: int = ...) -> str:
         """Convert a Python type to a JSON string."""
 
     def load_json(_: typing.AnyStr, /) -> typing.Union[JSONArray, JSONObject]:
diff --git a/hikari/internal/net.py b/hikari/internal/net.py
index f4fd6dc3..1dfc2283 100644
--- a/hikari/internal/net.py
+++ b/hikari/internal/net.py
@@ -35,6 +35,7 @@
 
 if typing.TYPE_CHECKING:
     from hikari import config
+    from hikari.internal import data_binding
 
 
 async def generate_error_response(response: aiohttp.ClientResponse) -> errors.HTTPError:
@@ -48,11 +49,12 @@ async def generate_error_response(response: aiohttp.ClientResponse) -> errors.HT
         json_body = await response.json()
         args.append(json_body.get("message", ""))
         args.append(errors.RESTErrorCode(json_body.get("code", 0)))
+        raw_error_array: typing.Optional[data_binding.JSONObject] = json_body.get("errors")
     except aiohttp.ContentTypeError:
-        pass
+        raw_error_array = None
 
     if response.status == http.HTTPStatus.BAD_REQUEST:
-        return errors.BadRequestError(*args)
+        return errors.BadRequestError(*args, errors=raw_error_array)
     if response.status == http.HTTPStatus.UNAUTHORIZED:
         return errors.UnauthorizedError(*args)
     if response.status == http.HTTPStatus.FORBIDDEN:
diff --git a/pipelines/format.nox.py b/pipelines/format.nox.py
index fc92685a..ec70992f 100644
--- a/pipelines/format.nox.py
+++ b/pipelines/format.nox.py
@@ -19,7 +19,7 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-"""Black code-style jobs."""
+"""Code-style jobs."""
 import os
 import shutil
 import subprocess
diff --git a/pipelines/mypy.nox.py b/pipelines/mypy.nox.py
index 31035236..d430dfb6 100644
--- a/pipelines/mypy.nox.py
+++ b/pipelines/mypy.nox.py
@@ -47,14 +47,16 @@ def generate_stubs(session: nox.Session) -> None:
 def _generate_stubs(session: nox.Session) -> None:
     session.run("stubgen", *STUBGEN_GENERATE, "-o", ".", "--include-private", "--no-import")
 
-    for path in STUBGEN_GENERATE:
-        # We add "i" because we turn ".py" into ".pyi"
-        stub_path = path + "i"
+    stub_paths = [path + "i" for path in STUBGEN_GENERATE]
 
+    session.run("isort", *stub_paths)
+    session.run("black", *stub_paths)
+
+    for stub_path in stub_paths:
         with open(stub_path, "r") as fp:
             content = fp.read()
 
         with open(stub_path, "w") as fp:
-            fp.write(
-                "# This file was generated by `nox -s generate-stubs`. Do not manually edit this file!\n\n" + content
-            )
+            fp.write("# DO NOT MANUALLY EDIT THIS FILE!\n")
+            fp.write("# This file was automatically generated by `nox -s generate-stubs`\n\n")
+            fp.write(content)


Test patch:
diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py
index 11adee3b..d765d128 100644
--- a/tests/hikari/impl/test_rest.py
+++ b/tests/hikari/impl/test_rest.py
@@ -2443,8 +2443,8 @@ async def test_edit_guild(self, rest_client, file_resource):
         expected_route = routes.PATCH_GUILD.compile(guild=123)
         expected_json = {
             "name": "hikari",
-            "verification": 3,
-            "notifications": 1,
+            "verification_level": 3,
+            "default_message_notifications": 1,
             "explicit_content_filter": 1,
             "afk_timeout": 60,
             "preferred_locale": "en-UK",
@@ -2487,8 +2487,8 @@ async def test_edit_guild_when_images_are_None(self, rest_client):
         expected_route = routes.PATCH_GUILD.compile(guild=123)
         expected_json = {
             "name": "hikari",
-            "verification": 3,
-            "notifications": 1,
+            "verification_level": 3,
+            "default_message_notifications": 1,
             "explicit_content_filter": 1,
             "afk_timeout": 60,
             "preferred_locale": "en-UK",
diff --git a/tests/hikari/internal/test_net.py b/tests/hikari/internal/test_net.py
index 83d205d6..5a3a5e6a 100644
--- a/tests/hikari/internal/test_net.py
+++ b/tests/hikari/internal/test_net.py
@@ -33,7 +33,6 @@
 @pytest.mark.parametrize(
     ("status_", "expected_error"),
     [
-        (http.HTTPStatus.BAD_REQUEST, "BadRequestError"),
         (http.HTTPStatus.UNAUTHORIZED, "UnauthorizedError"),
         (http.HTTPStatus.FORBIDDEN, "ForbiddenError"),
         (http.HTTPStatus.NOT_FOUND, "NotFoundError"),
@@ -74,14 +73,13 @@ async def json(self):
 @pytest.mark.parametrize(
     ("status_", "expected_error"),
     [
-        (http.HTTPStatus.BAD_REQUEST, "BadRequestError"),
         (http.HTTPStatus.UNAUTHORIZED, "UnauthorizedError"),
         (http.HTTPStatus.FORBIDDEN, "ForbiddenError"),
         (http.HTTPStatus.NOT_FOUND, "NotFoundError"),
     ],
 )
 @pytest.mark.asyncio()
-async def test_generate_error_when_error_with_json(status_, expected_error):
+async def test_generate_error_when_error_without_json(status_, expected_error):
     json_response = aiohttp.ContentTypeError(None, None)
 
     class StubResponse:
@@ -98,3 +96,50 @@ async def read(self):
 
     error.assert_called_once_with("https://some.url", {}, "some raw body")
     assert returned is error()
+
+
+@pytest.mark.asyncio()
+async def test_generate_bad_request_error_without_json_response():
+    class StubResponse:
+        real_url = "https://some.url"
+        status = http.HTTPStatus.BAD_REQUEST
+        headers = {}
+        json = mock.AsyncMock(side_effect=aiohttp.ContentTypeError(None, None))
+
+        async def read(self):
+            return "some raw body"
+
+    with mock.patch.object(errors, "BadRequestError", errors=None) as error:
+        returned = await net.generate_error_response(StubResponse())
+
+    error.assert_called_once_with("https://some.url", {}, "some raw body", errors=None)
+    assert returned is error()
+
+
+@pytest.mark.parametrize(
+    ("json_method", "expected_errors"),
+    [
+        (
+            mock.AsyncMock(return_value={"message": "raw message", "code": 123, "errors": {"component": []}}),
+            {"component": []},
+        ),
+        (mock.AsyncMock(return_value={"message": "raw message", "code": 123, "errors": {}}), {}),
+        (mock.AsyncMock(return_value={"message": "raw message", "code": 123}), None),
+    ],
+)
+@pytest.mark.asyncio()
+async def test_generate_bad_request_error_with_json_response(json_method, expected_errors):
+    class StubResponse:
+        real_url = "https://some.url"
+        status = http.HTTPStatus.BAD_REQUEST
+        headers = {}
+        json = json_method
+
+        async def read(self):
+            return "some raw body"
+
+    with mock.patch.object(errors, "BadRequestError", errors=None) as error:
+        returned = await net.generate_error_response(StubResponse())
+
+    error.assert_called_once_with("https://some.url", {}, "some raw body", "raw message", 123, errors=expected_errors)
+    assert returned is error()
diff --git a/tests/hikari/test_errors.py b/tests/hikari/test_errors.py
index ab0a5b27..0c067efe 100644
--- a/tests/hikari/test_errors.py
+++ b/tests/hikari/test_errors.py
@@ -22,10 +22,12 @@
 
 import http
 
+import mock
 import pytest
 
 from hikari import errors
 from hikari import intents
+from hikari.internal import data_binding
 
 
 class TestShardCloseCode:
@@ -88,6 +90,35 @@ def test_str_when_message_is_not_None(self, error):
         assert str(error) == "Some Status: 'Some message' for https://some.url"
 
 
+class TestBadRequestError:
+    @pytest.fixture()
+    def error(self):
+        return errors.BadRequestError(
+            "https://some.url", http.HTTPStatus.BAD_REQUEST, {}, "raw body", errors={"components": {"0": "ok"}}
+        )
+
+    def test_str(self, error):
+        assert (
+            str(error)
+            == 'Bad Request 400: \'raw body\' for https://some.url\n{\n  "components": {\n    "0": "ok"\n  }\n}'
+        )
+
+    def test_str_when_cached(self, error):
+        error._cached_str = "ok"
+
+        with mock.patch.object(data_binding, "dump_json") as dump_json:
+            assert str(error) == "ok"
+
+        dump_json.assert_not_called()
+
+    def test_str_when_no_errors(self, error):
+        error.errors = None
+        with mock.patch.object(data_binding, "dump_json") as dump_json:
+            assert str(error) == "Bad Request 400: 'raw body' for https://some.url"
+
+        dump_json.assert_not_called()
+
+
 class TestRateLimitTooLongError:
     @pytest.fixture()
     def error(self):