From 0240fe8a02a17ca03891be9ae89a5280a2040ff1 Mon Sep 17 00:00:00 2001 From: Chang-Hung Liang Date: Tue, 14 Jun 2016 09:27:05 +0800 Subject: [PATCH] Refactor context loading using execution event listener execution.py deals with all execution logic. But cli.py, an outsider module, may be interested in some events. For instance, when a user changes their base URL using `cd`, we should reload the context from the filesystem. This commit implements "execution event listener" to do such a thing. --- http_prompt/cli.py | 23 +++++++++++---- http_prompt/context.py | 2 +- http_prompt/contextio.py | 12 ++++++-- http_prompt/execution.py | 22 +++++++++++++-- tests/test_cli.py | 60 ++++++++++++++++++++++++++++++++-------- 5 files changed, 96 insertions(+), 23 deletions(-) diff --git a/http_prompt/cli.py b/http_prompt/cli.py index ac5415b..1bde697 100644 --- a/http_prompt/cli.py +++ b/http_prompt/cli.py @@ -15,7 +15,7 @@ from . import config from .completer import HttpPromptCompleter from .context import Context -from .contextio import load_context, save_context +from .contextio import load_context, save_context, url_to_context_filename from .execution import execute from .lexer import HttpPromptLexer @@ -30,6 +30,19 @@ def fix_incomplete_url(url): return url +class ExecutionListener(object): + + def url_changed(self, old_url, context): + # Load context from disk if base URL is changed + old_filename = url_to_context_filename(old_url) + new_filename = url_to_context_filename(context.url) + if old_filename != new_filename: + load_context(context) + + def context_changed(self, context): + save_context(context) + + @click.command(context_settings=dict( ignore_unknown_options=True, )) @@ -69,9 +82,10 @@ def cli(url, http_options): else: style = style_from_pygments(style) + listener = ExecutionListener() + # Execute default HTTPie options - execute(' '.join(http_options), context) - save_context(context) + execute(' '.join(http_options), context, listener=listener) while True: try: @@ -80,8 +94,7 @@ def cli(url, http_options): except EOFError: break # Control-D pressed else: - execute(text, context) - save_context(context) + execute(text, context, listener=listener) if context.should_exit: break diff --git a/http_prompt/context.py b/http_prompt/context.py index da48151..847100e 100644 --- a/http_prompt/context.py +++ b/http_prompt/context.py @@ -87,4 +87,4 @@ def json_obj(self): return obj def load_from_json_obj(self, json_obj): - self.__dict__ = deepcopy(json_obj) + self.__dict__.update(deepcopy(json_obj)) diff --git a/http_prompt/contextio.py b/http_prompt/contextio.py index fcdd3d6..b8f63f8 100644 --- a/http_prompt/contextio.py +++ b/http_prompt/contextio.py @@ -8,11 +8,14 @@ from . import xdg +# Don't save these attributes of a Context object +EXCLUDED_ATTRS = ['url', 'should_exit'] + # Don't save these HTTPie options to avoid collision with user config file EXCLUDED_OPTIONS = ['--style'] -def _url_to_filename(url): +def url_to_context_filename(url): r = urlparse(url) host = r.hostname port = r.port @@ -24,7 +27,7 @@ def _url_to_filename(url): def load_context(context): """Load a Context object in place from user data directory.""" dir_path = xdg.get_data_dir('context') - filename = _url_to_filename(context.url) + filename = url_to_context_filename(context.url) file_path = os.path.join(dir_path, filename) if os.path.exists(file_path): with open(file_path) as f: @@ -36,10 +39,13 @@ def load_context(context): def save_context(context): """Save a Context object to user data directory.""" dir_path = xdg.get_data_dir('context') - filename = _url_to_filename(context.url) + filename = url_to_context_filename(context.url) file_path = os.path.join(dir_path, filename) json_obj = context.json_obj() + for name in EXCLUDED_ATTRS: + json_obj.pop(name, None) + options = json_obj['options'] for name in EXCLUDED_OPTIONS: options.pop(name, None) diff --git a/http_prompt/execution.py b/http_prompt/execution.py index 8b1b813..13a8713 100644 --- a/http_prompt/execution.py +++ b/http_prompt/execution.py @@ -113,9 +113,18 @@ def generate_cmds_with_explanations(summary, cmds): return text +class DummyExecutionListener(object): + + def url_changed(self, old_url, context): + pass + + def context_changed(self, context): + pass + + class ExecutionVisitor(NodeVisitor): - def __init__(self, context): + def __init__(self, context, listener=None): super(ExecutionVisitor, self).__init__() self.context = context @@ -123,6 +132,8 @@ def __init__(self, context): self.method = None self.tool = None + self.listener = listener if listener else DummyExecutionListener() + def visit_method(self, node, children): self.method = node.text return node @@ -135,6 +146,10 @@ def visit_urlpath(self, node, children): def visit_cd(self, node, children): _, _, _, path, _ = children self.context_override.url = urljoin2(self.context_override.url, path) + + if self.context_override.url != self.context.url: + self.listener.url_changed(self.context.url, self.context_override) + return node def visit_rm(self, node, children): @@ -259,6 +274,7 @@ def visit_tool(self, node, children): def visit_mutation(self, node, children): self.context.update(self.context_override) + self.listener.context_changed(self.context) return node def _final_context(self): @@ -306,7 +322,7 @@ def generic_visit(self, node, children): return node -def execute(command, context): +def execute(command, context, listener=None): try: root = grammar.parse(command) except ParseError as err: @@ -314,7 +330,7 @@ def execute(command, context): part = command[err.pos:err.pos + 10] click.secho('Syntax error near "%s"' % part, err=True, fg='red') else: - visitor = ExecutionVisitor(context) + visitor = ExecutionVisitor(context, listener=listener) try: visitor.visit(root) except VisitationError as err: diff --git a/tests/test_cli.py b/tests/test_cli.py index b7f3d9d..e06cc8d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,25 +1,36 @@ import os from click.testing import CliRunner -from mock import patch +from mock import patch, DEFAULT from .base import TempAppDirTestCase from http_prompt import xdg from http_prompt.cli import cli, execute -@patch('http_prompt.cli.prompt') -@patch('http_prompt.cli.execute') -def run_and_exit(args, execute_mock, prompt_mock): - """Run http-prompt executable and exit immediately.""" - # Emulate a Ctrl+D on first call. - prompt_mock.side_effect = EOFError - execute_mock.side_effect = execute +def run_and_exit(cli_args=None, prompt_commands=None): + """Run http-prompt executable, execute some prompt commands, and exit.""" + if cli_args is None: + cli_args = [] - runner = CliRunner() - result = runner.invoke(cli, args) + # Make sure last command is 'exit' + if prompt_commands is None: + prompt_commands = ['exit'] + else: + prompt_commands += ['exit'] + + with patch.multiple('http_prompt.cli', + prompt=DEFAULT, execute=DEFAULT) as mocks: + mocks['execute'].side_effect = execute + + # prompt() is mocked to return the command in 'prompt_commands' in + # sequence, i.e., prompt() returns prompt_commands[i-1] when it is + # called for the ith time + mocks['prompt'].side_effect = prompt_commands + + result = CliRunner().invoke(cli, cli_args) + context = mocks['execute'].call_args[0][1] - context = execute_mock.call_args[0][1] return result, context @@ -99,3 +110,30 @@ def test_config_file(self): result, context = run_and_exit(['//example.com']) self.assertEqual(result.exit_code, 0) self.assertTrue(os.path.exists(config_path)) + + def test_base_url_changed(self): + result, context = run_and_exit(['example.com', 'name=bob', 'id==10']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(context.url, 'http://example.com') + self.assertEqual(context.options, {}) + self.assertEqual(context.body_params, {'name': 'bob'}) + self.assertEqual(context.headers, {}) + self.assertEqual(context.querystring_params, {'id': '10'}) + + # Changing hostname should trigger a context reload + result, context = run_and_exit(['localhost'], + ['cd http://example.com/api']) + self.assertEqual(result.exit_code, 0) + self.assertEqual(context.url, 'http://example.com/api') + self.assertEqual(context.options, {}) + self.assertEqual(context.body_params, {'name': 'bob'}) + self.assertEqual(context.headers, {}) + self.assertEqual(context.querystring_params, {'id': '10'}) + + @patch('http_prompt.cli.prompt') + @patch('http_prompt.cli.execute') + def test_press_ctrl_d(self, execute_mock, prompt_mock): + prompt_mock.side_effect = EOFError + execute_mock.side_effect = execute + result = CliRunner().invoke(cli, []) + self.assertEqual(result.exit_code, 0)