Repository: mitmproxy/mitmproxy

Issue:
Can't run mitmproxy after adding a command without type annotations
##### Steps to reproduce the problem:

1. Open `mitmproxy/tools/console/consoleaddons.py`
2. Add arbitrary command without type annotations. For example:
```
    @command.command("console.mycommand")
    def mycommand(self, flow):
        pass
```
3. Try to run mitmproxy. 

Nothing happens. Absolutely. No exceptions, no UI. 

##### Any other comments? What have you tried so far?
If you add a command with type annotations
```
    @command.command("console.mycommand")
    def mycommand(self, flow: flow.Flow) -> None:
        pass
```
everything will work ok.

##### System information

Mitmproxy: 3.0.0.dev0004 (commit 68c32d8) 
Python:    3.5.2
OpenSSL:   OpenSSL 1.1.0g  2 Nov 2017
Platform:  Linux-4.4.0-104-generic-x86_64-with-Ubuntu-16.04-xenial



Discussion:
I will look into this ASAP
This is causing the error

https://github.com/mitmproxy/mitmproxy/blob/8dfb8a9a7471e00b0f723de66d3b63bd089e9802/mitmproxy/command.py#L34-L41

I need to look into why are we checking type annotations at runtime before proposing any solution :)
So, the type annotations here are for dynamic command invocations. In the case of the return type, we ensure that the data returned from an addon's command matches the contract of the type annotations. This is a key part of ensuring safety in the command system, and making sure that everything remains composable without weird behaviour. 

We can go two ways here: either require a return type specification and give a proper error if it doesn't exist, or assume a return type of None. I lean (lightly) towards requiring an explicit annotation, since we absolutely require annotations on arguments. 







So I was thinking and came up with two solutions
1. Let this `NotImplementedError` stay and catch this [here](https://github.com/mitmproxy/mitmproxy/blob/eee109117f956600261bc938be52040d1474a97f/mitmproxy/command.py#L74) and throw `SyntaxError: missing type annotations in consoleaddons.py`.
2. Instead of `NotImplementedError`, use this `SyntaxError: missing type annotations in consoleaddons.py`,which might not be a pretty way

Resolving patch:
diff --git a/mitmproxy/command.py b/mitmproxy/command.py
--- a/mitmproxy/command.py
+++ b/mitmproxy/command.py
@@ -35,9 +35,11 @@ def typename(t: type) -> str:
     """
         Translates a type to an explanatory string.
     """
+    if t == inspect._empty:  # type: ignore
+        raise exceptions.CommandError("missing type annotation")
     to = mitmproxy.types.CommandTypes.get(t, None)
     if not to:
-        raise NotImplementedError(t)
+        raise exceptions.CommandError("unsupported type: %s" % getattr(t, "__name__", t))
     return to.display
 
 
@@ -58,7 +60,12 @@ def __init__(self, manager, path, func) -> None:
             if i.kind == i.VAR_POSITIONAL:
                 self.has_positional = True
         self.paramtypes = [v.annotation for v in sig.parameters.values()]
-        self.returntype = sig.return_annotation
+        if sig.return_annotation == inspect._empty:  # type: ignore
+            self.returntype = None
+        else:
+            self.returntype = sig.return_annotation
+        # This fails with a CommandException if types are invalid
+        self.signature_help()
 
     def paramnames(self) -> typing.Sequence[str]:
         v = [typename(i) for i in self.paramtypes]
@@ -133,7 +140,12 @@ def collect_commands(self, addon):
                     pass  # hasattr may raise if o implements __getattr__.
                 else:
                     if is_command:
-                        self.add(o.command_path, o)
+                        try:
+                            self.add(o.command_path, o)
+                        except exceptions.CommandError as e:
+                            self.master.log.warn(
+                                "Could not load command %s: %s" % (o.command_path, e)
+                            )
 
     def add(self, path: str, func: typing.Callable):
         self.commands[path] = Command(self, path, func)
diff --git a/mitmproxy/tools/console/commands.py b/mitmproxy/tools/console/commands.py
--- a/mitmproxy/tools/console/commands.py
+++ b/mitmproxy/tools/console/commands.py
@@ -46,12 +46,13 @@ def keypress(self, size, key):
 class CommandListWalker(urwid.ListWalker):
     def __init__(self, master):
         self.master = master
-
         self.index = 0
-        self.focusobj = None
-        self.cmds = list(master.commands.commands.values())
+        self.refresh()
+
+    def refresh(self):
+        self.cmds = list(self.master.commands.commands.values())
         self.cmds.sort(key=lambda x: x.signature_help())
-        self.set_focus(0)
+        self.set_focus(self.index)
 
     def get_edit_text(self):
         return self.focus_obj.get_edit_text()
@@ -137,6 +138,9 @@ def __init__(self, master):
         )
         self.master = master
 
+    def layout_pushed(self, prev):
+        self.widget_list[0].walker.refresh()
+
     def keypress(self, size, key):
         if key == "m_next":
             self.focus_position = (


Test patch:
diff --git a/test/mitmproxy/test_command.py b/test/mitmproxy/test_command.py
--- a/test/mitmproxy/test_command.py
+++ b/test/mitmproxy/test_command.py
@@ -1,4 +1,5 @@
 import typing
+import inspect
 from mitmproxy import command
 from mitmproxy import flow
 from mitmproxy import exceptions
@@ -55,7 +56,35 @@ def flow(self, f: flow.Flow, s: str) -> None:
         pass
 
 
+class Unsupported:
+    pass
+
+
+class TypeErrAddon:
+    @command.command("noret")
+    def noret(self):
+        pass
+
+    @command.command("invalidret")
+    def invalidret(self) -> Unsupported:
+        pass
+
+    @command.command("invalidarg")
+    def invalidarg(self, u: Unsupported):
+        pass
+
+
 class TestCommand:
+    def test_typecheck(self):
+        with taddons.context(loadcore=False) as tctx:
+            cm = command.CommandManager(tctx.master)
+            a = TypeErrAddon()
+            command.Command(cm, "noret", a.noret)
+            with pytest.raises(exceptions.CommandError):
+                command.Command(cm, "invalidret", a.invalidret)
+            with pytest.raises(exceptions.CommandError):
+                command.Command(cm, "invalidarg", a.invalidarg)
+
     def test_varargs(self):
         with taddons.context() as tctx:
             cm = command.CommandManager(tctx.master)
@@ -275,6 +304,11 @@ def test_typename():
     assert command.typename(mitmproxy.types.Path) == "path"
     assert command.typename(mitmproxy.types.Cmd) == "cmd"
 
+    with pytest.raises(exceptions.CommandError, match="missing type annotation"):
+        command.typename(inspect._empty)
+    with pytest.raises(exceptions.CommandError, match="unsupported type"):
+        command.typename(None)
+
 
 class DummyConsole:
     @command.command("view.resolve")
@@ -326,7 +360,8 @@ def empty(self) -> None:
         pass
 
 
-def test_collect_commands():
+@pytest.mark.asyncio
+async def test_collect_commands():
     """
         This tests for the error thrown by hasattr()
     """
@@ -336,6 +371,10 @@ def test_collect_commands():
         c.collect_commands(a)
         assert "empty" in c.commands
 
+        a = TypeErrAddon()
+        c.collect_commands(a)
+        await tctx.master.await_log("Could not load")
+
 
 def test_decorator():
     with taddons.context() as tctx: