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:
2026-08-14 23:57:28 -04:00
parent ce659048ca
commit b5752a42f0
11 changed files with 2545 additions and 3 deletions

View File

@@ -2701,7 +2701,17 @@
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
40000000,
"1y"
],
"op_return": [
"OP_RETURN:48656c6c6f",
"0",
"1y"
],
"op_return2": [
"OP_RETURN:426974636f696e2041667465726c696665",
"0",
"1y"
]
},

View File

@@ -0,0 +1,149 @@
"""
Test: BAL plugin CLI commands are registered with Electrum.
Verifies that importing the plugin through Electrum's own plugin loader
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
to pre-parse the command line) registers every ``bal_*`` command with
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
It also asserts the basic contract enforced by ``plugin_command``: each command
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
signing also ``'p'``).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_cli_commands_registered.py
"""
import inspect
import tempfile
from electrum import commands as electrum_commands
from electrum.plugin import Plugins
from electrum.simple_config import SimpleConfig
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
# added in later phases must be appended here so the registration test keeps
# proving the whole list is wired up.
EXPECTED_COMMANDS = {
# Settings (no wallet required)
"bal_settings_list": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_get": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_set": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
"bal_settings_reset": {
"requires_network": True,
"requires_wallet": False,
"requires_password": False,
},
# Heirs
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will-Executors
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
# Will
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
}
def _isolated_config(**overrides):
"""A throwaway SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files. The bal plugin is
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
cfg = SimpleConfig(opts)
cfg.enable_plugin("bal")
return cfg
def test_commands_registered():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name, flags in EXPECTED_COMMANDS.items():
assert name in electrum_commands.known_commands, f"{name} not registered"
cmd = electrum_commands.known_commands[name]
assert cmd.name == name
assert cmd.requires_network is flags["requires_network"]
assert cmd.requires_wallet is flags["requires_wallet"]
assert cmd.requires_password is flags["requires_password"]
def test_commands_are_coroutines():
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
func = getattr(electrum_commands.Commands, name, None)
assert func is not None, f"{name} missing from Commands"
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
def test_no_duplicate_registration():
"""Loading the plugin twice must not raise "Command name bal_... already
exists" (the guard in bal/__init__._register_cli_commands)."""
cfg = _isolated_config()
plugins = Plugins(cfg, cmd_only=True)
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
for name in EXPECTED_COMMANDS:
assert name in electrum_commands.known_commands
def test_command_docstrings_document_all_args():
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
CLI parser prints "undocumented argument ..." otherwise)."""
cfg = _isolated_config()
Plugins(cfg, cmd_only=True)
for name in EXPECTED_COMMANDS:
cmd = electrum_commands.known_commands[name]
for varname in list(cmd.params) + list(cmd.options):
if varname in ("wallet", "wallet_path", "plugin", "password"):
continue
assert varname in cmd.arg_descriptions, (
f"{name}: undocumented argument {varname}"
)
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All CLI registration tests passed")

View File

@@ -0,0 +1,233 @@
"""
Offline tests for the headless ``bal.cli.controller.BalController``.
These run without a wallet, a network or Qt: the controller is exercised
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
are covered here; build/sign/push flows need a live wallet and network and are
exercised by the group tests instead.
Run:
source electrum/env/bin/activate
python3 tests/test_cli_controller_offline.py
"""
import os
import shutil
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException
from bal.cli.controller import BalController
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def get_dict(self, key):
return self._data.setdefault(key, {})
def get_transaction(self, txid):
return None
def add_transaction(self, tx, *args, **kwargs):
pass
class FakeWallet:
def __init__(self):
self.db = FakeDB()
self.network = None
self.adb = None
self._dust = 500
def save_db(self):
pass
def dust_threshold(self):
return self._dust
def has_keystore_encryption(self):
return False
def set_label(self, txid, text):
pass
def get_utxos(self):
return []
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
return [VALID_ADDRESS]
class Plugin:
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
def __init__(self):
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
from bal.cli.plugin import Plugin as RealPlugin
self.config = SimpleConfig(
{"electrum_path": self.tmpdir},
read_user_config_function=lambda path: {},
)
self.plugin = RealPlugin(None, self.config, "bal")
def __enter__(self):
return self.plugin
def __exit__(self, *exc):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _make_controller(plugin):
return BalController(plugin, FakeWallet())
def test_controller_init_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willitems == {}
assert c.will == {}
assert c.heirs == {}
assert isinstance(c.will_settings, dict)
assert "baltx_fees" in c.will_settings
# Fresh config: no stored will-executors. On mainnet the default
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
# "bitcoin", so nothing is injected either.
assert c.willexecutors == {}
assert c.no_willexecutor is False
def test_settings_roundtrip():
with Plugin() as plugin:
c = _make_controller(plugin)
listing = c.settings_list()
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
assert c.settings_get(tx_key)["value"] == 100
c.settings_set("bal_tx_fees", "150")
assert c.settings_get("bal_tx_fees")["value"] == 150
assert c.settings_get("TX_FEES")["value"] == 150
c.settings_set("bal_no_willexecutor", "true")
assert c.settings_get("bal_no_willexecutor")["value"] is True
c.settings_reset("bal_tx_fees")
assert c.settings_get("bal_tx_fees")["value"] == 100
def test_settings_unknown_key():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.settings_get("bal_does_not_exist")
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "Unknown BAL setting" in str(e)
def test_heirs_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("alice", VALID_ADDRESS, "100000")
assert c.heirs["alice"][0] == VALID_ADDRESS
assert c.heirs["alice"][1] == "100000"
c.heirs_update("alice", amount="200000")
assert c.heirs["alice"][1] == "200000"
assert c.heirs_show("alice")["value"][1] == "200000"
assert "alice" in c.heirs_list()
c.heirs_delete(["alice"])
assert "alice" not in c.heirs_list()
def test_heirs_add_op_return():
with Plugin() as plugin:
c = _make_controller(plugin)
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
assert c.heirs["note"][1] == "0"
def test_willexecutors_crud():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors == {}
new_url = "https://executor.example.invalid"
c.willexecutors_add(new_url, address="", base_fee=250)
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
c.willexecutors_select([new_url], select=True)
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
renamed = "https://executor2.example.invalid"
c.willexecutors_update(new_url, rename_to=renamed)
assert renamed in c.willexecutors
assert new_url not in c.willexecutors
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
assert renamed not in c.willexecutors
def test_will_status_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
status = c.will_status()
assert status["count"] == 0
assert status["items"] == []
def test_will_check_no_heirs_raises():
with Plugin() as plugin:
c = _make_controller(plugin)
try:
c.will_check()
raise AssertionError("expected UserFacingException")
except UserFacingException as e:
assert "heir" in str(e).lower()
# ------------------------------------------------------------------ #
# runner
# ------------------------------------------------------------------ #
def main():
failures = 0
for name, fn in sorted(globals().items()):
if not name.startswith("test_") or not callable(fn):
continue
print(f" {name}")
try:
fn()
except Exception as e:
failures += 1
print(f" [FAIL] {name}: {e!r}")
if failures:
print(f"[FAIL] {failures} test(s) failed")
sys.exit(1)
print("[OK] All offline controller tests passed")
if __name__ == "__main__":
main()