Skip to content

Commit 246f03f

Browse files
committed
allow all plugins to be either zip or directory based
1 parent bea4c61 commit 246f03f

File tree

4 files changed

+98
-67
lines changed

4 files changed

+98
-67
lines changed

electrum/gui/qml/qeapp.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
460460
"MEMPOOL_MB": electrum.util.UI_UNIT_NAME_MEMPOOL_MB,
461461
})
462462

463-
self.plugins.load_internal_plugin('trustedcoin')
463+
self.plugins.load_plugin_by_name('trustedcoin')
464464

465465
qInstallMessageHandler(self.message_handler)
466466

electrum/gui/qt/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def __init__(self, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugin
159159
self.reload_app_stylesheet()
160160

161161
# always load 2fa
162-
self.plugins.load_internal_plugin('trustedcoin')
162+
self.plugins.load_plugin_by_name('trustedcoin')
163163

164164
run_hook('init_qt', self)
165165

electrum/plugin.py

+95-64
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import zipimport
3939
from concurrent import futures
4040
from functools import wraps, partial
41+
from itertools import chain
4142

4243
from .i18n import _
4344
from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException)
@@ -91,9 +92,9 @@ def __init__(self, config: SimpleConfig, gui_name = None, cmd_only: bool = False
9192
def descriptions(self):
9293
return dict(list(self.internal_plugin_metadata.items()) + list(self.external_plugin_metadata.items()))
9394

94-
def find_internal_plugins(self):
95-
"""Populates self.internal_plugin_metadata"""
96-
iter_modules = list(pkgutil.iter_modules([self.pkgpath]))
95+
def find_directory_plugins(self, pkg_path: str, external: bool):
96+
"""Finds plugins in directory form from the given pkg_path and populates the metadata dicts"""
97+
iter_modules = list(pkgutil.iter_modules([pkg_path]))
9798
for loader, name, ispkg in iter_modules:
9899
# FIXME pyinstaller binaries are packaging each built-in plugin twice:
99100
# once as data and once as code. To honor the "no duplicates" rule below,
@@ -102,8 +103,19 @@ def find_internal_plugins(self):
102103
continue
103104
if self.cmd_only and self.config.get('enable_plugin_' + name) is not True:
104105
continue
105-
full_name = f'electrum.plugins.{name}' + ('.commands' if self.cmd_only else '')
106-
spec = importlib.util.find_spec(full_name)
106+
base_name = 'electrum.plugins' if not external else 'electrum_external_plugins'
107+
full_name = f'{base_name}.{name}' + ('.commands' if self.cmd_only else '')
108+
if external:
109+
module_path = os.path.join(pkg_path, name)
110+
if not self._has_recursive_root_permissions(module_path):
111+
self.logger.info(f"Not loading plugin {module_path}: directory has user write permissions")
112+
continue
113+
module_path = os.path.join(module_path, 'commands.py' if self.cmd_only else '__init__.py')
114+
if not os.path.exists(module_path):
115+
continue
116+
spec = importlib.util.spec_from_file_location(full_name, module_path)
117+
else:
118+
spec = importlib.util.find_spec(full_name)
107119
if spec is None:
108120
if self.cmd_only:
109121
continue # no commands module in this plugin
@@ -129,10 +141,13 @@ def find_internal_plugins(self):
129141
if d.get('requires_wallet_type'):
130142
# trustedcoin will not be added to list
131143
continue
132-
if name in self.internal_plugin_metadata:
144+
if name in self.internal_plugin_metadata or name in self.external_plugin_metadata:
133145
_logger.info(f"Found the following plugin modules: {iter_modules=}")
134146
raise Exception(f"duplicate plugins? for {name=}")
135-
self.internal_plugin_metadata[name] = d
147+
if not external:
148+
self.internal_plugin_metadata[name] = d
149+
else:
150+
self.external_plugin_metadata[name] = d
136151

137152
@staticmethod
138153
def exec_module_from_spec(spec, path):
@@ -147,44 +162,36 @@ def exec_module_from_spec(spec, path):
147162
return module
148163

149164
def find_plugins(self):
150-
self.find_internal_plugins()
151-
self.find_external_plugins()
165+
internal_plugins_path = (self.pkgpath, False)
166+
external_plugins_path = (self.get_external_plugin_dir(), True)
167+
for pkg_path, external in (internal_plugins_path, external_plugins_path):
168+
# external plugins enforce root permissions on the directory
169+
if pkg_path and os.path.exists(pkg_path):
170+
self.find_directory_plugins(pkg_path=pkg_path, external=external)
171+
self.find_zip_plugins(pkg_path=pkg_path, external=external)
152172

153173
def load_plugins(self):
154-
self.load_internal_plugins()
155-
self.load_external_plugins()
156-
157-
def load_internal_plugins(self):
158-
for name, d in self.internal_plugin_metadata.items():
174+
for name, d in chain(self.internal_plugin_metadata.items(), self.external_plugin_metadata.items()):
159175
if not d.get('requires_wallet_type') and self.config.get('enable_plugin_' + name):
160176
try:
161-
self.load_internal_plugin(name)
177+
self.load_plugin_by_name(name)
162178
except BaseException as e:
163179
self.logger.exception(f"cannot initialize plugin {name}: {e}")
164180

165-
def load_external_plugin(self, name):
166-
if name in self.plugins:
167-
return self.plugins[name]
168-
# If we do not have the metadata, it was not detected by `load_external_plugins`
169-
# on startup, or added by manual user installation after that point.
170-
metadata = self.external_plugin_metadata.get(name)
171-
if metadata is None:
172-
self.logger.exception(f"attempted to load unknown external plugin {name}")
173-
return
174-
full_name = f'electrum_external_plugins.{name}.{self.gui_name}'
175-
spec = importlib.util.find_spec(full_name)
176-
if spec is None:
177-
raise RuntimeError(f"{self.gui_name} implementation for {name} plugin not found")
178-
module = self.exec_module_from_spec(spec, full_name)
179-
plugin = module.Plugin(self, self.config, name)
180-
self.add_jobs(plugin.thread_jobs())
181-
self.plugins[name] = plugin
182-
self.logger.info(f"loaded external plugin {name}")
183-
return plugin
184-
185181
def _has_root_permissions(self, path):
186182
return os.stat(path).st_uid == 0 and not os.access(path, os.W_OK)
187183

184+
@profiler(min_threshold=0.5)
185+
def _has_recursive_root_permissions(self, path):
186+
"""Check if a directory and all its subdirectories have root permissions"""
187+
for root, dirs, files in os.walk(path):
188+
if not self._has_root_permissions(root):
189+
return False
190+
for f in files:
191+
if not self._has_root_permissions(os.path.join(root, f)):
192+
return False
193+
return True
194+
188195
def get_external_plugin_dir(self):
189196
if sys.platform not in ['linux', 'darwin'] and not sys.platform.startswith('freebsd'):
190197
return
@@ -197,18 +204,23 @@ def get_external_plugin_dir(self):
197204
return
198205
return pkg_path
199206

200-
def external_plugin_path(self, name):
201-
metadata = self.external_plugin_metadata[name]
202-
filename = metadata['filename']
203-
return os.path.join(self.get_external_plugin_dir(), filename)
207+
def zip_plugin_path(self, name):
208+
filename = self.get_metadata(name)['filename']
209+
if name in self.internal_plugin_metadata:
210+
pkg_path = self.pkgpath
211+
else:
212+
pkg_path = self.get_external_plugin_dir()
213+
return os.path.join(pkg_path, filename)
204214

205-
def find_external_plugins(self):
206-
pkg_path = self.get_external_plugin_dir()
215+
def find_zip_plugins(self, pkg_path: str, external: bool):
216+
"""Finds plugins in zip form in the given pkg_path and populates the metadata dicts"""
207217
if pkg_path is None:
208218
return
209219
for filename in os.listdir(pkg_path):
210220
path = os.path.join(pkg_path, filename)
211-
if not self._has_root_permissions(path):
221+
if not filename.endswith('.zip'):
222+
continue
223+
if external and not self._has_root_permissions(path):
212224
self.logger.info(f'not loading {path}: file has user write permissions')
213225
continue
214226
try:
@@ -223,7 +235,7 @@ def find_external_plugins(self):
223235
raise Exception(f"duplicate plugins for name={name}")
224236
if name in self.external_plugin_metadata:
225237
raise Exception(f"duplicate plugins for name={name}")
226-
module_path = f'electrum_external_plugins.{name}'
238+
module_path = f'electrum_external_plugins.{name}' if external else f'electrum.plugins.{name}'
227239
spec = zipfile.find_spec(name)
228240
module = self.exec_module_from_spec(spec, module_path)
229241
if self.cmd_only:
@@ -245,16 +257,11 @@ def find_external_plugins(self):
245257
continue
246258
d['display_name'] = d['fullname']
247259
d['zip_hash_sha256'] = get_file_hash256(path)
248-
self.external_plugin_metadata[name] = d
249-
250-
def load_external_plugins(self):
251-
for name, d in self.external_plugin_metadata.items():
252-
if self.config.get('enable_plugin_' + name):
253-
try:
254-
self.load_external_plugin(name)
255-
except BaseException as e:
256-
traceback.print_exc(file=sys.stdout) # shouldn't this be... suppressed unless -v?
257-
self.logger.exception(f"cannot initialize plugin {name} {e!r}")
260+
d['is_zip'] = True
261+
if external:
262+
self.external_plugin_metadata[name] = d
263+
else:
264+
self.internal_plugin_metadata[name] = d
258265

259266
def get(self, name):
260267
return self.plugins.get(name)
@@ -266,23 +273,31 @@ def load_plugin(self, name) -> 'BasePlugin':
266273
"""Imports the code of the given plugin.
267274
note: can be called from any thread.
268275
"""
269-
if name in self.internal_plugin_metadata:
270-
return self.load_internal_plugin(name)
271-
elif name in self.external_plugin_metadata:
272-
return self.load_external_plugin(name)
276+
if self.get_metadata(name):
277+
return self.load_plugin_by_name(name)
273278
else:
274279
raise Exception(f"could not find plugin {name!r}")
275280

276-
def load_internal_plugin(self, name) -> 'BasePlugin':
281+
def load_plugin_by_name(self, name) -> 'BasePlugin':
277282
if name in self.plugins:
278283
return self.plugins[name]
279-
full_name = f'electrum.plugins.{name}.{self.gui_name}'
284+
285+
is_zip = self.is_plugin_zip(name)
286+
is_external = name in self.external_plugin_metadata
287+
if not is_external:
288+
full_name = f'electrum.plugins.{name}.{self.gui_name}'
289+
else:
290+
full_name = f'electrum_external_plugins.{name}.{self.gui_name}'
291+
280292
spec = importlib.util.find_spec(full_name)
281293
if spec is None:
282294
raise RuntimeError(f"{self.gui_name} implementation for {name} plugin not found")
283295
try:
284-
module = importlib.util.module_from_spec(spec)
285-
spec.loader.exec_module(module)
296+
if is_zip:
297+
module = self.exec_module_from_spec(spec, full_name)
298+
else:
299+
module = importlib.util.module_from_spec(spec)
300+
spec.loader.exec_module(module)
286301
plugin = module.Plugin(self, self.config, name)
287302
except Exception as e:
288303
raise Exception(f"Error loading {name} plugin: {repr(e)}") from e
@@ -376,6 +391,19 @@ def get_plugin(self, name: str) -> 'BasePlugin':
376391
self.load_plugin(name)
377392
return self.plugins[name]
378393

394+
def is_plugin_zip(self, name: str) -> bool:
395+
"""Returns True if the plugin is a zip file"""
396+
if (metadata := self.get_metadata(name)) is None:
397+
return False
398+
return metadata.get('is_zip', False)
399+
400+
def get_metadata(self, name: str) -> Optional[dict]:
401+
"""Returns the metadata of the plugin"""
402+
metadata = self.internal_plugin_metadata.get(name) or self.external_plugin_metadata.get(name)
403+
if not metadata:
404+
return None
405+
return metadata
406+
379407
def run(self):
380408
while self.is_running():
381409
self.wake_up_event.wait(0.1) # time.sleep(0.1) OR event
@@ -470,13 +498,16 @@ def settings_dialog(self, window):
470498

471499
def read_file(self, filename: str) -> bytes:
472500
import zipfile
473-
if self.name in self.parent.external_plugin_metadata:
474-
plugin_filename = self.parent.external_plugin_path(self.name)
501+
if self.parent.is_plugin_zip(self.name):
502+
plugin_filename = self.parent.zip_plugin_path(self.name)
475503
with zipfile.ZipFile(plugin_filename) as myzip:
476504
with myzip.open(os.path.join(self.name, filename)) as myfile:
477505
return myfile.read()
478506
else:
479-
path = os.path.join(os.path.dirname(__file__), 'plugins', self.name, filename)
507+
if filename in self.parent.internal_plugin_metadata:
508+
path = os.path.join(os.path.dirname(__file__), 'plugins', self.name, filename)
509+
else:
510+
path = os.path.join(self.parent.get_external_plugin_dir(), self.name, filename)
480511
with open(path, 'rb') as myfile:
481512
return myfile.read()
482513

tests/test_wizard.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def setUp(self):
4343
})
4444
self.wallet_path = os.path.join(self.electrum_path, "somewallet")
4545
self.plugins = Plugins(self.config, gui_name='cmdline')
46-
self.plugins.load_internal_plugin('trustedcoin')
46+
self.plugins.load_plugin_by_name('trustedcoin')
4747

4848
def tearDown(self):
4949
self.plugins.stop()

0 commit comments

Comments
 (0)