From 582e6a9fa62eeba05561ba2105bf7c8a73211b4d Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Fri, 15 Dec 2017 14:33:05 +1300 Subject: [PATCH 1/5] command: recursive command parsing This lets us complete commands passed to commands correctly. --- mitmproxy/command.py | 9 +++++++-- test/mitmproxy/test_command.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mitmproxy/command.py b/mitmproxy/command.py index 087f77704..05caf261e 100644 --- a/mitmproxy/command.py +++ b/mitmproxy/command.py @@ -181,13 +181,18 @@ class CommandManager: parse = [] # type: typing.List[ParseResult] params = [] # type: typing.List[type] + typ = None # type: typing.Type for i in range(len(parts)): if i == 0: - params[:] = [Cmd] + typ = Cmd if parts[i] in self.commands: params.extend(self.commands[parts[i]].paramtypes) - if params: + elif params: typ = params.pop(0) + # FIXME: Do we need to check that Arg is positional? + if typ == Cmd and params and params[0] == Arg: + if parts[i] in self.commands: + params[:] = self.commands[parts[i]].paramtypes else: typ = str parse.append(ParseResult(value=parts[i], type=typ)) diff --git a/test/mitmproxy/test_command.py b/test/mitmproxy/test_command.py index 76ce22458..298b34fb6 100644 --- a/test/mitmproxy/test_command.py +++ b/test/mitmproxy/test_command.py @@ -24,6 +24,10 @@ class TAddon: def cmd3(self, foo: int) -> int: return foo + @command.command("subcommand") + def subcommand(self, cmd: command.Cmd, *args: command.Arg) -> str: + return "ok" + @command.command("empty") def empty(self) -> None: pass @@ -102,6 +106,21 @@ class TestCommand: command.ParseResult(value = "", type = int), ] ], + [ + "subcommand ", + [ + command.ParseResult(value = "subcommand", type = command.Cmd), + command.ParseResult(value = "", type = command.Cmd), + ] + ], + [ + "subcommand cmd3 ", + [ + command.ParseResult(value = "subcommand", type = command.Cmd), + command.ParseResult(value = "cmd3", type = command.Cmd), + command.ParseResult(value = "", type = int), + ] + ], ] with taddons.context() as tctx: tctx.master.addons.add(TAddon()) From ea891b43f836047c57409415788ecd8a69f57638 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Fri, 15 Dec 2017 14:34:40 +1300 Subject: [PATCH 2/5] console: fix variable clash exposed by recent key binding work --- mitmproxy/tools/console/consoleaddons.py | 2 +- mitmproxy/tools/console/keybindings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mitmproxy/tools/console/consoleaddons.py b/mitmproxy/tools/console/consoleaddons.py index 87f794c24..61e107f40 100644 --- a/mitmproxy/tools/console/consoleaddons.py +++ b/mitmproxy/tools/console/consoleaddons.py @@ -513,7 +513,7 @@ class ConsoleAddon: kwidget = self.master.window.current("keybindings") if not kwidget: raise exceptions.CommandError("Not viewing key bindings.") - f = kwidget.focus() + f = kwidget.get_focused_binding() if not f: raise exceptions.CommandError("No key binding focused") return f diff --git a/mitmproxy/tools/console/keybindings.py b/mitmproxy/tools/console/keybindings.py index 45f5c33c8..312c19f94 100644 --- a/mitmproxy/tools/console/keybindings.py +++ b/mitmproxy/tools/console/keybindings.py @@ -135,7 +135,7 @@ class KeyBindings(urwid.Pile, layoutwidget.LayoutWidget): ) self.master = master - def focus(self): + def get_focused_binding(self): if self.focus_position != 0: return None f = self.widget_list[0] From a8ae006f2e798e05cfc9ad29b077f75f6078444b Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Fri, 15 Dec 2017 16:00:36 +1300 Subject: [PATCH 3/5] command: path completion --- .../tools/console/commander/commander.py | 40 ++++++++++- mitmproxy/tools/console/pathedit.py | 71 ------------------ .../mitmproxy/tools/console/test_commander.py | 13 ++++ test/mitmproxy/tools/console/test_pathedit.py | 72 ------------------- 4 files changed, 51 insertions(+), 145 deletions(-) delete mode 100644 mitmproxy/tools/console/pathedit.py delete mode 100644 test/mitmproxy/tools/console/test_pathedit.py diff --git a/mitmproxy/tools/console/commander/commander.py b/mitmproxy/tools/console/commander/commander.py index dbbc8ff2a..c79bc379f 100644 --- a/mitmproxy/tools/console/commander/commander.py +++ b/mitmproxy/tools/console/commander/commander.py @@ -1,7 +1,10 @@ +import abc +import glob +import os +import typing + import urwid from urwid.text_layout import calc_coords -import typing -import abc import mitmproxy.master import mitmproxy.command @@ -34,6 +37,30 @@ class ListCompleter(Completer): return ret +# Generates the completion options for a specific starting input +def pathOptions(start: str) -> typing.Sequence[str]: + if not start: + start = "./" + path = os.path.expanduser(start) + ret = [] + if os.path.isdir(path): + files = glob.glob(os.path.join(path, "*")) + prefix = start + else: + files = glob.glob(path + "*") + prefix = os.path.dirname(start) + prefix = prefix or "./" + for f in files: + display = os.path.normpath(os.path.join(prefix, os.path.basename(f))) + if os.path.isdir(f): + display += "/" + ret.append(display) + if not ret: + ret = [start] + ret.sort() + return ret + + CompletionState = typing.NamedTuple( "CompletionState", [ @@ -93,6 +120,15 @@ class CommandBuffer(): ), parse = parts, ) + elif last.type == mitmproxy.command.Path: + self.completion = CompletionState( + completer = ListCompleter( + "", + pathOptions(parts[1].value) + ), + parse = parts, + ) + if self.completion: nxt = self.completion.completer.cycle() buf = " ".join([i.value for i in self.completion.parse[:-1]]) + " " + nxt diff --git a/mitmproxy/tools/console/pathedit.py b/mitmproxy/tools/console/pathedit.py deleted file mode 100644 index 10ee14168..000000000 --- a/mitmproxy/tools/console/pathedit.py +++ /dev/null @@ -1,71 +0,0 @@ -import glob -import os.path - -import urwid - - -class _PathCompleter: - - def __init__(self, _testing=False): - """ - _testing: disables reloading of the lookup table to make testing - possible. - """ - self.lookup, self.offset = None, None - self.final = None - self._testing = _testing - - def reset(self): - self.lookup = None - self.offset = -1 - - def complete(self, txt): - """ - Returns the next completion for txt, or None if there is no - completion. - """ - path = os.path.expanduser(txt) - if not self.lookup: - if not self._testing: - # Lookup is a set of (display value, actual value) tuples. - self.lookup = [] - if os.path.isdir(path): - files = glob.glob(os.path.join(path, "*")) - prefix = txt - else: - files = glob.glob(path + "*") - prefix = os.path.dirname(txt) - prefix = prefix or "./" - for f in files: - display = os.path.join(prefix, os.path.basename(f)) - if os.path.isdir(f): - display += "/" - self.lookup.append((display, f)) - if not self.lookup: - self.final = path - return path - self.lookup.sort() - self.offset = -1 - self.lookup.append((txt, txt)) - self.offset += 1 - if self.offset >= len(self.lookup): - self.offset = 0 - ret = self.lookup[self.offset] - self.final = ret[1] - return ret[0] - - -class PathEdit(urwid.Edit, _PathCompleter): - - def __init__(self, prompt, last_path): - urwid.Edit.__init__(self, prompt, last_path) - _PathCompleter.__init__(self) - - def keypress(self, size, key): - if key == "tab": - comp = self.complete(self.get_edit_text()) - self.set_edit_text(comp) - self.set_edit_pos(len(comp)) - else: - self.reset() - return urwid.Edit.keypress(self, size, key) diff --git a/test/mitmproxy/tools/console/test_commander.py b/test/mitmproxy/tools/console/test_commander.py index 1ac4c5c62..e8974869a 100644 --- a/test/mitmproxy/tools/console/test_commander.py +++ b/test/mitmproxy/tools/console/test_commander.py @@ -1,5 +1,18 @@ +import os + from mitmproxy.tools.console.commander import commander from mitmproxy.test import taddons +from mitmproxy.test import tutils + + +def test_pathOptions(): + cd = os.path.normpath(tutils.test_data.path("mitmproxy/completion")) + + ret = [x[len(cd):] for x in commander.pathOptions(cd)] + assert ret == ['/aaa', '/aab', '/aac', '/bbb/'] + + ret = [x[len(cd):] for x in commander.pathOptions(os.path.join(cd, "a"))] + assert ret == ['/aaa', '/aab', '/aac'] class TestListCompleter: diff --git a/test/mitmproxy/tools/console/test_pathedit.py b/test/mitmproxy/tools/console/test_pathedit.py deleted file mode 100644 index b9f51f5aa..000000000 --- a/test/mitmproxy/tools/console/test_pathedit.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -from os.path import normpath -from unittest import mock - -from mitmproxy.tools.console import pathedit -from mitmproxy.test import tutils - - -class TestPathCompleter: - - def test_lookup_construction(self): - c = pathedit._PathCompleter() - - cd = os.path.normpath(tutils.test_data.path("mitmproxy/completion")) - ca = os.path.join(cd, "a") - assert c.complete(ca).endswith(normpath("/completion/aaa")) - assert c.complete(ca).endswith(normpath("/completion/aab")) - c.reset() - ca = os.path.join(cd, "aaa") - assert c.complete(ca).endswith(normpath("/completion/aaa")) - assert c.complete(ca).endswith(normpath("/completion/aaa")) - c.reset() - assert c.complete(cd).endswith(normpath("/completion/aaa")) - - def test_completion(self): - c = pathedit._PathCompleter(True) - c.reset() - c.lookup = [ - ("a", "x/a"), - ("aa", "x/aa"), - ] - assert c.complete("a") == "a" - assert c.final == "x/a" - assert c.complete("a") == "aa" - assert c.complete("a") == "a" - - c = pathedit._PathCompleter(True) - r = c.complete("l") - assert c.final.endswith(r) - - c.reset() - assert c.complete("/nonexistent") == "/nonexistent" - assert c.final == "/nonexistent" - c.reset() - assert c.complete("~") != "~" - - c.reset() - s = "thisisatotallynonexistantpathforsure" - assert c.complete(s) == s - assert c.final == s - - -class TestPathEdit: - - def test_keypress(self): - - pe = pathedit.PathEdit("", "") - - with mock.patch('urwid.widget.Edit.get_edit_text') as get_text, \ - mock.patch('urwid.widget.Edit.set_edit_text') as set_text: - - cd = os.path.normpath(tutils.test_data.path("mitmproxy/completion")) - get_text.return_value = os.path.join(cd, "a") - - # Pressing tab should set completed path - pe.keypress((1,), "tab") - set_text_called_with = set_text.call_args[0][0] - assert set_text_called_with.endswith(normpath("/completion/aaa")) - - # Pressing any other key should reset - pe.keypress((1,), "a") - assert pe.lookup is None From 1d2cdcff07bc9db090c22699432a0006589abe37 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Fri, 15 Dec 2017 16:26:33 +1300 Subject: [PATCH 4/5] commander: sort options for completion --- mitmproxy/tools/console/commander/commander.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mitmproxy/tools/console/commander/commander.py b/mitmproxy/tools/console/commander/commander.py index c79bc379f..bef43a7e4 100644 --- a/mitmproxy/tools/console/commander/commander.py +++ b/mitmproxy/tools/console/commander/commander.py @@ -27,6 +27,7 @@ class ListCompleter(Completer): for o in options: if o.startswith(start): self.options.append(o) + self.options.sort() self.offset = 0 def cycle(self) -> str: From 198c7b19a3c2777c064aeba54e16b3f0b78ba143 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Fri, 15 Dec 2017 17:12:44 +1300 Subject: [PATCH 5/5] commander: test++ --- .../tools/console/commander/commander.py | 6 +-- .../mitmproxy/tools/console/test_commander.py | 54 ++++++++++++++++--- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/mitmproxy/tools/console/commander/commander.py b/mitmproxy/tools/console/commander/commander.py index bef43a7e4..5fc7dd128 100644 --- a/mitmproxy/tools/console/commander/commander.py +++ b/mitmproxy/tools/console/commander/commander.py @@ -10,7 +10,7 @@ import mitmproxy.master import mitmproxy.command -class Completer: +class Completer: # pragma: no cover @abc.abstractmethod def cycle(self) -> str: pass @@ -52,7 +52,7 @@ def pathOptions(start: str) -> typing.Sequence[str]: prefix = os.path.dirname(start) prefix = prefix or "./" for f in files: - display = os.path.normpath(os.path.join(prefix, os.path.basename(f))) + display = os.path.join(prefix, os.path.normpath(os.path.basename(f))) if os.path.isdir(f): display += "/" ret.append(display) @@ -157,9 +157,9 @@ class CommandEdit(urwid.WidgetWrap): leader = ": " def __init__(self, master: mitmproxy.master.Master, text: str) -> None: + super().__init__(urwid.Text(self.leader)) self.master = master self.cbuf = CommandBuffer(master, text) - self._w = urwid.Text(self.leader) self.update() def keypress(self, size, key): diff --git a/test/mitmproxy/tools/console/test_commander.py b/test/mitmproxy/tools/console/test_commander.py index e8974869a..823af06d2 100644 --- a/test/mitmproxy/tools/console/test_commander.py +++ b/test/mitmproxy/tools/console/test_commander.py @@ -1,18 +1,36 @@ import os +import contextlib from mitmproxy.tools.console.commander import commander from mitmproxy.test import taddons from mitmproxy.test import tutils +@contextlib.contextmanager +def chdir(path: str): + old_dir = os.getcwd() + os.chdir(path) + yield + os.chdir(old_dir) + + +def normPathOpts(prefix, match): + ret = [] + for s in commander.pathOptions(match): + s = s[len(prefix):] + s = s.replace(os.sep, "/") + ret.append(s) + return ret + + def test_pathOptions(): cd = os.path.normpath(tutils.test_data.path("mitmproxy/completion")) - - ret = [x[len(cd):] for x in commander.pathOptions(cd)] - assert ret == ['/aaa', '/aab', '/aac', '/bbb/'] - - ret = [x[len(cd):] for x in commander.pathOptions(os.path.join(cd, "a"))] - assert ret == ['/aaa', '/aab', '/aac'] + assert normPathOpts(cd, cd) == ['/aaa', '/aab', '/aac', '/bbb/'] + assert normPathOpts(cd, os.path.join(cd, "a")) == ['/aaa', '/aab', '/aac'] + with chdir(cd): + assert normPathOpts("", "./") == ['./aaa', './aab', './aac', './bbb/'] + assert normPathOpts("", "") == ['./aaa', './aab', './aac', './bbb/'] + assert commander.pathOptions("nonexistent") == ["nonexistent"] class TestListCompleter: @@ -59,6 +77,24 @@ class TestCommandBuffer: assert cb.buf == output[0] assert cb.cursor == output[1] + def test_left(self): + cursors = [3, 2, 1, 0, 0] + with taddons.context() as tctx: + cb = commander.CommandBuffer(tctx.master) + cb.buf, cb.cursor = "abcd", 4 + for c in cursors: + cb.left() + assert cb.cursor == c + + def test_right(self): + cursors = [1, 2, 3, 4, 4] + with taddons.context() as tctx: + cb = commander.CommandBuffer(tctx.master) + cb.buf, cb.cursor = "abcd", 0 + for c in cursors: + cb.right() + assert cb.cursor == c + def test_insert(self): tests = [ [("", 0), ("x", 1)], @@ -79,3 +115,9 @@ class TestCommandBuffer: cb.buf = "foo bar" cb.cursor = len(cb.buf) cb.cycle_completion() + + def test_render(self): + with taddons.context() as tctx: + cb = commander.CommandBuffer(tctx.master) + cb.buf = "foo" + assert cb.render() == "foo"