From facc564cd86e27ad13c383934eccd18a73fbf1a4 Mon Sep 17 00:00:00 2001 From: Ben Shaw Date: Thu, 12 Dec 2019 17:40:44 +1300 Subject: [PATCH] chore: Added unit tests for servers.py --- stencila/pyla/servers.py | 42 +++++-- tests/test_servers.py | 237 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 13 deletions(-) diff --git a/stencila/pyla/servers.py b/stencila/pyla/servers.py index b2d2430..04d4ca5 100644 --- a/stencila/pyla/servers.py +++ b/stencila/pyla/servers.py @@ -9,8 +9,8 @@ import logging import typing from socket import socket -from stencila.schema.types import CodeChunk -from stencila.schema.util import to_json, from_dict +from stencila.schema.types import CodeChunk, Node +from stencila.schema.util import from_dict, object_encode from .code_parsing import simple_code_chunk_parse from .interpreter import Interpreter @@ -18,6 +18,23 @@ StreamType = typing.Union[typing.BinaryIO, socket] +def rpc_json_object_encode(node: Node) -> typing.Union[dict, str]: + """Like `stencila.schema.util.object_encode` but with support for JsonRpcError.""" + if isinstance(node, JsonRpcError): + return { + 'code': node.code.value, + 'message': str(node), + 'data': node.data + } + + return object_encode(node) + + +def to_json(node: Node) -> str: + """Convert a node including JsonRrpcErrors, to JSON""" + return json.dumps(node, default=rpc_json_object_encode, indent=2) + + def data_to_bytes(data: typing.Any) -> bytes: """Convert `data` to `bytes`.""" return bytes((data,)) @@ -36,6 +53,7 @@ def encode_int(number: int) -> bytes: break return buf + def read_one(stream: StreamType) -> int: """Read a byte from the file (as an integer). @@ -46,6 +64,7 @@ def read_one(stream: StreamType) -> int: raise EOFError('Unexpected EOF while reading bytes') return ord(char) + def read_length_prefix(stream: StreamType) -> int: """Read a varint from `stream`""" shift = 0 @@ -196,6 +215,16 @@ def write_message(self, message: str) -> None: """Write a length-prefixed message to the output stream.""" message_write(self.output_stream, message) + def execute_node(self, node: dict) -> Node: + """Parse a `CodeChunk` or `CodeExpression` from `node` and execute it with the `interpreter`.""" + code = from_dict(node) + if isinstance(code, CodeChunk): + to_execute = simple_code_chunk_parse(code) + else: + to_execute = code + self.interpreter.execute([to_execute], {}) + return code + def receive_message(self, message: str) -> str: """ Receive a JSON-RPC request and send back a JSON-RPC response. @@ -232,14 +261,7 @@ def receive_message(self, message: str) -> str: if node is None: raise JsonRpcError(JsonRpcErrorCode.InvalidParams, 'Invalid params: "node" is missing') - code = from_dict(node) - if isinstance(code, CodeChunk): - to_execute = simple_code_chunk_parse(code) - else: - to_execute = code - self.interpreter.execute([to_execute], {}) - - result = code + result = self.execute_node(node) else: raise JsonRpcError(JsonRpcErrorCode.MethodNotFound, 'Method not found: {}'.format(method)) except JsonRpcError as exc: diff --git a/tests/test_servers.py b/tests/test_servers.py index 10e8dbf..2c48d9f 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -1,15 +1,246 @@ from io import BytesIO import json import pytest +from unittest import mock + +from stencila.schema.types import CodeChunk, CodeExpression from stencila.pyla.interpreter import Interpreter -from stencila.pyla.servers import StreamServer, message_read, message_write +from stencila.pyla.servers import StreamServer, message_read, message_write, read_one, encode_int, JsonRpcErrorCode + -@pytest.mark.skip(reason="ain't working due to EOF error") def test_read_message(): + """Test that a message written to a stream is read correctly by the server.""" input_str = BytesIO() output_str = BytesIO() server = StreamServer(Interpreter(), input_str, output_str) message_write(input_str, "Hello server, my old friend") - assert next(server.read_message()) == '' + input_str.seek(0) + + assert next(server.read_message()) == "Hello server, my old friend" + + +def test_write_message(): + """Test that a message that the server writes to a stream can be read back out.""" + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + + server.write_message("I've come to .send() to you again") + + output_str.seek(0) + + assert message_read(output_str) == "I've come to .send() to you again" + + +def test_lps_encoding(): + """Test various numbers are encoding by the `encode_int` function correctly.""" + for encoded, raw in ((b'\x01', 1), (b'\x80\x02', 256), (b'\x80\x08', 1024), (b'\x80\x80\x04', 65536)): + assert encoded == encode_int(raw) + + +def test_eof_read(): + """Test that EOFError is raised at the end of the stream read.""" + stream = BytesIO() + with pytest.raises(EOFError): + read_one(stream) + + +def test_receive_message_manifest(): + """Test receiving a manifest message.""" + message = json.dumps({ + 'id': 10, + 'method': 'manifest', + }) + + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + response = server.receive_message(message) + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': 10, + 'result': { + 'capabilities': { + 'manifest': True, + 'execute': True + } + }, + 'error': None + } + + +def test_receive_message_execute(): + """Test receiving an execute method, the execute_node method should be called with the node.""" + message = json.dumps({ + 'id': 11, + 'method': 'execute', + 'params': { + 'node': 'code-node' + } + }) + + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + server.execute_node = mock.MagicMock(name='execute_node', return_value='executed-code') + + response = server.receive_message(message) + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': 11, + 'result': 'executed-code', + 'error': None + } + server.execute_node.assert_called_with('code-node') + + +def test_receive_message_execute_without_node(): + """Test that JsonRpcError with code JsonRpcErrorCode.InvalidParams is returned if node is missing/None""" + message = json.dumps({ + 'id': 12, + 'method': 'execute', + 'params': { + } + }) + + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + + response = server.receive_message(message) + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': 12, + 'result': None, + 'error': { + 'code': JsonRpcErrorCode.InvalidParams.value, + 'message': 'Invalid params: "node" is missing', + 'data': None + } + } + + +def test_receive_message_with_invalid_json(): + """Test that a JsonRpcError with code JsonRpcErrorCode.ParseError is returned if JSON is not valid.""" + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + + response = server.receive_message("not a valid json") + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': None, + 'result': None, + 'error': { + 'code': JsonRpcErrorCode.ParseError.value, + 'message': 'Parse error: Expecting value: line 1 column 1 (char 0)', + 'data': None + } + } + + +def test_receive_message_with_unknown_method(): + """Test that a JsonRpcError with code JsonRpcErrorCode.MethodNotFound is returned if method is not valid.""" + message = json.dumps({ + 'id': 13, + 'method': 'not-real', + 'params': { + } + }) + + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + + response = server.receive_message(message) + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': 13, + 'result': None, + 'error': { + 'code': JsonRpcErrorCode.MethodNotFound.value, + 'message': 'Method not found: not-real', + 'data': None + } + } + + +def test_receive_message_with_internal_server_error(): + """Test that a JsonRpcError with code JsonRpcErrorCode.MethodNotFound if some other exception occurs""" + message = json.dumps({ + 'id': 13, + 'method': 'execute', + 'params': { + 'node': 'code-node' + } + }) + + input_str = BytesIO() + output_str = BytesIO() + server = StreamServer(Interpreter(), input_str, output_str) + + server.execute_node = mock.MagicMock(name='execute_node', side_effect=ValueError('test exception')) + + response = server.receive_message(message) + decoded = json.loads(response) + assert decoded == { + 'jsonrpc': '2.0', + 'id': 13, + 'result': None, + 'error': { + 'code': JsonRpcErrorCode.ServerError.value, + 'message': 'Internal error: test exception', + 'data': None + } + } + + +@mock.patch('stencila.pyla.servers.simple_code_chunk_parse', name='simple_code_chunk_parse') +@mock.patch('stencila.pyla.servers.from_dict', name='from_dict') +def test_execute_code_chunk(from_dict, simple_code_chunk_parse): + """Test execution of a CodeChunk (with some mocks)""" + cc = CodeChunk('1+1') + + from_dict.return_value = cc + node = {'node': 'node_value'} + + input_str = BytesIO() + output_str = BytesIO() + interpreter = mock.MagicMock(spec=Interpreter, name='interpreter') + server = StreamServer(interpreter, input_str, output_str) + executed = server.execute_node(node) + + from_dict.assert_called_with(node) + simple_code_chunk_parse.assert_called_with(cc) + interpreter.execute.assert_called_with([simple_code_chunk_parse.return_value], {}) + + assert executed == cc + + + +@mock.patch('stencila.pyla.servers.from_dict', name='from_dict') +def test_execute_code_expr(from_dict): + """Test execution of a CodeExpression (with some mocks)""" + ce = CodeExpression('1+1') + + from_dict.return_value = ce + node = {'node': 'node_value'} + + input_str = BytesIO() + output_str = BytesIO() + interpreter = mock.MagicMock(spec=Interpreter, name='interpreter') + server = StreamServer(interpreter, input_str, output_str) + executed = server.execute_node(node) + + from_dict.assert_called_with(node) + interpreter.execute.assert_called_with([ce], {}) + + assert executed == ce +