diff --git a/electrum/commands.py b/electrum/commands.py index 5f24c5f19..2c1e338fd 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -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 diff --git a/electrum/daemon.py b/electrum/daemon.py index 9b110e9a9..51eb25418 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -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 diff --git a/electrum/plugin.py b/electrum/plugin.py index f9eca84b8..ca99ed530 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -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: diff --git a/electrum/plugins/labels/commands.py b/electrum/plugins/labels/commands.py new file mode 100644 index 000000000..e74fcf98c --- /dev/null +++ b/electrum/plugins/labels/commands.py @@ -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) diff --git a/electrum/plugins/labels/labels.py b/electrum/plugins/labels/labels.py index 20e9946e9..004ba9e7b 100644 --- a/electrum/plugins/labels/labels.py +++ b/electrum/plugins/labels/labels.py @@ -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 diff --git a/run_electrum b/run_electrum index 8683112c1..0b71a496f 100755 --- a/run_electrum +++ b/run_electrum @@ -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: