mirror of
https://github.com/ElementsProject/lightning.git
synced 2025-02-20 13:54:36 +01:00
pylightning: Added a tiny library for python plugins
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>
This commit is contained in:
parent
968aeac908
commit
ada69e7943
3 changed files with 403 additions and 0 deletions
|
@ -1 +1,2 @@
|
|||
from .lightning import LightningRpc, RpcError
|
||||
from .plugin import Plugin, monkey_patch
|
||||
|
|
231
contrib/pylightning/lightning/plugin.py
Normal file
231
contrib/pylightning/lightning/plugin.py
Normal file
|
@ -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"))
|
171
contrib/pylightning/tests/test_plugin.py
Normal file
171
contrib/pylightning/tests/test_plugin.py
Normal file
|
@ -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…
Add table
Reference in a new issue