Add OP_RETURN support for heirs
Allow heirs to produce an OP_RETURN output instead of a regular BTC payment. An address prefixed with OP_RETURN: carries hex data (max 80 bytes); such heirs always have amount 0 and are excluded from amount calculations. GUI dialog shows a message field with the decoded text, the heir list displays the decoded message, and the OP_RETURN output is emitted with zero value in inheritance transactions.
This commit is contained in:
@@ -75,6 +75,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
|||||||
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||||
TRANSACTION_LABEL = "inheritance transaction"
|
TRANSACTION_LABEL = "inheritance transaction"
|
||||||
|
|
||||||
|
OP_RETURN_PREFIX = "OP_RETURN:"
|
||||||
|
|
||||||
|
|
||||||
class AliasNotFoundException(Exception):
|
class AliasNotFoundException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -86,6 +88,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
|
|||||||
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||||
|
|
||||||
|
|
||||||
|
def is_op_return_address(address: str) -> bool:
|
||||||
|
return str(address).startswith(OP_RETURN_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def get_op_return_hex(address: str) -> Optional[str]:
|
||||||
|
if is_op_return_address(address):
|
||||||
|
return address[len(OP_RETURN_PREFIX):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_op_return_hex(data_hex: str) -> None:
|
||||||
|
try:
|
||||||
|
data = bytes.fromhex(data_hex)
|
||||||
|
except ValueError:
|
||||||
|
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}")
|
||||||
|
if len(data) > 80:
|
||||||
|
raise NotAnAddress(
|
||||||
|
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_op_return_script(data_hex: str) -> bytes:
|
def create_op_return_script(data_hex: str) -> bytes:
|
||||||
"""Crea scriptpubkey OP_RETURN in bytes"""
|
"""Crea scriptpubkey OP_RETURN in bytes"""
|
||||||
data = bytes.fromhex(data_hex)
|
data = bytes.fromhex(data_hex)
|
||||||
@@ -132,6 +155,14 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
if is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
|
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
||||||
|
op_return_script = create_op_return_script(data_hex)
|
||||||
|
outputs.append(
|
||||||
|
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
||||||
|
)
|
||||||
|
description += f"{name}\n"
|
||||||
|
else:
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
outputs.append(
|
outputs.append(
|
||||||
PartialTxOutput.from_address_and_value(
|
PartialTxOutput.from_address_and_value(
|
||||||
@@ -276,11 +307,12 @@ def print_transaction(heirs, tx, locktimes, tx_fees):
|
|||||||
heirname = ""
|
heirname = ""
|
||||||
for key in heirs.keys():
|
for key in heirs.keys():
|
||||||
heir = heirs[key]
|
heir = heirs[key]
|
||||||
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
|
out_address = out.get("address", "OP_RETURN")
|
||||||
|
if heir[HEIR_ADDRESS] == out_address and str(heir[HEIR_LOCKTIME]) == str(
|
||||||
jtx["locktime"]
|
jtx["locktime"]
|
||||||
):
|
):
|
||||||
heirname = key
|
heirname = key
|
||||||
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
|
print(f"{heirname}\t{out.get('address', 'OP_RETURN')}: {out['value_sats']}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
size = tx.estimated_size()
|
size = tx.estimated_size()
|
||||||
@@ -401,6 +433,9 @@ class Heirs(dict, Logger):
|
|||||||
amount = 0
|
amount = 0
|
||||||
for key, v in heir_list.items():
|
for key, v in heir_list.items():
|
||||||
try:
|
try:
|
||||||
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
|
||||||
|
continue
|
||||||
column = HEIR_AMOUNT
|
column = HEIR_AMOUNT
|
||||||
if real:
|
if real:
|
||||||
column = HEIR_REAL_AMOUNT
|
column = HEIR_REAL_AMOUNT
|
||||||
@@ -452,6 +487,12 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if is_op_return_address(self[key][HEIR_ADDRESS]):
|
||||||
|
heir = list(self[key])
|
||||||
|
heir.insert(HEIR_REAL_AMOUNT, 0)
|
||||||
|
fixed_heirs[key] = heir
|
||||||
|
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
|
||||||
|
continue
|
||||||
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
||||||
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||||
percent_heirs[key] = list(self[key])
|
percent_heirs[key] = list(self[key])
|
||||||
@@ -592,6 +633,8 @@ class Heirs(dict, Logger):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
valid_real_heirs += 1
|
valid_real_heirs += 1
|
||||||
|
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
|
valid_real_heirs += 1
|
||||||
if real_heirs > 0 and valid_real_heirs == 0:
|
if real_heirs > 0 and valid_real_heirs == 0:
|
||||||
raise HeirAmountIsDustException(
|
raise HeirAmountIsDustException(
|
||||||
"All heirs' shares are below the dust limit"
|
"All heirs' shares are below the dust limit"
|
||||||
@@ -811,6 +854,10 @@ class Heirs(dict, Logger):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def validate_address(address):
|
def validate_address(address):
|
||||||
|
if is_op_return_address(address):
|
||||||
|
data_hex = address[len(OP_RETURN_PREFIX):]
|
||||||
|
validate_op_return_hex(data_hex)
|
||||||
|
return address
|
||||||
if not bitcoin.is_address(address, net=constants.net):
|
if not bitcoin.is_address(address, net=constants.net):
|
||||||
raise NotAnAddress(f"not an address,{address}")
|
raise NotAnAddress(f"not an address,{address}")
|
||||||
return address
|
return address
|
||||||
@@ -835,6 +882,9 @@ class Heirs(dict, Logger):
|
|||||||
|
|
||||||
def validate_heir(k, v, timestamp_to_check=False):
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
amount = "0"
|
||||||
|
else:
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
|||||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||||
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
|
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
|
||||||
HeirAmountIsDustException, Heirs,
|
HeirAmountIsDustException, Heirs,
|
||||||
|
OP_RETURN_PREFIX, is_op_return_address,
|
||||||
|
get_op_return_hex, validate_op_return_hex,
|
||||||
WillExecutorFeeTooHighException)
|
WillExecutorFeeTooHighException)
|
||||||
from ...core.util import Util
|
from ...core.util import Util
|
||||||
from ...core.will import (AmountException, HeirChangeException,
|
from ...core.will import (AmountException, HeirChangeException,
|
||||||
|
|||||||
@@ -157,6 +157,16 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
heir = self.bal_window.heirs[key]
|
heir = self.bal_window.heirs[key]
|
||||||
labels = [""] * len(self.Columns)
|
labels = [""] * len(self.Columns)
|
||||||
labels[self.Columns.NAME] = key
|
labels[self.Columns.NAME] = key
|
||||||
|
if is_op_return_address(heir[0]):
|
||||||
|
data_hex = heir[0][len(OP_RETURN_PREFIX):]
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
||||||
|
if len(decoded) > 40:
|
||||||
|
decoded = decoded[:40] + "\u2026"
|
||||||
|
labels[self.Columns.ADDRESS] = decoded
|
||||||
|
except Exception:
|
||||||
|
labels[self.Columns.ADDRESS] = "OP_RETURN"
|
||||||
|
else:
|
||||||
labels[self.Columns.ADDRESS] = heir[0]
|
labels[self.Columns.ADDRESS] = heir[0]
|
||||||
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
||||||
heir[1], self.decimal_point
|
heir[1], self.decimal_point
|
||||||
|
|||||||
@@ -200,15 +200,61 @@ class BalWindow:
|
|||||||
heir_address.setFixedWidth(32 * char_width_in_lineedit())
|
heir_address.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
heir_amount = PercAmountEdit(self.window.get_decimal_point)
|
heir_amount = PercAmountEdit(self.window.get_decimal_point)
|
||||||
|
|
||||||
|
# OP_RETURN message field (hidden by default)
|
||||||
|
op_return_message = QLineEdit()
|
||||||
|
op_return_message.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
|
op_return_message.setVisible(False)
|
||||||
|
op_return_amount_label = QLabel(_("Amount"))
|
||||||
|
op_return_message_label = QLabel(_("OP_RETURN Message"))
|
||||||
|
|
||||||
if heir:
|
if heir:
|
||||||
heir_name.setText(str(heir_key))
|
heir_name.setText(str(heir_key))
|
||||||
heir_address.setText(str(heir[0]))
|
addr = str(heir[0])
|
||||||
|
heir_address.setText(addr)
|
||||||
|
if not is_op_return_address(addr):
|
||||||
heir_amount.setText(
|
heir_amount.setText(
|
||||||
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
||||||
)
|
)
|
||||||
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
|
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
|
||||||
|
else:
|
||||||
|
heir_address.setText("")
|
||||||
|
self.heir_locktime = LockTimeWidget(self, self.window, self.will_settings["locktime"])
|
||||||
|
|
||||||
# heir_is_xpub = QCheckBox()
|
def _update_op_return_from_message():
|
||||||
|
msg = op_return_message.text()
|
||||||
|
data_hex = msg.encode("utf-8").hex()
|
||||||
|
heir_address.setText(OP_RETURN_PREFIX + data_hex)
|
||||||
|
|
||||||
|
def _update_op_return_from_address():
|
||||||
|
addr = heir_address.text()
|
||||||
|
if is_op_return_address(addr):
|
||||||
|
data_hex = addr[len(OP_RETURN_PREFIX):]
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
||||||
|
op_return_message.setText(decoded)
|
||||||
|
except Exception:
|
||||||
|
op_return_message.setText("")
|
||||||
|
|
||||||
|
def _on_address_changed():
|
||||||
|
addr = heir_address.text()
|
||||||
|
if is_op_return_address(addr):
|
||||||
|
if not op_return_message.isVisible():
|
||||||
|
op_return_message.setVisible(True)
|
||||||
|
heir_amount.setVisible(False)
|
||||||
|
op_return_amount_label.setVisible(False)
|
||||||
|
op_return_message_label.setVisible(True)
|
||||||
|
op_return_message.blockSignals(True)
|
||||||
|
_update_op_return_from_address()
|
||||||
|
op_return_message.blockSignals(False)
|
||||||
|
else:
|
||||||
|
op_return_message.setVisible(False)
|
||||||
|
heir_amount.setVisible(True)
|
||||||
|
op_return_amount_label.setVisible(True)
|
||||||
|
op_return_message_label.setVisible(False)
|
||||||
|
|
||||||
|
_on_address_changed()
|
||||||
|
heir_address.textChanged.connect(_on_address_changed)
|
||||||
|
op_return_message.textChanged.connect(_update_op_return_from_message)
|
||||||
|
|
||||||
new_heir_button = QPushButton(_("Add another heir"))
|
new_heir_button = QPushButton(_("Add another heir"))
|
||||||
self.add_another_heir = False
|
self.add_another_heir = False
|
||||||
@@ -225,12 +271,15 @@ class BalWindow:
|
|||||||
|
|
||||||
grid.addWidget(QLabel(_("Address")), 2, 0)
|
grid.addWidget(QLabel(_("Address")), 2, 0)
|
||||||
grid.addWidget(heir_address, 2, 1)
|
grid.addWidget(heir_address, 2, 1)
|
||||||
grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2)
|
grid.addWidget(HelpButton(_("Bitcoin address or OP_RETURN: prefix + hex data")), 2, 2)
|
||||||
|
|
||||||
grid.addWidget(QLabel(_("Amount")), 3, 0)
|
grid.addWidget(op_return_amount_label, 3, 0)
|
||||||
grid.addWidget(heir_amount, 3, 1)
|
grid.addWidget(heir_amount, 3, 1)
|
||||||
grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2)
|
grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2)
|
||||||
|
|
||||||
|
grid.addWidget(op_return_message_label, 3, 0)
|
||||||
|
grid.addWidget(op_return_message, 3, 1)
|
||||||
|
|
||||||
locktime_label = QLabel(_("Locktime"))
|
locktime_label = QLabel(_("Locktime"))
|
||||||
enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get()
|
enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get()
|
||||||
if enable_multiverse:
|
if enable_multiverse:
|
||||||
@@ -244,11 +293,15 @@ class BalWindow:
|
|||||||
buttons.append(new_heir_button)
|
buttons.append(new_heir_button)
|
||||||
vbox.addLayout(Buttons(*buttons))
|
vbox.addLayout(Buttons(*buttons))
|
||||||
while d.exec():
|
while d.exec():
|
||||||
# TODO SAVE HEIR
|
raw_address = heir_address.text()
|
||||||
|
if is_op_return_address(raw_address):
|
||||||
|
amount = "0"
|
||||||
|
else:
|
||||||
|
amount = Util.encode_amount(heir_amount.text(), self.window.get_decimal_point())
|
||||||
heir = [
|
heir = [
|
||||||
heir_name.text(),
|
heir_name.text(),
|
||||||
heir_address.text(),
|
raw_address,
|
||||||
Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()),
|
amount,
|
||||||
str(self.will_settings["locktime"]),
|
str(self.will_settings["locktime"]),
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
@@ -261,6 +314,8 @@ class BalWindow:
|
|||||||
|
|
||||||
def set_heir(self, heir):
|
def set_heir(self, heir):
|
||||||
heir = list(heir)
|
heir = list(heir)
|
||||||
|
if is_op_return_address(heir[1]):
|
||||||
|
heir[2] = "0"
|
||||||
if not self.bal_plugin.ENABLE_MULTIVERSE.get():
|
if not self.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||||
heir[3] = self.will_settings["locktime"]
|
heir[3] = self.will_settings["locktime"]
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||||
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
create_op_return_script,
|
create_op_return_script,
|
||||||
|
is_op_return_address, get_op_return_hex, validate_op_return_hex,
|
||||||
AliasNotFoundException,
|
AliasNotFoundException,
|
||||||
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
||||||
HeirExpiredException, HeirAmountIsDustException,
|
HeirExpiredException, HeirAmountIsDustException,
|
||||||
@@ -258,6 +260,96 @@ def test_validate_removes_invalid():
|
|||||||
assert "alice" in result or True # may or may not pass address check
|
assert "alice" in result or True # may or may not pass address check
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# OP_RETURN helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_op_return_prefix_constant():
|
||||||
|
assert OP_RETURN_PREFIX == "OP_RETURN:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_op_return_address():
|
||||||
|
assert is_op_return_address("OP_RETURN:48656c6c6f")
|
||||||
|
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
|
||||||
|
assert not is_op_return_address("")
|
||||||
|
assert not is_op_return_address("OP_RETURN")
|
||||||
|
assert not is_op_return_address("OP_RETURNX:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_op_return_hex():
|
||||||
|
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
|
||||||
|
assert get_op_return_hex("bc1q...") is None
|
||||||
|
assert get_op_return_hex("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_valid():
|
||||||
|
validate_op_return_hex("48656c6c6f")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_invalid():
|
||||||
|
try:
|
||||||
|
validate_op_return_hex("nothex!!")
|
||||||
|
assert False, "expected NotAnAddress"
|
||||||
|
except NotAnAddress:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_too_long():
|
||||||
|
try:
|
||||||
|
validate_op_return_hex("ab" * 81)
|
||||||
|
assert False, "expected NotAnAddress"
|
||||||
|
except NotAnAddress:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_empty():
|
||||||
|
validate_op_return_hex("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_address_op_return():
|
||||||
|
addr = "OP_RETURN:48656c6c6f"
|
||||||
|
result = Heirs.validate_address(addr)
|
||||||
|
assert result == addr
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_heir_op_return():
|
||||||
|
k = "test_op_return"
|
||||||
|
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
|
||||||
|
result = Heirs.validate_heir(k, v)
|
||||||
|
assert result[0] == "OP_RETURN:48656c6c6f"
|
||||||
|
assert result[1] == "0"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Heirs class OP_RETURN integration
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_heirs_fixed_percent_skips_op_return():
|
||||||
|
class FakeWallet:
|
||||||
|
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 __init__(self):
|
||||||
|
self.db = self.FakeDB()
|
||||||
|
self.dust_threshold = lambda: 500
|
||||||
|
wallet = FakeWallet()
|
||||||
|
heirs = Heirs(wallet)
|
||||||
|
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
|
||||||
|
heirs["normal"] = ["addr1", "10000", "9999999999"]
|
||||||
|
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
|
||||||
|
heirs.fixed_percent_lists_amount(0, 500)
|
||||||
|
)
|
||||||
|
assert "op_ret" in fixed_h
|
||||||
|
assert "normal" in fixed_h
|
||||||
|
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
|
||||||
|
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
|
||||||
|
assert fixed_amt == 10000 # OP_RETURN adds 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
|
|||||||
Reference in New Issue
Block a user