rgb-cln/contrib/pylightning/lightning/plugin.py

341 lines
11 KiB
Python
Raw Normal View History

from collections import OrderedDict
import sys
import os
import json
import inspect
import re
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 = {}
# A dict from topics to handler functions
self.subscriptions = {}
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 add_subscription(self, topic, func):
"""Add a subscription to our list of subscriptions.
A subscription is an association between a topic and a handler
function. Adding a subscription means that we will
automatically subscribe to events from that topic with
`lightningd` and, upon receiving a matching notification, we
will call the associated handler. Notice that in order for the
automatic subscriptions to work, the handlers need to be
registered before we send our manifest, hence before
`Plugin.run` is called.
"""
if topic in self.subscriptions:
raise ValueError(
"Topic {} already has a handler".format(topic)
)
self.subscriptions[topic] = func
def subscribe(self, topic):
"""Function decorator to register a notification handler.
"""
def decorator(f):
self.add_subscription(topic, f)
return f
return decorator
def add_option(self, name, default, description):
"""Add an option that we'd like to register with lightningd.
Needs to be called before `Plugin.run`, otherwise we might not
end up getting it set.
"""
if name in self.options:
raise ValueError(
"Name {} is already used by another option".format(name)
)
self.options[name] = {
'name': name,
'default': default,
'description': description,
'type': 'string',
'value': None,
}
def get_option(self, name):
if name not in self.options:
raise ValueError("No option with name {} registered".format(name))
if self.options[name]['value'] is not None:
return self.options[name]['value']
else:
return self.options[name]['default']
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 _exec_func(self, func, request):
params = request['params']
sig = inspect.signature(func)
arguments = OrderedDict()
for name, value in sig.parameters.items():
arguments[name] = inspect.Signature.empty
# Fill in any injected parameters
if 'plugin' in arguments:
arguments['plugin'] = self
if 'request' in arguments:
arguments['request'] = request
# Now zip the provided arguments and the prefilled a together
if isinstance(params, dict):
arguments.update(params)
else:
pos = 0
for k, v in arguments.items():
if v is not inspect.Signature.empty:
continue
if pos < len(params):
# Apply positional args if we have them
arguments[k] = params[pos]
else:
# For the remainder apply default args
arguments[k] = sig.parameters[k].default
pos += 1
ba = sig.bind(**arguments)
ba.apply_defaults()
return func(*ba.args, **ba.kwargs)
def _dispatch_request(self, request):
name = request['method']
if name not in self.methods:
raise ValueError("No method {} found.".format(name))
func = self.methods[name]
try:
result = {
'jsonrpc': '2.0',
'id': request['id'],
'result': self._exec_func(func, request)
}
except Exception as e:
result = {
'jsonrpc': '2.0',
'id': request['id'],
"error": "Error while processing {}: {}".format(
request['method'], repr(e)
),
}
self.log(traceback.format_exc())
json.dump(result, fp=self.stdout)
self.stdout.write('\n\n')
self.stdout.flush()
def _dispatch_notification(self, request):
name = request['method']
if name not in self.subscriptions:
raise ValueError("No subscription for {} found.".format(name))
func = self.subscriptions[name]
try:
self._exec_func(func, request)
except Exception as _:
self.log(traceback.format_exc())
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'):
# Split the log into multiple lines and print them
# individually. Makes tracebacks much easier to read.
for line in message.split('\n'):
self.notify('log', {'level': level, 'message': line})
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)
# If this has an 'id'-field, it's a request and returns a
# result. Otherwise it's a notification and it doesn't
# return anything.
if 'id' in request:
self._dispatch_request(request)
else:
self._dispatch_notification(request)
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)
doc = re.sub('\n+', ' ', doc)
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': list(self.options.values()),
'rpcmethods': methods,
'subscriptions': list(self.subscriptions.keys()),
}
def _init(self, options, configuration, request):
self.rpc_filename = configuration['rpc-file']
self.lightning_dir = configuration['lightning-dir']
for name, value in options.items():
self.options[name]['value'] = value
# Swap the registered `init` method handler back in and
# re-dispatch
if self.init:
self.methods['init'] = self.init
self.init = None
return self._exec_func(self.methods['init'], 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"))