Repository: Cog-Creators/Red-DiscordBot

Issue:
[RPC] Some built-in methods error
### What Red version are you using?

3.4.14

### What were you trying to do?

Use the built-in `CORE__LOAD` RPC method to load a cog via RPC.

### What did you expect to happen?

The cog to load and some form of output through RPC confirming it succeeded (which looking at the code would be a tuple)

### What actually happened?

The RPC server tried to serialize a tuple (which was returned from `CoreLogic._load`) but it can't serialize it.

Bot error:
![https://img.vexcodes.com/hUm2gu](https://img.vexcodes.com/hUm2gu)

RPC client error:
![https://img.vexcodes.com/W8CtEJ](https://img.vexcodes.com/W8CtEJ)

### How can we reproduce this error?

1. Start red with `--rpc`
2. Start up an RPC client
3. Call the `CORE__LOAD` function with any arguments
4. Observe RPC returning Internal Error and the bot logs saying serialization error.


### Anything else?

Reproduction code, using the RPC library Red uses:
```py
from aiohttp_json_rpc import JsonRpcClient

client = JsonRpcClient()
await client.connect("localhost", 6133)

try:
    await client.call("CORE__LOAD", ["customcom"])  # raises RpcInternalError
finally:
    await client.disconnect()
```


`CORE__RELOAD` is broken in the same way as `CORE__LOAD`

I would expect `CORE_UNLOAD` to also be broken but instead it does this:
![https://img.vexcodes.com/X1OvRx](https://img.vexcodes.com/X1OvRx)


Discussion:

Resolving patch:
diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py
--- a/redbot/core/core_commands.py
+++ b/redbot/core/core_commands.py
@@ -116,11 +116,7 @@ def __init__(self, bot: "Red"):
         self.bot.register_rpc_handler(self._version_info)
         self.bot.register_rpc_handler(self._invite_url)
 
-    async def _load(
-        self, pkg_names: Iterable[str]
-    ) -> Tuple[
-        List[str], List[str], List[str], List[str], List[str], List[Tuple[str, str]], Set[str]
-    ]:
+    async def _load(self, pkg_names: Iterable[str]) -> Dict[str, Union[List[str], Dict[str, str]]]:
         """
         Loads packages by name.
 
@@ -131,23 +127,23 @@ async def _load(
 
         Returns
         -------
-        tuple
-            7-tuple of:
-              1. List of names of packages that loaded successfully
-              2. List of names of packages that failed to load without specified reason
-              3. List of names of packages that don't have a valid package name
-              4. List of names of packages that weren't found in any cog path
-              5. List of names of packages that are already loaded
-              6. List of 2-tuples (pkg_name, reason) for packages
-              that failed to load with a specified reason
-              7. Set of repo names that use deprecated shared libraries
+        dict
+            Dictionary with keys:
+              ``loaded_packages``: List of names of packages that loaded successfully
+              ``failed_packages``: List of names of packages that failed to load without specified reason
+              ``invalid_pkg_names``: List of names of packages that don't have a valid package name
+              ``notfound_packages``: List of names of packages that weren't found in any cog path
+              ``alreadyloaded_packages``: List of names of packages that are already loaded
+              ``failed_with_reason_packages``: Dictionary of packages that failed to load with
+              a specified reason with mapping of package names -> failure reason
+              ``repos_with_shared_libs``: List of repo names that use deprecated shared libraries
         """
         failed_packages = []
         loaded_packages = []
         invalid_pkg_names = []
         notfound_packages = []
         alreadyloaded_packages = []
-        failed_with_reason_packages = []
+        failed_with_reason_packages = {}
         repos_with_shared_libs = set()
 
         bot = self.bot
@@ -179,7 +175,7 @@ async def _load(
             except errors.PackageAlreadyLoaded:
                 alreadyloaded_packages.append(name)
             except errors.CogLoadError as e:
-                failed_with_reason_packages.append((name, str(e)))
+                failed_with_reason_packages[name] = str(e)
             except Exception as e:
                 if isinstance(e, commands.CommandRegistrationError):
                     if e.alias_conflict:
@@ -192,7 +188,7 @@ async def _load(
                             "Command {command_name} is already an existing command"
                             " or alias in one of the loaded cogs."
                         ).format(command_name=inline(e.name))
-                    failed_with_reason_packages.append((name, error_message))
+                    failed_with_reason_packages[name] = error_message
                     continue
 
                 log.exception("Package loading failed", exc_info=e)
@@ -219,15 +215,15 @@ async def _load(
                 if maybe_repo is not None:
                     repos_with_shared_libs.add(maybe_repo.name)
 
-        return (
-            loaded_packages,
-            failed_packages,
-            invalid_pkg_names,
-            notfound_packages,
-            alreadyloaded_packages,
-            failed_with_reason_packages,
-            repos_with_shared_libs,
-        )
+        return {
+            "loaded_packages": loaded_packages,
+            "failed_packages": failed_packages,
+            "invalid_pkg_names": invalid_pkg_names,
+            "notfound_packages": notfound_packages,
+            "alreadyloaded_packages": alreadyloaded_packages,
+            "failed_with_reason_packages": failed_with_reason_packages,
+            "repos_with_shared_libs": list(repos_with_shared_libs),
+        }
 
     @staticmethod
     def _cleanup_and_refresh_modules(module_name: str) -> None:
@@ -255,7 +251,7 @@ def maybe_reload(new_name):
         for child_name, lib in children.items():
             importlib._bootstrap._exec(lib.__spec__, lib)
 
-    async def _unload(self, pkg_names: Iterable[str]) -> Tuple[List[str], List[str]]:
+    async def _unload(self, pkg_names: Iterable[str]) -> Dict[str, List[str]]:
         """
         Unloads packages with the given names.
 
@@ -266,10 +262,13 @@ async def _unload(self, pkg_names: Iterable[str]) -> Tuple[List[str], List[str]]
 
         Returns
         -------
-        tuple
-            2 element tuple of successful unloads and failed unloads.
+        dict
+            Dictionary with keys:
+              ``unloaded_packages``: List of names of packages that unloaded successfully.
+              ``notloaded_packages``: List of names of packages that weren't unloaded
+              because they weren't loaded.
         """
-        failed_packages = []
+        notloaded_packages = []
         unloaded_packages = []
 
         bot = self.bot
@@ -280,15 +279,13 @@ async def _unload(self, pkg_names: Iterable[str]) -> Tuple[List[str], List[str]]
                 await bot.remove_loaded_package(name)
                 unloaded_packages.append(name)
             else:
-                failed_packages.append(name)
+                notloaded_packages.append(name)
 
-        return unloaded_packages, failed_packages
+        return {"unloaded_packages": unloaded_packages, "notloaded_packages": notloaded_packages}
 
     async def _reload(
         self, pkg_names: Sequence[str]
-    ) -> Tuple[
-        List[str], List[str], List[str], List[str], List[str], List[Tuple[str, str]], Set[str]
-    ]:
+    ) -> Dict[str, Union[List[str], Dict[str, str]]]:
         """
         Reloads packages with the given names.
 
@@ -299,30 +296,12 @@ async def _reload(
 
         Returns
         -------
-        tuple
-            Tuple as returned by `CoreLogic._load()`
+        dict
+            Dictionary with keys as returned by `CoreLogic._load()`
         """
         await self._unload(pkg_names)
 
-        (
-            loaded,
-            load_failed,
-            invalid_pkg_names,
-            not_found,
-            already_loaded,
-            load_failed_with_reason,
-            repos_with_shared_libs,
-        ) = await self._load(pkg_names)
-
-        return (
-            loaded,
-            load_failed,
-            invalid_pkg_names,
-            not_found,
-            already_loaded,
-            load_failed_with_reason,
-            repos_with_shared_libs,
-        )
+        return await self._load(pkg_names)
 
     async def _name(self, name: Optional[str] = None) -> str:
         """
@@ -1687,24 +1666,16 @@ async def load(self, ctx: commands.Context, *cogs: str):
         """
         cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
         async with ctx.typing():
-            (
-                loaded,
-                failed,
-                invalid_pkg_names,
-                not_found,
-                already_loaded,
-                failed_with_reason,
-                repos_with_shared_libs,
-            ) = await self._load(cogs)
+            outcomes = await self._load(cogs)
 
         output = []
 
-        if loaded:
+        if loaded := outcomes["loaded_packages"]:
             loaded_packages = humanize_list([inline(package) for package in loaded])
             formed = _("Loaded {packs}.").format(packs=loaded_packages)
             output.append(formed)
 
-        if already_loaded:
+        if already_loaded := outcomes["alreadyloaded_packages"]:
             if len(already_loaded) == 1:
                 formed = _("The following package is already loaded: {pack}").format(
                     pack=inline(already_loaded[0])
@@ -1715,7 +1686,7 @@ async def load(self, ctx: commands.Context, *cogs: str):
                 )
             output.append(formed)
 
-        if failed:
+        if failed := outcomes["failed_packages"]:
             if len(failed) == 1:
                 formed = _(
                     "Failed to load the following package: {pack}."
@@ -1728,7 +1699,7 @@ async def load(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in failed]))
             output.append(formed)
 
-        if invalid_pkg_names:
+        if invalid_pkg_names := outcomes["invalid_pkg_names"]:
             if len(invalid_pkg_names) == 1:
                 formed = _(
                     "The following name is not a valid package name: {pack}\n"
@@ -1743,7 +1714,7 @@ async def load(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in invalid_pkg_names]))
             output.append(formed)
 
-        if not_found:
+        if not_found := outcomes["notfound_packages"]:
             if len(not_found) == 1:
                 formed = _("The following package was not found in any cog path: {pack}.").format(
                     pack=inline(not_found[0])
@@ -1754,8 +1725,8 @@ async def load(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in not_found]))
             output.append(formed)
 
-        if failed_with_reason:
-            reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason])
+        if failed_with_reason := outcomes["failed_with_reason_packages"]:
+            reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason.items()])
             if len(failed_with_reason) == 1:
                 formed = _(
                     "This package could not be loaded for the following reason:\n\n{reason}"
@@ -1766,7 +1737,7 @@ async def load(self, ctx: commands.Context, *cogs: str):
                 ).format(reasons=reasons)
             output.append(formed)
 
-        if repos_with_shared_libs:
+        if repos_with_shared_libs := outcomes["repos_with_shared_libs"]:
             if len(repos_with_shared_libs) == 1:
                 formed = _(
                     "**WARNING**: The following repo is using shared libs"
@@ -1805,11 +1776,11 @@ async def unload(self, ctx: commands.Context, *cogs: str):
             - `<cogs...>` - The cog packages to unload.
         """
         cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
-        unloaded, failed = await self._unload(cogs)
+        outcomes = await self._unload(cogs)
 
         output = []
 
-        if unloaded:
+        if unloaded := outcomes["unloaded_packages"]:
             if len(unloaded) == 1:
                 formed = _("The following package was unloaded: {pack}.").format(
                     pack=inline(unloaded[0])
@@ -1820,7 +1791,7 @@ async def unload(self, ctx: commands.Context, *cogs: str):
                 )
             output.append(formed)
 
-        if failed:
+        if failed := outcomes["notloaded_packages"]:
             if len(failed) == 1:
                 formed = _("The following package was not loaded: {pack}.").format(
                     pack=inline(failed[0])
@@ -1854,24 +1825,16 @@ async def reload(self, ctx: commands.Context, *cogs: str):
         """
         cogs = tuple(map(lambda cog: cog.rstrip(","), cogs))
         async with ctx.typing():
-            (
-                loaded,
-                failed,
-                invalid_pkg_names,
-                not_found,
-                already_loaded,
-                failed_with_reason,
-                repos_with_shared_libs,
-            ) = await self._reload(cogs)
+            outcomes = await self._reload(cogs)
 
         output = []
 
-        if loaded:
+        if loaded := outcomes["loaded_packages"]:
             loaded_packages = humanize_list([inline(package) for package in loaded])
             formed = _("Reloaded {packs}.").format(packs=loaded_packages)
             output.append(formed)
 
-        if failed:
+        if failed := outcomes["failed_packages"]:
             if len(failed) == 1:
                 formed = _(
                     "Failed to reload the following package: {pack}."
@@ -1884,7 +1847,7 @@ async def reload(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in failed]))
             output.append(formed)
 
-        if invalid_pkg_names:
+        if invalid_pkg_names := outcomes["invalid_pkg_names"]:
             if len(invalid_pkg_names) == 1:
                 formed = _(
                     "The following name is not a valid package name: {pack}\n"
@@ -1899,7 +1862,7 @@ async def reload(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in invalid_pkg_names]))
             output.append(formed)
 
-        if not_found:
+        if not_found := outcomes["notfound_packages"]:
             if len(not_found) == 1:
                 formed = _("The following package was not found in any cog path: {pack}.").format(
                     pack=inline(not_found[0])
@@ -1910,8 +1873,8 @@ async def reload(self, ctx: commands.Context, *cogs: str):
                 ).format(packs=humanize_list([inline(package) for package in not_found]))
             output.append(formed)
 
-        if failed_with_reason:
-            reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason])
+        if failed_with_reason := outcomes["failed_with_reason_packages"]:
+            reasons = "\n".join([f"`{x}`: {y}" for x, y in failed_with_reason.items()])
             if len(failed_with_reason) == 1:
                 formed = _(
                     "This package could not be reloaded for the following reason:\n\n{reason}"
@@ -1922,7 +1885,7 @@ async def reload(self, ctx: commands.Context, *cogs: str):
                 ).format(reasons=reasons)
             output.append(formed)
 
-        if repos_with_shared_libs:
+        if repos_with_shared_libs := outcomes["repos_with_shared_libs"]:
             if len(repos_with_shared_libs) == 1:
                 formed = _(
                     "**WARNING**: The following repo is using shared libs"


Test patch: