From ae64583ebc1404a38f38ac1890815ea6457c0b5c Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 6 Mar 2025 11:43:50 +0100 Subject: [PATCH] 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: