Repository: Cog-Creators/Red-DiscordBot

Issue:
[V3 Unload/Reload] edge cases in bot.unload_extension
### The issue

Right now, `bot.unload_extension` will break unload and reload if any cogs exist on the bot where `Cog.__module__` is `None` 

While this is definitely an edge case, I think it is at least worth considering just filtering out any case where there isn't a module, and trust that in that case, the user of created that situation can handle any required cleanup.

### The use case

Adding a cog through eval to test a specific behavior temporarily, without creating an entire module for adding through standard means.

(yes, this is an extreme edge case, but it's how I've run into it, so even a "we won't support this" is a fine answer if that's the decision)







Discussion:

Resolving patch:
diff --git a/redbot/core/bot.py b/redbot/core/bot.py
--- a/redbot/core/bot.py
+++ b/redbot/core/bot.py
@@ -24,6 +24,10 @@
 from .sentry import SentryManager
 
 
+def _is_submodule(parent, child):
+    return parent == child or child.startswith(parent + ".")
+
+
 class RedBase(BotBase, RPCMixin):
     """Mixin for the main bot class.
 
@@ -211,12 +215,12 @@ async def remove_loaded_package(self, pkg_name: str):
     async def load_extension(self, spec: ModuleSpec):
         name = spec.name.split(".")[-1]
         if name in self.extensions:
-            return
+            raise discord.ClientException(f"there is already a package named {name} loaded")
 
         lib = spec.loader.load_module()
         if not hasattr(lib, "setup"):
             del lib
-            raise discord.ClientException("extension does not have a setup function")
+            raise discord.ClientException(f"extension {name} does not have a setup function")
 
         if asyncio.iscoroutinefunction(lib.setup):
             await lib.setup(self)
@@ -225,44 +229,41 @@ async def load_extension(self, spec: ModuleSpec):
 
         self.extensions[name] = lib
 
+    def remove_cog(self, cogname):
+        super().remove_cog(cogname)
+
+        for meth in self.rpc_handlers.pop(cogname.upper(), ()):
+            self.unregister_rpc_handler(meth)
+
     def unload_extension(self, name):
         lib = self.extensions.get(name)
+
         if lib is None:
             return
 
         lib_name = lib.__name__  # Thank you
 
         # find all references to the module
-        cog_names = []
 
         # remove the cogs registered from the module
         for cogname, cog in self.cogs.copy().items():
-            if cog.__module__.startswith(lib_name):
+            if cog.__module__ and _is_submodule(lib_name, cog.__module__):
                 self.remove_cog(cogname)
 
-                cog_names.append(cogname)
-
-        # remove all rpc handlers
-        for cogname in cog_names:
-            if cogname.upper() in self.rpc_handlers:
-                methods = self.rpc_handlers[cogname]
-                for meth in methods:
-                    self.unregister_rpc_handler(meth)
-
-                del self.rpc_handlers[cogname]
-
         # first remove all the commands from the module
         for cmd in self.all_commands.copy().values():
-            if cmd.module.startswith(lib_name):
+            if cmd.module and _is_submodule(lib_name, cmd.module):
                 if isinstance(cmd, GroupMixin):
                     cmd.recursively_remove_all_commands()
+
                 self.remove_command(cmd.name)
 
         # then remove all the listeners from the module
         for event_list in self.extra_events.copy().values():
             remove = []
+
             for index, event in enumerate(event_list):
-                if event.__module__.startswith(lib_name):
+                if event.__module__ and _is_submodule(lib_name, event.__module__):
                     remove.append(index)
 
             for index in reversed(remove):
@@ -282,11 +283,12 @@ def unload_extension(self, name):
             pkg_name = lib.__package__
             del lib
             del self.extensions[name]
-            for m, _ in sys.modules.copy().items():
-                if m.startswith(pkg_name):
-                    del sys.modules[m]
 
-            if pkg_name.startswith("redbot.cogs"):
+            for module in list(sys.modules):
+                if _is_submodule(lib_name, module):
+                    del sys.modules[module]
+
+            if pkg_name.startswith("redbot.cogs."):
                 del sys.modules["redbot.cogs"].__dict__[name]
 
 
diff --git a/redbot/core/rpc.py b/redbot/core/rpc.py
--- a/redbot/core/rpc.py
+++ b/redbot/core/rpc.py
@@ -111,7 +111,7 @@ def __init__(self, **kwargs):
         super().__init__(**kwargs)
         self.rpc = RPC()
 
-        self.rpc_handlers = {}  # Lowered cog name to method
+        self.rpc_handlers = {}  # Uppercase cog name to method
 
     def register_rpc_handler(self, method):
         """
@@ -132,6 +132,7 @@ def register_rpc_handler(self, method):
         self.rpc.add_method(method)
 
         cog_name = method.__self__.__class__.__name__.upper()
+
         if cog_name not in self.rpc_handlers:
             self.rpc_handlers[cog_name] = []
 


Test patch: