Browse Source
It's flask inspired with the Plugin instance and decorators to add methods to the plugin description. Signed-off-by: Christian Decker <decker.christian@gmail.com>plugin-7
Christian Decker
6 years ago
3 changed files with 403 additions and 0 deletions
@ -1 +1,2 @@ |
|||||
from .lightning import LightningRpc, RpcError |
from .lightning import LightningRpc, RpcError |
||||
|
from .plugin import Plugin, monkey_patch |
||||
|
@ -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")) |
@ -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 == [] |
Loading…
Reference in new issue