Repository: hikari-py/hikari
Pull request #719 (2021-08-06 00:43:45)

Account for embed attachments in InteractionMessageBuilder.build
### Summary
Account for embed attachments in InteractionMessageBuilder.build

### 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/README.md b/README.md
index 568e80e3..8a6daf3b 100644
--- a/README.md
+++ b/README.md
@@ -51,17 +51,17 @@ writing their first bot in other frameworks simply because of working blind afte
 to set up standard logging messages.
 
 If you wish to customise the intents being used in order to change which events your bot is notified about, then you
-can pass the `intents` kwarg to the `BotApp` constructor:
+can pass the `intents` kwarg to the `GatewayBot` constructor:
 
 ```py
 # the default is to enable all unprivileged intents (all events that do not target the
 # presence or activity of a specific member).
-bot = hikari.BotApp(intents=hikari.Intents.ALL, token="...")
+bot = hikari.GatewayBot(intents=hikari.Intents.ALL, token="...")
 ```
 
 The above example would enable all intents, thus enabling events relating to member presences to be received
 (you'd need to whitelist your application first to be able to start the bot if you do this).
-[Other options also exist](https://hikari-py.github.io/hikari/hikari/impl/bot.html#hikari.impl.bot.BotApp) such as
+[Other options also exist](https://hikari-py.github.io/hikari/hikari/impl/bot.html#hikari.impl.bot.GatewayBot) such as
 [customising timeouts for requests](https://hikari-py.github.io/hikari/hikari/config.html#hikari.config.HTTPSettings.timeouts)
 and [enabling a proxy](https://hikari-py.github.io/hikari/hikari/config.html#hikari.config.ProxySettings).
 
@@ -78,7 +78,7 @@ bot.run(
 )
 ```
 
-[Many other helpful options](https://hikari-py.github.io/hikari/hikari/impl/bot.html#hikari.impl.bot.BotApp.run)
+[Many other helpful options](https://hikari-py.github.io/hikari/hikari/impl/bot.html#hikari.impl.bot.GatewayBot.run)
 exist for you to take advantage of if you wish.
 
 Events are determined by the type annotation on the event parameter, or alternatively as a type passed to the
@@ -179,7 +179,7 @@ if os.name != "nt":
     import uvloop
     uvloop.install()
 
-bot = hikari.BotApp(...)
+bot = hikari.GatewayBot(...)
 ...
 ```
 
diff --git a/hikari/api/cache.py b/hikari/api/cache.py
index ac902731..fc037641 100644
--- a/hikari/api/cache.py
+++ b/hikari/api/cache.py
@@ -53,9 +53,19 @@ class CacheView(typing.Mapping[_KeyT, _ValueT], abc.ABC):
 
     __slots__: typing.Sequence[str] = ()
 
+    @typing.overload
     @abc.abstractmethod
-    def get_item_at(self, index: int) -> _ValueT:
-        """Get an entry in the view at position `index`."""
+    def get_item_at(self, index: int, /) -> _ValueT:
+        ...
+
+    @typing.overload
+    @abc.abstractmethod
+    def get_item_at(self, index: slice, /) -> typing.Sequence[_ValueT]:
+        ...
+
+    @abc.abstractmethod
+    def get_item_at(self, index: typing.Union[slice, int], /) -> typing.Union[_ValueT, typing.Sequence[_ValueT]]:
+        ...
 
     @abc.abstractmethod
     def iterator(self) -> iterators.LazyIterator[_ValueT]:
diff --git a/hikari/config.py b/hikari/config.py
index c57d3269..351c6d78 100644
--- a/hikari/config.py
+++ b/hikari/config.py
@@ -330,55 +330,47 @@ def _(self, _: attr.Attribute[typing.Optional[int]], value: typing.Optional[int]
         if value is not None and (not isinstance(value, int) or value <= 0):
             raise ValueError("http_settings.max_redirects must be None or a POSITIVE integer")
 
-    _ssl: typing.Union[bool, ssl_.SSLContext] = attr.field(
-        default=True,
+    ssl: ssl_.SSLContext = attr.field(
+        factory=lambda: _ssl_factory(True),
         converter=_ssl_factory,
-        validator=attr.validators.instance_of(ssl_.SSLContext),  # type: ignore[assignment,arg-type]
+        validator=attr.validators.instance_of(ssl_.SSLContext),
     )
+    """SSL context to use.
 
-    @property
-    def ssl(self) -> ssl_.SSLContext:
-        """SSL context to use.
-
-        This may be __assigned__ a `builtins.bool` or an `ssl.SSLContext` object.
+    This may be __assigned__ a `builtins.bool` or an `ssl.SSLContext` object.
 
-        If assigned to `builtins.True`, a default SSL context is generated by
-        this class that will enforce SSL verification. This is then stored in
-        this field.
+    If assigned to `builtins.True`, a default SSL context is generated by
+    this class that will enforce SSL verification. This is then stored in
+    this field.
 
-        If `builtins.False`, then a default SSL context is generated by this
-        class that will **NOT** enforce SSL verification. This is then stored
-        in this field.
+    If `builtins.False`, then a default SSL context is generated by this
+    class that will **NOT** enforce SSL verification. This is then stored
+    in this field.
 
-        If an instance of `ssl.SSLContext`, then this context will be used.
+    If an instance of `ssl.SSLContext`, then this context will be used.
 
-        !!! warning
-            Setting a custom value here may have security implications, or
-            may result in the application being unable to connect to Discord
-            at all.
+    !!! warning
+        Setting a custom value here may have security implications, or
+        may result in the application being unable to connect to Discord
+        at all.
 
-        !!! warning
-            Disabling SSL verification is almost always unadvised. This
-            is because your application will no longer check whether you are
-            connecting to Discord, or to some third party spoof designed
-            to steal personal credentials such as your application token.
+    !!! warning
+        Disabling SSL verification is almost always unadvised. This
+        is because your application will no longer check whether you are
+        connecting to Discord, or to some third party spoof designed
+        to steal personal credentials such as your application token.
 
-            There may be cases where SSL certificates do not get updated,
-            and in this case, you may find that disabling this explicitly
-            allows you to work around any issues that are occurring, but
-            you should immediately seek a better solution where possible
-            if any form of personal security is in your interest.
+        There may be cases where SSL certificates do not get updated,
+        and in this case, you may find that disabling this explicitly
+        allows you to work around any issues that are occurring, but
+        you should immediately seek a better solution where possible
+        if any form of personal security is in your interest.
 
-        Returns
-        -------
-        ssl.SSLContext
-            The SSL context to use for this application.
-        """
-        ssl = self._ssl
-        assert isinstance(
-            ssl, ssl_.SSLContext
-        ), f"expected ssl.SSLContext, found {type(ssl)!r}. Did you overwrite the value?"
-        return ssl
+    Returns
+    -------
+    ssl.SSLContext
+        The SSL context to use for this application.
+    """
 
     timeouts: HTTPTimeoutSettings = attr.field(
         factory=HTTPTimeoutSettings, validator=attr.validators.instance_of(HTTPTimeoutSettings)
diff --git a/hikari/files.py b/hikari/files.py
index 993e4fd7..6a065aba 100644
--- a/hikari/files.py
+++ b/hikari/files.py
@@ -980,7 +980,7 @@ async def _wrap_iter(self) -> typing.AsyncGenerator[typing.Any, bytes]:
                 pass
 
         elif aio.is_async_iterable(self.data):
-            async for chunk in self.data:  # type: ignore[union-attr]
+            async for chunk in self.data:
                 yield self._assert_bytes(chunk)
 
         elif isinstance(self.data, typing.Iterable):
diff --git a/hikari/impl/special_endpoints.py b/hikari/impl/special_endpoints.py
index 7d158adf..6a2b6924 100644
--- a/hikari/impl/special_endpoints.py
+++ b/hikari/impl/special_endpoints.py
@@ -845,7 +845,14 @@ def build(self, entity_factory: entity_factory_.EntityFactory, /) -> data_bindin
         data = data_binding.JSONObjectBuilder()
         data.put("content", self.content)
         if self._embeds:
-            data["embeds"] = [entity_factory.serialize_embed(embed) for embed in self._embeds]
+            embeds: typing.List[data_binding.JSONObject] = []
+            for embed, attachments in map(entity_factory.serialize_embed, self._embeds):
+                if attachments:
+                    raise ValueError("Cannot send an embed with attachments in a slash command's initial response")
+
+                embeds.append(embed)
+
+            data["embeds"] = embeds
 
         data.put("flags", self.flags)
         data.put("tts", self.is_tts)
diff --git a/hikari/internal/aio.py b/hikari/internal/aio.py
index 977844cb..20bbdd63 100644
--- a/hikari/internal/aio.py
+++ b/hikari/internal/aio.py
@@ -37,6 +37,10 @@
 import inspect
 import typing
 
+if typing.TYPE_CHECKING:
+    # typing_extensions is a dependency of mypy, and pyright vendors it.
+    from typing_extensions import TypeGuard
+
 T_co = typing.TypeVar("T_co", covariant=True)
 T_inv = typing.TypeVar("T_inv")
 
@@ -84,12 +88,12 @@ def completed_future(result: typing.Optional[T_inv] = None, /) -> asyncio.Future
 # ... so I guess I will have to determine this some other way.
 
 
-def is_async_iterator(obj: typing.Any) -> bool:
+def is_async_iterator(obj: typing.Any) -> TypeGuard[typing.AsyncIterator[object]]:
     """Determine if the object is an async iterator or not."""
     return asyncio.iscoroutinefunction(getattr(obj, "__anext__", None))
 
 
-def is_async_iterable(obj: typing.Any) -> bool:
+def is_async_iterable(obj: typing.Any) -> TypeGuard[typing.AsyncIterable[object]]:
     """Determine if the object is an async iterable or not."""
     attr = getattr(obj, "__aiter__", None)
     return inspect.isfunction(attr) or inspect.ismethod(attr)
diff --git a/hikari/internal/cache.py b/hikari/internal/cache.py
index 575da5bd..fa23ac04 100644
--- a/hikari/internal/cache.py
+++ b/hikari/internal/cache.py
@@ -83,80 +83,77 @@
 """Type-hint for mapping values."""
 
 
-class CacheMappingView(cache.CacheView[KeyT, ValueT], typing.Generic[KeyT, ValueT]):
+class CacheMappingView(cache.CacheView[KeyT, ValueT]):
     """A cache mapping view implementation used for representing cached data.
 
     Parameters
     ----------
-    items : typing.Mapping[KeyT, typing.Union[ValueT, DataT, RefCell[ValueT]]]
+    items : typing.Union[typing.Mapping[KeyT, ValueT], typing.Mapping[KeyT, DataT]]
         A mapping of keys to the values in their raw forms, wrapped by a ref
         wrapper or in a data form.
     builder : typing.Optional[typing.Callable[[DataT], ValueT]]
         The callable used to build entities before they're returned by the
         mapping. This is used to cover the case when items stores `DataT` objects.
-    predicate : typing.Optional[typing.Callable[[typing.Any], bool]]
-        A callable to use to determine whether entries should be returned or hidden,
-        this should take in whatever raw type was passed for the value in `items`.
-        This may be `builtins.None` if all entries should be exposed.
     """
 
-    __slots__: typing.Sequence[str] = ("_builder", "_data", "_predicate")
+    __slots__: typing.Sequence[str] = ("_data", "_builder")
 
+    @typing.overload
     def __init__(
         self,
-        items: typing.Mapping[KeyT, typing.Union[ValueT, DataT]],
+        items: typing.Mapping[KeyT, ValueT],
+    ) -> None:
+        ...
+
+    @typing.overload
+    def __init__(
+        self,
+        items: typing.Mapping[KeyT, DataT],
+        *,
+        builder: typing.Callable[[DataT], ValueT],
+    ) -> None:
+        ...
+
+    def __init__(
+        self,
+        items: typing.Union[typing.Mapping[KeyT, ValueT], typing.Mapping[KeyT, DataT]],
         *,
         builder: typing.Optional[typing.Callable[[DataT], ValueT]] = None,
-        predicate: typing.Optional[typing.Callable[[typing.Any], bool]] = None,
     ) -> None:
         self._builder = builder
         self._data = items
-        self._predicate = predicate
 
-    @classmethod
-    def _copy(cls, value: ValueT) -> ValueT:
+    @staticmethod
+    def _copy(value: ValueT) -> ValueT:
         return copy.copy(value)
 
     def __contains__(self, key: typing.Any) -> bool:
-        return key in self._data and (self._predicate is None or self._predicate(self._data[key]))
+        return key in self._data
 
     def __getitem__(self, key: KeyT) -> ValueT:
         entry = self._data[key]
 
-        if self._predicate is not None and not self._predicate(entry):
-            raise KeyError(key)
-
-        if self._builder is not None:
-            entry = self._builder(entry)  # type: ignore[arg-type]
-
-        else:
-            entry = self._copy(entry)  # type: ignore[arg-type]
+        if self._builder:
+            return self._builder(entry)  # type: ignore[arg-type]
 
-        return entry
+        return self._copy(entry)  # type: ignore[arg-type]
 
     def __iter__(self) -> typing.Iterator[KeyT]:
-        if self._predicate is None:
-            return iter(self._data)
-        else:
-            return (key for key, value in self._data.items() if self._predicate(value))
+        return iter(self._data)
 
     def __len__(self) -> int:
-        if self._predicate is None:
-            return len(self._data)
-        else:
-            return sum(1 for value in self._data.values() if self._predicate(value))
+        return len(self._data)
 
-    def get_item_at(self, index: int) -> ValueT:
-        current_index = -1
+    @typing.overload
+    def get_item_at(self, index: int, /) -> ValueT:
+        ...
 
-        for key, value in self._data.items():
-            if self._predicate is None or self._predicate(value):
-                index += 1
+    @typing.overload
+    def get_item_at(self, index: slice, /) -> typing.Sequence[ValueT]:
+        ...
 
-            if current_index == index:
-                return self[key]
-
-        raise IndexError(index)
+    def get_item_at(self, index: typing.Union[slice, int], /) -> typing.Union[ValueT, typing.Sequence[ValueT]]:
+        return collections.get_index_or_slice(self, index)
 
     def iterator(self) -> iterators.LazyIterator[ValueT]:
         return iterators.FlatLazyIterator(self.values())
@@ -179,7 +176,7 @@ def __iter__(self) -> typing.Iterator[typing.Any]:
     def __len__(self) -> typing.Literal[0]:
         return 0
 
-    def get_item_at(self, index: int) -> typing.NoReturn:
+    def get_item_at(self, index: typing.Union[slice, int]) -> typing.NoReturn:
         raise IndexError(index)
 
     def iterator(self) -> iterators.LazyIterator[ValueT]:
@@ -740,7 +737,7 @@ def _copy_embed(embed: embeds_.Embed) -> embeds_.Embed:
         author=copy.copy(embed.author) if embed.author else None,
         provider=copy.copy(embed.provider) if embed.provider else None,
         footer=copy.copy(embed.footer) if embed.footer else None,
-        fields=list(map(copy.copy, embed.fields)),  # type: ignore[arg-type]
+        fields=[copy.copy(field) for field in embed.fields],
     )
 
 
@@ -1028,6 +1025,6 @@ class Cache3DMappingView(CacheMappingView[snowflakes.Snowflake, cache.CacheView[
 
     __slots__: typing.Sequence[str] = ()
 
-    @classmethod
-    def _copy(cls, value: cache.CacheView[KeyT, ValueT]) -> cache.CacheView[KeyT, ValueT]:
+    @staticmethod
+    def _copy(value: cache.CacheView[KeyT, ValueT]) -> cache.CacheView[KeyT, ValueT]:
         return value


Test patch:
diff --git a/tests/hikari/impl/test_special_endpoints.py b/tests/hikari/impl/test_special_endpoints.py
index 594addac..e8b0565b 100644
--- a/tests/hikari/impl/test_special_endpoints.py
+++ b/tests/hikari/impl/test_special_endpoints.py
@@ -121,6 +121,8 @@ def test_user_mentions_property(self):
     def test_build(self):
         mock_entity_factory = mock.Mock()
         mock_embed = object()
+        mock_serialized_embed = object()
+        mock_entity_factory.serialize_embed.return_value = (mock_serialized_embed, [])
         builder = (
             special_endpoints.InteractionMessageBuilder(base_interactions.ResponseType.MESSAGE_CREATE)
             .add_embed(mock_embed)
@@ -139,13 +141,25 @@ def test_build(self):
             "type": base_interactions.ResponseType.MESSAGE_CREATE,
             "data": {
                 "content": "a content",
-                "embeds": [mock_entity_factory.serialize_embed.return_value],
+                "embeds": [mock_serialized_embed],
                 "flags": 2323,
                 "tts": True,
                 "allowed_mentions": {"parse": [], "users": ["123"], "roles": ["54234"]},
             },
         }
 
+    def test_build_handles_attachments(self):
+        mock_entity_factory = mock.Mock()
+        mock_entity_factory.serialize_embed.return_value = (object(), [object()])
+        builder = special_endpoints.InteractionMessageBuilder(base_interactions.ResponseType.MESSAGE_CREATE).add_embed(
+            object()
+        )
+
+        with pytest.raises(
+            ValueError, match="Cannot send an embed with attachments in a slash command's initial response"
+        ):
+            builder.build(mock_entity_factory)
+
 
 class TestCommandBuilder:
     def test_description_property(self):