Repository: evennia/evennia

Issue:
[BUG - Develop] CmdSet.remove() does not take string args
#### Describe the bug
Not sure if it's a documentation error or a code error - this claims that you can pass it the key of a command to remove, but causes a traceback.
https://github.com/evennia/evennia/blob/7f4769bd9d3403b8011998b0c903a490caa07be9/evennia/commands/cmdset.py#L566-L568 

#### To Reproduce
Steps to reproduce the behavior:
1. Attempt to remove a command from any CmdSet with the key, e.g. `self.remove("charcreate")`
2. The code gives a traceback because it cannot handle a string.

#### Expected behavior
Either it should remove the command in the set with that key or the docstring is inaccurate.

#### Develop-branch commit
7f4769bd9

#### Additional context
The traceback:
```
Traceback (most recent call last):
  File ".../evennia/evennia/commands/cmdsethandler.py", line 208, in import_cmdset
    cmdsetclass = cmdsetclass(cmdsetobj)
  File ".../evennia/evennia/commands/cmdset.py", line 222, in __init__
    self.at_cmdset_creation()
  File "./accounts/commands.py", line 27, in at_cmdset_creation
    self.remove("charcreate")
  File ".../evennia/evennia/commands/cmdset.py", line 572, in remove
    if cmd.key.startswith("__"):
AttributeError: 'str' object has no attribute 'key'
```



Discussion:
Looks like a code bug; everyone's been using this with the command object :) 
It's written in this issue as `self.remove()`, but shouldn't it be `self.cmdset.remove()`?
@kovitikus Not if you're working with a cmdset class, no.
@Griatch the cmdset methods all use Cmds directly, I suggest we simply remove the docstring mentioning the str to keep things standard, since we're all using cmds anyway
@ChrisLR We _could_, but it's indeed meant to work with keys too, and I would think there's code in place to do so ... but something's failing in it. So just removing the doc mention could leave unused or confusing code behind (I have not checked this though)
Based on what I found when I first poked at it, it's not so much that it doesn't work as it was never implemented.

The first line of `cmdset.remove` is `cmd = self._instantiate(cmd)` - which is supposed to convert `cmd` into a command instance.

But `cmdset._instantiate` passes strings through, as per its _own_ documentation:

https://github.com/evennia/evennia/blob/7f4769bd9d3403b8011998b0c903a490caa07be9/evennia/commands/cmdset.py#L317-L333
Hm. I think the docstring is correct; it's consistent with the rest of the system to support removal both by cmd, key or even cmd-class-path. I don't recall why this was never implemented, but most likely it's because I mainly thought of `CmdsetHandler.remove` as being the main thing people would use. As it turns out, `Cmdset.remove()` is a much more common use case than expected, since this is the easiest way to remove default commands. 

Resolving patch:
diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py
--- a/evennia/commands/cmdset.py
+++ b/evennia/commands/cmdset.py
@@ -546,10 +546,7 @@ def add(self, cmd, allow_duplicates=False):
                 commands[ic] = cmd  # replace
             except ValueError:
                 commands.append(cmd)
-            self.commands = commands
-            if not allow_duplicates:
-                # extra run to make sure to avoid doublets
-                self.commands = list(set(self.commands))
+
             # add system_command to separate list as well,
             # for quick look-up
             if cmd.key.startswith("__"):
@@ -559,6 +556,11 @@ def add(self, cmd, allow_duplicates=False):
                 except ValueError:
                     system_commands.append(cmd)
 
+        self.commands = commands
+        if not allow_duplicates:
+            # extra run to make sure to avoid doublets
+            self.commands = list(set(self.commands))
+
     def remove(self, cmd):
         """
         Remove a command instance from the cmdset.
@@ -568,6 +570,11 @@ def remove(self, cmd):
                 or the key of such a command.
 
         """
+        if isinstance(cmd, str):
+            cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None)
+            if cmd is None:
+                return None
+
         cmd = self._instantiate(cmd)
         if cmd.key.startswith("__"):
             try:
@@ -591,6 +598,11 @@ def get(self, cmd):
             cmd (Command): The first matching Command in the set.
 
         """
+        if isinstance(cmd, str):
+            cmd = next((_cmd for _cmd in self.commands if _cmd.key == cmd), None)
+            if cmd is None:
+                return None
+
         cmd = self._instantiate(cmd)
         for thiscmd in self.commands:
             if thiscmd == cmd:


Test patch:
diff --git a/evennia/commands/tests.py b/evennia/commands/tests.py
--- a/evennia/commands/tests.py
+++ b/evennia/commands/tests.py
@@ -1199,3 +1199,21 @@ def at_cmdset_creation(self):
 
         cmd = self.char1.cmdset.cmdset_stack[-1].commands[0]
         self.assertEqual(cmd.obj, self.char1)
+
+
+class TestCmdSet(BaseEvenniaTest):
+    """
+    General tests for cmdsets
+    """
+
+    def test_cmdset_remove_by_key(self):
+        test_cmd_set = _CmdSetTest()
+        test_cmd_set.remove("another command")
+
+        self.assertNotIn(_CmdTest2, test_cmd_set.commands)
+
+    def test_cmdset_gets_by_key(self):
+        test_cmd_set = _CmdSetTest()
+        result = test_cmd_set.get("another command")
+
+        self.assertIsInstance(result, _CmdTest2)