Overview of pyln-client implementation - @plugin.method() - Part 2
In this episode, we look a pyln-client Python package implementation focusing specifically on the method method of the class Plugin. We write a very simplified version of Method and Plugin classes to understand plugin.method().
Transcript with corrections and improvements
Today in this episode 12 we are goind to look at pyln-client
implementation, specifically the method method of the class Plugin.
This method is used with Python decorators to register JSON-RPC
methods to lightningd.
For instance in the following Python snippet, we register the JSON-RPC
method foo which takes one argument x and returns the object {"foo": x}
(with x replaced by its value) in the result field of the JSON-RPC
responses:
...
@plugin.method("foo")
def foo_func(plugin,x):
return {"foo":x}
...
Install pyln-client
Let's install pyln-client in .venv Python virtual
environment:
◉ tony@tony:~/lnroom:
$ python -m venv .venv
◉ tony@tony:~/lnroom:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/lnroom:
$ pip install pyln-client
...
Setup
Here is my setup
(.venv) ◉ tony@tony:~/lnroom:
$ ./setup.sh
Ubuntu 22.04.2 LTS
Python 3.10.6
lightningd v23.02.2
pyln-client 23.2
Start 2 Lightning nodes running on regtest
Let's start two Lightning nodes running on the Bitcoin regtest chain
by sourcing the script lightning/contrib/startup_regtest.sh provided
in CLN repository and by running the command start_ln:
(.venv) ◉ tony@tony:~/lnroom:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
start_ln 3: start three nodes, l1, l2, l3
connect 1 2: connect l1 and l2
fund_nodes: connect all nodes with channels, in a row
stop_ln: shutdown
destroy_ln: remove ln directories
(.venv) ◉ tony@tony:~/lnroom:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 347148
[2] 347186
WARNING: eatmydata not found: instal it for faster testing
Commands:
l1-cli, l1-log,
l2-cli, l2-log,
bt-cli, stop_ln, fund_nodes
We can check that l1-cli is just an alias for lightning-cli with the
base directory being /tmp/l1-regtest:
(.venv) ◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
myplugin.py
The plugin myplugin.py is defined like this:
from pyln.client import Plugin
plugin = Plugin()
@plugin.method("foo")
def foo_func(plugin,x):
return {"foo":x}
plugin.run()
It register a JSON-RPC method to lightningd that we can try like this:
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
"command": "start",
"plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli foo bar
{
"foo": "bar"
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli foo baz
{
"foo": "baz"
}
Plugin.run
In Overview of pyln-client implementation - Plugin.run() - Part 1 we
saw how Plugin.run is implemented.
Let's describe what happens in plugin.run() in the case of
myplugin.py plugin.
When we start our plugin, lightningd sends the getmanifest request to
the plugin. To compute the payload of the response, the plugin looks
for the function plugin.methods["getmanifest"].func. It executes that
function, constructs the response and sends it back to lightningd.
Then lightningd sends the init request to the plugin. To compute the
payload of the response, the plugin looks for the function
plugin.methods["init"].func. It executes that function,
constructs the response and sends it back to lightningd.
Then the plugins waits for incoming requests from lightningd.
Let's say a client sends a foo request to lightningd, hence lightningd
forwards that request to the plugin. To compute the payload of the
response, the plugin looks for the function
plugin.methods["foo"].func. It executes that function, constructs
the response and sends it back to lightningd. Finally, lightningd
forwards that repsonse to the client.
And now, the plugin waits again for incoming requests from
lightningd.
In this episode we are interested in how foo method is added to
plugin.methods dictionary.
Look at plugin.methods in Python interpreter
Let's play with our plugin in a Python interpreter and take a look at
plugin.methods property.
First we send those expressions to the Python interpreter:
from pyln.client import Plugin
plugin = Plugin()
Then we can see that plugin.methods is filled with two entries, one
for the getmanifest request and the other for the init request:
>>> plugin.methods
{'init': <pyln.client.plugin.Method object at 0x7f21e699c640>,
'getmanifest': <pyln.client.plugin.Method object at 0x7f21e699d360>}
>>> plugin.methods["init"].name
'init'
>>> plugin.methods["init"].func
<bound method Plugin._init of <pyln.client.plugin.Plugin object at 0x7f21e699c5e0>>
Due to the way Python decorators work, sending the following to the Python interpreter
@plugin.method("foo")
def foo_func(plugin,x):
return {"foo":x}
would have the effect to first define normally the function foo_func
and then to set foo_func like this:
foo_func = plugin.method("foo")(foo_func)
To understand what would happened case we can look at the result of
applying the method (function) plugin.method to the string "foo":
>>> plugin.method("foo")
<function Plugin.method.<locals>.decorator at 0x7f21e6647a30>
>>> import inspect
>>> print(inspect.getsource(plugin.method("foo")))
def decorator(f: Callable[..., JSONType]) -> Callable[..., JSONType]:
self.add_method(method_name,
f,
background=False,
category=category,
desc=desc,
long_desc=long_desc,
deprecated=deprecated)
return f
We see that plugin.method("foo") returns a function called decorator.
That function takes a function f as argument (in our case it is
foo_func), uses add_method method to do some stuff (add foo method to
plugin.methods, in our case method_name is foo and f is foo_func) and
return f (which is foo_func in our case).
Let's try it by sending the following to the Python interpreter:
@plugin.method("foo")
def foo_func(plugin,x):
return {"foo":x}
This added the entry foo to plugin.methods dictionary as we can see:
>>> plugin.methods
{'init': <pyln.client.plugin.Method object at 0x7f21e699c640>,
'getmanifest': <pyln.client.plugin.Method object at 0x7f21e699d360>,
'foo': <pyln.client.plugin.Method object at 0x7f21e51ab8b0>}
>>> plugin.methods["foo"].func
<function foo_func at 0x7f21e6991750>
>>> plugin.methods["foo"].func(plugin, "bar")
{'foo': 'bar'}
class_MyPlugin.py
To understand how methods of the class Method are added to the
property methods of the class Plugin using Python decorators we
implement a simplified and narrowed version of the classes Method and
Plugin. We call them MyMethod and MyPlugin.
The class MyMethod has only two properties which are name and func.
We define it like this:
class MyMethod:
def __init__(self,name,func):
self.name = name
self.func = func
Let's send it to the Python interpreter to define it and interpret the following expressions:
>>> mymethod = MyMethod("foo", lambda x: x)
>>> mymethod
<__main__.MyMethod object at 0x7f21e51bc970>
>>> mymethod.func
<function <lambda> at 0x7f21e69916c0>
>>> mymethod.func("bar")
'bar'
Now we write MyPlugin class. This class has one property named
methods which is a dictionary that we initialize with the entries
"init" and "getmanifest". The values of those entries are of the
class MyMethod and depend of the values of the methods _init and
_getmanifest of the class itself:
class MyPlugin:
def __init__(self):
self.methods = {
"init": MyMethod("init", self._init),
"getmanifest": MyMethod("getmanifest", self._getmanifest)
}
def _init(self):
return "I'm _init"
def _getmanifest(self):
return "I'm _getmanifest"
Let's send it to the Python interpreter to define it and interpret the following expressions:
>>> myplugin = MyPlugin()
>>> myplugin.methods
{'init': <__main__.MyMethod object at 0x7f21e51be290>,
'getmanifest': <__main__.MyMethod object at 0x7f21e51be770>}
>>> myplugin.methods["init"].func()
"I'm _init"
>>>
Let's add add_method methods which given a name name and a function
func constructs an object MyMethod and a name entry in methods
dictionary. The class MyPlugin is then:
class MyPlugin:
def __init__(self):
self.methods = {
"init": MyMethod("init", self._init),
"getmanifest": MyMethod("getmanifest", self._getmanifest)
}
def _init(self):
return "I'm _init"
def _getmanifest(self):
return "I'm _getmanifest"
def add_method(self,name,func):
self.methods[name] = MyMethod(name,func)
We redefine MyPlugin class by sending the previous snippet to the
Python interpreter and interpret the following expressions:
>>> myplugin = MyPlugin()
>>> myplugin.methods
{'init': <__main__.MyMethod object at 0x7f21e51ab100>,
'getmanifest': <__main__.MyMethod object at 0x7f21e51ab190>}
>>> myplugin.add_method("foo", lambda x: x)
>>> myplugin.methods
{'init': <__main__.MyMethod object at 0x7f21e51ab100>,
'getmanifest': <__main__.MyMethod object at 0x7f21e51ab190>,
'foo': <__main__.MyMethod object at 0x7f21e6ae35e0>}
>>> myplugin.methods["foo"].func("bar")
'bar'
Finally we can define method method in MyPlugin in a way that we can
use Python decorators to add methods to methods property of the class
MyPlugin.
The method method of MyPlugin takes name as argument, defines a
"local" function decorator which takes a function f, adds the entry
name to methods property with its value being Method(name,f). This is
done by using add_method method. The function decorator returns f
and method method returns the "local" function decorator. Hence the
class MyPlugin is now:
class MyPlugin:
def __init__(self):
self.methods = {
"init": MyMethod("init", self._init),
"getmanifest": MyMethod("getmanifest", self._getmanifest)
}
def _init(self):
return "I'm _init"
def _getmanifest(self):
return "I'm _getmanifest"
def add_method(self,name,func):
self.methods[name] = MyMethod(name,func)
def method(self,name):
def decorator(f):
self.add_method(name,f)
return f
return decorator
We redefine MyPlugin class by sending the previous snippet to the
Python interpreter and interpret the following expressions:
>>> myplugin = MyPlugin()
>>> myplugin.methods
{'init': <__main__.MyMethod object at 0x7f21e51abfa0>,
'getmanifest': <__main__.MyMethod object at 0x7f21e51abbe0>}
Then we add foo method to myplugin.methods by sending the following
snippet to the Python interpreter:
@myplugin.method("foo")
def foo_func(myplugin,x):
return {"foo": x}
An we check that myplugin.methods is as expected:
>>> myplugin.methods
{'init': <__main__.MyMethod object at 0x7f21e51abfa0>,
'getmanifest': <__main__.MyMethod object at 0x7f21e51abbe0>,
'foo': <__main__.MyMethod object at 0x7f21e51ab490>}
>>> myplugin.methods["foo"].func
<function foo_func at 0x7f21e51bad40>
>>> myplugin.methods["foo"].func(myplugin,"bar")
{'foo': 'bar'}
Method, Plugin.__init__, Plugin.add_method, Plugin.method
We can finally looks at the implementation of Method class and the methods Plugin.__init__, Plugin.add_method and Plugin.method defined in lightning:contrib/pyln-client/pyln/client/plugin.py file keeping only the "meaningful" parts:
class Method(object):
...
def __init__(self, name: str, func: Callable[..., JSONType],
mtype: MethodType = MethodType.RPCMETHOD,
category: str = None, desc: str = None,
long_desc: str = None, deprecated: bool = False):
self.name = name
self.func = func
...
class Plugin(object):
...
def __init__(self, stdout: Optional[io.TextIOBase] = None,
stdin: Optional[io.TextIOBase] = None, autopatch: bool = True,
dynamic: bool = True,
init_features: Optional[Union[int, str, bytes]] = None,
node_features: Optional[Union[int, str, bytes]] = None,
invoice_features: Optional[Union[int, str, bytes]] = None):
self.methods = {
'init': Method('init', self._init, MethodType.RPCMETHOD)
}
...
self.add_method("getmanifest", self._getmanifest, background=False)
...
def add_method(self, name: str, func: Callable[..., Any],
background: bool = False,
category: Optional[str] = None,
desc: Optional[str] = None,
long_desc: Optional[str] = None,
deprecated: bool = False) -> None:
...
if name in self.methods:
raise ValueError(
"Name {} is already bound to a method.".format(name)
)
# Register the function with the name
method = Method(
name, func, MethodType.RPCMETHOD, category, desc, long_desc,
deprecated
)
method.background = background
self.methods[name] = method
def method(self, method_name: str, category: Optional[str] = None,
desc: Optional[str] = None,
long_desc: Optional[str] = None,
deprecated: bool = False) -> JsonDecoratorType:
...
def decorator(f: Callable[..., JSONType]) -> Callable[..., JSONType]:
self.add_method(method_name,
f,
background=False,
category=category,
desc=desc,
long_desc=long_desc,
deprecated=deprecated)
return f
return decorator
We are done!
Terminal session
We ran the following commands in this order:
$ python -m venv .venv
$ source .venv/bin/activate
$ pip install pyln-client
$ source lightning/contrib/startup_regtest.sh
$ start_ln
$ l1-cli plugin start $(pwd)/myplugin.py
$ ./setup.sh
$ alias l1-cli
$ l1-cli foo bar
$ l1-cli foo baz
And below you can read the terminal session (command lines and outputs):
◉ tony@tony:~/lnroom:
$ python -m venv .venv
◉ tony@tony:~/lnroom:
$ source .venv/bin/activate
(.venv) ◉ tony@tony:~/lnroom:
$ pip install pyln-client
...
(.venv) ◉ tony@tony:~/lnroom:
$ source lightning/contrib/startup_regtest.sh
lightning-cli is /usr/local/bin/lightning-cli
lightningd is /usr/local/bin/lightningd
Useful commands:
start_ln 3: start three nodes, l1, l2, l3
connect 1 2: connect l1 and l2
fund_nodes: connect all nodes with channels, in a row
stop_ln: shutdown
destroy_ln: remove ln directories
(.venv) ◉ tony@tony:~/lnroom:
$ start_ln
Bitcoin Core starting
awaiting bitcoind...
Making "default" bitcoind wallet.
[1] 347148
[2] 347186
WARNING: eatmydata not found: instal it for faster testing
Commands:
l1-cli, l1-log,
l2-cli, l2-log,
bt-cli, stop_ln, fund_nodes
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli plugin start $(pwd)/myplugin.py
{
"command": "start",
"plugins": [...]
}
(.venv) ◉ tony@tony:~/lnroom:
$ ./setup.sh
Ubuntu 22.04.2 LTS
Python 3.10.6
lightningd v23.02.2
pyln-client 23.2
(.venv) ◉ tony@tony:~/lnroom:
$ alias l1-cli
alias l1-cli='lightning-cli --lightning-dir=/tmp/l1-regtest'
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli foo bar
{
"foo": "bar"
}
(.venv) ◉ tony@tony:~/lnroom:
$ l1-cli foo baz
{
"foo": "baz"
}
Source code
myplugin.py
#!/usr/bin/env python
from pyln.client import Plugin
plugin = Plugin()
@plugin.method("foo")
def foo_func(plugin,x):
return {"foo":x}
plugin.run()
class_MyPlugin.py
class MyMethod:
def __init__(self,name,func):
self.name = name
self.func = func
class MyPlugin:
def __init__(self):
self.methods = {
"init": MyMethod("init", self._init),
"getmanifest": MyMethod("getmanifest", self._getmanifest)
}
def _init(self):
return "I'm _init"
def _getmanifest(self):
return "I'm _getmanifest"
def add_method(self,name,func):
self.methods[name] = MyMethod(name,func)
def method(self,name):
def decorator(f):
self.add_method(name,f)
return f
return decorator
setup.sh
#!/usr/bin/env bash
ubuntu=$(lsb_release -ds)
lightningd=$(lightningd --version | xargs printf "lightningd %s\n")
python=$(python --version)
pyln_client=$(pip list | rg pyln-client)
printf "%s\n%s\n%s\n%s\n" "$ubuntu" "$python" "$lightningd" "$pyln_client"