cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests)
This commit is contained in:
@@ -24,11 +24,18 @@ distinct sub-packages:
|
||||
lists.py Tree/list views (heirs, preview, will-executors)
|
||||
window.py BalWindow controller (per-wallet GUI state)
|
||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
||||
cli/ Headless command-line layer (no Qt)
|
||||
commands.py The @plugin_command transport layer (registers
|
||||
the ``bal_*`` commands)
|
||||
controller.py Headless replica of the Qt flows (later phases)
|
||||
plugin.py Plugin(BalPlugin) entry point for the daemon
|
||||
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
||||
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||
from ``gui.qt.plugin``.
|
||||
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
|
||||
(the shim), which imports ``Plugin`` from ``cli.plugin``.
|
||||
|
||||
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||
``json_db.register_dict`` and replaced it with the path-based
|
||||
@@ -40,3 +47,85 @@ available and adapts, so both releases keep working.
|
||||
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
||||
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI command registration
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
|
||||
# only imports the plugin package ``__init__`` to discover its commands.
|
||||
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
|
||||
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
|
||||
# commands become available on the command line and over JSON-RPC without any Qt.
|
||||
#
|
||||
# The import must be zip-safe: when the plugin is loaded as an external zip,
|
||||
# Electrum registers the package under the synthetic name
|
||||
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
|
||||
# ``bal`` (the zip-internal directory name), which is not present in
|
||||
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
|
||||
# resolve the real package name and import through ``importlib`` (the same
|
||||
# trick as ``qt.py``).
|
||||
import importlib
|
||||
import sys as _sys
|
||||
|
||||
|
||||
def _resolve_package_name() -> str:
|
||||
"""Return the name this package is registered under in ``sys.modules``.
|
||||
|
||||
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
|
||||
so ``__package__`` is already correct). External zip plugins are imported
|
||||
under the synthetic name ``electrum_external_plugins.bal`` with
|
||||
``__package__`` set to just the zip-internal directory name (``bal``); only
|
||||
the synthetic name is present in ``sys.modules``.
|
||||
"""
|
||||
pkg = __package__ or "bal"
|
||||
if pkg in _sys.modules:
|
||||
return pkg
|
||||
synthetic = "electrum_external_plugins." + __name__
|
||||
if synthetic in _sys.modules:
|
||||
return synthetic
|
||||
return pkg
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Backfill missing ancestor packages in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
|
||||
may be missing, which would break sub-module imports. We stub it out as a
|
||||
namespace package so ``importlib`` can still resolve its children (same
|
||||
helper as ``qt.py``).
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in _sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
_sys.modules[ancestor] = module
|
||||
|
||||
|
||||
def _register_cli_commands() -> None:
|
||||
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
|
||||
|
||||
Guarded so a dual install (internal package AND external zip) cannot
|
||||
register the same command names twice, which would make
|
||||
``electrum.commands.plugin_command`` raise
|
||||
"Command name bal_... already exists".
|
||||
"""
|
||||
from electrum import commands as _electrum_commands
|
||||
|
||||
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
|
||||
return
|
||||
pkg = _resolve_package_name()
|
||||
_ensure_parent_packages(pkg)
|
||||
importlib.import_module(pkg + ".cli.commands")
|
||||
_electrum_commands._bal_cli_commands_registered = True
|
||||
|
||||
|
||||
_register_cli_commands()
|
||||
|
||||
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
bal.cli
|
||||
=======
|
||||
|
||||
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||
PyQt or ``electrum.gui``.
|
||||
|
||||
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||
zip-safe shim following the same pattern as ``qt.py``.
|
||||
"""
|
||||
407
bal/cli/commands.py
Normal file
407
bal/cli/commands.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
bal.cli.commands
|
||||
================
|
||||
|
||||
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||
|
||||
This module is the *transport layer* of the command-line front-end: every
|
||||
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||
validate their arguments and delegate the real work to
|
||||
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||
never imports Qt.
|
||||
|
||||
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||
|
||||
Flags (see ``electrum.commands.plugin_command``):
|
||||
|
||||
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||
* ``p`` -> requires the wallet password (for signing).
|
||||
"""
|
||||
|
||||
from electrum.commands import plugin_command
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from .controller import BalController, _user_facing
|
||||
|
||||
plugin_name = "bal"
|
||||
|
||||
|
||||
def _controller(plugin, wallet):
|
||||
"""Build the headless controller, or fail with a clear message."""
|
||||
if plugin is None:
|
||||
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||
if wallet is None:
|
||||
raise UserFacingException("wallet not loaded")
|
||||
return BalController(plugin, wallet)
|
||||
|
||||
|
||||
def _call(plugin, wallet, method, *args, **kwargs):
|
||||
controller = _controller(plugin, wallet)
|
||||
try:
|
||||
return getattr(controller, method)(*args, **kwargs)
|
||||
except Exception as e:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_list(self, plugin=None):
|
||||
"""List all BAL plugin configuration options (key, name and value).
|
||||
|
||||
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||
to an object with ``value``, ``default`` and ``name``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_list")
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_get(self, key, plugin=None):
|
||||
"""Show the current value of one BAL configuration option.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_get", key)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_set(self, key, value, plugin=None):
|
||||
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_set", key, value)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_reset(self, key, plugin=None):
|
||||
"""Reset a BAL configuration option to its default value.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_reset", key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Heirs
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_list(self, wallet=None, plugin=None):
|
||||
"""List the heirs of the current wallet.
|
||||
|
||||
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||
locktime]`` values.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||
"""Show the details of a single heir.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_show", name)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||
"""Add (or replace) an heir in the current wallet.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_update(
|
||||
self,
|
||||
name,
|
||||
address=None,
|
||||
amount=None,
|
||||
locktime=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing heir (only the given fields).
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The new destination address.
|
||||
arg:str:amount:The new amount in satoshis or a percentage.
|
||||
arg:str:locktime:The new delivery locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||
"""Delete one or more heirs.
|
||||
|
||||
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_delete", names)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||
"""Import heirs from a JSON file (validated, merged).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the heirs to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will-Executors
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||
"""List the will-executors for the current network.
|
||||
|
||||
Returns a JSON object mapping executor URLs to their records (address,
|
||||
base_fee, status, info, selected, ...).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||
"""Show the details of a single will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_show", url)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_add(
|
||||
self,
|
||||
url,
|
||||
address="",
|
||||
base_fee=0,
|
||||
info=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Add a new will-executor (not selected by default).
|
||||
|
||||
arg:str:url:The will-executor base URL.
|
||||
arg:str:address:The executor fee address for this network.
|
||||
arg:int:base_fee:The executor base fee in satoshis.
|
||||
arg:str:info:A human-readable description.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_update(
|
||||
self,
|
||||
url,
|
||||
address=None,
|
||||
base_fee=None,
|
||||
info=None,
|
||||
promo_code=None,
|
||||
rename_to=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing will-executor (only the given fields).
|
||||
|
||||
arg:str:url:The will-executor URL to update.
|
||||
arg:str:address:The new fee address.
|
||||
arg:int:base_fee:The new base fee in satoshis.
|
||||
arg:str:info:The new description.
|
||||
arg:str:promo_code:The new promo code.
|
||||
arg:str:rename_to:Optionally move the record to a new URL.
|
||||
"""
|
||||
return _call(
|
||||
plugin,
|
||||
wallet,
|
||||
"willexecutors_update",
|
||||
url,
|
||||
address,
|
||||
base_fee,
|
||||
info,
|
||||
promo_code,
|
||||
rename_to,
|
||||
)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_select(
|
||||
self, url, value=True, wallet=None, plugin=None
|
||||
):
|
||||
"""Select (or deselect) a will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
arg:bool:value:True to select, False to deselect.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||
"""Delete one or more will-executors.
|
||||
|
||||
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||
"""Ping the selected (or the given) will-executor servers.
|
||||
|
||||
Updates status/base_fee/address from each server and saves. Returns
|
||||
``{url: {status, ok}}``.
|
||||
|
||||
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||
"""Download the will-executor list from the welist server and merge it.
|
||||
|
||||
Returns the number of records downloaded and the new total.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_download")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||
"""Import will-executors from a JSON file (``{url: record}``).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the will-executors to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_status(self, wallet=None, plugin=None):
|
||||
"""Show the current will: per-transaction status, locktime and executors.
|
||||
|
||||
Returns a JSON object with a per-txid detail list and global status counts.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_status")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check(self, wallet=None, plugin=None):
|
||||
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||
|
||||
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_check")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_prepare(self, wallet=None, plugin=None):
|
||||
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||
|
||||
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||
``expired``, ``postponed``) and, when needed, the invalidation
|
||||
transaction to sign and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "prepare_will")
|
||||
|
||||
|
||||
@plugin_command("nwp", plugin_name)
|
||||
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||
|
||||
Updates the COMPLETE status and the signature counters and persists.
|
||||
|
||||
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||
return {wid: str(tx) for wid, tx in txs.items()}
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_broadcast(
|
||||
self, txid=None, force=False, wallet=None, plugin=None
|
||||
):
|
||||
"""Send the signed will transactions to their will-executors (in parallel).
|
||||
|
||||
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the whole will to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "export_will", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||
|
||||
arg:str:path:Path to the will JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_invalidate(self, wallet=None, plugin=None):
|
||||
"""Build the on-chain invalidation transaction for the current will.
|
||||
|
||||
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||
transaction still needs to be signed and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "invalidate_will_command")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||
"""Ask the will-executors whether they hold our pushed transactions.
|
||||
|
||||
Runs the searchtx check in parallel, applies the per-item status and
|
||||
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "check_transactions", txids)
|
||||
1129
bal/cli/controller.py
Normal file
1129
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.cli.plugin
|
||||
==============
|
||||
|
||||
The headless (command-line) entry point of the plugin.
|
||||
|
||||
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||
"""
|
||||
|
||||
from ..core.plugin_base import BalPlugin
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
69
bal/cmdline.py
Normal file
69
bal/cmdline.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
bal.cmdline
|
||||
===========
|
||||
|
||||
Compatibility shim for Electrum's plugin loader (command-line front-end).
|
||||
|
||||
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
|
||||
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
|
||||
The real implementation lives in the ``bal.cli`` sub-package, so this module
|
||||
re-exports ``Plugin`` from ``bal.cli.plugin``.
|
||||
|
||||
Like ``qt.py``, this file is not a one-line relative import because the very
|
||||
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
|
||||
imports the package under the synthetic top-level name
|
||||
``electrum_external_plugins.bal`` and never registers the intermediate parent
|
||||
packages. See the module docstring of ``bal.qt`` for the full rationale. The
|
||||
shim resolves the run-time package name, backfills the missing parents into
|
||||
``sys.modules`` and imports the real implementation via
|
||||
:func:`importlib.import_module`.
|
||||
|
||||
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
|
||||
headless process).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
|
||||
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||
sub-module imports. We backfill them here using this module's own loader
|
||||
so that ``importlib`` can find sibling sub-packages.
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||
# real spec. Create a minimal namespace package stub so that the
|
||||
# import machinery can still resolve its children.
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
sys.modules[ancestor] = module
|
||||
|
||||
|
||||
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||
# loaded us.
|
||||
_PKG = __package__ or "bal"
|
||||
|
||||
_ensure_parent_packages(_PKG)
|
||||
|
||||
# Import the real implementation using the fully-qualified, run-time package
|
||||
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
|
||||
|
||||
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||
@@ -6,7 +6,8 @@
|
||||
"author": "Svatantrya",
|
||||
"licence": "MIT",
|
||||
"available_for": [
|
||||
"qt"
|
||||
"qt",
|
||||
"cmdline"
|
||||
],
|
||||
"icon": "icons/bal32x32.png"
|
||||
}
|
||||
Reference in New Issue
Block a user