add handling of plugin commands
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
25
run_electrum
25
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:
|
||||
|
||||
Reference in New Issue
Block a user