From ae64583ebc1404a38f38ac1890815ea6457c0b5c Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 6 Mar 2025 11:43:50 +0100 Subject: [PATCH 1/4] add handling of plugin commands --- electrum/commands.py | 53 ++++++++++++++++++++++++++++++++++++++-- electrum/daemon.py | 4 +++- electrum/plugin.py | 57 ++++++++++++++++++++++++++++++++++---------- run_electrum | 25 +++++++++---------- 4 files changed, 112 insertions(+), 27 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 5f24c5f19..8a59f425c 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -83,6 +83,7 @@ if TYPE_CHECKING: known_commands = {} # type: Dict[str, Command] +plugin_commands = defaultdict(set) # type: Dict[str, set[str]] # plugin_name -> set(command_name) class NotSynchronizedException(UserFacingException): @@ -127,7 +128,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 @@ -137,7 +138,12 @@ 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 + known_commands[name] = Command(func.__wrapped__, s) + else: # regular command function + known_commands[name] = Command(func, s) + @wraps(func) async def func_wrapper(*args, **kwargs): cmd_runner = args[0] # type: Commands @@ -1557,6 +1563,49 @@ class Commands(Logger): return encoded_blinded_path.hex() +def plugin_command(s, plugin_name = None): + """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 + global plugin_commands + name = func.__name__ + if name in known_commands or hasattr(Commands, name): + raise Exception(f"Plugins should not override other commands: {name}") + assert name.startswith('plugin_'), f"Plugin command names should start with 'plugin_': {name}" + assert asyncio.iscoroutinefunction(func), f"Plugin commands must be a coroutine: {name}" + + if not plugin_name: + # this is way slower than providing the plugin name, so it should only be considered a fallback + caller_frame = sys._getframe(1) + module_name = caller_frame.f_globals.get('__name__') + plugin_name_from_frame = module_name.rsplit('.', 2)[-2] + # reassigning to plugin_name doesn't work here + plugin_commands[plugin_name_from_frame].add(name) + else: + plugin_commands[plugin_name].add(name) + + setattr(Commands, name, func) + + @command(s) + @wraps(func) + async def func_wrapper(*args, **kwargs): + return func(*args, **kwargs) + return func_wrapper + return decorator + +def remove_disabled_plugin_commands(config: SimpleConfig, plugins_with_commands: set[str]): + """Removes registered commands of plugins that are not enabled in the config.""" + global known_commands + global plugin_commands + assert len(known_commands) > 0, "known_commands should not be empty, called too early?" + for plugin_name in plugins_with_commands: + if not config.get(f'enable_plugin_{plugin_name}'): + registered_commands: set[str] = plugin_commands[plugin_name] + assert len(registered_commands) > 0, "plugin command registered with the invalid plugin name" + for command_name in registered_commands: + del known_commands[command_name] + delattr(Commands, command_name) def eval_bool(x: str) -> bool: if x == 'false': return False 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..059f41f8e 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -65,29 +65,42 @@ class Plugins(DaemonThread): pkgpath = os.path.dirname(plugins.__file__) @profiler - def __init__(self, config: SimpleConfig, gui_name): + def __init__(self, config: SimpleConfig = None, gui_name = None, cmd_only: bool = False): + self.cmd_only = cmd_only # type: bool + self.internal_plugin_metadata = {} + self.external_plugin_metadata = {} + self.loaded_command_modules = set() # type: set[str] + if cmd_only: + # only import the command modules of plugins + self.find_plugins() + return DaemonThread.__init__(self) + self.device_manager = DeviceMgr(config) self.name = 'Plugins' # set name of thread self.config = config self.hw_wallets = {} self.plugins = {} # type: Dict[str, BasePlugin] - self.internal_plugin_metadata = {} - self.external_plugin_metadata = {} self.gui_name = gui_name - self.device_manager = DeviceMgr(config) - self.find_internal_plugins() - self.find_external_plugins() + self.find_plugins() self.load_plugins() self.add_jobs(self.device_manager.thread_jobs()) self.start() + def __getattr__(self, item): + # to prevent accessing of a cmd_only instance of this class + if self.cmd_only: + if item == 'logger': + # if something tries to access logger and it is not initialized it gets initialized here + Logger.__init__(self) + return self.logger + raise Exception(f"This instance of Plugins is only for command importing, cannot access {item}") + @property def descriptions(self): 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 +108,17 @@ class Plugins(DaemonThread): # we exclude the ones packaged as *code*, here: if loader.__class__.__qualname__ == "PyiFrozenImporter": continue - full_name = f'electrum.plugins.{name}' + 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 +140,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 +152,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 +196,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 +232,14 @@ 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: + 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 +386,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/run_electrum b/run_electrum index 8683112c1..b96581099 100755 --- a/run_electrum +++ b/run_electrum @@ -102,7 +102,8 @@ 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.commands import get_parser, known_commands, Commands, config_variables +from electrum.plugin import Plugins +from electrum.commands import get_parser, known_commands, Commands, config_variables, remove_disabled_plugin_commands from electrum import daemon from electrum import keystore from electrum.util import create_and_start_event_loop, UserFacingException, JsonRPCError @@ -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] @@ -313,7 +307,8 @@ def main(): sys.argv[i] = input("Enter argument:") elif arg == ':': sys.argv[i] = prompt_password('Enter argument (will not echo):', confirm=False) - + # load (only) the commands modules of plugins so their commands are registered + plugin_commands = Plugins(cmd_only=True) # parse command line parser = get_parser() args = parser.parse_args() @@ -363,10 +358,13 @@ def main(): if not config_options.get('verbosity'): warnings.simplefilter('ignore', DeprecationWarning) - config = SimpleConfig(config_options) cmdname = config.get('cmd') + # now that we know which plugins are enabled we can remove commands of disabled plugins again + # enabled plugins depend on the datadir which is parsed by argparse, so they can only be unloaded afterwards + remove_disabled_plugin_commands(config, plugin_commands.loaded_command_modules) + # set language as early as possible # Note: we are already too late for strings that are declared in the global scope # of an already imported module. However, the GUI and the plugins at least have @@ -498,7 +496,10 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): else: # command line configure_logging(config, log_to_file=False) # don't spam logfiles for each client-side RPC, but support "-v" - cmd = known_commands[cmdname] + cmd = known_commands.get(cmdname) + if not cmd: + print_stderr("unknown command:", cmdname) + sys_exit(1) wallet_path = config.get_wallet_path() if not config.NETWORK_OFFLINE: init_cmdline(config_options, wallet_path, rpcserver=True, config=config) @@ -538,7 +539,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: From 4f79e516e43312ec4b7b89d73019cfc04187198b Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 15 Mar 2025 11:44:24 +0100 Subject: [PATCH 2/4] run_electrum: add parse_command_line method --- run_electrum | 66 ++++++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/run_electrum b/run_electrum index b96581099..5771674a6 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__)) @@ -271,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 @@ -309,9 +342,6 @@ def main(): sys.argv[i] = prompt_password('Enter argument (will not echo):', confirm=False) # load (only) the commands modules of plugins so their commands are registered plugin_commands = Plugins(cmd_only=True) - # 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: @@ -330,34 +360,8 @@ 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 = parse_command_line() - 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) config = SimpleConfig(config_options) cmdname = config.get('cmd') From cb39737a39cd80ac4e8d26f3e1374cbb4c9ed847 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sat, 15 Mar 2025 11:40:26 +0100 Subject: [PATCH 3/4] Plugins call with cmd_only: - pass temporary config to Plugins - load only enabled plugins - parse the command line again after plugins are loaded --- electrum/commands.py | 13 +------------ electrum/plugin.py | 18 +++++++----------- run_electrum | 25 ++++++++++++++----------- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 8a59f425c..fea336408 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1594,18 +1594,6 @@ def plugin_command(s, plugin_name = None): return func_wrapper return decorator -def remove_disabled_plugin_commands(config: SimpleConfig, plugins_with_commands: set[str]): - """Removes registered commands of plugins that are not enabled in the config.""" - global known_commands - global plugin_commands - assert len(known_commands) > 0, "known_commands should not be empty, called too early?" - for plugin_name in plugins_with_commands: - if not config.get(f'enable_plugin_{plugin_name}'): - registered_commands: set[str] = plugin_commands[plugin_name] - assert len(registered_commands) > 0, "plugin command registered with the invalid plugin name" - for command_name in registered_commands: - del known_commands[command_name] - delattr(Commands, command_name) def eval_bool(x: str) -> bool: if x == 'false': return False @@ -1890,5 +1878,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/plugin.py b/electrum/plugin.py index 059f41f8e..ca99ed530 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -65,19 +65,20 @@ class Plugins(DaemonThread): pkgpath = os.path.dirname(plugins.__file__) @profiler - def __init__(self, config: SimpleConfig = None, gui_name = None, cmd_only: bool = False): + def __init__(self, config: SimpleConfig, gui_name = None, cmd_only: bool = False): + self.config = config self.cmd_only = cmd_only # type: bool self.internal_plugin_metadata = {} self.external_plugin_metadata = {} 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.name = 'Plugins' # set name of thread - self.config = config self.hw_wallets = {} self.plugins = {} # type: Dict[str, BasePlugin] self.gui_name = gui_name @@ -86,15 +87,6 @@ class Plugins(DaemonThread): self.add_jobs(self.device_manager.thread_jobs()) self.start() - def __getattr__(self, item): - # to prevent accessing of a cmd_only instance of this class - if self.cmd_only: - if item == 'logger': - # if something tries to access logger and it is not initialized it gets initialized here - Logger.__init__(self) - return self.logger - raise Exception(f"This instance of Plugins is only for command importing, cannot access {item}") - @property def descriptions(self): return dict(list(self.internal_plugin_metadata.items()) + list(self.external_plugin_metadata.items())) @@ -108,6 +100,8 @@ class Plugins(DaemonThread): # we exclude the ones packaged as *code*, here: if loader.__class__.__qualname__ == "PyiFrozenImporter": continue + 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: @@ -233,6 +227,8 @@ class Plugins(DaemonThread): 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 diff --git a/run_electrum b/run_electrum index 5771674a6..0b71a496f 100755 --- a/run_electrum +++ b/run_electrum @@ -103,7 +103,7 @@ 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, remove_disabled_plugin_commands +from electrum.commands import get_parser, known_commands, Commands, config_variables from electrum import daemon from electrum import keystore from electrum.util import create_and_start_event_loop, UserFacingException, JsonRPCError @@ -340,8 +340,6 @@ def main(): sys.argv[i] = input("Enter argument:") elif arg == ':': sys.argv[i] = prompt_password('Enter argument (will not echo):', confirm=False) - # load (only) the commands modules of plugins so their commands are registered - plugin_commands = Plugins(cmd_only=True) # config is an object passed to the various constructors (wallet, interface, gui) if is_android: @@ -360,15 +358,23 @@ def main(): # ~hack for easier regtest builds. pkgname subject to change. config_options['regtest'] = True else: + # 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') - # now that we know which plugins are enabled we can remove commands of disabled plugins again - # enabled plugins depend on the datadir which is parsed by argparse, so they can only be unloaded afterwards - remove_disabled_plugin_commands(config, plugin_commands.loaded_command_modules) - # set language as early as possible # Note: we are already too late for strings that are declared in the global scope # of an already imported module. However, the GUI and the plugins at least have @@ -500,10 +506,7 @@ def handle_cmd(*, cmdname: str, config: 'SimpleConfig', config_options: dict): else: # command line configure_logging(config, log_to_file=False) # don't spam logfiles for each client-side RPC, but support "-v" - cmd = known_commands.get(cmdname) - if not cmd: - print_stderr("unknown command:", cmdname) - sys_exit(1) + cmd = known_commands[cmdname] wallet_path = config.get_wallet_path() if not config.NETWORK_OFFLINE: init_cmdline(config_options, wallet_path, rpcserver=True, config=config) From a474b8674d5b26fc10ca0ade9b2ba93cc438ea46 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Sun, 16 Mar 2025 11:12:04 +0100 Subject: [PATCH 4/4] plugin commands: - make plugin commands start with plugin name + underscore - plugin_name must be passed to the plugin_command decorator - fixes: - remove plugin_commands (unneeded) - func_wrapper must await func() - setattr(Commands, name, func_wrapper) - add push/pull commands to labels plugin --- electrum/commands.py | 47 ++++++++++------------------- electrum/plugins/labels/commands.py | 22 ++++++++++++++ electrum/plugins/labels/labels.py | 8 +++-- 3 files changed, 43 insertions(+), 34 deletions(-) create mode 100644 electrum/plugins/labels/commands.py diff --git a/electrum/commands.py b/electrum/commands.py index fea336408..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 @@ -83,7 +79,6 @@ if TYPE_CHECKING: known_commands = {} # type: Dict[str, Command] -plugin_commands = defaultdict(set) # type: Dict[str, set[str]] # plugin_name -> set(command_name) class NotSynchronizedException(UserFacingException): @@ -104,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 @@ -137,17 +132,20 @@ class Command: def command(s): def decorator(func): global known_commands - name = func.__name__ - if hasattr(func, '__wrapped__'): # plugin command function - known_commands[name] = Command(func.__wrapped__, s) - else: # regular command function - 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: @@ -1563,34 +1561,21 @@ class Commands(Logger): return encoded_blinded_path.hex() -def plugin_command(s, plugin_name = None): +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 - global plugin_commands - name = func.__name__ + 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 name.startswith('plugin_'), f"Plugin command names should start with 'plugin_': {name}" assert asyncio.iscoroutinefunction(func), f"Plugin commands must be a coroutine: {name}" - - if not plugin_name: - # this is way slower than providing the plugin name, so it should only be considered a fallback - caller_frame = sys._getframe(1) - module_name = caller_frame.f_globals.get('__name__') - plugin_name_from_frame = module_name.rsplit('.', 2)[-2] - # reassigning to plugin_name doesn't work here - plugin_commands[plugin_name_from_frame].add(name) - else: - plugin_commands[plugin_name].add(name) - - setattr(Commands, name, func) - @command(s) @wraps(func) async def func_wrapper(*args, **kwargs): - return func(*args, **kwargs) + return await func(*args, **kwargs) + setattr(Commands, name, func_wrapper) return func_wrapper return decorator 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