Repository: hikari-py/hikari
Pull request #1046 (2022-03-02 06:09:41)

Raise on File.stream's aenter if the file is a dir or doesn't exist
### Summary
This matches the behaviour seen for URL resources (which happen to be the only other kind where the resource may not exist or may be invalid) and should solve the issue of aiohttp giving off confusing errors on unknown files  by erroring before we enter an aiohttp context.

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [ ] I have run `nox` and all the pipelines have passed.
- [ ] I have made unittests according to the code I have added/modified/deleted.

### Related issues
Fixes: #826



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 48b82b3e..130ddf50 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
@@ -57,7 +57,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
@@ -87,7 +87,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
@@ -114,7 +114,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
@@ -131,7 +131,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index f20fad6e..5aece327 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -15,7 +15,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v2.4.0
+      uses: actions/checkout@v3
 
     - name: Initialize CodeQL
       uses: github/codeql-action/init@v1
diff --git a/.github/workflows/fragments-check.yml b/.github/workflows/fragments-check.yml
index 3c7cec9b..5f40c08c 100644
--- a/.github/workflows/fragments-check.yml
+++ b/.github/workflows/fragments-check.yml
@@ -13,7 +13,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
         with:
           # `towncrier check` runs `git diff --name-only origin/main...`, which
           # needs a non-shallow clone.
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index cdd80e24..91b1dfb0 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -13,7 +13,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
 
       - name: Setup python
         uses: actions/setup-python@v3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 62237822..73f30e0a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,7 +10,7 @@ jobs:
 
     steps:
       - name: Checkout repo
-        uses: actions/checkout@v2.4.0
+        uses: actions/checkout@v3
         with:
           token: ${{ secrets.PAT_TOKEN }}
 
diff --git a/changes/1046.bugfix.md b/changes/1046.bugfix.md
new file mode 100644
index 00000000..e4775646
--- /dev/null
+++ b/changes/1046.bugfix.md
@@ -0,0 +1,3 @@
+The async context manager returned by `File.stream` now errors on enter if the target file doesn't exist to improve error handling when a file that doesn't exist is sent as an attachment.
+
+The multiprocessing file reader strategy now expands user relative (`~`) links (like the threaded strategy).
diff --git a/changes/796.feature.md b/changes/796.feature.md
new file mode 100644
index 00000000..6e79c534
--- /dev/null
+++ b/changes/796.feature.md
@@ -0,0 +1,3 @@
+Added a `total_length` function to `hikari.embeds.Embed`
+- Takes into account the character length of the embed's title, description, fields (all field names and values), footer, and author combined.
+- Useful for determining if the embed exceeds Discord's 6000 character limit.
diff --git a/changes/841.bugfix.md b/changes/841.bugfix.md
new file mode 100644
index 00000000..3bc02402
--- /dev/null
+++ b/changes/841.bugfix.md
@@ -0,0 +1,1 @@
+Fix bucket lock not being released on errors while being acquired, which locked the bucket infinitely
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 1e9e4d32..b7781f3d 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -7,7 +7,7 @@ mock==4.0.3
 
 # Py.test stuff.
 pytest==7.0.1
-pytest-asyncio==0.18.1
+pytest-asyncio==0.18.2
 pytest-cov==3.0.0
 pytest-randomly==3.11.0
 
@@ -29,7 +29,7 @@ sphobjinv==2.2.1
 #################
 
 mypy==0.931
-pyright==1.1.225
+pyright==1.1.226
 
 #######################
 # DEPENDENCY CHECKING #
diff --git a/hikari/embeds.py b/hikari/embeds.py
index 388bd18e..0b385cc8 100644
--- a/hikari/embeds.py
+++ b/hikari/embeds.py
@@ -991,3 +991,26 @@ def __eq__(self, other: typing.Any) -> bool:
             else:
                 return True
         return False
+
+    def total_length(self) -> int:
+        """Get the total character count of the embed.
+
+        Returns
+        -------
+        builtins.int
+            The total character count of this embed, including title, description,
+            fields, footer, and author combined.
+        """
+        total = len(self._title or "") + len(self._description or "")
+
+        if self._fields:
+            for field in self._fields:
+                total += len(field.name) + len(field.value)
+
+        if self._footer and self._footer.text:
+            total += len(self._footer.text)
+
+        if self._author and self._author.name:
+            total += len(self._author.name)
+
+        return total
diff --git a/hikari/files.py b/hikari/files.py
index 0a09008f..10b6dbac 100644
--- a/hikari/files.py
+++ b/hikari/files.py
@@ -53,6 +53,7 @@
 import mimetypes
 import os
 import pathlib
+import stat
 import typing
 import urllib.parse
 import urllib.request
@@ -394,7 +395,7 @@ def __exit__(self, exc_type: typing.Type[Exception], exc_val: Exception, exc_tb:
 
 @attr.define(weakref_slot=False)
 @typing.final
-class _NoOpAsyncReaderContextManagerImpl(typing.Generic[ReaderImplT], AsyncReaderContextManager[ReaderImplT]):
+class _NoOpAsyncReaderContextManagerImpl(AsyncReaderContextManager[ReaderImplT]):
     impl: ReaderImplT = attr.field()
 
     async def __aenter__(self) -> ReaderImplT:
@@ -498,6 +499,8 @@ def stream(
         -------
         AsyncReaderContextManager[AsyncReader]
             An async iterable of bytes to stream.
+
+            This will error on enter if the target resource doesn't exist.
         """
 
     def __str__(self) -> str:
@@ -768,6 +771,54 @@ class FileReader(AsyncReader, abc.ABC):
     """The path to the resource to read."""
 
 
+def _stat(path: pathlib.Path) -> os.stat_result:
+    # While paths will be implicitly resolved, we still need to explicitly
+    # call expanduser to deal with a ~ base.
+    try:
+        path = path.expanduser()
+    except RuntimeError:
+        pass  # A home directory couldn't be resolved, so we'll just use the path as-is.
+
+    return path.stat()
+
+
+@attr.define(weakref_slot=False)
+@typing.final
+class _FileAsyncReaderContextManagerImpl(AsyncReaderContextManager[FileReader]):
+    impl: FileReader = attr.field()
+
+    async def __aenter__(self) -> FileReader:
+        loop = asyncio.get_running_loop()
+
+        # Will raise FileNotFoundError if the file doesn't exist (unlike is_dir),
+        # which is what we want here.
+        file_stats = await loop.run_in_executor(self.impl.executor, _stat, self.impl.path)
+
+        if stat.S_ISDIR(file_stats.st_mode):
+            raise IsADirectoryError(self.impl.path)
+
+        return self.impl
+
+    async def __aexit__(
+        self,
+        exc_type: typing.Optional[typing.Type[BaseException]],
+        exc: typing.Optional[BaseException],
+        exc_tb: typing.Optional[types.TracebackType],
+    ) -> None:
+        pass
+
+
+def _open_file(path: pathlib.Path) -> typing.BinaryIO:
+    # While paths will be implicitly resolved, we still need to explicitly
+    # call expanduser to deal with a ~ base.
+    try:
+        path = path.expanduser()
+    except RuntimeError:
+        pass  # A home directory couldn't be resolved, so we'll just use the path as-is.
+
+    return path.open("rb")
+
+
 @attr.define(weakref_slot=False)
 class ThreadedFileReader(FileReader):
     """Asynchronous file reader that reads a resource from local storage.
@@ -780,40 +831,29 @@ class ThreadedFileReader(FileReader):
     async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
         loop = asyncio.get_running_loop()
 
-        path = self.path
-        if isinstance(path, pathlib.Path):
-            path = await loop.run_in_executor(self.executor, self._expand, self.path)
-
-        fp = await loop.run_in_executor(self.executor, self._open, path)
+        fp = await loop.run_in_executor(self.executor, _open_file, self.path)
 
         try:
             while True:
-                chunk = await loop.run_in_executor(self.executor, self._read_chunk, fp, _MAGIC)
+                chunk = await loop.run_in_executor(self.executor, fp.read, _MAGIC)
                 yield chunk
                 if len(chunk) < _MAGIC:
                     break
 
         finally:
-            await loop.run_in_executor(self.executor, self._close, fp)
-
-    @staticmethod
-    def _expand(path: pathlib.Path) -> pathlib.Path:
-        # .expanduser is Platform dependent. Will expand stuff like ~ to /home/<user> on posix.
-        # .resolve will follow symlinks and what-have-we to translate stuff like `..` to proper paths.
-        return path.expanduser().resolve()
+            await loop.run_in_executor(self.executor, fp.close)
 
-    @staticmethod
-    @typing.final
-    def _read_chunk(fp: typing.IO[bytes], n: int = 10_000) -> bytes:
-        return fp.read(n)
 
-    @staticmethod
-    def _open(path: Pathish) -> typing.IO[bytes]:
-        return open(path, "rb")
+def _read_all(path: pathlib.Path) -> bytes:
+    # While paths will be implicitly resolved, we still need to explicitly
+    # call expanduser to deal with a ~ base.
+    try:
+        path = path.expanduser()
+    except RuntimeError:
+        pass  # A home directory couldn't be resolved, so we'll just use the path as-is.
 
-    @staticmethod
-    def _close(fp: typing.IO[bytes]) -> None:
-        fp.close()
+    with path.open("rb") as fp:
+        return fp.read()
 
 
 @attr.define(slots=False, weakref_slot=False)  # Do not slot (pickle)
@@ -827,7 +867,7 @@ class MultiprocessingFileReader(FileReader):
     """
 
     async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
-        yield await asyncio.get_running_loop().run_in_executor(self.executor, self._read_all)
+        yield await asyncio.get_running_loop().run_in_executor(self.executor, _read_all, self.path)
 
     def __getstate__(self) -> typing.Dict[str, typing.Any]:
         return {"path": self.path, "filename": self.filename}
@@ -838,10 +878,6 @@ def __setstate__(self, state: typing.Dict[str, typing.Any]) -> None:
         self.executor: typing.Optional[concurrent.futures.Executor] = None
         self.mimetype: typing.Optional[str] = None
 
-    def _read_all(self) -> bytes:
-        with open(self.path, "rb") as fp:
-            return fp.read()
-
 
 class File(Resource[FileReader]):
     """A resource that exists on the local machine's storage to be uploaded.
@@ -921,7 +957,7 @@ def stream(
         # so this is safe enough to do.:
         is_threaded = executor is None or isinstance(executor, concurrent.futures.ThreadPoolExecutor)
         impl = ThreadedFileReader if is_threaded else MultiprocessingFileReader
-        return _NoOpAsyncReaderContextManagerImpl(impl(self.filename, None, executor, self.path))
+        return _FileAsyncReaderContextManagerImpl(impl(self.filename, None, executor, self.path))
 
 
 ########################################################################
diff --git a/hikari/impl/buckets.py b/hikari/impl/buckets.py
index 35e30bd3..a0441ffc 100644
--- a/hikari/impl/buckets.py
+++ b/hikari/impl/buckets.py
@@ -244,19 +244,24 @@ async def __aexit__(
         exc: typing.Optional[BaseException],
         exc_tb: typing.Optional[types.TracebackType],
     ) -> None:
-        self._lock.release()
+        self.release()
 
     @property
     def is_unknown(self) -> bool:
         """Return `builtins.True` if the bucket represents an `UNKNOWN` bucket."""
         return self.name.startswith(UNKNOWN_HASH)
 
+    def release(self) -> None:
+        """Release the lock on the bucket."""
+        self._lock.release()
+
     async def acquire(self) -> None:
-        """Acquire time on this rate limiter.
+        """Acquire time and the lock on this bucket.
 
         !!! note
             You should afterwards invoke `RESTBucket.update_rate_limit` to
-            update any rate limit information you are made aware of.
+            update any rate limit information you are made aware of and
+            `RESTBucket.release` to release the lock.
 
         Raises
         ------
@@ -272,6 +277,8 @@ async def acquire(self) -> None:
         retry_after = self.reset_at - now
 
         if self.is_rate_limited(now) and retry_after > self._max_rate_limit:
+            # Release lock before we error
+            self._lock.release()
             raise errors.RateLimitTooLongError(
                 route=self._compiled_route,
                 retry_after=retry_after,
@@ -304,18 +311,6 @@ def update_rate_limit(self, remaining: int, limit: int, reset_at: float) -> None
         self.reset_at: float = reset_at
         self.period: float = max(0.0, self.reset_at - time.monotonic())
 
-    def drip(self) -> None:
-        """Decrement the remaining count for this bucket.
-
-        !!! note
-            If the bucket is marked as `RESTBucket.is_unknown`, then this will
-            not do anything. `Unknown` buckets have infinite rate limits.
-        """
-        # We don't drip unknown buckets: we cannot rate limit them as we don't know their real bucket hash or
-        # the current rate limit values Discord put on them...
-        if not self.is_unknown:
-            self.remaining -= 1
-
     def resolve(self, real_bucket_hash: str) -> None:
         """Resolve an unknown bucket.
 
@@ -533,7 +528,7 @@ def do_gc_pass(self, expire_after: float) -> None:
 
         _LOGGER.log(ux.TRACE, "purged %s stale buckets, %s remain in survival, %s active", dead, survival, active)
 
-    def acquire(self, compiled_route: routes.CompiledRoute) -> typing.AsyncContextManager[None]:
+    def acquire(self, compiled_route: routes.CompiledRoute) -> RESTBucket:
         """Acquire a bucket for the given route.
 
         Parameters
@@ -543,17 +538,16 @@ def acquire(self, compiled_route: routes.CompiledRoute) -> typing.AsyncContextMa
 
         Returns
         -------
-        typing.AsyncContextManager[builtins.None]
-            A context manager to enter while doing the request.
+        hikari.impl.RESTBucket
+            The bucket for this route.
 
         !!! note
-            You MUST keep the context manager acquired during the whole of the
-            request. From making the request until calling `update_rate_limits`.
+            You MUST keep the context manager of the bucket acquired during the
+            full duration of the request. From making the request until calling
+            `update_rate_limits`.
         """
-        template = compiled_route.route
-
         try:
-            bucket_hash = self.routes_to_hashes[template]
+            bucket_hash = self.routes_to_hashes[compiled_route.route]
             real_bucket_hash = compiled_route.create_real_bucket_hash(bucket_hash)
         except KeyError:
             real_bucket_hash = _create_unknown_hash(compiled_route)
diff --git a/hikari/impl/rate_limits.py b/hikari/impl/rate_limits.py
index 97551e6c..5818cce1 100644
--- a/hikari/impl/rate_limits.py
+++ b/hikari/impl/rate_limits.py
@@ -307,22 +307,21 @@ async def acquire(self) -> None:
         Calling this function will cause it to block until you are not longer
         being rate limited.
         """
-        loop = asyncio.get_running_loop()
-        future = loop.create_future()
-
         # If we are rate limited, delegate invoking this to the throttler and spin it up
         # if it hasn't started. Likewise, if the throttle task is still running, we should
         # delegate releasing the future to the throttler task so that we still process
         # first-come-first-serve
         if self.throttle_task is not None or self.is_rate_limited(time.monotonic()):
+            loop = asyncio.get_running_loop()
+            future = loop.create_future()
+
             self.queue.append(future)
             if self.throttle_task is None:
                 self.throttle_task = loop.create_task(self.throttle())
+
+            await future
         else:
             self.drip()
-            future.set_result(None)
-
-        await future
 
     def get_time_until_reset(self, now: float) -> float:
         """Determine how long until the current rate limit is reset.
diff --git a/hikari/impl/rest.py b/hikari/impl/rest.py
index 9f3c92e8..260164a9 100644
--- a/hikari/impl/rest.py
+++ b/hikari/impl/rest.py
@@ -721,6 +721,7 @@ async def _request(
         retry_count = 0
 
         stack = contextlib.AsyncExitStack()
+        trace_logging_enabled = _LOGGER.isEnabledFor(ux.TRACE)
         while True:
             try:
                 uuid = time.uuid()
@@ -733,7 +734,7 @@ async def _request(
                     if not no_auth:
                         await live_attributes.still_alive().global_rate_limit.acquire()
 
-                    if _LOGGER.isEnabledFor(ux.TRACE):
+                    if trace_logging_enabled:
                         _LOGGER.log(
                             ux.TRACE,
                             "%s %s %s\n%s",
@@ -758,7 +759,7 @@ async def _request(
                         proxy_headers=self._proxy_settings.all_headers,
                     )
 
-                    if _LOGGER.isEnabledFor(ux.TRACE):
+                    if trace_logging_enabled:
                         time_taken = (time.monotonic() - start) * 1_000
                         _LOGGER.log(
                             ux.TRACE,
@@ -1331,11 +1332,11 @@ def _build_message_payload(  # noqa: C901- Function too complex
             )
 
         if final_attachments:
-            form = data_binding.URLEncodedFormBuilder(executor=self._executor)
+            form_builder = data_binding.URLEncodedFormBuilder(executor=self._executor)
 
             for i, attachment in enumerate(final_attachments):
-                form.add_resource(f"file{i}", attachment)
-            return body, form
+                form_builder.add_resource(f"file{i}", attachment)
+            return body, form_builder
 
         return body, None
 
@@ -1363,7 +1364,7 @@ async def create_message(
         ] = undefined.UNDEFINED,
     ) -> messages_.Message:
         route = routes.POST_CHANNEL_MESSAGES.compile(channel=channel)
-        body, form = self._build_message_payload(
+        body, form_builder = self._build_message_payload(
             content=content,
             attachment=attachment,
             attachments=attachments,
@@ -1380,9 +1381,9 @@ async def create_message(
         body.put("nonce", nonce)
         body.put("message_reference", reply, conversion=lambda m: {"message_id": str(int(m))})
 
-        if form is not None:
-            form.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
-            response = await self._request(route, form_builder=form)
+        if form_builder is not None:
+            form_builder.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
+            response = await self._request(route, form_builder=form_builder)
         else:
             response = await self._request(route, json=body)
 
@@ -1427,7 +1428,7 @@ async def edit_message(
         flags: typing.Union[undefined.UndefinedType, int, messages_.MessageFlag] = undefined.UNDEFINED,
     ) -> messages_.Message:
         route = routes.PATCH_CHANNEL_MESSAGE.compile(channel=channel, message=message)
-        body, form = self._build_message_payload(
+        body, form_builder = self._build_message_payload(
             content=content,
             attachment=attachment,
             attachments=attachments,
@@ -1444,9 +1445,9 @@ async def edit_message(
             edit=True,
         )
 
-        if form is not None:
-            form.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
-            response = await self._request(route, form_builder=form)
+        if form_builder is not None:
+            form_builder.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
+            response = await self._request(route, form_builder=form_builder)
         else:
             response = await self._request(route, json=body)
 
@@ -1756,7 +1757,7 @@ async def execute_webhook(
         query = data_binding.StringMapBuilder()
         query.put("wait", True)
 
-        body, form = self._build_message_payload(
+        body, form_builder = self._build_message_payload(
             content=content,
             attachment=attachment,
             attachments=attachments,
@@ -1773,9 +1774,9 @@ async def execute_webhook(
         body.put("username", username)
         body.put("avatar_url", avatar_url, conversion=str)
 
-        if form is not None:
-            form.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
-            response = await self._request(route, form_builder=form, query=query, no_auth=True)
+        if form_builder is not None:
+            form_builder.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
+            response = await self._request(route, form_builder=form_builder, query=query, no_auth=True)
         else:
             response = await self._request(route, json=body, query=query, no_auth=True)
 
@@ -1823,7 +1824,7 @@ async def edit_webhook_message(
         webhook_id = webhook if isinstance(webhook, int) else webhook.webhook_id
         route = routes.PATCH_WEBHOOK_MESSAGE.compile(webhook=webhook_id, token=token, message=message)
 
-        body, form = self._build_message_payload(
+        body, form_builder = self._build_message_payload(
             content=content,
             attachment=attachment,
             attachments=attachments,
@@ -1838,9 +1839,9 @@ async def edit_webhook_message(
             edit=True,
         )
 
-        if form is not None:
-            form.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
-            response = await self._request(route, form_builder=form, no_auth=True)
+        if form_builder is not None:
+            form_builder.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
+            response = await self._request(route, form_builder=form_builder, no_auth=True)
         else:
             response = await self._request(route, json=body, no_auth=True)
 
@@ -1982,11 +1983,13 @@ async def authorize_client_credentials_token(
         scopes: typing.Sequence[typing.Union[applications.OAuth2Scope, str]],
     ) -> applications.PartialOAuth2Token:
         route = routes.POST_TOKEN.compile()
-        form = data_binding.URLEncodedFormBuilder()
-        form.add_field("grant_type", "client_credentials")
-        form.add_field("scope", " ".join(scopes))
+        form_builder = data_binding.URLEncodedFormBuilder()
+        form_builder.add_field("grant_type", "client_credentials")
+        form_builder.add_field("scope", " ".join(scopes))
 
-        response = await self._request(route, form_builder=form, auth=self._gen_oauth2_token(client, client_secret))
+        response = await self._request(
+            route, form_builder=form_builder, auth=self._gen_oauth2_token(client, client_secret)
+        )
         assert isinstance(response, dict)
         return self._entity_factory.deserialize_partial_token(response)
 
@@ -1998,12 +2001,14 @@ async def authorize_access_token(
         redirect_uri: str,
     ) -> applications.OAuth2AuthorizationToken:
         route = routes.POST_TOKEN.compile()
-        form = data_binding.URLEncodedFormBuilder()
-        form.add_field("grant_type", "authorization_code")
-        form.add_field("code", code)
-        form.add_field("redirect_uri", redirect_uri)
+        form_builder = data_binding.URLEncodedFormBuilder()
+        form_builder.add_field("grant_type", "authorization_code")
+        form_builder.add_field("code", code)
+        form_builder.add_field("redirect_uri", redirect_uri)
 
-        response = await self._request(route, form_builder=form, auth=self._gen_oauth2_token(client, client_secret))
+        response = await self._request(
+            route, form_builder=form_builder, auth=self._gen_oauth2_token(client, client_secret)
+        )
         assert isinstance(response, dict)
         return self._entity_factory.deserialize_authorization_token(response)
 
@@ -2018,14 +2023,16 @@ async def refresh_access_token(
         ] = undefined.UNDEFINED,
     ) -> applications.OAuth2AuthorizationToken:
         route = routes.POST_TOKEN.compile()
-        form = data_binding.URLEncodedFormBuilder()
-        form.add_field("grant_type", "refresh_token")
-        form.add_field("refresh_token", refresh_token)
+        form_builder = data_binding.URLEncodedFormBuilder()
+        form_builder.add_field("grant_type", "refresh_token")
+        form_builder.add_field("refresh_token", refresh_token)
 
         if scopes is not undefined.UNDEFINED:
-            form.add_field("scope", " ".join(scopes))
+            form_builder.add_field("scope", " ".join(scopes))
 
-        response = await self._request(route, form_builder=form, auth=self._gen_oauth2_token(client, client_secret))
+        response = await self._request(
+            route, form_builder=form_builder, auth=self._gen_oauth2_token(client, client_secret)
+        )
         assert isinstance(response, dict)
         return self._entity_factory.deserialize_authorization_token(response)
 
@@ -2036,9 +2043,9 @@ async def revoke_access_token(
         token: typing.Union[str, applications.PartialOAuth2Token],
     ) -> None:
         route = routes.POST_TOKEN_REVOKE.compile()
-        form = data_binding.URLEncodedFormBuilder()
-        form.add_field("token", str(token))
-        await self._request(route, form_builder=form, auth=self._gen_oauth2_token(client, client_secret))
+        form_builder = data_binding.URLEncodedFormBuilder()
+        form_builder.add_field("token", str(token))
+        await self._request(route, form_builder=form_builder, auth=self._gen_oauth2_token(client, client_secret))
 
     async def add_user_to_guild(
         self,
@@ -3474,7 +3481,7 @@ async def edit_interaction_response(
     ) -> messages_.Message:
         route = routes.PATCH_INTERACTION_RESPONSE.compile(webhook=application, token=token)
 
-        body, form = self._build_message_payload(
+        body, form_builder = self._build_message_payload(
             content=content,
             attachment=attachment,
             attachments=attachments,
@@ -3489,9 +3496,9 @@ async def edit_interaction_response(
             edit=True,
         )
 
-        if form is not None:
-            form.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
-            response = await self._request(route, form_builder=form, no_auth=True)
+        if form_builder is not None:
+            form_builder.add_field("payload_json", data_binding.dump_json(body), content_type=_APPLICATION_JSON)
+            response = await self._request(route, form_builder=form_builder, no_auth=True)
         else:
             response = await self._request(route, json=body, no_auth=True)
 


Test patch:
diff --git a/tests/hikari/impl/test_buckets.py b/tests/hikari/impl/test_buckets.py
index 391945ff..4984250e 100644
--- a/tests/hikari/impl/test_buckets.py
+++ b/tests/hikari/impl/test_buckets.py
@@ -20,6 +20,7 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 import asyncio
+import contextlib
 import time
 
 import mock
@@ -44,20 +45,27 @@ def compiled_route(self, template):
 
     @pytest.mark.asyncio()
     async def test_async_context_manager(self, compiled_route):
-        with mock.patch.object(asyncio, "Lock") as lock:
-            lock.return_value.acquire = mock.AsyncMock()
-            with mock.patch.object(buckets.RESTBucket, "acquire", new=mock.AsyncMock()) as acquire:
+        with mock.patch.object(buckets.RESTBucket, "acquire", new=mock.AsyncMock()) as acquire:
+            with mock.patch.object(buckets.RESTBucket, "release") as release:
                 async with buckets.RESTBucket("spaghetti", compiled_route, float("inf")):
                     acquire.assert_awaited_once_with()
-                    lock.return_value.release.assert_not_called()
+                    release.assert_not_called()
 
-                lock.return_value.release.assert_called_once_with()
+            release.assert_called_once_with()
 
     @pytest.mark.parametrize("name", ["spaghetti", buckets.UNKNOWN_HASH])
     def test_is_unknown(self, name, compiled_route):
         with buckets.RESTBucket(name, compiled_route, float("inf")) as rl:
             assert rl.is_unknown is (name == buckets.UNKNOWN_HASH)
 
+    def test_release(self, compiled_route):
+        with buckets.RESTBucket(__name__, compiled_route, float("inf")) as rl:
+            rl._lock = mock.Mock()
+
+            rl.release()
+
+            rl._lock.release.assert_called_once_with()
+
     def test_update_rate_limit(self, compiled_route):
         with buckets.RESTBucket(__name__, compiled_route, float("inf")) as rl:
             rl.remaining = 1
@@ -73,13 +81,6 @@ def test_update_rate_limit(self, compiled_route):
             assert rl.reset_at == 27
             assert rl.period == 27 - 4.20
 
-    @pytest.mark.parametrize("name", ["spaghetti", buckets.UNKNOWN_HASH])
-    def test_drip(self, name, compiled_route):
-        with buckets.RESTBucket(name, compiled_route, float("inf")) as rl:
-            rl.remaining = 1
-            rl.drip()
-            assert rl.remaining == 0 if name != buckets.UNKNOWN_HASH else 1
-
     @pytest.mark.asyncio()
     async def test_acquire_when_unknown_bucket(self, compiled_route):
         with buckets.RESTBucket(buckets.UNKNOWN_HASH, compiled_route, float("inf")) as rl:
@@ -92,11 +93,18 @@ async def test_acquire_when_unknown_bucket(self, compiled_route):
 
     @pytest.mark.asyncio()
     async def test_acquire_when_too_long_ratelimit(self, compiled_route):
-        with buckets.RESTBucket("spaghetti", compiled_route, 60) as rl:
-            rl.reset_at = time.perf_counter() + 999999999999999999999999999
-            with mock.patch.object(buckets.RESTBucket, "is_rate_limited", return_value=True):
-                with pytest.raises(errors.RateLimitTooLongError):
-                    await rl.acquire()
+        stack = contextlib.ExitStack()
+        rl = stack.enter_context(buckets.RESTBucket("spaghetti", compiled_route, 60))
+        rl._lock = mock.Mock(acquire=mock.AsyncMock())
+        rl.reset_at = time.perf_counter() + 999999999999999999999999999
+        stack.enter_context(mock.patch.object(buckets.RESTBucket, "is_rate_limited", return_value=True))
+        stack.enter_context(pytest.raises(errors.RateLimitTooLongError))
+
+        with stack:
+            await rl.acquire()
+
+        rl._lock.acquire.assert_awaited_once_with()
+        rl._lock.release.assert_called_once_with()
 
     @pytest.mark.asyncio()
     async def test_acquire(self, compiled_route):
diff --git a/tests/hikari/impl/test_rate_limits.py b/tests/hikari/impl/test_rate_limits.py
index 059997a4..20783737 100644
--- a/tests/hikari/impl/test_rate_limits.py
+++ b/tests/hikari/impl/test_rate_limits.py
@@ -210,13 +210,12 @@ async def test_drip_if_not_throttled_and_not_ratelimited(self, ratelimiter, even
         ratelimiter.drip = mock.Mock()
         ratelimiter.throttle_task = None
         ratelimiter.is_rate_limited = mock.Mock(return_value=False)
-        future = MockFuture()
-        event_loop.create_future = mock.Mock(return_value=future)
+        event_loop.create_future = mock.Mock()
 
         await ratelimiter.acquire()
 
         ratelimiter.drip.assert_called_once_with()
-        future.set_result.assert_called_once_with(None)
+        event_loop.create_future.assert_not_called()
 
     @pytest.mark.asyncio()
     async def test_no_drip_if_throttle_task_is_not_None(self, ratelimiter, event_loop):
diff --git a/tests/hikari/test_embeds.py b/tests/hikari/test_embeds.py
index 7faa731c..f7574e75 100644
--- a/tests/hikari/test_embeds.py
+++ b/tests/hikari/test_embeds.py
@@ -62,3 +62,49 @@ def test_proxy_filename(self, resource_with_proxy):
     def test_proxy_filename_when_resource_is_none(self, resource_with_proxy):
         resource_with_proxy.proxy_resource = None
         assert resource_with_proxy.proxy_filename is None
+
+
+class TestEmbed:
+    def test_total_length_when_embed_is_empty(self):
+        embed = embeds.Embed()
+        assert embed.total_length() == 0
+
+    def test_total_length_when_title_is_none(self):
+        embed = embeds.Embed(title=None)
+        assert embed.total_length() == 0
+
+    def test_total_length_title(self):
+        embed = embeds.Embed(title="title")
+        assert embed.total_length() == 5
+
+    def test_total_length_when_description_is_none(self):
+        embed = embeds.Embed(description=None)
+        assert embed.total_length() == 0
+
+    def test_total_length_description(self):
+        embed = embeds.Embed(description="description")
+        assert embed.total_length() == 11
+
+    def test_total_length_author_name(self):
+        embed = embeds.Embed().set_author(name="author name")
+        assert embed.total_length() == 11
+
+    def test_total_length_footer_text(self):
+        embed = embeds.Embed().set_footer(text="footer text")
+        assert embed.total_length() == 11
+
+    def test_total_length_field_name(self):
+        embed = embeds.Embed().add_field(name="field name", value="")
+        assert embed.total_length() == 10
+
+    def test_total_length_field_value(self):
+        embed = embeds.Embed().add_field(name="", value="field value")
+        assert embed.total_length() == 11
+
+    def test_total_length_all(self):
+        embed = embeds.Embed(title="title", description="description")
+        embed.set_author(name="author name")
+        embed.set_footer(text="footer text")
+        embed.add_field(name="field name 1", value="field value 2")
+        embed.add_field(name="field name 3", value="field value 4")
+        assert embed.total_length() == 88
diff --git a/tests/hikari/test_files.py b/tests/hikari/test_files.py
index 6084e257..59ee0c04 100644
--- a/tests/hikari/test_files.py
+++ b/tests/hikari/test_files.py
@@ -20,6 +20,15 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 
+import base64
+import concurrent.futures
+import contextlib
+import pathlib
+import random
+import tempfile
+import typing
+
+import mock
 import pytest
 
 from hikari import files
@@ -41,3 +50,71 @@ def test___exit__(self, reader):
             reader().__exit__(None, None, None)
         except AttributeError as exc:
             pytest.fail(exc)
+
+
+class Test_FileAsyncReaderContextManagerImpl:
+    @pytest.mark.parametrize(
+        "executor", [concurrent.futures.ThreadPoolExecutor, concurrent.futures.ProcessPoolExecutor]
+    )
+    @pytest.mark.asyncio()
+    async def test_context_manager(self, executor: typing.Callable[[], concurrent.futures.Executor]):
+        mock_reader = mock.Mock(executor=executor())
+        context_manager = files._FileAsyncReaderContextManagerImpl(mock_reader)
+
+        with tempfile.NamedTemporaryFile() as file:
+            mock_reader.path = pathlib.Path(file.name)
+
+            async with context_manager as reader:
+                assert reader is mock_reader
+
+    @pytest.mark.asyncio()
+    async def test_context_manager_when_expandname_raises_runtime_error(self):
+        # We can't mock patch stuff in other processes easily (if at all) so
+        # for this test we only run it threaded.
+        mock_reader = mock.Mock(executor=concurrent.futures.ThreadPoolExecutor())
+        context_manager = files._FileAsyncReaderContextManagerImpl(mock_reader)
+
+        stack = contextlib.ExitStack()
+        file = stack.enter_context(tempfile.NamedTemporaryFile())
+        expandname = stack.enter_context(mock.patch.object(pathlib.Path, "expanduser", side_effect=RuntimeError))
+
+        with file:
+            mock_reader.path = pathlib.Path(file.name)
+
+            async with context_manager as reader:
+                assert reader is mock_reader
+
+        expandname.assert_called_once_with()
+
+    @pytest.mark.parametrize(
+        "executor", [concurrent.futures.ThreadPoolExecutor, concurrent.futures.ProcessPoolExecutor]
+    )
+    @pytest.mark.asyncio()
+    async def test_context_manager_for_unknown_file(self, executor: typing.Callable[[], concurrent.futures.Executor]):
+        mock_reader = mock.Mock(executor=executor())
+        context_manager = files._FileAsyncReaderContextManagerImpl(mock_reader)
+
+        mock_reader.path = pathlib.Path(
+            base64.urlsafe_b64encode(random.getrandbits(512).to_bytes(64, "little")).decode()
+        )
+
+        with pytest.raises(FileNotFoundError):  # noqa:  PT012 - raises block should contain a single statement
+            async with context_manager:
+                ...
+
+    @pytest.mark.parametrize(
+        "executor", [concurrent.futures.ThreadPoolExecutor, concurrent.futures.ProcessPoolExecutor]
+    )
+    @pytest.mark.asyncio()
+    async def test_test_context_manager_when_target_is_dir(
+        self, executor: typing.Callable[[], concurrent.futures.Executor]
+    ):
+        mock_reader = mock.Mock(executor=executor())
+        context_manager = files._FileAsyncReaderContextManagerImpl(mock_reader)
+
+        with tempfile.TemporaryDirectory() as name:
+            mock_reader.path = pathlib.Path(name)
+
+            with pytest.raises(IsADirectoryError):  # noqa:  PT012 - raises block should contain a single statement
+                async with context_manager:
+                    ...