Skip to content

Commit

Permalink
Refactor context loading using execution event listener
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
eliangcs committed Jun 14, 2016
1 parent 1d4dfea commit 0240fe8
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 23 deletions.
23 changes: 18 additions & 5 deletions http_prompt/cli.py
Expand Up @@ -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

Expand All @@ -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,
))
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion http_prompt/context.py
Expand Up @@ -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))
12 changes: 9 additions & 3 deletions http_prompt/contextio.py
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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)
Expand Down
22 changes: 19 additions & 3 deletions http_prompt/execution.py
Expand Up @@ -113,16 +113,27 @@ 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

self.context_override = Context(context.url)
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
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -306,15 +322,15 @@ 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:
# TODO: Better error message
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:
Expand Down
60 changes: 49 additions & 11 deletions 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


Expand Down Expand Up @@ -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)

0 comments on commit 0240fe8

Please sign in to comment.