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()
|
||||
|
||||
Reference in New Issue
Block a user