diff --git a/contrib/pylightning/lightning/__init__.py b/contrib/pylightning/lightning/__init__.py index 084d5797c..2fe4821eb 100644 --- a/contrib/pylightning/lightning/__init__.py +++ b/contrib/pylightning/lightning/__init__.py @@ -1 +1,2 @@ from .lightning import LightningRpc, RpcError +from .plugin import Plugin, monkey_patch diff --git a/contrib/pylightning/lightning/plugin.py b/contrib/pylightning/lightning/plugin.py new file mode 100644 index 000000000..fc796622f --- /dev/null +++ b/contrib/pylightning/lightning/plugin.py @@ -0,0 +1,231 @@ +import sys +import os +import json +import inspect +import traceback + + +class Plugin(object): + """Controls interactions with lightningd, and bundles functionality. + + The Plugin class serves two purposes: it collects RPC methods and + options, and offers a control loop that dispatches incoming RPC + calls and hooks. + + """ + + def __init__(self, stdout=None, stdin=None, autopatch=True): + self.methods = {} + self.options = {} + + if not stdout: + self.stdout = sys.stdout + if not stdin: + self.stdin = sys.stdin + + if os.getenv('LIGHTNINGD_PLUGIN') and autopatch: + monkey_patch(self, stdout=True, stderr=True) + + self.add_method("getmanifest", self._getmanifest) + self.rpc_filename = None + self.lightning_dir = None + self.init = None + + def add_method(self, name, func): + """Add a plugin method to the dispatch table. + + The function will be expected at call time (see `_dispatch`) + and the parameters from the JSON-RPC call will be mapped to + the function arguments. In addition to the parameters passed + from the JSON-RPC call we add a few that may be useful: + + - `plugin`: gets a reference to this plugin. + + - `request`: gets a reference to the raw request as a + dict. This corresponds to the JSON-RPC message that is + being dispatched. + + Notice that due to the python binding logic we may be mapping + the arguments wrongly if we inject the plugin and/or request + in combination with positional binding. To prevent issues the + plugin and request argument should always be the last two + arguments and have a default on None. + + """ + if name in self.methods: + raise ValueError( + "Name {} is already bound to a method.".format(name) + ) + + # Register the function with the name + self.methods[name] = func + + def method(self, method_name, *args, **kwargs): + """Decorator to add a plugin method to the dispatch table. + + Internally uses add_method. + """ + def decorator(f): + self.add_method(method_name, f) + return f + return decorator + + def _dispatch(self, request): + name = request['method'] + params = request['params'] + + if name not in self.methods: + raise ValueError("No method {} found.".format(name)) + + args = params.copy() if isinstance(params, list) else [] + kwargs = params.copy() if isinstance(params, dict) else {} + + func = self.methods[name] + sig = inspect.signature(func) + + if 'plugin' in sig.parameters: + kwargs['plugin'] = self + + if 'request' in sig.parameters: + kwargs['request'] = request + + ba = sig.bind(*args, **kwargs) + ba.apply_defaults() + return func(*ba.args, **ba.kwargs) + + def notify(self, method, params): + payload = { + 'jsonrpc': '2.0', + 'method': method, + 'params': params, + } + json.dump(payload, self.stdout) + self.stdout.write("\n\n") + self.stdout.flush() + + def log(self, message, level='info'): + self.notify('log', {'level': level, 'message': message}) + + def _multi_dispatch(self, msgs): + """We received a couple of messages, now try to dispatch them all. + + Returns the last partial message that was not complete yet. + """ + for payload in msgs[:-1]: + request = json.loads(payload) + + try: + result = { + "jsonrpc": "2.0", + "result": self._dispatch(request), + "id": request['id'] + } + except Exception as e: + result = { + "jsonrpc": "2.0", + "error": "Error while processing {}".format( + request['method'] + ), + "id": request['id'] + } + self.log(traceback.format_exc()) + json.dump(result, fp=self.stdout) + self.stdout.write('\n\n') + self.stdout.flush() + return msgs[-1] + + def run(self): + # Stash the init method handler, we'll handle opts first and + # then unstash this and call it. + if 'init' in self.methods: + self.init = self.methods['init'] + self.methods['init'] = self._init + + partial = "" + for l in self.stdin: + partial += l + + msgs = partial.split('\n\n') + if len(msgs) < 2: + continue + + partial = self._multi_dispatch(msgs) + + def _getmanifest(self): + methods = [] + + for name, func in self.methods.items(): + # Skip the builtin ones, they don't get reported + if name in ['getmanifest', 'init']: + continue + + doc = inspect.getdoc(func) + if not doc: + self.log( + 'RPC method \'{}\' does not have a docstring.'.format(name) + ) + doc = "Undocumented RPC method from a plugin." + + methods.append({ + 'name': name, + 'description': doc, + }) + + return { + 'options': [], + 'rpcmethods': methods, + } + + def _init(self, options, configuration, request): + # Swap the registered `init` method handler back in and + # re-dispatch + if self.init: + self.methods['init'] = self.init + self.init = None + return self._dispatch(request) + return None + + +class PluginStream(object): + """Sink that turns everything that is written to it into a notification. + """ + + def __init__(self, plugin, level="info"): + self.plugin = plugin + self.level = level + self.buff = '' + + def write(self, payload): + self.buff += payload + + if payload[-1] == '\n': + self.flush() + + def flush(self): + lines = self.buff.split('\n') + if len(lines) < 2: + return + + for l in lines[:-1]: + self.plugin.log(l, self.level) + + # lines[-1] is either an empty string or a partial line + self.buff = lines[-1] + + +def monkey_patch(plugin, stdout=True, stderr=False): + """Monkey patch stderr and stdout so we use notifications instead. + + A plugin commonly communicates with lightningd over its stdout and + stdin filedescriptor, so if we use them in some other way + (printing, logging, ...) we're breaking our communication + channel. This function monkey patches these streams in the `sys` + module to be redirected to a `PluginStream` which wraps anything + that would get written to these streams into valid log + notifications that can be interpreted and printed by `lightningd`. + + """ + if stdout: + setattr(sys, "stdout", PluginStream(plugin, level="info")) + if stderr: + setattr(sys, "stderr", PluginStream(plugin, level="warn")) diff --git a/contrib/pylightning/tests/test_plugin.py b/contrib/pylightning/tests/test_plugin.py new file mode 100644 index 000000000..60e37de2e --- /dev/null +++ b/contrib/pylightning/tests/test_plugin.py @@ -0,0 +1,171 @@ +from lightning import Plugin + + +import pytest + + +def test_simple_methods(): + """Test the dispatch of methods, with a variety of bindings. + """ + call_list = [] + p = Plugin(autopatch=False) + + @p.method("test1") + def test1(name): + """Has a single positional argument.""" + assert name == 'World' + call_list.append(test1) + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test1', + 'params': {'name': 'World'} + } + p._dispatch(request) + assert call_list == [test1] + + @p.method("test2") + def test2(name, plugin): + """Also asks for the plugin instance. """ + assert plugin == p + call_list.append(test2) + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test2', + 'params': {'name': 'World'} + } + p._dispatch(request) + assert call_list == [test1, test2] + + @p.method("test3") + def test3(name, request): + """Also asks for the request instance. """ + assert request is not None + call_list.append(test3) + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test3', + 'params': {'name': 'World'} + } + p._dispatch(request) + assert call_list == [test1, test2, test3] + + @p.method("test4") + def test4(name): + """Try the positional arguments.""" + assert name == 'World' + call_list.append(test4) + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test4', + 'params': ['World'] + } + p._dispatch(request) + assert call_list == [test1, test2, test3, test4] + + @p.method("test5") + def test5(name, request, plugin): + """Try the positional arguments, mixing in the request and plugin.""" + assert name == 'World' + assert request is not None + assert p == plugin + call_list.append(test5) + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test5', + 'params': ['World'] + } + p._dispatch(request) + assert call_list == [test1, test2, test3, test4, test5] + + answers = [] + + @p.method("test6") + def test6(name, answer=42): + """This method has a default value for one of its params""" + assert name == 'World' + answers.append(answer) + call_list.append(test6) + + # Both calls should work (with and without the default param + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test6', + 'params': ['World'] + } + p._dispatch(request) + assert call_list == [test1, test2, test3, test4, test5, test6] + assert answers == [42] + + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test6', + 'params': ['World', 31337] + } + p._dispatch(request) + assert call_list == [test1, test2, test3, test4, test5, test6, test6] + assert answers == [42, 31337] + + +def test_methods_errors(): + """A bunch of tests that should fail calling the methods.""" + call_list = [] + p = Plugin(autopatch=False) + + # Fails because we haven't added the method yet + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test1', + 'params': {} + } + with pytest.raises(ValueError): + p._dispatch(request) + assert call_list == [] + + @p.method("test1") + def test1(name): + call_list.append(test1) + + # Attempting to add it twice should fail + with pytest.raises(ValueError): + p.add_method("test1", test1) + + # Fails because it is missing the 'name' argument + request = {'id': 1, 'jsonrpc': '2.0', 'method': 'test1', 'params': {}} + with pytest.raises(TypeError): + p._dispatch(request) + assert call_list == [] + + # The same with positional arguments + request = {'id': 1, 'jsonrpc': '2.0', 'method': 'test1', 'params': []} + with pytest.raises(TypeError): + p._dispatch(request) + assert call_list == [] + + # Fails because we have a non-matching argument + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test1', + 'params': {'name': 'World', 'extra': 1} + } + with pytest.raises(TypeError): + p._dispatch(request) + assert call_list == [] + + request = { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'test1', + 'params': ['World', 1] + } + with pytest.raises(TypeError): + p._dispatch(request) + assert call_list == []