BAL - Bitcoin After Life Electrum plugin (v0.2.8)

Behavior-preserving refactor of the original BAL plugin with clean separation
of business logic from the PyQt GUI.

Layout:
  bal/core/   GUI-free logic (util, plugin_base, heirs, will, willexecutors)
  bal/gui/qt/ PyQt6 presentation (theme, common, widgets, calendar, dialogs,
              lists, window, plugin)
  bal/qt.py   Qt entry-point shim (works as internal and external zip plugin)
  bal/manifest.json  standard-conforming metadata

Tooling:
  build_zip.py            deterministic, zipimport-friendly archive builder
  tests/smoke_test.py     imports + behavior regression test
  tests/external_zip_test.py  reproduces Electrum's external-zip loading

Targets Electrum 4.7.2 + PyQt6. Logic kept byte-identical where possible.
This commit is contained in:
GenSpark AI Developer
2026-06-07 09:22:34 +00:00
commit 4198a5145b
41 changed files with 8345 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
"""Regression test: load the plugin the way Electrum loads an *external* zip.
When a user installs the plugin via Electrum's "Plugins" dialog from a .zip,
Electrum 4.7.x imports it under the synthetic top-level package
``electrum_external_plugins.bal`` and only executes the package ``__init__``
and the ``qt`` module. It does NOT pre-register the synthetic root package
nor the nested ``gui`` / ``gui.qt`` sub-packages.
A naive ``from .gui.qt.plugin import Plugin`` in ``qt.py`` therefore fails with::
ModuleNotFoundError: No module named 'electrum_external_plugins'
This test reproduces that exact loading sequence against the built zip and
asserts that the resilient ``qt.py`` shim resolves the ``Plugin`` class.
Usage:
QT_QPA_PLATFORM=offscreen \
PYTHONPATH=<electrum-src> \
python3 tests/external_zip_test.py <path-to-bal-electrum-plugin.zip>
"""
import importlib.util
import sys
import zipimport
def main(zip_path: str) -> int:
base = "electrum_external_plugins.bal"
gui = "qt"
dirname = "bal" # directory name inside the zip archive
def exec_module_from_spec(spec, path):
# Mirrors electrum.plugin.PluginManager.exec_module_from_spec
module = importlib.util.module_from_spec(spec)
sys.modules[path] = module
spec.loader.exec_module(module)
return module
zi = zipimport.zipimporter(zip_path)
# Step 1: load the package __init__ as electrum_external_plugins.bal
init_spec = zi.find_spec(dirname)
assert init_spec is not None, "could not find package __init__ inside zip"
exec_module_from_spec(init_spec, base)
# Step 2: load the qt entry-point as electrum_external_plugins.bal.qt
full = f"{base}.{gui}"
spec = importlib.util.find_spec(full)
assert spec is not None, f"could not find spec for {full!r}"
module = exec_module_from_spec(spec, full)
# The loader expects a `Plugin` class to be exported.
plugin_cls = getattr(module, "Plugin", None)
assert plugin_cls is not None, "qt module did not export a Plugin class"
print(f"[OK] external zip loads Plugin -> {plugin_cls!r}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
sys.exit(main(sys.argv[1]))

92
tests/smoke_test.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Smoke test for the BAL Electrum plugin.
Goal: after every refactor step, prove that the plugin still imports cleanly
under a real Electrum 4.7.2 + PyQt6 install, and that a handful of pure-logic
behaviours produce *exactly* the same results as before (regression guard).
Run with:
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py <PLUGIN_IMPORT_NAME>
where <PLUGIN_IMPORT_NAME> is the dotted module path the plugin is reachable
at, e.g. "electrum.plugins.BAL" (original) or "electrum.plugins.bal" (new).
"""
import importlib
import sys
PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.BAL"
def imp(mod):
return importlib.import_module(f"{PKG}.{mod}")
def main():
# --- Qt must be initialised before importing any gui module ---
from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports.
qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util
assert Util.is_perc("50%") is True
assert Util.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0
assert heirs.HEIR_AMOUNT == 1
assert heirs.HEIR_LOCKTIME == 2
assert heirs.HEIR_REAL_AMOUNT == 3
assert heirs.HEIR_DUST_AMOUNT == 4
# WillItem default status table must stay intact.
assert will.WillItem.STATUS_DEFAULT["VALID"][1] is True
# 4) Plugin class wiring.
assert qt.Plugin.__bases__[0] is bal.BalPlugin
for h in ("create_status_bar", "init_menubar", "load_wallet", "close_wallet"):
assert hasattr(qt.Plugin, h), f"missing hook {h}"
print(f"[OK] smoke test passed for package '{PKG}'")
def imp_core(old_name, new_name):
"""Import a core module, trying the new layout first then the old flat one."""
for candidate in (new_name, old_name):
try:
return importlib.import_module(f"{PKG}.{candidate}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"cannot import {old_name}/{new_name} from {PKG}")
def imp_gui():
for candidate in ("gui.qt.plugin", "qt"):
try:
return importlib.import_module(f"{PKG}.{candidate}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"cannot import gui module from {PKG}")
if __name__ == "__main__":
main()