1
0

Merge pull request #9629 from f321x/plugin-commands

Allow plugins to register CLI commands
This commit is contained in:
ThomasV
2025-03-16 12:09:09 +01:00
committed by GitHub
6 changed files with 155 additions and 69 deletions

View File

@@ -25,12 +25,10 @@
import io
import sys
import datetime
import copy
import argparse
import json
import ast
import base64
import operator
import asyncio
import inspect
from collections import defaultdict
@@ -43,7 +41,6 @@ import os
import electrum_ecc as ecc
from . import util
from . import keystore
from .lnmsg import OnionWireSerializer
from .logging import Logger
from .onion_message import create_blinded_path, send_onion_message_to
@@ -71,7 +68,6 @@ from .version import ELECTRUM_VERSION
from .simple_config import SimpleConfig
from .invoices import Invoice
from .fee_policy import FeePolicy
from . import submarine_swaps
from . import GuiImportError
from . import crypto
from . import constants
@@ -103,8 +99,8 @@ def format_satoshis(x):
class Command:
def __init__(self, func, s):
self.name = func.__name__
def __init__(self, func, name, s):
self.name = name
self.requires_network = 'n' in s
self.requires_wallet = 'w' in s
self.requires_password = 'p' in s
@@ -127,7 +123,7 @@ class Command:
assert self.requires_wallet
for varname in ('wallet_path', 'wallet'):
if varname in varnames:
assert varname in self.options
assert varname in self.options, f"cmd: {self.name}: {varname} not in options {self.options}"
assert not ('wallet_path' in varnames and 'wallet' in varnames)
if self.requires_wallet:
assert 'wallet' in varnames
@@ -136,12 +132,20 @@ class Command:
def command(s):
def decorator(func):
global known_commands
name = func.__name__
known_commands[name] = Command(func, s)
if hasattr(func, '__wrapped__'):
# plugin command function
name = func.plugin_name + '_' + func.__name__
known_commands[name] = Command(func.__wrapped__, name, s)
else:
# regular command function
name = func.__name__
known_commands[name] = Command(func, name, s)
@wraps(func)
async def func_wrapper(*args, **kwargs):
cmd_runner = args[0] # type: Commands
cmd = known_commands[func.__name__] # type: Command
cmd = known_commands[name] # type: Command
password = kwargs.get('password')
daemon = cmd_runner.daemon
if daemon:
@@ -1557,6 +1561,24 @@ class Commands(Logger):
return encoded_blinded_path.hex()
def plugin_command(s, plugin_name):
"""Decorator to register a cli command inside a plugin. To be used within a commands.py file
in the plugins root."""
def decorator(func):
global known_commands
func.plugin_name = plugin_name
name = plugin_name + '_' + func.__name__
if name in known_commands or hasattr(Commands, name):
raise Exception(f"Plugins should not override other commands: {name}")
assert asyncio.iscoroutinefunction(func), f"Plugin commands must be a coroutine: {name}"
@command(s)
@wraps(func)
async def func_wrapper(*args, **kwargs):
return await func(*args, **kwargs)
setattr(Commands, name, func_wrapper)
return func_wrapper
return decorator
def eval_bool(x: str) -> bool:
if x == 'false': return False
@@ -1841,5 +1863,6 @@ def get_parser():
group.add_argument(k, nargs='?', help=v)
# 'gui' is the default command
# note: set_default_subparser modifies sys.argv
parser.set_default_subparser('gui')
return parser

View File

@@ -349,7 +349,9 @@ class CommandsServer(AuthenticatedServer):
async def run_cmdline(self, config_options):
cmdname = config_options['cmd']
cmd = known_commands[cmdname]
cmd = known_commands.get(cmdname)
if not cmd:
return f"unknown command: {cmdname}"
# arguments passed to function
args = [config_options.get(x) for x in cmd.params]
# decode json arguments

View File

@@ -65,18 +65,24 @@ class Plugins(DaemonThread):
pkgpath = os.path.dirname(plugins.__file__)
@profiler
def __init__(self, config: SimpleConfig, gui_name):
DaemonThread.__init__(self)
self.name = 'Plugins' # set name of thread
def __init__(self, config: SimpleConfig, gui_name = None, cmd_only: bool = False):
self.config = config
self.hw_wallets = {}
self.plugins = {} # type: Dict[str, BasePlugin]
self.cmd_only = cmd_only # type: bool
self.internal_plugin_metadata = {}
self.external_plugin_metadata = {}
self.gui_name = gui_name
self.loaded_command_modules = set() # type: set[str]
if cmd_only:
# only import the command modules of plugins
Logger.__init__(self)
self.find_plugins()
return
DaemonThread.__init__(self)
self.device_manager = DeviceMgr(config)
self.find_internal_plugins()
self.find_external_plugins()
self.name = 'Plugins' # set name of thread
self.hw_wallets = {}
self.plugins = {} # type: Dict[str, BasePlugin]
self.gui_name = gui_name
self.find_plugins()
self.load_plugins()
self.add_jobs(self.device_manager.thread_jobs())
self.start()
@@ -86,8 +92,7 @@ class Plugins(DaemonThread):
return dict(list(self.internal_plugin_metadata.items()) + list(self.external_plugin_metadata.items()))
def find_internal_plugins(self):
"""Populates self.internal_plugin_metadata
"""
"""Populates self.internal_plugin_metadata"""
iter_modules = list(pkgutil.iter_modules([self.pkgpath]))
for loader, name, ispkg in iter_modules:
# FIXME pyinstaller binaries are packaging each built-in plugin twice:
@@ -95,11 +100,19 @@ class Plugins(DaemonThread):
# we exclude the ones packaged as *code*, here:
if loader.__class__.__qualname__ == "PyiFrozenImporter":
continue
full_name = f'electrum.plugins.{name}'
if self.cmd_only and self.config.get('enable_plugin_' + name) is not True:
continue
full_name = f'electrum.plugins.{name}' + ('.commands' if self.cmd_only else '')
spec = importlib.util.find_spec(full_name)
if spec is None: # pkgutil found it but importlib can't ?!
if spec is None:
if self.cmd_only:
continue # no commands module in this plugin
raise Exception(f"Error pre-loading {full_name}: no spec")
module = self.exec_module_from_spec(spec, full_name)
if self.cmd_only:
assert name not in self.loaded_command_modules, f"duplicate command modules for: {name}"
self.loaded_command_modules.add(name)
continue
d = module.__dict__
if 'fullname' not in d:
continue
@@ -121,7 +134,8 @@ class Plugins(DaemonThread):
raise Exception(f"duplicate plugins? for {name=}")
self.internal_plugin_metadata[name] = d
def exec_module_from_spec(self, spec, path):
@staticmethod
def exec_module_from_spec(spec, path):
try:
module = importlib.util.module_from_spec(spec)
# sys.modules needs to be modified for relative imports to work
@@ -132,6 +146,10 @@ class Plugins(DaemonThread):
raise Exception(f"Error pre-loading {path}: {repr(e)}") from e
return module
def find_plugins(self):
self.find_internal_plugins()
self.find_external_plugins()
def load_plugins(self):
self.load_internal_plugins()
self.load_external_plugins()
@@ -172,7 +190,7 @@ class Plugins(DaemonThread):
return
pkg_path = '/opt/electrum_plugins'
if not os.path.exists(pkg_path):
self.logger.info(f'direcctory {pkg_path} does not exist')
self.logger.info(f'directory {pkg_path} does not exist')
return
if not self._has_root_permissions(pkg_path):
self.logger.info(f'not loading {pkg_path}: directory has user write permissions')
@@ -208,6 +226,16 @@ class Plugins(DaemonThread):
module_path = f'electrum_external_plugins.{name}'
spec = zipfile.find_spec(name)
module = self.exec_module_from_spec(spec, module_path)
if self.cmd_only:
if self.config.get('enable_plugin_' + name) is not True:
continue
spec2 = importlib.util.find_spec(module_path + '.commands')
if spec2 is None: # no commands module in this plugin
continue
self.exec_module_from_spec(spec2, module_path + '.commands')
assert name not in self.loaded_command_modules, f"duplicate command modules for: {name}"
self.loaded_command_modules.add(name)
continue
d = module.__dict__
gui_good = self.gui_name in d.get('available_for', [])
if not gui_good:
@@ -354,6 +382,7 @@ class Plugins(DaemonThread):
self.run_jobs()
self.on_stop()
def get_file_hash256(path: str) -> str:
'''Get the sha256 hash of a file in hex, similar to `sha256sum`.'''
with open(path, 'rb') as f:

View File

@@ -0,0 +1,22 @@
from electrum.commands import plugin_command
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .labels import LabelsPlugin
from electrum.commands import Commands
plugin_name = "labels"
@plugin_command('w', plugin_name)
async def push(self: 'Commands', wallet=None) -> int:
""" push labels to server """
plugin: 'LabelsPlugin' = self.daemon._plugins.get_plugin(plugin_name)
return await plugin.push_thread(wallet)
@plugin_command('w', plugin_name)
async def pull(self: 'Commands', wallet=None) -> int:
""" pull labels from server """
assert wallet is not None
plugin: 'LabelsPlugin' = self.daemon._plugins.get_plugin(plugin_name)
return await plugin.pull_thread(wallet, force=False)

View File

@@ -108,7 +108,7 @@ class LabelsPlugin(BasePlugin):
except Exception as e:
raise Exception('Could not decode: ' + await result.text()) from e
async def push_thread(self, wallet: 'Abstract_Wallet'):
async def push_thread(self, wallet: 'Abstract_Wallet') -> int:
wallet_data = self.wallets.get(wallet, None)
if not wallet_data:
raise Exception('Wallet {} not loaded'.format(wallet))
@@ -126,8 +126,9 @@ class LabelsPlugin(BasePlugin):
bundle["labels"].append({'encryptedLabel': encoded_value,
'externalId': encoded_key})
await self.do_post("/labels", bundle)
return len(bundle['labels'])
async def pull_thread(self, wallet: 'Abstract_Wallet', force: bool):
async def pull_thread(self, wallet: 'Abstract_Wallet', force: bool) -> int:
wallet_data = self.wallets.get(wallet, None)
if not wallet_data:
raise Exception('Wallet {} not loaded'.format(wallet))
@@ -140,7 +141,7 @@ class LabelsPlugin(BasePlugin):
raise ErrorConnectingServer(e) from e
if response["labels"] is None or len(response["labels"]) == 0:
self.logger.info('no new labels')
return
return 0
self.logger.info(f'received {len(response["labels"])} labels')
result = {}
@@ -165,6 +166,7 @@ class LabelsPlugin(BasePlugin):
self.set_nonce(wallet, response["nonce"] + 1)
util.trigger_callback('labels_received', wallet, result)
self.on_pulled(wallet)
return len(result)
def on_pulled(self, wallet: 'Abstract_Wallet') -> None:
pass

View File

@@ -37,7 +37,7 @@ if sys.version_info[:3] < _min_python_version_tuple:
import warnings
import asyncio
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, Dict
script_dir = os.path.dirname(os.path.realpath(__file__))
@@ -102,6 +102,7 @@ from electrum.wallet import Wallet
from electrum.storage import WalletStorage
from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled
from electrum.util import InvalidPassword
from electrum.plugin import Plugins
from electrum.commands import get_parser, known_commands, Commands, config_variables
from electrum import daemon
from electrum import keystore
@@ -111,8 +112,6 @@ from electrum.i18n import set_language
if TYPE_CHECKING:
import threading
from electrum.plugin import Plugins
_logger = get_logger(__name__)
@@ -261,11 +260,6 @@ async def run_offline_command(config, config_options, plugins: 'Plugins'):
return result
def init_plugins(config, gui_name):
from electrum.plugin import Plugins
return Plugins(config, gui_name)
loop = None # type: Optional[asyncio.AbstractEventLoop]
stop_loop = None # type: Optional[asyncio.Future]
loop_thread = None # type: Optional[threading.Thread]
@@ -277,6 +271,39 @@ def sys_exit(i):
loop_thread.join(timeout=1)
sys.exit(i)
def parse_command_line() -> Dict:
# parse command line from sys.argv
parser = get_parser()
args = parser.parse_args()
config_options = args.__dict__
f = lambda key: config_options[key] is not None and key not in config_variables.get(args.cmd, {}).keys()
config_options = {key: config_options[key] for key in filter(f, config_options.keys())}
if config_options.get(SimpleConfig.NETWORK_SERVER.key()):
config_options[SimpleConfig.NETWORK_AUTO_CONNECT.key()] = False
config_options['cwd'] = cwd = os.getcwd()
# fixme: this can probably be achieved with a runtime hook (pyinstaller)
if is_pyinstaller and os.path.exists(os.path.join(sys._MEIPASS, 'is_portable')):
config_options['portable'] = True
if config_options.get('portable'):
if is_local:
# running from git clone or local source: put datadir next to main script
datadir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data')
else:
# Running a binary or installed source. The most generic but still reasonable thing
# is to use the current working directory. (see #7732)
# note: The main script is often unpacked to a temporary directory from a bundled executable,
# and we don't want to put the datadir inside a temp dir.
# note: Re the portable .exe on Windows, when the user double-clicks it, CWD gets set
# to the parent dir, i.e. we will put the datadir next to the exe.
datadir = os.path.join(os.path.realpath(cwd), 'electrum_data')
config_options['electrum_path'] = datadir
if not config_options.get('verbosity'):
warnings.simplefilter('ignore', DeprecationWarning)
return config_options
def main():
global loop, stop_loop, loop_thread
@@ -314,10 +341,6 @@ def main():
elif arg == ':':
sys.argv[i] = prompt_password('Enter argument (will not echo):', confirm=False)
# parse command line
parser = get_parser()
args = parser.parse_args()
# config is an object passed to the various constructors (wallet, interface, gui)
if is_android:
import importlib.util
@@ -335,34 +358,19 @@ def main():
# ~hack for easier regtest builds. pkgname subject to change.
config_options['regtest'] = True
else:
config_options = args.__dict__
f = lambda key: config_options[key] is not None and key not in config_variables.get(args.cmd, {}).keys()
config_options = {key: config_options[key] for key in filter(f, config_options.keys())}
if config_options.get(SimpleConfig.NETWORK_SERVER.key()):
config_options[SimpleConfig.NETWORK_AUTO_CONNECT.key()] = False
config_options['cwd'] = cwd = os.getcwd()
# fixme: this can probably be achieved with a runtime hook (pyinstaller)
if is_pyinstaller and os.path.exists(os.path.join(sys._MEIPASS, 'is_portable')):
config_options['portable'] = True
if config_options.get('portable'):
if is_local:
# running from git clone or local source: put datadir next to main script
datadir = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data')
else:
# Running a binary or installed source. The most generic but still reasonable thing
# is to use the current working directory. (see #7732)
# note: The main script is often unpacked to a temporary directory from a bundled executable,
# and we don't want to put the datadir inside a temp dir.
# note: Re the portable .exe on Windows, when the user double-clicks it, CWD gets set
# to the parent dir, i.e. we will put the datadir next to the exe.
datadir = os.path.join(os.path.realpath(cwd), 'electrum_data')
config_options['electrum_path'] = datadir
if not config_options.get('verbosity'):
warnings.simplefilter('ignore', DeprecationWarning)
# save sys args for next parser
saved_sys_argv = sys.argv[:]
# disable help, the next parser will display it
if '-h' in sys.argv:
sys.argv.remove('-h')
# parse first without plugins
config_options = parse_command_line()
tmp_config = SimpleConfig(config_options)
# load (only) the commands modules of plugins so their commands are registered
plugin_commands = Plugins(tmp_config, cmd_only=True)
# re-parse command line
sys.argv = saved_sys_argv[:]
config_options = parse_command_line()
config = SimpleConfig(config_options)
cmdname = config.get('cmd')
@@ -538,7 +546,7 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict):
print_stderr("Run this command without --offline to interact with the daemon")
sys_exit(1)
init_cmdline(config_options, wallet_path, rpcserver=False, config=config)
plugins = init_plugins(config, 'cmdline')
plugins = Plugins(config, 'cmdline')
coro = run_offline_command(config, config_options, plugins)
fut = asyncio.run_coroutine_threadsafe(coro, loop)
try: