"""
+ for i in range(self.num_keystores())
+ ]
+ )
+ + "
",
add_html_and_body=True,
p=True,
size=12,
- ),
+ )
)
@@ -373,16 +511,33 @@ class GenerateSeed(BaseTab):
def create(self) -> TutorialWidget:
widget = QWidget()
- widget.setLayout(QHBoxLayout())
- widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
+ widget_layout = QHBoxLayout(widget)
+ widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
self.screenshot = ScreenshotsGenerateSeed()
- widget.layout().addWidget(self.screenshot)
+ widget_layout.addWidget(self.screenshot)
+
+ self.hardware_signer_interactions: Dict[str, HardwareSignerInteractionWidget] = {}
+ usb_device_names = [
+ enabled_hardware_signer.name
+ for enabled_hardware_signer in self.screenshot.enabled_hardware_signers
+ if enabled_hardware_signer.usb_preferred
+ ]
+ for usb_device_name in usb_device_names:
+ if usb_device_name in self.screenshot.tabs:
+
+ hardware_signer_interaction = HardwareSignerInteractionWidget()
+ self.hardware_signer_interactions[usb_device_name] = hardware_signer_interaction
+ button_hwi = hardware_signer_interaction.add_hwi_button()
+ button_hwi.clicked.connect(self.on_hwi_click)
+
+ tab = self.screenshot.tabs[usb_device_name]
+ tab.layout().addWidget(hardware_signer_interaction) # type: ignore
self.never_label = QLabel()
self.never_label.setWordWrap(True)
self.never_label.setMinimumWidth(300)
- widget.layout().addWidget(self.never_label)
+ widget_layout.addWidget(self.never_label)
tutorial_widget = TutorialWidget(
self.refs.container, widget, self.buttonbox, buttonbox_always_visible=False
@@ -402,20 +557,56 @@ def updateUi(self) -> None:
self.screenshot.updateUi()
self.never_label.setText(self.get_never_label_text())
+ for hardware_signer_interaction in self.hardware_signer_interactions.values():
+ hardware_signer_interaction.updateUi()
+
+ def on_hwi_click(self, initalization_label="") -> None:
+ initalization_label, ok = QInputDialog.getText(
+ None,
+ self.tr("Sticker Label"),
+ self.tr("Please enter the name (sticker label) of the hardware signer"),
+ text=self.refs.qtwalletbase.get_editable_protowallet().sticker_name(""),
+ )
+ if not ok:
+ Message("Aborted setup.")
+ return
+
+ address_type = AddressTypes.p2wpkh # any address type is OK, since we wont use it
+ usb = USBGui(self.refs.qtwalletbase.config.network, initalization_label=initalization_label)
+ key_origin = address_type.key_origin(self.refs.qtwalletbase.config.network)
+ try:
+ result = usb.get_fingerprint_and_xpub(key_origin=key_origin)
+ except Exception as e:
+ Message(
+ str(e)
+ + "\n\n"
+ + self.tr("Please ensure that there are no other programs accessing the Hardware signer"),
+ type=MessageType.Error,
+ )
+ return
+ if not result:
+ Message(self.tr("The setup didnt complete. Please repeat."), type=MessageType.Error)
+ return
+
+ Message(
+ self.tr("Success! Please complete this step with all hardware signers and then click Next."),
+ type=MessageType.Info,
+ )
+
class ValidateBackup(BaseTab):
def create(self) -> TutorialWidget:
widget = QWidget()
- widget.setLayout(QHBoxLayout())
- widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
+ widget_layout = QHBoxLayout(widget)
+ widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
self.screenshot = ScreenshotsViewSeed()
- widget.layout().addWidget(self.screenshot)
+ widget_layout.addWidget(self.screenshot)
self.never_label = QLabel()
self.never_label.setWordWrap(True)
self.never_label.setMinimumWidth(300)
- widget.layout().addWidget(self.never_label)
+ widget_layout.addWidget(self.never_label)
buttonbox = QDialogButtonBox()
self.custom_yes_button = QPushButton()
@@ -452,35 +643,38 @@ class ImportXpubs(BaseTab):
def create(self) -> TutorialWidget:
widget = QWidget()
- widget.setLayout(QVBoxLayout())
- widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
-
- self.screenshot = ScreenshotsExportXpub()
- widget.layout().addWidget(self.screenshot)
+ widget_layout = QVBoxLayout(widget)
+ widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
self.label_import = QLabel()
# handle protowallet and qt_wallet differently:
+ self.button_previous_signer = QPushButton("")
+ self.button_next_signer = QPushButton("")
self.button_create_wallet = QPushButton("")
if self.refs.qt_wallet:
# show the full walet descriptor tab below
- pass
+ self.keystore_uis = None
else:
# integrater the KeyStoreUIs into the tutorials, hide wallet_tabs
- self.label_import.setFont(self.screenshot.title.font())
- widget.layout().addWidget(self.label_import)
-
# this is used in TutorialStep.import_xpub
self.keystore_uis = KeyStoreUIs(
get_editable_protowallet=self.refs.qtwalletbase.get_editable_protowallet,
get_address_type=lambda: self.refs.qtwalletbase.get_editable_protowallet().address_type,
signals_min=self.refs.qtwalletbase.signals,
)
- self.keystore_uis.setCurrentIndex(0)
- widget.layout().addWidget(self.keystore_uis)
+ self.set_current_signer(0)
+ self.keystore_uis.setMovable(False)
+ widget_layout.addWidget(self.keystore_uis)
def create_wallet() -> None:
+ if not self.keystore_uis:
+ return
+
+ if not self.ask_if_can_proceed():
+ return
+
try:
self.keystore_uis.set_protowallet_from_keystore_ui()
self.refs.qtwalletbase.get_editable_protowallet().tutorial_index = (
@@ -491,7 +685,15 @@ def create_wallet() -> None:
caught_exception_message(e)
# hide the next button
- self.buttonbox_buttons[0].setHidden(True)
+ self.button_next.setHidden(True)
+ # and add the prev signer button
+ self.buttonbox_buttons.append(self.button_previous_signer)
+ self.buttonbox.addButton(self.button_previous_signer, QDialogButtonBox.ButtonRole.RejectRole)
+ self.button_previous_signer.clicked.connect(self.previous_signer)
+ # and add the next signer button
+ self.buttonbox_buttons.append(self.button_next_signer)
+ self.buttonbox.addButton(self.button_next_signer, QDialogButtonBox.ButtonRole.AcceptRole)
+ self.button_next_signer.clicked.connect(self.next_signer)
# and add the create wallet button
self.buttonbox_buttons.append(self.button_create_wallet)
self.buttonbox.addButton(self.button_create_wallet, QDialogButtonBox.ButtonRole.AcceptRole)
@@ -518,6 +720,57 @@ def callback() -> None:
self.updateUi()
return tutorial_widget
+ def set_current_signer(self, value: int):
+ if not self.keystore_uis:
+ return
+ self.keystore_uis.setCurrentIndex(value)
+ for i in range(self.keystore_uis.count()):
+ self.keystore_uis.setTabEnabled(i, value == i)
+ self.keystore_uis.setMovable(value >= self.keystore_uis.count() - 1)
+ self.updateUi()
+
+ def ask_if_can_proceed(self) -> bool:
+ if not self.keystore_uis:
+ return False
+
+ messages = self.keystore_uis.get_warning_and_error_messages(
+ keystore_uis=list(self.keystore_uis.getAllTabData().values())[
+ : self.keystore_uis.currentIndex() + 1
+ ],
+ )
+
+ # error_messages are blocking and MUST be fixed before one can proceed
+ error_messages = [message for message in messages if message.type == MessageType.Error]
+ if error_messages:
+ error_messages[0].show()
+ return False
+
+ # show all warning messages. but do not block
+ warning_messages = [message for message in messages if message.type == MessageType.Warning]
+ if warning_messages:
+ for warning_message in warning_messages:
+ if not warning_message.ask(
+ yes_button=QMessageBox.StandardButton.Ignore, no_button=QMessageBox.StandardButton.Cancel
+ ):
+ return False
+ return True
+
+ def next_signer(self):
+ if not self.keystore_uis:
+ return
+
+ if not self.ask_if_can_proceed():
+ return
+
+ if self.keystore_uis.currentIndex() + 1 < self.keystore_uis.count():
+ self.set_current_signer(self.keystore_uis.currentIndex() + 1)
+
+ def previous_signer(self):
+ if not self.keystore_uis:
+ return
+ if self.keystore_uis.currentIndex() - 1 >= 0:
+ self.set_current_signer(self.keystore_uis.currentIndex() - 1)
+
def updateUi(self) -> None:
super().updateUi()
self.label_import.setText(self.tr("2. Import wallet information into Bitcoin Safe"))
@@ -525,39 +778,46 @@ def updateUi(self) -> None:
self.button_create_wallet.setText(self.tr("Skip step"))
else:
self.button_create_wallet.setText(self.tr("Next step"))
- self.buttonbox_buttons[1].setText(self.tr("Previous Step"))
- self.screenshot.updateUi()
+ self.button_next_signer.setText(self.tr("Next signer"))
+ self.button_previous_signer.setText(self.tr("Previous signer"))
+ self.button_previous.setText(self.tr("Previous Step"))
+
+ if self.keystore_uis:
+ self.button_create_wallet.setVisible(
+ self.keystore_uis.currentIndex() == self.keystore_uis.count() - 1
+ )
+ self.button_next_signer.setVisible(
+ self.keystore_uis.currentIndex() != self.keystore_uis.count() - 1
+ )
+ self.button_previous_signer.setVisible(self.keystore_uis.currentIndex() > 0)
+
+ # previous button
+ self.button_previous.setVisible(self.keystore_uis.currentIndex() == 0)
class BackupSeed(BaseTab):
def create(self) -> TutorialWidget:
widget = QWidget()
- widget.setLayout(QHBoxLayout())
- widget.layout().setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
+ widget_layout = QHBoxLayout(widget)
+ widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins
add_centered_icons(
- ["descriptor-backup.svg"],
- widget,
- max_sizes=[(100 * self.num_keystores(), 120)],
+ ["descriptor-backup.svg"] * self.num_keystores(),
+ widget_layout,
+ max_sizes=[(100, 200)] * self.num_keystores(),
)
self.label_print_instructions = QLabel(widget)
self.label_print_instructions.setWordWrap(True)
- widget.layout().addWidget(self.label_print_instructions)
+ widget_layout.addWidget(self.label_print_instructions)
def do_pdf() -> None:
if not self.refs.qt_wallet:
Message(self.tr("Please complete the previous steps."))
return
- make_and_open_pdf(self.refs.qt_wallet.wallet)
-
- # button = create_button(
- # "Print the descriptor", icon_path("pdf-file.svg"), widget, layout
- # )
- # button.setMaximumWidth(150)
- # button.clicked.connect(do_pdf)
+ make_and_open_pdf(self.refs.qt_wallet.wallet, lang_code=self.refs.qtwalletbase.get_lang_code())
buttonbox = QDialogButtonBox()
self.custom_yes_button = QPushButton()
@@ -592,8 +852,7 @@ def updateUi(self) -> None:
html_f(
f"""
{self.tr('Print the pdf (it also contains the wallet descriptor)')}
-
{self.tr('Write each {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) if self.num_keystores()>1 else self.tr('Write the {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) }
- """,
+
{self.tr('Glue the {number} word seed onto the matching printed pdf.').format(number=TEXT_24_WORDS) if self.num_keystores()>1 else self.tr('Glue the {number} word seed onto the printed pdf.').format(number=TEXT_24_WORDS) }
allows you to create a watch-only wallet, to see your balances, but to spent from it you need {threshold} Seeds and the wallet descriptor.",
+ translate(
+ "pdf",
+ "The wallet descriptor (QR Code)
{wallet_descriptor_string}
allows you to create a watch-only wallet to see your balance. To spent from it you need {threshold} Seeds and the wallet descriptor.",
+ no_translate=self.no_translate,
+ ).format(threshold=threshold, wallet_descriptor_string=wallet_descriptor_string),
self.style_paragraph,
)
else:
desc_str = Paragraph(
translate(
"pdf",
- "The wallet descriptor (QR Code)
{wallet_descriptor_string}
allows you to create a watch-only wallet, to see your balances, but to spent from it you need the secret {number} words (Seed).",
- ).format(number=TEXT_24_WORDS, wallet_descriptor_string=wallet_descriptor_string),
+ "The wallet descriptor (QR Code)
{wallet_descriptor_string}
allows you to create a watch-only wallet to see your balance. To spent from it you need the secret {number} words (Seed).",
+ no_translate=self.no_translate,
+ ).format(number=self.TEXT_24_WORDS, wallet_descriptor_string=wallet_descriptor_string),
self.style_paragraph,
)
- self.elements.append(create_table([[qr_image], [desc_str]], [250, 300]))
+ self.elements.append(self.create_table([[qr_image], [desc_str]], [250, 300]))
def create_pdf(
self,
title: str,
wallet_descriptor_string: str,
keystore_description: str,
+ keystore_label: str,
+ keystore_xpub: str,
+ keystore_key_origin: str,
+ keystore_fingerprint: str,
threshold: int,
seed: Optional[str] = None,
num_signers: int = 1,
) -> None:
- self.elements.append(Paragraph(f"{title}", self.style_heading))
+ self.elements.append(Paragraph(title, style=self.style_heading))
# Small subtitle
self.elements.append(
Paragraph(
- f"Created with Bitcoin Safe: https://github.com/andreasgriffin/bitcoin-safe ",
+ translate("pdf", "Created with", no_translate=self.no_translate)
+ + f" Bitcoin Safe: {white_space*5} https://github.com/andreasgriffin/bitcoin-safe",
self.style_paragraph,
)
)
self.elements.append(Paragraph(f"", self.style_paragraph))
- self._seed_part(seed, keystore_description, num_signers)
+ self._seed_part(
+ seed,
+ num_signers=num_signers,
+ keystore_label=keystore_label,
+ keystore_fingerprint=keystore_fingerprint,
+ keystore_description=keystore_description,
+ )
- self.elements.append(Spacer(1, 10))
+ self.elements.append(Spacer(1, 15))
# Add a horizontal line as an element
line = Paragraph(
"________________________________________________________________________________",
self.style_paragraph,
)
- line.keepWithNext = True # Ensure line and text stay together on the same page
+ # line.keepWithNext = True # Ensure line and text stay together on the same page
self.elements.append(line)
# Add text at the line as an element
- text = "Please fold here! Please fold here! Please fold here! Please fold here! Please fold here!"
- text_paragraph = Paragraph(text, style=getSampleStyleSheet()["Normal"])
- text_paragraph.spaceBefore = -10 # Adjust the space before the text if needed
+ text = (white_space * 5).join(
+ [translate("pdf", "Please fold here!", no_translate=self.no_translate)] * 5
+ )
+ text_paragraph = Paragraph(text, style=self.style_text)
+ # text_paragraph.spaceBefore = -10 # Adjust the space before the text if needed
self.elements.append(text_paragraph)
self._descriptor_part(wallet_descriptor_string, threshold)
+ keystore_info_text = Paragraph(
+ translate(
+ "pdf",
+ "{keystore_label}: Fingerprint: {keystore_fingerprint}, Key origin: {keystore_key_origin}, {keystore_xpub}",
+ no_translate=self.no_translate,
+ ).format(
+ keystore_label=keystore_label,
+ keystore_fingerprint=keystore_fingerprint,
+ keystore_key_origin=keystore_key_origin,
+ keystore_xpub=keystore_xpub,
+ ),
+ self.style_paragraph_left,
+ )
+ self.elements.append(keystore_info_text)
+
def save_pdf(self, filename: str) -> None:
- document = SimpleDocTemplate(filename, pagesize=letter)
+
+ # Adjust these values to set your desired margins (values are in points; 72 points = 1 inch)
+ LEFT_MARGIN = 36 # 0.5 inch
+ RIGHT_MARGIN = 36 # 0.5 inch
+ TOP_MARGIN = 36 # 0.5 inch
+ BOTTOM_MARGIN = 36 # 0.5 inch
+
+ document = SimpleDocTemplate(
+ filename,
+ pagesize=letter,
+ leftMargin=LEFT_MARGIN,
+ rightMargin=RIGHT_MARGIN,
+ topMargin=TOP_MARGIN,
+ bottomMargin=BOTTOM_MARGIN,
+ )
document.build(self.elements)
def open_pdf(self, filename: str) -> None:
if os.path.exists(filename):
xdg_open_file(Path(filename))
else:
- print("File not found!")
+ logger.info("File not found!")
-def make_and_open_pdf(wallet: Wallet) -> None:
+def make_and_open_pdf(wallet: Wallet, lang_code: str) -> None:
info = DescriptorInfo.from_str(wallet.multipath_descriptor.as_string())
- pdf_recovery = BitcoinWalletRecoveryPDF()
+ pdf_recovery = BitcoinWalletRecoveryPDF(lang_code=lang_code)
for i, keystore in enumerate(wallet.keystores):
title = (
- f'Descriptor and {i+1}. seed backup of a ({info.threshold} of {len(wallet.keystores)}) Multi-Sig Wallet: "{wallet.id}"'
+ translate(
+ "pdf",
+ '{i}. Seed backup of a {threshold} of {m} Multi-Sig Wallet: "{id}"',
+ no_translate=pdf_recovery.no_translate,
+ ).format(i=i + 1, threshold=info.threshold, m=len(wallet.keystores), id=wallet.id)
if len(wallet.keystores) > 1
else f"{wallet.id }"
)
pdf_recovery.create_pdf(
- title,
- wallet.multipath_descriptor.as_string(),
- f"Description of hardware signer {i+1}: {wallet.keystores[i].description}"
- if wallet.keystores[i].description
- else "",
+ title=title,
+ wallet_descriptor_string=wallet.multipath_descriptor.as_string(),
threshold=info.threshold,
seed=keystore.mnemonic,
num_signers=len(wallet.keystores),
+ keystore_xpub=keystore.xpub,
+ keystore_description=keystore.description,
+ keystore_fingerprint=keystore.fingerprint,
+ keystore_key_origin=keystore.key_origin,
+ keystore_label=keystore.label,
)
pdf_recovery.add_page_break()
- temp_file = os.path.join(Path.home(), f"Descriptor and seed backup of {wallet.id}.pdf")
+ temp_file = os.path.join(
+ Path.home(), translate("pdf", "Seed backup of {id}").format(id=wallet.id) + ".pdf"
+ )
pdf_recovery.save_pdf(temp_file)
pdf_recovery.open_pdf(temp_file)
diff --git a/bitcoin_safe/pythonbdk_types.py b/bitcoin_safe/pythonbdk_types.py
index 9dcd117..8b1c680 100644
--- a/bitcoin_safe/pythonbdk_types.py
+++ b/bitcoin_safe/pythonbdk_types.py
@@ -35,12 +35,20 @@
from packaging import version
from PyQt6.QtCore import QObject
-from .storage import SaveAllClass
+from .storage import BaseSaveableClass, SaveAllClass
from .util import Satoshis, serialized_to_hex
logger = logging.getLogger(__name__)
+def is_address(a: str, network: bdk.Network) -> bool:
+ try:
+ bdk.Address(a, network=network)
+ except:
+ return False
+ return True
+
+
class Recipient:
def __init__(
self, address: str, amount: int, label: Optional[str] = None, checked_max_amount=False
@@ -96,6 +104,10 @@ def from_str(cls, outpoint_str: str) -> "OutPoint":
return OutPoint(txid, int(vout))
+def get_outpoints(tx: bdk.Transaction) -> List[OutPoint]:
+ return [OutPoint.from_bdk(input.previous_output) for input in tx.input()]
+
+
class TxOut(bdk.TxOut):
def __key__(self) -> Tuple:
return (serialized_to_hex(self.script_pubkey.to_bytes()), self.value)
@@ -161,22 +173,25 @@ def __init__(self, tx: bdk.TransactionDetails, received=None, send=None) -> None
self.tx = tx
self.txid = tx.txid
- def involved_addresses(self) -> Set:
+ def involved_addresses(self) -> Set[str]:
input_addresses = [input.address for input in self.inputs.values() if input]
output_addresses = [output.address for output in self.outputs.values() if output]
return set(input_addresses).union(output_addresses)
@classmethod
def fill_received(
- cls, tx: bdk.TransactionDetails, get_address_of_txout: Callable[[TxOut], str]
+ cls, tx: bdk.TransactionDetails, get_address_of_txout: Callable[[TxOut], str | None]
) -> "FullTxDetail":
res = FullTxDetail(tx)
txid = tx.txid
for vout, txout in enumerate(tx.transaction.output()):
address = get_address_of_txout(TxOut.from_bdk(txout))
- out_point = OutPoint(txid, vout)
- if address is None:
+ if not address:
+ logger.error(
+ f"Could not calculate the address of {TxOut.from_bdk(txout)}. This should not happen, unless it is a mining input."
+ )
continue
+ out_point = OutPoint(txid, vout)
python_utxo = PythonUtxo(address, out_point, txout)
python_utxo.is_spent_by_txid = None
res.outputs[str(out_point)] = python_utxo
@@ -186,8 +201,7 @@ def fill_inputs(
self,
lookup_dict_fulltxdetail: Dict[str, "FullTxDetail"],
) -> None:
- for input in self.tx.transaction.input():
- prev_outpoint = OutPoint.from_bdk(input.previous_output)
+ for prev_outpoint in get_outpoints(self.tx.transaction):
prev_outpoint_str = str(prev_outpoint)
# check if I have the prev_outpoint fulltxdetail
@@ -315,7 +329,12 @@ def from_text(cls, t) -> "CBFServerType":
return CBFServerType._member_map_[t] # type: ignore
-class Balance(QObject):
+class Balance(QObject, SaveAllClass):
+ VERSION = "0.0.1"
+ known_classes = {
+ **BaseSaveableClass.known_classes,
+ }
+
def __init__(self, immature=0, trusted_pending=0, untrusted_pending=0, confirmed=0) -> None:
super().__init__()
self.immature = immature
@@ -333,7 +352,7 @@ def spendable(self) -> int:
def __add__(self, other: "Balance") -> "Balance":
summed = {key: self.__dict__[key] + other.__dict__[key] for key in self.__dict__.keys()}
- return Balance(**summed)
+ return self.__class__(**summed)
def format_long(self, network: bdk.Network) -> str:
@@ -357,15 +376,25 @@ def format_short(self, network: bdk.Network) -> str:
return short
+ @classmethod
+ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]:
+ if version.parse(str(dct["VERSION"])) <= version.parse("0.0.0"):
+ pass
+
+ # now the version is newest, so it can be deleted from the dict
+ if "VERSION" in dct:
+ del dct["VERSION"]
+ return dct
+
-def robust_address_str_from_script(script_pubkey: bdk.Script, network, on_error_return_hex=False) -> str:
+def robust_address_str_from_script(script_pubkey: bdk.Script, network, on_error_return_hex=True) -> str:
try:
return bdk.Address.from_script(script_pubkey, network).as_string()
except:
if on_error_return_hex:
return serialized_to_hex(script_pubkey.to_bytes())
else:
- raise
+ return ""
if __name__ == "__main__":
diff --git a/bitcoin_safe/signals.py b/bitcoin_safe/signals.py
index dda5f9c..cd33b3e 100644
--- a/bitcoin_safe/signals.py
+++ b/bitcoin_safe/signals.py
@@ -27,28 +27,63 @@
# SOFTWARE.
+import enum
import logging
+from collections import defaultdict
+from enum import Enum
logger = logging.getLogger(__name__)
import threading
-from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
+from typing import (
+ Any,
+ Callable,
+ DefaultDict,
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Set,
+ Tuple,
+)
import bdkpython as bdk
-from PyQt6.QtCore import QObject, QThread, pyqtSignal
+from bitcoin_nostr_chat.signals_min import SignalsMin as NostrSignalsMin
+from PyQt6.QtCore import QThread, pyqtSignal
+
+
+class UpdateFilterReason(Enum):
+ UserInput = enum.auto()
+ UserImport = enum.auto()
+ SourceLabelSyncer = enum.auto()
+ Unknown = enum.auto()
+ UserReplacedAddress = enum.auto()
+ NewAddressRevealed = enum.auto()
+ CategoryAssigned = enum.auto()
+ CategoryAdded = enum.auto()
+ CategoryRenamed = enum.auto()
+ CategoryDeleted = enum.auto()
+ GetUnusedCategoryAddress = enum.auto()
+ RefreshCaches = enum.auto()
+ CreatePSBT = enum.auto()
+ TxCreator = enum.auto()
class UpdateFilter:
def __init__(
self,
- addresses: Union[Set[str], List[str]] = None,
- categories: Union[Set[str], List[Optional[str]]] = None,
- txids: Union[Set[str], List[str]] = None,
+ outpoints: Iterable[str] | None = None,
+ addresses: Iterable[str] | None = None,
+ categories: Iterable[Optional[str]] | None = None,
+ txids: Iterable[str] | None = None,
refresh_all=False,
+ reason: UpdateFilterReason = UpdateFilterReason.Unknown,
) -> None:
- self.addresses = set(addresses) if addresses else set()
+ self.outpoints: Set[str] = set(outpoints) if outpoints else set()
+ self.addresses: Set[str] = set(addresses) if addresses else set()
self.categories = set(categories) if categories else set()
self.txids = set(txids) if txids else set()
self.refresh_all = refresh_all
+ self.reason = reason
def __key__(self) -> Tuple:
return tuple(self.__dict__.items())
@@ -66,16 +101,19 @@ def __init__(self, name: Optional[str] = None) -> None:
self.slots: Dict[str, Callable] = {}
self.lock = threading.Lock()
- def connect(self, slot: Callable, slot_name=None) -> None:
+ def connect(self, slot: Callable[[], Any], slot_name=None) -> None:
with self.lock:
key = slot_name if slot_name and (slot_name not in self.slots) else str(slot)
self.slots[key] = slot
def disconnect(self, slot) -> None:
with self.lock:
- keys, values = zip(*list(self.slots.items()))
- idx = values.index(slot)
- del self.slots[keys[idx]]
+ keys, values = list(self.slots.keys()), list(self.slots.values())
+ if slot in values:
+ idx = values.index(slot)
+ del self.slots[keys[idx]]
+ else:
+ logger.debug(f"Tried to disconnect {slot}. But it is not in {values}. Skipping.")
def __call__(self, *args, **kwargs) -> Dict[str, Any]:
return self.emit(*args, **kwargs)
@@ -89,7 +127,7 @@ def emit(self, *args, slot_name=None, **kwargs) -> Dict[str, Any]:
f"SignalFunction {self.name if self.name else ''}.emit() was called, but no listeners {self.slots} are listening."
)
- delete_slots = []
+ delete_slots: List[Callable[[], Any]] = []
with self.lock:
for key, slot in self.slots.items():
if allow_list and key not in allow_list:
@@ -101,7 +139,9 @@ def emit(self, *args, slot_name=None, **kwargs) -> Dict[str, Any]:
try:
responses[key] = slot(*args, **kwargs)
except:
- logger.warning(f"{slot} with key {key} could not be called. The slot will be deleted.")
+ logger.warning(
+ f"{slot} with key {key} caused an exception. {slot} with key {key} could not be called, perhaps because the object doesnt exisst anymore. The slot will be deleted."
+ )
delete_slots.append(slot)
continue
logger.debug(
@@ -125,14 +165,35 @@ def emit(self, *args, **kwargs) -> Any:
responses = super().emit(*args, **kwargs)
return list(responses.values())[0] if responses else responses
+ def __call__(self, *args, **kwargs) -> Any:
+ return self.emit(*args, **kwargs)
-class SignalsMin(QObject):
- language_switch = pyqtSignal()
+class SignalsMin(NostrSignalsMin):
signal_add_threat = pyqtSignal(QThread)
signal_stop_threat = pyqtSignal(QThread)
+class WalletSignals(SignalsMin):
+ updated = pyqtSignal(UpdateFilter)
+ completions_updated = pyqtSignal()
+
+ show_utxo = pyqtSignal(object)
+ show_address = pyqtSignal(str, str) # address, wallet_id
+
+ export_bip329_labels = pyqtSignal(str) # str= wallet_id
+ export_labels = pyqtSignal(str) # str= wallet_id
+ import_labels = pyqtSignal(str) # str= wallet_id
+ import_bip329_labels = pyqtSignal(str) # str= wallet_id
+ import_electrum_wallet_labels = pyqtSignal(str) # str= wallet_id
+
+ finished_psbt_creation = pyqtSignal()
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.get_display_balance = SignalFunction(name="get_display_balance")
+
+
class Signals(SignalsMin):
"""The idea here is to define events that might need to trigger updates of
the UI or other events (careful of circular loops)
@@ -152,45 +213,29 @@ class Signals(SignalsMin):
"""
open_tx_like = pyqtSignal(object)
- utxos_updated = pyqtSignal(UpdateFilter)
- addresses_updated = pyqtSignal(UpdateFilter)
- labels_updated = pyqtSignal(UpdateFilter)
- category_updated = pyqtSignal(UpdateFilter)
- completions_updated = pyqtSignal()
event_wallet_tab_closed = pyqtSignal()
event_wallet_tab_added = pyqtSignal()
- update_all_in_qt_wallet = pyqtSignal()
-
- show_utxo = pyqtSignal(object)
- show_address = pyqtSignal(str)
- show_private_key = pyqtSignal(str)
-
chain_data_changed = pyqtSignal(str) # the string is the reason
-
notification = pyqtSignal(object) # should be a Message instance
- cpfp_dialog = pyqtSignal(bdk.TransactionDetails)
- dscancel_dialog = pyqtSignal()
- bump_fee_dialog = pyqtSignal()
- show_onchain_invoice = pyqtSignal()
- save_transaction_into_wallet = pyqtSignal(object)
-
show_network_settings = pyqtSignal()
- export_bip329_labels = pyqtSignal(str) # str= wallet_id
- import_bip329_labels = pyqtSignal(str) # str= wallet_id
- import_electrum_wallet_labels = pyqtSignal(str) # str= wallet_id
open_wallet = pyqtSignal(str) # str= filepath
finished_open_wallet = pyqtSignal(str) # str= wallet_id
create_qt_wallet_from_wallet = pyqtSignal(object, object, object) # object = wallet, file_path, password
close_qt_wallet = pyqtSignal(str) # str = wallet_id
request_manual_sync = pyqtSignal()
-
signal_broadcast_tx = pyqtSignal(bdk.Transaction)
+ # this is for non-wallet bound objects like UitxViewer
+ any_wallet_updated = pyqtSignal(UpdateFilter)
+
def __init__(self) -> None:
super().__init__()
self.get_wallets = SignalFunction(name="get_wallets")
self.get_qt_wallets = SignalFunction(name="get_qt_wallets")
self.get_network = SingularSignalFunction(name="get_network")
+ self.get_mempool_url = SingularSignalFunction(name="get_mempool_url")
+
+ self.wallet_signals: DefaultDict[str, WalletSignals] = defaultdict(WalletSignals)
diff --git a/bitcoin_safe/signature_manager.py b/bitcoin_safe/signature_manager.py
index b96f636..5026d03 100644
--- a/bitcoin_safe/signature_manager.py
+++ b/bitcoin_safe/signature_manager.py
@@ -55,6 +55,11 @@ class GPGKey:
def extract_prefix_and_version(filename: str) -> tuple[Optional[str], Optional[str]]:
import re
+ if filename.endswith(".deb"):
+ match = re.search(r"(.+)_(.+?)(?:-.+)?_.*\.deb", filename)
+ if match:
+ return (match.group(1), match.group(2))
+
# try with - separator
match = re.search(r"(.*?)-([\d\.]+[a-zA-Z0-9]*)", filename)
if match:
@@ -117,7 +122,7 @@ def __init__(
list_of_known_keys: Optional[List[GPGKey]],
) -> None:
self.list_of_known_keys = list_of_known_keys if list_of_known_keys else []
- self._gpg = None
+ self._gpg: Any = None
self.import_known_keys()
@staticmethod
@@ -167,7 +172,10 @@ def gpg(self) -> Any:
gnupg = _gnupg
- self._gpg = gnupg.GPG(gpgbinary=self._get_gpg_path())
+ gpg_path = self._get_gpg_path()
+ if not gpg_path:
+ raise EnvironmentError("GnuPG path could not be determined.")
+ self._gpg = gnupg.GPG(gpgbinary=gpg_path)
return self._gpg
else:
raise EnvironmentError("GnuPG is not installed on this system.")
diff --git a/bitcoin_safe/signer.py b/bitcoin_safe/signer.py
index f2548bd..261f601 100644
--- a/bitcoin_safe/signer.py
+++ b/bitcoin_safe/signer.py
@@ -32,6 +32,7 @@
from bitcoin_safe.gui.qt.dialogs import question_dialog
from bitcoin_safe.gui.qt.util import Message, MessageType, caught_exception_message
+from bitcoin_safe.i18n import translate
from bitcoin_safe.psbt_util import PubKeyInfo
from .dynamic_lib_load import setup_libsecp256k1
@@ -46,12 +47,12 @@
from bitcoin_qr_tools.bitcoin_video_widget import BitcoinVideoWidget
from bitcoin_qr_tools.data import Data, DataType
from bitcoin_usb.gui import USBGui
-from bitcoin_usb.psbt_finalizer import PSBTFinalizer
+from bitcoin_usb.psbt_tools import PSBTTools
from bitcoin_usb.software_signer import SoftwareSigner
from PyQt6.QtCore import QObject, pyqtSignal
from .keystore import KeyStoreImporterTypes
-from .util import tx_of_psbt_to_hex
+from .util import tx_of_psbt_to_hex, tx_to_hex
from .wallet import Wallet
@@ -63,19 +64,17 @@ class AbstractSignatureImporter(QObject):
def __init__(
self,
network: bdk.Network,
- blockchain: bdk.Blockchain,
signature_available: bool = False,
key_label: str = "",
- pub_keys_without_signature: List[PubKeyInfo] = None,
+ pub_keys_without_signature: List[PubKeyInfo] | None = None,
) -> None:
super().__init__()
self.network = network
- self.blockchain = blockchain
self.signature_available = signature_available
self.key_label = key_label
self.pub_keys_without_signature = pub_keys_without_signature
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
pass
@property
@@ -104,7 +103,6 @@ def __init__(
) -> None:
super().__init__(
network=network,
- blockchain=wallet.blockchain,
signature_available=signature_available,
key_label=key_label,
pub_keys_without_signature=[
@@ -123,7 +121,7 @@ def __init__(
def can_sign(self) -> bool:
return bool(self.software_signers)
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
original_psbt = psbt
original_serialized_tx = tx_of_psbt_to_hex(psbt)
for software_signer in self.software_signers:
@@ -147,7 +145,7 @@ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptio
@property
def label(self) -> str:
- return f"Sign with mnemonic seed"
+ return self.tr("Sign with mnemonic seed")
class SignatureImporterQR(AbstractSignatureImporter):
@@ -156,20 +154,19 @@ class SignatureImporterQR(AbstractSignatureImporter):
def __init__(
self,
network: bdk.Network,
- blockchain: bdk.Blockchain,
signature_available: bool = False,
key_label: str = "",
pub_keys_without_signature=None,
- label: str = None,
+ label: str | None = None,
) -> None:
super().__init__(
network=network,
- blockchain=blockchain,
signature_available=signature_available,
key_label=key_label,
pub_keys_without_signature=pub_keys_without_signature,
)
self._label = label if label else self.tr("Scan QR code")
+ self._temp_bitcoin_video_widget: BitcoinVideoWidget | None = None
def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, data: Data):
logger.debug(str(data.data))
@@ -194,11 +191,14 @@ def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, da
return
if psbt2.serialize() == original_psbt.serialize():
- logger.debug(f"psbt unchanged {psbt2.serialize()}")
+ Message(
+ self.tr("No additional signatures were added"),
+ type=MessageType.Error,
+ )
return
# check if the tx can be finalized:
- finalized_tx = PSBTFinalizer.finalize(psbt2)
+ finalized_tx = PSBTTools.finalize(psbt2, network=self.network)
if finalized_tx:
assert finalized_tx.txid() == original_psbt.txid(), self.tr(
"bitcoin_tx libary error. The txid should not be changed during finalizing"
@@ -217,18 +217,26 @@ def scan_result_callback(self, original_psbt: bdk.PartiallySignedTransaction, da
type=MessageType.Error,
)
return
+ if tx_to_hex(scanned_tx) == tx_to_hex(original_psbt.extract_tx()):
+ Message(
+ self.tr("No additional signatures were added"),
+ type=MessageType.Error,
+ )
+ return
# TODO: Actually check if the tx is fully signed
self.signal_final_tx_received.emit(scanned_tx)
else:
logger.warning(f"Datatype {data.data_type} is not valid for importing signatures")
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
-
- window = BitcoinVideoWidget(
- result_callback=lambda data: self.scan_result_callback(psbt, data), network=self.network
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
+ if self._temp_bitcoin_video_widget:
+ self._temp_bitcoin_video_widget.close()
+ self._temp_bitcoin_video_widget = BitcoinVideoWidget(network=self.network)
+ self._temp_bitcoin_video_widget.signal_data.connect(
+ lambda data: self.scan_result_callback(psbt, data)
)
- window.show()
+ self._temp_bitcoin_video_widget.show()
@property
def label(self) -> str:
@@ -241,25 +249,23 @@ class SignatureImporterFile(SignatureImporterQR):
def __init__(
self,
network: bdk.Network,
- blockchain: bdk.Blockchain,
signature_available: bool = False,
key_label: str = "",
pub_keys_without_signature=None,
- label: str = "Import file",
+ label: str = translate("importer", "Import file"),
) -> None:
super().__init__(
network=network,
- blockchain=blockchain,
signature_available=signature_available,
key_label=key_label,
pub_keys_without_signature=pub_keys_without_signature,
label=label,
)
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
tx_dialog = ImportDialog(
network=self.network,
- window_title="Import signed PSBT",
+ window_title=self.tr("Import signed PSBT"),
on_open=lambda s: self.scan_result_callback(psbt, Data.from_str(s, network=self.network)),
text_button_ok=self.tr("OK"),
text_instruction_label=self.tr("Please paste your PSBT in here, or drop a file"),
@@ -279,22 +285,20 @@ class SignatureImporterClipboard(SignatureImporterFile):
def __init__(
self,
network: bdk.Network,
- blockchain: bdk.Blockchain,
signature_available: bool = False,
key_label: str = "",
pub_keys_without_signature=None,
- label: str = "Import Signature",
+ label: str = translate("importer", "Import Signature"),
) -> None:
super().__init__(
network=network,
- blockchain=blockchain,
signature_available=signature_available,
key_label=key_label,
pub_keys_without_signature=pub_keys_without_signature,
label=label,
)
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
tx_dialog = ImportDialog(
network=self.network,
window_title=self.tr("Import signed PSBT"),
@@ -316,16 +320,14 @@ class SignatureImporterUSB(SignatureImporterQR):
def __init__(
self,
network: bdk.Network,
- blockchain: bdk.Blockchain,
signature_available: bool = False,
key_label: str = "",
pub_keys_without_signature=None,
- label: str = None,
+ label: str | None = None,
) -> None:
label = label if label else self.tr("USB Signing")
super().__init__(
network=network,
- blockchain=blockchain,
signature_available=signature_available,
key_label=key_label,
pub_keys_without_signature=pub_keys_without_signature,
@@ -333,7 +335,7 @@ def __init__(
)
self.usb = USBGui(self.network)
- def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions = None):
+ def sign(self, psbt: bdk.PartiallySignedTransaction, sign_options: bdk.SignOptions | None = None):
try:
signed_psbt = self.usb.sign(psbt)
if signed_psbt:
diff --git a/bitcoin_safe/simple_mailer.py b/bitcoin_safe/simple_mailer.py
index 0463c68..8cb9d0b 100755
--- a/bitcoin_safe/simple_mailer.py
+++ b/bitcoin_safe/simple_mailer.py
@@ -38,7 +38,7 @@
def compose_email(
- email: str, subject: str, body: str, attachment_filenames: List[str] = None, run_in_background=True
+ email: str, subject: str, body: str, attachment_filenames: List[str] | None = None, run_in_background=True
) -> None:
# Encode the subject and body to ensure spaces and special characters are handled correctly
subject_encoded = urllib.parse.quote(subject)
diff --git a/bitcoin_safe/storage.py b/bitcoin_safe/storage.py
index 37a00ac..7f96208 100644
--- a/bitcoin_safe/storage.py
+++ b/bitcoin_safe/storage.py
@@ -137,14 +137,30 @@ class ClassSerializer:
def general_deserializer(cls, known_classes, class_kwargs) -> Callable:
def deserializer(dct) -> Dict:
cls_string = dct.get("__class__") # e.g. KeyStore
- if cls_string and cls_string in known_classes:
- obj_cls = known_classes[cls_string]
- if hasattr(obj_cls, "from_dump"): # is there KeyStore.from_dump ?
- if class_kwargs.get(cls_string): # apply additional arguments to the class from_dump
- dct.update(class_kwargs.get(cls_string))
- return obj_cls.from_dump(dct, class_kwargs=class_kwargs) # do: KeyStore.from_dump(**dct)
+ if cls_string:
+ if cls_string in known_classes:
+ obj_cls = known_classes[cls_string]
+ if hasattr(obj_cls, "from_dump"): # is there KeyStore.from_dump ?
+ if class_kwargs.get(cls_string): # apply additional arguments to the class from_dump
+ dct.update(class_kwargs.get(cls_string))
+ return obj_cls.from_dump(
+ dct, class_kwargs=class_kwargs
+ ) # do: KeyStore.from_dump(**dct)
+ else:
+ raise Exception(f"{obj_cls} doesnt have a from_dump classmethod.")
else:
- raise Exception(f"{obj_cls} doesnt have a from_dump classmethod.")
+ raise Exception(
+ f"""{cls_string} not in known_classes {known_classes}."""
+ """Did you add the following to the child class?
+ VERSION = "0.0.1"
+ known_classes = {
+ **BaseSaveableClass.known_classes,
+ }"""
+ f"""And did you add
+ "cls_string":{cls_string}
+ to the parent BaseSaveableClass ?
+ """
+ )
elif dct.get("__enum__"):
obj_cls = known_classes.get(dct["name"])
if obj_cls:
@@ -212,7 +228,6 @@ def save(self, filename: Union[Path, str], password: Optional[str] = None):
if directory:
os.makedirs(directory, exist_ok=True)
- not bool(password)
storage = Storage()
storage.save(
self.dumps(indent=None if password else 4),
diff --git a/bitcoin_safe/threading_manager.py b/bitcoin_safe/threading_manager.py
index 76d27e9..3e108ec 100644
--- a/bitcoin_safe/threading_manager.py
+++ b/bitcoin_safe/threading_manager.py
@@ -35,6 +35,7 @@
from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
+from bitcoin_safe.execute_config import ENABLE_THREADING
from bitcoin_safe.signals import SignalsMin
logger = logging.getLogger(__name__)
@@ -68,10 +69,6 @@ def thread_name(self):
def run_task(self) -> None:
"""Executes the provided task and emits signals based on the outcome."""
threading.current_thread().name = self.thread_name
- if not self.task:
- logger.debug("No task to run.")
- return
-
try:
logger.debug(f"Task started: {self.task.do}")
result: Any = self.task.do()
@@ -89,12 +86,13 @@ def run_task(self) -> None:
class TaskThread(QThread):
"""Manages execution of tasks in separate threads."""
- def __init__(self, parent: QObject, signals_min: SignalsMin) -> None:
- super().__init__(parent)
+ def __init__(self, signals_min: SignalsMin, enable_threading: bool = ENABLE_THREADING) -> None:
+ super().__init__()
self.signals_min = signals_min
- self.worker: Optional[
- Worker
- ] = None # Type hint adjusted because it will be immediately initialized in add_and_start
+ self.worker: Optional[Worker] = (
+ None # Type hint adjusted because it will be immediately initialized in add_and_start
+ )
+ self.enable_threading = enable_threading
def add_and_start(
self,
@@ -103,7 +101,12 @@ def add_and_start(
on_done: Callable[[Any], None],
on_error: Callable[[Tuple[Any, ...]], None],
cancel: Optional[Callable[[], None]] = None,
- ) -> None:
+ ) -> "TaskThread":
+
+ if not self.enable_threading:
+ NoThread().add_and_start(do, on_success, on_done, on_error)
+ return self
+
logger.debug(f"Starting new thread {do}.")
task: Task = Task(do, on_success, on_done, on_error, cancel)
self.worker = Worker(task)
@@ -114,6 +117,8 @@ def add_and_start(
self.signals_min.signal_add_threat.emit(self)
self.start()
+ return self
+
@property
def thread_name(self):
if not self.worker:
@@ -136,16 +141,21 @@ def on_done(self, result: Any, cb_done: Callable[[Any], None], cb_result: Callab
def stop(self) -> None:
"""Stops the thread and any associated task cancellation if defined."""
- logger.debug("Stopping TaskThread and associated worker.")
+ logger.info("Stopping TaskThread and associated worker.")
if self.worker and self.worker.task.cancel:
logger.debug(f"Stopping {self.thread_name}.")
self.worker.task.cancel()
self.my_quit()
def my_quit(self):
- self.quit()
- self.wait()
- self.signals_min.signal_stop_threat.emit(self)
+ try:
+ self.quit()
+ self.wait()
+ # cannot put it in finally, since QThread might be already destroyed
+ # and then self is not recognized as a QThread decendent any more in pyqtSignal
+ self.deleteLater()
+ except:
+ pass
class NoThread:
@@ -157,9 +167,9 @@ def __init__(self, *args, **kwargs):
def add_and_start(
self,
task,
- on_success: Callable = None,
- on_done: Callable = None,
- on_error: Callable = None,
+ on_success: Optional[Callable] = None,
+ on_done: Optional[Callable] = None,
+ on_error: Optional[Callable] = None,
):
result = None
try:
@@ -178,37 +188,45 @@ def add_and_start(
class ThreadingManager:
- def __init__(self, signals_min: SignalsMin) -> None:
+ def __init__(
+ self, signals_min: SignalsMin, threading_parent: "ThreadingManager" = None, **kwargs # type: ignore
+ ) -> None:
+ super().__init__(**kwargs)
self.signals_min = signals_min
- self.threads: deque[TaskThread] = deque()
+ self.taskthreads: deque[TaskThread] = deque()
+ self.threading_manager_children: deque[ThreadingManager] = deque()
self.lock = Lock()
- self.signals_min.signal_add_threat.connect(self._append)
- self.signals_min.signal_stop_threat.connect(self._remove)
+ if threading_parent:
+ threading_parent.threading_manager_children.append(self)
+
+ if self.signals_min:
+ self.signals_min.signal_add_threat.connect(self._append)
+ self.signals_min.signal_stop_threat.connect(self._remove)
def _append(self, thread: TaskThread):
with self.lock:
- self.threads.append(thread)
+ self.taskthreads.append(thread)
logger.debug(
- f"Appended thread {thread.thread_name}, Number of threads = {len(self.threads)} {[thread.thread_name for thread in self.threads]}"
+ f"Appended thread {thread.thread_name}, Number of threads = {len(self.taskthreads)} {[thread.thread_name for thread in self.taskthreads]}"
)
- assert thread in self.threads
+ assert thread in self.taskthreads
def _remove(self, thread: TaskThread):
with self.lock:
- if thread in self.threads:
- self.threads.remove(thread)
+ if thread in self.taskthreads:
+ self.taskthreads.remove(thread)
thread.deleteLater()
logger.debug(
- f"Removed thread {thread.thread_name}, Number of threads = {len(self.threads)} {[thread.thread_name for thread in self.threads]}"
+ f"Removed thread {thread.thread_name}, Number of threads = {len(self.taskthreads)} {[thread.thread_name for thread in self.taskthreads]}"
)
def stop_and_wait_all(self, timeout=10):
+ while self.threading_manager_children:
+ child = self.threading_manager_children.pop()
+ child.stop_and_wait_all()
+
# Wait for all threads to finish
- if self.threads:
- logger.warning(f"unfinished Threads {list(self.threads)}")
- for thread in list(self.threads):
- if thread.isRunning():
- thread.stop()
- if not thread.wait(timeout * 1000):
- logger.warning(f"Thread {thread.thread_name } did not finish timely")
+ while self.taskthreads:
+ taskthread = self.taskthreads.pop()
+ taskthread.stop()
diff --git a/bitcoin_safe/tx.py b/bitcoin_safe/tx.py
index 6cc5ac3..521fee4 100644
--- a/bitcoin_safe/tx.py
+++ b/bitcoin_safe/tx.py
@@ -33,11 +33,17 @@
from bitcoin_safe.psbt_util import FeeInfo
from bitcoin_safe.util import serialized_to_hex
-from .pythonbdk_types import OutPoint, PythonUtxo, Recipient, UtxosForInputs
+from .pythonbdk_types import (
+ OutPoint,
+ PythonUtxo,
+ Recipient,
+ UtxosForInputs,
+ robust_address_str_from_script,
+)
logger = logging.getLogger(__name__)
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Tuple
import bdkpython as bdk
@@ -72,7 +78,10 @@ class TxUiInfos:
"A wrapper around tx_builder to collect even more infos"
def __init__(self) -> None:
- self.utxo_dict: Dict[str, PythonUtxo] = {} # {outpoint_string:utxo} It is Ok if outpoint_string:None
+ self.utxo_dict: Dict[OutPoint, PythonUtxo] = (
+ {}
+ ) # {outpoint_string:utxo} It is Ok if outpoint_string:None
+ self.global_xpubs: Dict[str, Tuple[str, str]] = {} # xpub:(fingerprint, key_origin)
self.fee_rate: Optional[float] = None
self.opportunistic_merge_utxos = True
self.spend_all_utxos = False
@@ -82,6 +91,9 @@ def __init__(self) -> None:
# self.exclude_fingerprints_from_signing :List[str]=[]
+ self.hide_UTXO_selection = False
+ self.recipient_read_only = False
+
def add_recipient(self, recipient: Recipient):
self.recipients.append(recipient)
@@ -90,7 +102,7 @@ def set_fee_rate(self, fee_rate: float):
def fill_utxo_dict_from_utxos(self, utxos: List[PythonUtxo]):
for utxo in utxos:
- self.utxo_dict[str(OutPoint.from_bdk(utxo.outpoint))] = utxo
+ self.utxo_dict[OutPoint.from_bdk(utxo.outpoint)] = utxo
class TxBuilderInfos:
@@ -118,7 +130,7 @@ def set_fee_rate(self, fee_rate: float):
self.fee_rate = fee_rate
-def transaction_to_dict(tx: bdk.Transaction) -> Dict[str, Any]:
+def transaction_to_dict(tx: bdk.Transaction, network: bdk.Network) -> Dict[str, Any]:
# Serialize inputs
inputs = []
for inp in tx.input():
@@ -138,6 +150,7 @@ def transaction_to_dict(tx: bdk.Transaction) -> Dict[str, Any]:
{
"value": out.value,
"script_pubkey": serialized_to_hex(out.script_pubkey.to_bytes()),
+ "address": robust_address_str_from_script(out.script_pubkey, network=network),
}
)
diff --git a/bitcoin_safe/util.py b/bitcoin_safe/util.py
index d4d0c61..8301177 100644
--- a/bitcoin_safe/util.py
+++ b/bitcoin_safe/util.py
@@ -53,6 +53,8 @@
import json
import logging
+from bitcoin_safe.gui.qt.data_tab_widget import T
+
logger = logging.getLogger(__name__)
import builtins
@@ -77,7 +79,7 @@
Union,
)
-from PyQt6.QtCore import QLocale
+from PyQt6.QtCore import QByteArray, QLocale
from .i18n import translate
@@ -103,6 +105,14 @@
import bdkpython as bdk
+def is_int(a: Any) -> bool:
+ try:
+ int(a)
+ except:
+ return False
+ return True
+
+
def path_to_rel_home_path(path: Union[Path, str]) -> Path:
try:
@@ -127,6 +137,10 @@ def tx_of_psbt_to_hex(psbt: bdk.PartiallySignedTransaction):
return serialized_to_hex(psbt.extract_tx().serialize())
+def tx_to_hex(tx: bdk.Transaction):
+ return serialized_to_hex(tx.serialize())
+
+
def call_call_functions(functions: List[Callable]):
for f in functions:
f()
@@ -264,7 +278,7 @@ def clean_dict(d: Dict):
return {k: v for k, v in d.items() if v}
-def clean_list(l: List):
+def clean_list(l: Iterable[T | None]) -> List[T]:
return [v for v in l if v]
@@ -416,7 +430,7 @@ def color_format_str(
def format_dollar(value: float) -> str:
- return f"${round(value, 2)}"
+ return f"${value:.2f}"
# Main formatting function
@@ -545,7 +559,7 @@ def calc_satoshi(v: Union[List, Tuple, "Satoshis"]) -> Satoshis:
return summed
-def resource_path(*parts):
+def resource_path(*parts: str):
return os.path.join(pkg_dir, *parts)
@@ -557,6 +571,10 @@ def unit_str(network: bdk.Network) -> str:
return "BTC" if network is None or network == bdk.Network.BITCOIN else "tBTC"
+def unit_sat_str(network: bdk.Network) -> str:
+ return "Sat" if network is None or network == bdk.Network.BITCOIN else "tSat"
+
+
def unit_fee_str(network: bdk.Network) -> str:
"Sat/vB"
return "Sat/vB" if network is None or network == bdk.Network.BITCOIN else "tSat/vB"
@@ -569,7 +587,7 @@ def format_fee_rate(fee_rate: float, network: bdk.Network) -> str:
def age(
from_date: Union[int, float, None, timedelta], # POSIX timestamp
*,
- since_date: datetime = None,
+ since_date: datetime | None = None,
target_tz=None,
include_seconds: bool = False,
) -> str:
@@ -655,7 +673,7 @@ def confirmation_wait_formatted(projected_mempool_block_index: int):
return age(estimated_duration)
-def block_explorer_URL(mempool_url: str, kind: str, item: str) -> Optional[str]:
+def block_explorer_URL(mempool_url: str, kind: Literal["tx", "addr"], item: str) -> Optional[str]:
explorer_url, explorer_dict = mempool_url, {
"tx": "tx/",
"addr": "address/",
@@ -763,3 +781,11 @@ def calculate_ema(data, alpha=0.1):
def briefcase_project_dir() -> Path:
# __file__ == /tmp/.mount_Bitcoix7tQIZ/usr/app/bitcoin_safe/util.py
return Path(__file__).parent
+
+
+def qbytearray_to_str(a: QByteArray) -> str:
+ return a.data().decode()
+
+
+def str_to_qbytearray(s: str) -> QByteArray:
+ return QByteArray(s.encode()) # type: ignore[call-overload]
diff --git a/bitcoin_safe/wallet.py b/bitcoin_safe/wallet.py
index c763160..2d9f86b 100644
--- a/bitcoin_safe/wallet.py
+++ b/bitcoin_safe/wallet.py
@@ -30,6 +30,7 @@
import functools
import logging
import os
+import random
from time import time
from bitcoin_safe.psbt_util import FeeInfo
@@ -41,11 +42,12 @@
import json
from collections import defaultdict
from threading import Lock
-from typing import Any, Callable, Dict, List, Optional, Set, Tuple
+from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
import bdkpython as bdk
import numpy as np
from bitcoin_usb.address_types import DescriptorInfo
+from bitcoin_usb.psbt_tools import PSBTTools
from bitcoin_usb.software_signer import derive as software_signer_derive
from packaging import version
@@ -93,8 +95,8 @@ def to_str(cls, status: "TxConfirmationStatus") -> str:
class TxStatus:
def __init__(
self,
- tx: bdk.Transaction,
- confirmation_time: bdk.BlockTime,
+ tx: bdk.Transaction | None,
+ confirmation_time: bdk.BlockTime | None,
get_height: Callable,
is_in_mempool: bool,
confirmation_status: Optional[TxConfirmationStatus] = None,
@@ -124,6 +126,8 @@ def __init__(
@classmethod
def from_wallet(cls, txid: str, wallet: "Wallet") -> "TxStatus":
txdetails = wallet.get_tx(txid)
+ if not txdetails:
+ return TxStatus(None, None, wallet.get_height, False)
return TxStatus(
txdetails.transaction,
txdetails.confirmation_time,
@@ -267,15 +271,22 @@ def signer_names(threshold: int, i: int) -> str:
def signer_name(self, i: int) -> str:
return self.signer_names(self.threshold, i)
+ def sticker_name(self, i: int | str) -> str:
+ number = i if isinstance(i, str) else f"{i+1}"
+ name = f"{self.id} {number}" if len(self.keystores) > 1 else f"{self.id}"
+ return name.strip()
+
def set_gap(self, gap: int) -> None:
self.gap = gap
def to_multipath_descriptor(self) -> Optional[MultipathDescriptor]:
if not all(self.keystores):
return None
+ # type checking doesnt recognize that all(self.keystores) already ensures that all are set
+ cleaned_keystores = [keystore for keystore in self.keystores if keystore]
return MultipathDescriptor.from_keystores(
self.threshold,
- spk_providers=self.keystores,
+ spk_providers=cleaned_keystores,
address_type=self.address_type,
network=self.network,
)
@@ -313,6 +324,11 @@ def was_changed(self) -> Dict[str, List[bdk.TransactionDetails]]:
return d
+class TxoType(enum.Enum):
+ InputTxo = enum.auto()
+ OutputTxo = enum.auto()
+
+
class BdkWallet(bdk.Wallet, CacheManager):
"""This is a caching wrapper around bdk.Wallet. It should not provide any
logic. Only wrapping existing methods and minimal new methods useful for
@@ -327,7 +343,7 @@ def __init__(
descriptor: bdk.Descriptor,
change_descriptor: bdk.Descriptor,
network: bdk.Network,
- database_config: bdk.DatabaseConfig,
+ database_config: Any,
) -> None:
bdk.Wallet.__init__(self, descriptor, change_descriptor, network, database_config)
CacheManager.__init__(self)
@@ -435,7 +451,11 @@ def network(self) -> bdk.Network:
@instance_lru_cache(always_keep=True)
def get_address_of_txout(self, txout: TxOut) -> Optional[str]:
if txout.value == 0:
- return None
+ # this can happen if it is an input of a coinbase TX
+ try:
+ return bdk.Address.from_script(txout.script_pubkey, self.network()).as_string()
+ except:
+ return None
else:
return bdk.Address.from_script(txout.script_pubkey, self.network()).as_string()
@@ -448,12 +468,13 @@ class Wallet(BaseSaveableClass, CacheManager):
"""If any bitcoin logic (ontop of bdk) has to be done, then here is the
place."""
- VERSION = "0.1.4"
+ VERSION = "0.2.0"
known_classes = {
**BaseSaveableClass.known_classes,
"KeyStore": KeyStore,
"UserConfig": UserConfig,
"Labels": Labels,
+ "Balance": Balance,
}
def __init__(
@@ -465,12 +486,13 @@ def __init__(
config: UserConfig,
gap=20,
gap_change=5,
- data_dump: Dict = None,
- labels: Labels = None,
- _blockchain_height: int = None,
- _tips: List[int] = None,
+ data_dump: Dict | None = None,
+ labels: Labels | None = None,
+ _blockchain_height: int | None = None,
+ _tips: List[int] | None = None,
refresh_wallet=False,
- tutorial_index: Optional[int] = None,
+ tutorial_index: Optional[int] | None = None,
+ default_category="default",
**kwargs,
) -> None:
super().__init__()
@@ -489,8 +511,7 @@ def __init__(
self.config: UserConfig = config
self.write_lock = Lock()
self.data_dump: Dict = data_dump if data_dump else {}
- self.labels: Labels = labels if labels else Labels()
- self.labels.default_category = "Friends"
+ self.labels: Labels = labels if labels else Labels(default_category=default_category)
# refresh dependent values
self._tips = _tips if _tips and not refresh_wallet else [0, 0]
self._blockchain_height = _blockchain_height if _blockchain_height and not refresh_wallet else 0
@@ -502,7 +523,7 @@ def __init__(
# end refresh dependent values
self.create_bdkwallet(MultipathDescriptor.from_descriptor_str(descriptor_str, self.network))
- self.blockchain = None
+ self.blockchain: Optional[bdk.Blockchain] = None
self.clear_cache()
@staticmethod
@@ -571,6 +592,7 @@ def _get_addresses(
for i in range(0, self.tips[int(is_change)] + 1)
]
+ @instance_lru_cache(always_keep=True)
def get_mn_tuple(self) -> Tuple[int, int]:
info = DescriptorInfo.from_str(self.multipath_descriptor.as_string())
return info.threshold, len(info.spk_providers)
@@ -592,11 +614,12 @@ def from_protowallet(
cls,
protowallet: ProtoWallet,
config: UserConfig,
- data_dump: Dict = None,
- labels: Labels = None,
- _blockchain_height: int = None,
- _tips: List[int] = None,
+ data_dump: Dict | None = None,
+ labels: Labels | None = None,
+ _blockchain_height: int | None = None,
+ _tips: List[int] | None = None,
refresh_wallet=False,
+ default_category="default",
) -> "Wallet":
keystores = []
@@ -628,6 +651,7 @@ def from_protowallet(
_blockchain_height=_blockchain_height,
_tips=_tips,
refresh_wallet=refresh_wallet,
+ default_category=default_category,
)
def get_relevant_differences(self, other_wallet: "Wallet") -> Set[str]:
@@ -691,7 +715,7 @@ def dump(self) -> Dict[str, Any]:
return d
@classmethod
- def from_file(cls, filename: str, config: UserConfig, password: str = None) -> "Wallet":
+ def from_file(cls, filename: str, config: UserConfig, password: str | None = None) -> "Wallet":
return super()._from_file(
filename=filename,
password=password,
@@ -721,6 +745,11 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]:
if dct.get("sync_tab_dump"):
dct["data_dump"] = {"SyncTab": dct["sync_tab_dump"]}
+ if version.parse(str(dct["VERSION"])) <= version.parse("0.1.4"):
+ if dct.get("data_dump"):
+ if "SyncTab" in dct["data_dump"]:
+ del dct["data_dump"]["SyncTab"]
+
# now the VERSION is newest, so it can be deleted from the dict
if "VERSION" in dct:
del dct["VERSION"]
@@ -729,8 +758,6 @@ def from_dump_migration(cls, dct: Dict[str, Any]) -> Dict[str, Any]:
@classmethod
def from_dump(cls, dct, class_kwargs=None) -> "Wallet":
super()._from_dump(dct, class_kwargs=class_kwargs)
- config: UserConfig = class_kwargs[cls.__name__]["config"] # passed via class_kwargs
-
if class_kwargs:
# must contain "Wallet":{"config": ... }
dct.update(class_kwargs[cls.__name__])
@@ -771,10 +798,11 @@ def init_blockchain(self) -> bdk.Blockchain:
bdk.Network.REGTEST,
bdk.Network.SIGNET,
]:
- start_height = 0
+ pass
elif self.config.network == bdk.Network.TESTNET:
- start_height = 2000000
+ pass
+ blockchain_config = None
if self.config.network_config.server_type == BlockchainType.Electrum:
full_url = (
"ssl://" if self.config.network_config.electrum_use_ssl else ""
@@ -799,19 +827,19 @@ def init_blockchain(self) -> bdk.Blockchain:
10,
)
)
- elif self.config.network_config.server_type == BlockchainType.CompactBlockFilter:
- folder = f"./compact-filters-{self.id}-{self.config.network.name}"
- blockchain_config = bdk.BlockchainConfig.COMPACT_FILTERS(
- bdk.CompactFiltersConfig(
- [
- f"{self.config.network_config.compactblockfilters_ip}:{self.config.network_config.compactblockfilters_port}"
- ]
- * 5,
- self.config.network,
- folder,
- start_height,
- )
- )
+ # elif self.config.network_config.server_type == BlockchainType.CompactBlockFilter:
+ # folder = f"./compact-filters-{self.id}-{self.config.network.name}"
+ # blockchain_config = bdk.BlockchainConfig.COMPACT_FILTERS(
+ # bdk.CompactFiltersConfig(
+ # [
+ # f"{self.config.network_config.compactblockfilters_ip}:{self.config.network_config.compactblockfilters_port}"
+ # ]
+ # * 5,
+ # self.config.network,
+ # folder,
+ # start_height,
+ # )
+ # )
elif self.config.network_config.server_type == BlockchainType.RPC:
blockchain_config = bdk.BlockchainConfig.RPC(
bdk.RpcConfig(
@@ -825,23 +853,23 @@ def init_blockchain(self) -> bdk.Blockchain:
bdk.RpcSyncParams(0, 0, False, 10),
)
)
+ if not blockchain_config:
+ raise Exception("Could not find a blockchain_config.")
self.blockchain = bdk.Blockchain(blockchain_config)
return self.blockchain
def _get_uniquie_wallet_id(self) -> str:
return f"{replace_non_alphanumeric(self.id)}-{hash_string(self.multipath_descriptor.as_string())}"
- def sync(self, progress_function_threadsafe=None) -> None:
+ def sync(self, progress_function_threadsafe: Callable[[float, str], None] | None = None) -> None:
if self.blockchain is None:
self.init_blockchain()
- if not progress_function_threadsafe:
-
- def progress_function_threadsafe(progress: float, message: str) -> None:
- logger.info((progress, message))
+ def default_progress_function_threadsafe(progress: float, message: str) -> None:
+ logger.info((progress, message))
progress = bdk.Progress()
- progress.update = progress_function_threadsafe
+ progress.update = progress_function_threadsafe if progress_function_threadsafe else default_progress_function_threadsafe # type: ignore
try:
start_time = time()
@@ -849,50 +877,60 @@ def progress_function_threadsafe(progress: float, message: str) -> None:
logger.debug(f"{self.id} self.bdkwallet.sync in { time()-start_time}s")
logger.info(f"Wallet balance is: { self.bdkwallet.get_balance().__dict__ }")
except Exception as e:
- logger.debug(f"{self.id} error syncing wallet {self.id}")
+ logger.error(f"{self.id} error syncing wallet {self.id}")
raise e
def reverse_search_unused_address(
self, category: Optional[str] = None, is_change=False
- ) -> bdk.AddressInfo:
+ ) -> Optional[bdk.AddressInfo]:
+
+ result: Optional[bdk.AddressInfo] = None
- earliest_address_info = None
for index, address_str in reversed(list(enumerate(self._get_addresses(is_change=is_change)))):
if self.address_is_used(address_str) or self.labels.get_label(address_str):
break
else:
- if not category or (category and self.labels.get_category(address_str) == category):
- earliest_address_info = self.bdkwallet.peek_addressinfo(index, is_change=is_change)
- return earliest_address_info
+ if (
+ not category
+ or (not self.labels.get_category_raw(address_str))
+ or (category and self.labels.get_category(address_str) == category)
+ ):
+ result = self.bdkwallet.peek_addressinfo(index, is_change=is_change)
+
+ return result
def get_unused_category_address(self, category: Optional[str], is_change=False) -> bdk.AddressInfo:
if category is None:
category = self.labels.get_default_category()
address_info = self.reverse_search_unused_address(category=category, is_change=is_change)
- if address_info:
- return address_info
+ if not address_info:
+ address_info = self.get_address(force_new=True, is_change=is_change)
- address_info = self.get_address(force_new=True, is_change=is_change)
self.labels.set_addr_category(address_info.address.as_string(), category, timestamp="now")
return address_info
- def get_address(self, force_new=False, is_change=False) -> bdk.AddressInfo:
- "Gives an unused address reverse searched from the tip"
+ def get_force_new_address(self, is_change) -> bdk.AddressInfo:
+ bdk_get_address = self.bdkwallet.get_internal_address if is_change else self.bdkwallet.get_address
- def get_force_new_address() -> bdk.AddressInfo:
- address_info = bdk_get_address(bdk.AddressIndex.NEW())
- index = address_info.index
- self._tips[int(is_change)] = index
+ address_info = bdk_get_address(bdk.AddressIndex.NEW())
+ index = address_info.index
+ self._tips[int(is_change)] = index
- logger.info(f"advanced_tip to {self._tips} , is_change={is_change}")
- return address_info
+ logger.info(f"advanced_tip to {self._tips} , is_change={is_change}")
- bdk_get_address = self.bdkwallet.get_internal_address if is_change else self.bdkwallet.get_address
+ address = address_info.address.as_string()
+ if address in self.labels.data:
+ # if the address is already labeled/categorized, then advance forward
+ return self.get_force_new_address(is_change=is_change)
+
+ return address_info
+ def get_address(self, force_new=False, is_change=False) -> bdk.AddressInfo:
+ "Gives an unused address reverse searched from the tip"
if force_new:
- return get_force_new_address()
+ return self.get_force_new_address(is_change=is_change)
# try finding an unused one
address_info = self.reverse_search_unused_address(is_change=is_change)
@@ -900,22 +938,14 @@ def get_force_new_address() -> bdk.AddressInfo:
return address_info
# create a new address
- return get_force_new_address()
+ return self.get_force_new_address(is_change=is_change)
def get_output_addresses(self, transaction: bdk.Transaction) -> List[str]:
# print(f'Getting output addresses for txid {transaction.txid}')
- return [
+ output_addresses = [
self.bdkwallet.get_address_of_txout(TxOut.from_bdk(output)) for output in transaction.output()
]
-
- def get_txin_address(self, txin: bdk.TxIn) -> Optional[bdk.Address]:
- previous_output = txin.previous_output
- tx = self.get_tx(previous_output.txid)
- if tx:
- output_for_input = tx.transaction.output()[previous_output.vout]
- return bdk.Address.from_script(output_for_input.script_pubkey, self.config.network)
- else:
- return None
+ return [a for a in output_addresses if a]
def fill_commonly_used_caches(self) -> None:
i = 0
@@ -929,7 +959,7 @@ def fill_commonly_used_caches(self) -> None:
self.get_addresses()
advanced_tips = self.advance_tips_by_gap()
- logger.debug(f"{self.id} tips were advanced by {advanced_tips}")
+ logger.info(f"{self.id} tips were advanced by {advanced_tips}")
new_addresses_were_watched = any(advanced_tips)
i += 1
if i > 100:
@@ -943,22 +973,15 @@ def get_txs(self) -> Dict[str, bdk.TransactionDetails]:
return {tx.txid: tx for tx in self.sorted_delta_list_transactions()}
@instance_lru_cache()
- def get_tx(self, txid: str) -> bdk.TransactionDetails:
+ def get_tx(self, txid: str) -> bdk.TransactionDetails | None:
return self.get_txs().get(txid)
def list_input_bdk_addresses(self, transaction: bdk.Transaction) -> List[str]:
addresses = []
for tx_in in transaction.input():
- previous_output = tx_in.previous_output
- tx = self.get_tx(previous_output.txid)
- if tx:
- output_for_input = tx.transaction.output()[previous_output.vout]
-
- add = bdk.Address.from_script(output_for_input.script_pubkey, self.config.network).as_string()
- else:
- add = None
-
- addresses.append(add)
+ address = self.get_address_of_outpoint(OutPoint.from_bdk(tx_in.previous_output))
+ if address:
+ addresses.append(address)
return addresses
def list_tx_addresses(self, transaction: bdk.Transaction) -> Dict[str, List[str]]:
@@ -1016,11 +1039,11 @@ def _advance_tip_if_necessary(self, is_change: bool, target: int) -> None:
self._tips[int(is_change)] = old_bdk_tip
return
- logger.debug(f"{self.id} indexing {number} new addresses")
+ logger.info(f"{self.id} indexing {number} new addresses")
def add_new_address() -> bdk.AddressInfo:
address_info: bdk.AddressInfo = bdk_get_address(bdk.AddressIndex.NEW())
- logger.debug(
+ logger.info(
f"{self.id} Added {'change' if is_change else ''} address with index {address_info.index}"
)
return address_info
@@ -1049,13 +1072,14 @@ def search_index_tuple(self, address, forward_search=500) -> Optional[AddressInf
)
# if not then search forward
- for index in range(self.tips[int(is_change)] + 1, forward_search + self.tips[int(is_change)] + 1):
- for is_change in [False, True]:
- peek_address = self.bdkwallet.peek_address(index, is_change)
- if peek_address == address:
- return AddressInfoMin(
- address, index, keychain=AddressInfoMin.is_change_to_keychain(is_change)
- )
+ for is_change in [False, True]:
+ for index in range(self.tips[int(is_change)] + 1, forward_search + self.tips[int(is_change)] + 1):
+ for is_change in [False, True]:
+ peek_address = self.bdkwallet.peek_address(index, is_change)
+ if peek_address == address:
+ return AddressInfoMin(
+ address, index, keychain=AddressInfoMin.is_change_to_keychain(is_change)
+ )
return None
def advance_tip_to_address(self, address: str, forward_search=500) -> Optional[AddressInfoMin]:
@@ -1118,43 +1142,44 @@ def get_address_info_min(self, address: str) -> Optional[AddressInfoMin]:
return None
- def utxo_of_outpoint(self, outpoint: bdk.OutPoint) -> Optional[PythonUtxo]:
- for python_utxo in self.get_all_txos():
- if OutPoint.from_bdk(outpoint) == OutPoint.from_bdk(python_utxo.outpoint):
- return python_utxo
+ def txo_of_outpoint(self, outpoint: bdk.OutPoint) -> Optional[PythonUtxo]:
+ txo_dict = self.get_all_txos_dict()
+ outpoint_str = str(OutPoint.from_bdk(outpoint))
+ if outpoint_str in txo_dict:
+ return txo_dict[outpoint_str]
return None
@instance_lru_cache()
def get_address_balances(self) -> defaultdict[str, Balance]:
- """Return the balance of a set of addresses:
- confirmed and matured, unconfirmed, unmatured
+ """Converts the known utxos into
+ a dict of addresses and their balance
"""
def get_confirmation_time(txid: str) -> Optional[bdk.BlockTime]:
- tx_details = self.get_tx(txid)
- return tx_details.confirmation_time
+ if tx_details := self.get_tx(txid):
+ return tx_details.confirmation_time
+ return None
utxos = self.bdkwallet.list_unspent()
balances: defaultdict[str, Balance] = defaultdict(Balance)
for i, utxo in enumerate(utxos):
- tx = self.get_tx(utxo.outpoint.txid)
- if not tx:
+ outpoint = OutPoint.from_bdk(utxo.outpoint)
+ txout = self.get_txout_of_outpoint(outpoint)
+ if not txout:
logger.warning(f"This should not happen. Most likely it is due to outdated caches.")
# this way of handeling this special case is suboptimal.
# Better would be to handle the caches such that the caches are always consistent
self.clear_instance_cache()
- tx = self.get_tx(utxo.outpoint.txid)
- if not tx:
- raise InconsistentBDKState(f"{utxo.outpoint.txid} not present in transaction details")
-
- txout: bdk.TxOut = tx.transaction.output()[utxo.outpoint.vout]
+ txout = self.get_txout_of_outpoint(outpoint)
+ if not txout:
+ raise InconsistentBDKState(f"{outpoint.txid} not present in transaction details")
- address = self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout))
+ address = self.bdkwallet.get_address_of_txout(txout)
if address is None:
continue
- if get_confirmation_time(tx.txid):
+ if get_confirmation_time(outpoint.txid):
balances[address].confirmed += txout.value
else:
balances[address].untrusted_pending += txout.value
@@ -1173,6 +1198,7 @@ def get_involved_txids(self, address: str) -> Set[str]:
self.get_dict_fulltxdetail()
return self.cache_address_to_txids.get(address, set())
+ @instance_lru_cache()
def get_dict_fulltxdetail(self) -> Dict[str, FullTxDetail]:
"""
Createa a map of txid : to FullTxDetail
@@ -1199,7 +1225,9 @@ def append_dicts(txid, python_utxos: List[Optional[PythonUtxo]]) -> None:
def process_outputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]:
fulltxdetail = FullTxDetail.fill_received(tx, self.bdkwallet.get_address_of_txout)
if fulltxdetail.txid in self.cache_dict_fulltxdetail:
- logger.error(f"Trying to add a tx with txid {fulltxdetail.txid} twice. ")
+ logger.error(
+ f"Trying to add a tx with txid {fulltxdetail.txid} twice. Is it a mining output?"
+ )
return fulltxdetail.txid, fulltxdetail
def process_inputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]:
@@ -1228,7 +1256,9 @@ def process_inputs(tx: bdk.TransactionDetails) -> Tuple[str, FullTxDetail]:
return self.cache_dict_fulltxdetail
- def _get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]:
+ @instance_lru_cache(always_keep=False)
+ def get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]:
+ "Returns {str(outpoint) : python_utxo}"
dict_fulltxdetail = self.get_dict_fulltxdetail()
my_addresses = self.get_addresses()
@@ -1243,8 +1273,12 @@ def _get_all_txos_dict(self, include_not_mine=False) -> Dict[str, PythonUtxo]:
txos[str(python_utxo.outpoint)] = python_utxo
return txos
- def get_all_txos(self, include_not_mine=False) -> List[PythonUtxo]:
- return list(self._get_all_txos_dict(include_not_mine=include_not_mine).values())
+ def get_all_utxos(self, include_not_mine=False) -> List[PythonUtxo]:
+ return [
+ txo
+ for txo in self.get_all_txos_dict(include_not_mine=include_not_mine).values()
+ if not txo.is_spent_by_txid
+ ]
@instance_lru_cache()
def address_is_used(self, address: str) -> bool:
@@ -1252,74 +1286,97 @@ def address_is_used(self, address: str) -> bool:
return bool(self.get_involved_txids(address))
def get_address_path_str(self, address: str) -> str:
- index = None
- is_change = None
- if address in self.get_receiving_addresses():
- index = self.get_receiving_addresses().index(address)
- is_change = False
- if address in self.get_change_addresses():
- index = self.get_change_addresses().index(address)
- is_change = True
-
- if index is None:
+ address_info = self.get_address_info_min(address)
+ if not address_info:
return ""
- addresses_info = self.bdkwallet.peek_addressinfo(index, is_change=is_change)
- public_descriptor_string_combined: str = self.multipath_descriptor.as_string().replace(
- "<0;1>/*",
- f"{0 if addresses_info.keychain==bdk.KeychainKind.EXTERNAL else 1}/{addresses_info.index}",
+ return self.multipath_descriptor.address_descriptor(
+ kind=address_info.keychain, address_index=address_info.index
)
- return public_descriptor_string_combined
- def get_input_and_output_utxos(self, txid: str) -> List[PythonUtxo]:
+ def get_input_and_output_txo_dict(self, txid: str) -> Dict[TxoType, List[PythonUtxo]]:
fulltxdetail = self.get_dict_fulltxdetail().get(txid)
if not fulltxdetail:
- return []
+ return {}
- l = [python_utxo for python_utxo in fulltxdetail.outputs.values() if python_utxo] + [
- python_utxo for python_utxo in fulltxdetail.inputs.values() if python_utxo
- ]
+ d = {TxoType.OutputTxo: [python_utxo for python_utxo in fulltxdetail.outputs.values()]}
+ input_dict = {
+ TxoType.InputTxo: [python_utxo for python_utxo in fulltxdetail.inputs.values() if python_utxo]
+ }
+ d.update(input_dict)
+ return d
+
+ def get_output_txos(self, txid: str) -> List[PythonUtxo]:
+ return self.get_input_and_output_txo_dict(txid)[TxoType.OutputTxo]
- return l
+ def get_input_txos(self, txid: str) -> List[PythonUtxo]:
+ return self.get_input_and_output_txo_dict(txid)[TxoType.InputTxo]
def get_categories_for_txid(self, txid: str) -> List[str]:
- python_utxos = self.get_input_and_output_utxos(txid)
- if not python_utxos:
+ input_and_output_txo_dict = self.get_input_and_output_txo_dict(txid)
+ python_txos = sum(input_and_output_txo_dict.values(), [])
+ if not python_txos:
return []
- l = np.unique(
- clean_list([self.labels.get_category(python_utxo.address) for python_utxo in python_utxos])
- )
+ categories = np.unique(
+ clean_list([self.labels.get_category_raw(python_utxo.address) for python_utxo in python_txos])
+ ).tolist()
- return list(l)
+ if not categories:
+ categories = [self.labels.get_default_category()]
+ return categories
- def get_label_for_address(self, address: str, autofill_from_txs=True) -> str:
- maybe_label = self.labels.get_label(address, "")
- label = maybe_label if maybe_label else ""
+ def get_label_for_address(self, address: str, autofill_from_txs=True, verbose_label=False) -> str:
+ stored_label = self.labels.get_label(address, "")
+ if stored_label:
+ return stored_label
+ label = ""
- if not label and autofill_from_txs:
+ if autofill_from_txs:
txids = self.get_involved_txids(address)
- tx_labels = clean_list(
- [self.get_label_for_txid(txid, autofill_from_addresses=False) for txid in txids]
- )
- label = ", ".join(tx_labels)
+ if verbose_label:
+ tx_labels = [
+ (self.get_label_for_txid(txid, autofill_from_addresses=False) or txid) for txid in txids
+ ]
+ label = translate("wallet", "") + "Funded by : " + ", ".join(tx_labels)
+ else:
+ tx_labels = clean_list(
+ [(self.get_label_for_txid(txid, autofill_from_addresses=False)) for txid in txids]
+ )
+ label = ", ".join(tx_labels)
return label
- def get_label_for_txid(self, txid: str, autofill_from_addresses=True) -> str:
- maybe_label = self.labels.get_label(txid, "")
- label = maybe_label if maybe_label else ""
+ def get_label_for_txid(self, txid: str, autofill_from_addresses=True, verbose_label=False) -> str:
+ stored_label = self.labels.get_label(txid, "")
+ if stored_label:
+ return stored_label
+
+ label = ""
- if not label and autofill_from_addresses:
- python_utxos = self.get_input_and_output_utxos(txid)
+ if autofill_from_addresses:
+ python_utxos = self.get_output_txos(txid)
if not python_utxos:
return label
- address_labels = clean_list(
- [self.get_label_for_address(python_utxo.address) for python_utxo in python_utxos]
- )
- label = ", ".join(address_labels)
+ if verbose_label:
+ address_labels = [
+ (
+ self.get_label_for_address(python_utxo.address, autofill_from_txs=False)
+ or python_utxo.address
+ )
+ for python_utxo in python_utxos
+ ]
+ label = translate("wallet", "") + "Sending to addresses: " + ", ".join(address_labels)
+ else:
+ address_labels = clean_list(
+ [
+ (self.get_label_for_address(python_utxo.address, autofill_from_txs=False))
+ for python_utxo in python_utxos
+ ]
+ )
+ label = ", ".join(address_labels)
return label
@@ -1332,13 +1389,10 @@ def get_balance(self) -> Balance:
confirmed=balance.confirmed,
)
- def get_utxo_name(self, utxo: PythonUtxo) -> str:
- tx = self.get_tx(utxo.outpoint.txid)
- return f"{tx.txid}:{utxo.outpoint.vout}"
-
- def get_utxo_address(self, utxo: PythonUtxo) -> str:
+ def get_txo_name(self, utxo: PythonUtxo) -> str:
tx = self.get_tx(utxo.outpoint.txid)
- return self.get_output_addresses(tx.transaction)[utxo.outpoint.vout]
+ txid = tx.txid if tx else translate("wallet", "Unknown")
+ return f"{txid}:{utxo.outpoint.vout}"
@instance_lru_cache()
def get_height(self) -> int:
@@ -1350,7 +1404,7 @@ def get_height(self) -> int:
logger.error(f"Could not fetch self.blockchain.get_height()")
return self._blockchain_height
- def coin_select(
+ def opportunistic_coin_select(
self, utxos: List[PythonUtxo], total_sent_value: int, opportunistic_merge_utxos: bool
) -> UtxosForInputs:
def utxo_value(utxo: PythonUtxo) -> int:
@@ -1363,9 +1417,9 @@ def is_outpoint_in_list(outpoint, utxos) -> bool:
return True
return False
- # coin selection
+ # 1. select random utxos until >= total_sent_value
utxos = list(utxos).copy()
- np.random.shuffle(utxos)
+ random.shuffle(utxos)
selected_utxos = []
selected_value = 0
opportunistic_merging_utxos = []
@@ -1378,17 +1432,26 @@ def is_outpoint_in_list(outpoint, utxos) -> bool:
f"Selected {len(selected_utxos)} outpoints with {Satoshis(selected_value, self.network).str_with_unit()}"
)
- # now opportunistically add additional outputs for merging
+ # 2. opportunistically add additional outputs for merging
if opportunistic_merge_utxos:
non_selected_utxos = [
utxo for utxo in utxos if not is_outpoint_in_list(utxo.outpoint, selected_utxos)
]
# never choose more than half of all remaining outputs
- number_of_opportunistic_outpoints = (
- np.random.randint(0, len(non_selected_utxos) // 2) if len(non_selected_utxos) // 2 > 0 else 0
+ # on average this exponentially merges the utxos
+ # and never more than 200 additional utoxs
+ number_of_opportunistic_outpoints = min(
+ 200,
+ (
+ np.random.randint(0, len(non_selected_utxos) // 2)
+ if len(non_selected_utxos) // 2 > 0
+ else 0
+ ),
)
+ # here we choose the smalles utxos first
+ # Alternatively one could also choose them from the random order
opportunistic_merging_utxos = sorted(non_selected_utxos, key=utxo_value)[
:number_of_opportunistic_outpoints
]
@@ -1396,8 +1459,11 @@ def is_outpoint_in_list(outpoint, utxos) -> bool:
f"Selected {len(opportunistic_merging_utxos)} additional opportunistic outpoints with small values (so total ={len(selected_utxos)+len(opportunistic_merging_utxos)}) with {Satoshis(sum([utxo.txout.value for utxo in opportunistic_merging_utxos]), self.network).str_with_unit()}"
)
+ # now shuffle again the final utxos
+ final_utxo_selection = selected_utxos + opportunistic_merging_utxos
+ random.shuffle(final_utxo_selection)
return UtxosForInputs(
- utxos=selected_utxos + opportunistic_merging_utxos,
+ utxos=final_utxo_selection,
included_opportunistic_merging_utxos=opportunistic_merging_utxos,
spend_all_utxos=True,
)
@@ -1420,7 +1486,7 @@ def handle_opportunistic_merge_utxos(self, txinfos: TxUiInfos) -> UtxosForInputs
# if more opportunistic_merge should be done, than I have to use my coin selection
elif txinfos.opportunistic_merge_utxos:
# use my coin selection algo, which uses more utxos than needed
- return self.coin_select(
+ return self.opportunistic_coin_select(
utxos=utxos_for_input.utxos,
total_sent_value=total_sent_value,
opportunistic_merge_utxos=txinfos.opportunistic_merge_utxos,
@@ -1432,16 +1498,67 @@ def handle_opportunistic_merge_utxos(self, txinfos: TxUiInfos) -> UtxosForInputs
def is_my_address(self, address: str) -> bool:
return address in self.get_addresses()
+ def determine_recipient_category(self, utxos: Iterable[PythonUtxo]) -> str:
+ "Returns the first category it can determine from the addreses or txids"
+ address_categories = clean_list(
+ [self.labels.get_category_raw(utxo.address) for utxo in utxos],
+ )
+
+ if address_categories:
+ category = address_categories[0]
+ if len(set(address_categories)) >= 2:
+ logger.warning(
+ f"Selecting category {category} out of {set(address_categories)} for the output addresses"
+ )
+
+ return category
+
+ tx_id_categories = clean_list(
+ sum(
+ [list(self.get_categories_for_txid(utxo.outpoint.txid)) for utxo in utxos],
+ [],
+ )
+ )
+ if tx_id_categories:
+ category = tx_id_categories[0]
+ if len(address_categories) >= 2:
+ logger.warning(
+ f"Selecting category {category} out of {tx_id_categories} for the output addresses"
+ )
+
+ return category
+
+ logger.warning(f"determine_recipient_category returns default category")
+ return self.labels.get_default_category()
+
def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos:
recipients = txinfos.recipients.copy()
+ # bdk only saves the last drained address
+ # therefore we rely on the estimation of
+ # recipient.amount to set the correct amount
+ # the last set checked_max_amount will get what is left over.
+ # that could be a little more or a little less than the estimated recipient.amount
+
+ # this has the positive side effect, that if spend_all_utxos was set,
+ # the previously chosen drain_to(change address), because of spend_all_utxos will be overrwritten
+ max_amount_recipients = [
+ recipient for recipient in txinfos.recipients if recipient.checked_max_amount
+ ]
+ selected_max_amount_recipient = max_amount_recipients[-1] if max_amount_recipients else None
+
+ if selected_max_amount_recipient:
+ txinfos.spend_all_utxos = True
+
tx_builder = bdk.TxBuilder()
tx_builder = tx_builder.enable_rbf()
- if txinfos.fee_rate:
+ if txinfos.fee_rate is not None:
tx_builder = tx_builder.fee_rate(txinfos.fee_rate)
utxos_for_input = self.handle_opportunistic_merge_utxos(txinfos)
selected_outpoints = [OutPoint.from_bdk(utxo.outpoint) for utxo in utxos_for_input.utxos]
+ # bdk doesnt seem to shuffle the inputs, so I do it here
+ random.shuffle(selected_outpoints)
if utxos_for_input.spend_all_utxos:
# spend_all_utxos requires using add_utxo
@@ -1450,7 +1567,7 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos:
for outpoint in selected_outpoints:
tx_builder = tx_builder.add_utxo(outpoint)
# TODO no add_foreign_utxo yet: see https://github.com/bitcoindevkit/bdk-ffi/issues/329 https://docs.rs/bdk/latest/bdk/wallet/tx_builder/struct.TxBuilder.html#method.add_foreign_utxo
- # manually add a change output for draining all added utxos
+ # ensure all utxos are spent (so we get a change address)
tx_builder = tx_builder.drain_to(self.get_address(is_change=True).address.script_pubkey())
else:
# exclude all other coins, to leave only selected_outpoints to choose from
@@ -1462,9 +1579,7 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos:
tx_builder = tx_builder.unspendable(unspendable_outpoints)
for recipient in txinfos.recipients:
- if recipient.checked_max_amount:
- if len(txinfos.recipients) == 1:
- tx_builder = tx_builder.drain_wallet()
+ if recipient == selected_max_amount_recipient:
tx_builder = tx_builder.drain_to(
bdk.Address(recipient.address, network=self.network).script_pubkey()
)
@@ -1475,28 +1590,19 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos:
start_time = time()
builder_result: bdk.TxBuilderResult = tx_builder.finish(self.bdkwallet)
+ # in bdkpython 0.31.0 still needed, because https://github.com/bitcoindevkit/bdk-ffi/issues/572
+ # TODO: remove for bdkpython 1.0
+ builder_result.psbt = PSBTTools.add_global_xpub_dict_to_psbt(
+ psbt=builder_result.psbt, global_xpub=txinfos.global_xpubs, network=self.network
+ )
logger.debug(f"{self.id} tx_builder.finish in { time()-start_time}s")
# inputs: List[bdk.TxIn] = builder_result.psbt.extract_tx().input()
logger.info(json.loads(builder_result.psbt.json_serialize()))
- logger.debug(f"psbt fee after finalized {builder_result.psbt.fee_rate().as_sat_per_vb()}")
-
- # get category of first utxo
- categories = clean_list(
- sum(
- [list(self.get_categories_for_txid(utxo.outpoint.txid)) for utxo in utxos_for_input.utxos],
- [],
- )
- )
- recipient_category = categories[0] if categories else None
- logger.debug(f"Selecting category {recipient_category} out of {categories} for the output addresses")
+ logger.info(f"psbt fee after finalized {builder_result.psbt.fee_rate().as_sat_per_vb()}")
- labels = [recipient.label for recipient in txinfos.recipients if recipient.label]
- if labels:
- self.labels.set_tx_label(
- builder_result.transaction_details.txid, ",".join(labels), timestamp="now"
- )
+ recipient_category = self.determine_recipient_category(utxos_for_input.utxos)
builder_infos = TxBuilderInfos(
recipients=recipients,
@@ -1504,71 +1610,103 @@ def create_psbt(self, txinfos: TxUiInfos) -> TxBuilderInfos:
builder_result=builder_result,
recipient_category=recipient_category,
)
- self.set_output_categories_and_labels(builder_infos)
- return builder_infos
- def on_addresses_updated(self, update_filter: UpdateFilter) -> None:
- """Checks if the tip reaches the addresses and updated the tips if
- necessary (This is especially relevant if a psbt creates a new change
- address)"""
- self.clear_method(self._get_addresses)
+ self.set_psbt_output_categories(
+ recipient_category=recipient_category,
+ addresses=[
+ self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout))
+ for txout in builder_infos.builder_result.psbt.extract_tx().output()
+ ],
+ )
+ self._set_recipient_address_labels(builder_infos.recipients)
+ self._set_labels_for_change_outputs(builder_infos)
- not_indexed_addresses = set(update_filter.addresses) - set(self.get_addresses())
- for not_indexed_address in not_indexed_addresses:
- self.advance_tip_to_address(not_indexed_address)
+ # self._label_txid_by_recipient_labels(builder_infos)
+ return builder_infos
- def set_output_categories_and_labels(self, infos: TxBuilderInfos) -> None:
- # set category for all outputs
- if infos.recipient_category:
- self._set_category_for_all_recipients(
- infos.builder_result.psbt.extract_tx().output(),
- infos.recipient_category,
- )
+ def set_addr_category_if_unused(self, category: str, address: str) -> None:
+ "sets the address category, if the category was unassigned"
+ if address and self.is_my_address(address) and not self.address_is_used(address):
+ # old self.labels.get_category(address, default_value="not_set_category") == "not_set_category":
+ self.labels.set_addr_category(address, category=category)
+ def set_psbt_output_categories(
+ self, recipient_category: str | None, addresses: Iterable[str | None]
+ ) -> None:
+ if not recipient_category:
+ return
+ for address in addresses:
+ if address:
+ self.set_addr_category_if_unused(category=recipient_category, address=address)
+
+ def _set_recipient_address_labels(self, recipients: List[Recipient]) -> None:
# set label for the recipient output
- for recipient in infos.recipients:
+ for recipient in recipients:
# this does not include the change output
- if recipient.label and self.is_my_address(recipient.address):
+ if recipient.label: # it doesnt have to be my address (in fact most often it is not)
self.labels.set_addr_label(recipient.address, recipient.label, timestamp="now")
+ def _set_labels_for_change_outputs(self, infos: TxBuilderInfos) -> None:
# add a label for the change output
labels = [recipient.label for recipient in infos.recipients if recipient.label]
+ if not labels:
+ return
for txout in infos.builder_result.psbt.extract_tx().output():
address = self.bdkwallet.get_address_of_txout(TxOut.from_bdk(txout))
- if not self.is_change(address):
+ if not address:
continue
- if address and labels and self.is_my_address(address):
- self.labels.set_addr_label(address, ",".join(labels), timestamp="now")
+ if not self.is_my_address(address):
+ continue
+ if self.is_change(address):
+ change_label = translate("wallet", "Change of:") + " " + ", ".join(labels)
+ self.labels.set_addr_label(address, change_label, timestamp="now")
- def _set_category_for_all_recipients(self, outputs: List[bdk.TxOut], category: str) -> None:
- "Will assign all outputs (also change) the category"
+ def _label_txid_by_recipient_labels(self, infos: TxBuilderInfos) -> None:
+ labels = [recipient.label for recipient in infos.recipients if recipient.label]
+ if labels:
+ tx_label = translate("wallet", "Send to:") + " " + ",".join(labels)
+ self.labels.set_tx_label(infos.builder_result.transaction_details.txid, tx_label, timestamp="now")
- recipients = [
- Recipient(
- address=bdk.Address.from_script(output.script_pubkey, self.network).as_string(),
- amount=output.value,
- )
- for output in outputs
- ]
+ def on_addresses_updated(self, update_filter: UpdateFilter) -> None:
+ """Checks if the tip reaches the addresses and updated the tips if
+ necessary (This is especially relevant if a psbt creates a new change
+ address)"""
+ self.clear_method(self._get_addresses)
+ logger.debug(f"{self.__class__.__name__} update_with_filter {update_filter}")
- for recipient in recipients:
- if self.is_my_address(recipient.address):
- self.labels.set_addr_category(recipient.address, category, timestamp="now")
- self.labels.add_category(category)
+ not_indexed_addresses = set(update_filter.addresses) - set(self.get_addresses())
+ for not_indexed_address in not_indexed_addresses:
+ self.advance_tip_to_address(not_indexed_address)
- def get_python_utxo(self, outpoint_str: str) -> Optional[PythonUtxo]:
- all_txos_dict = self._get_all_txos_dict()
+ def get_txout_of_outpoint(self, outpoint: OutPoint) -> Optional[TxOut]:
+ tx_details = self.get_tx(outpoint.txid)
+ if not tx_details:
+ return None
+
+ txouts = list(tx_details.transaction.output())
+ if outpoint.vout > len(txouts) - 1:
+ return None
+
+ txout = txouts[outpoint.vout]
+ return TxOut.from_bdk(txout)
+
+ def get_address_of_outpoint(self, outpoint: OutPoint) -> Optional[str]:
+ txout = self.get_txout_of_outpoint(outpoint)
+ if not txout:
+ return None
+ return self.bdkwallet.get_address_of_txout(txout)
+
+ def get_python_txo(self, outpoint_str: str) -> Optional[PythonUtxo]:
+ all_txos_dict = self.get_all_txos_dict()
return all_txos_dict.get(outpoint_str)
- def get_conflicting_python_utxos(self, input_outpoints: List[OutPoint]) -> List[PythonUtxo]:
+ def get_conflicting_python_txos(self, input_outpoints: Iterable[OutPoint]) -> List[PythonUtxo]:
conflicting_python_utxos = []
- python_txos = self.get_all_txos()
- wallet_outpoints: List[OutPoint] = [python_utxo.outpoint for python_utxo in python_txos]
-
- for outpoint in input_outpoints:
- if outpoint in wallet_outpoints:
- python_utxo = python_txos[wallet_outpoints.index(outpoint)]
+ txos_dict = self.get_all_txos_dict()
+ for input_outpoint in input_outpoints:
+ if str(input_outpoint) in txos_dict:
+ python_utxo = txos_dict[str(input_outpoint)]
if python_utxo.is_spent_by_txid:
conflicting_python_utxos.append(python_utxo)
return conflicting_python_utxos
@@ -1578,18 +1716,28 @@ def sorted_delta_list_transactions(self, access_marker=None) -> List[bdk.Transac
"Returns a List of TransactionDetails, sorted from old to new"
def check_relation(child: FullTxDetail, parent: FullTxDetail) -> bool:
- for inp in child.inputs.values():
- if not inp:
- continue
- this_parent_txid = inp.outpoint.txid
- if this_parent_txid == parent.txid:
- # if the parent is found already
+ visited = set()
+ stack = [child]
+
+ while stack:
+ current = stack.pop()
+
+ if current.txid == parent.txid:
return True
- this_parent = dict_fulltxdetail.get(this_parent_txid)
- if this_parent:
- relation = check_relation(this_parent, parent)
- if relation:
- return True
+
+ if current.txid in visited:
+ continue
+ visited.add(current.txid)
+
+ # the following loop puts all acenstors on the stack
+ # and the while loop will check if any of them matches the parent
+ for child_inp in current.inputs.values():
+ if not child_inp:
+ continue
+ child_parent_txid = child_inp.outpoint.txid
+ this_parent = dict_fulltxdetail.get(child_parent_txid)
+ if this_parent:
+ stack.append(this_parent)
return False
@@ -1632,7 +1780,10 @@ def compare_items(item1: FullTxDetail, item2: FullTxDetail) -> int:
return [fulltxdetail.tx for fulltxdetail in sorted_fulltxdetail]
def is_in_mempool(self, txid: str) -> bool:
- # TODO: Currently in mempool and is in wallet is the same thing. In the future I have to differentiate here
+ # TODO: Currently in mempool and is in wallet is the same thing.
+ # In the future I have to differentiate here, if it is a locally saved tx,
+ # or already broadcasted.
+ # But for now I don't have locally saved transactions
if txid in self.get_txs():
return True
return False
@@ -1655,6 +1806,14 @@ def get_fulltxdetail_and_dependents(self, txid: str, include_root_tx=True) -> Li
def get_ema_fee_rate(self, alpha=0.2, default=MIN_RELAY_FEE) -> float:
"""
Calculate Exponential Moving Average (EMA) of the fee_rate of all transactions.
+
+ This is not ideal, because it also takes incoming transactions (from exchanges)
+ into account, which typically use a very high fee-rate.
+ However, given that without any outgoing tx, it is not possible to determine any
+ reasonable average fee-rate, this is better than nothing.
+
+ Assuming, that in a high fee environment , the exchanges are more careful,
+ then this calculation will be close to the optimal fee-rate.
"""
fee_rates = [
FeeInfo.from_txdetails(txdetail).fee_rate() for txdetail in self.sorted_delta_list_transactions()
@@ -1668,7 +1827,8 @@ def get_ema_fee_rate(self, alpha=0.2, default=MIN_RELAY_FEE) -> float:
class DescriptorExportTools:
@staticmethod
def get_coldcard_str(wallet_id: str, descriptor: MultipathDescriptor) -> str:
- return f"""# Coldcard descriptor export of wallet: {wallet_id}\n{ descriptor.bdk_descriptors[0].as_string() }"""
+ return f"""# Coldcard descriptor export of wallet: {filename_clean( wallet_id, file_extension='', replace_spaces_by='_')}
+{ descriptor.bdk_descriptors[0].as_string() }"""
###########
@@ -1683,6 +1843,13 @@ def get_wallet(wallet_id: str, signals: Signals) -> Optional[Wallet]:
return signals.get_wallets().get(wallet_id)
+def get_wallet_of_address(address: str, signals: Signals) -> Optional[Wallet]:
+ for wallet in get_wallets(signals):
+ if wallet.is_my_address(address):
+ return wallet
+ return None
+
+
def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Optional[Wallet]:
wallets = get_wallets(signals)
if not wallets:
@@ -1690,7 +1857,7 @@ def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Opti
number_intersections = []
for wallet in wallets:
- python_utxos = wallet.get_all_txos()
+ python_utxos = wallet.get_all_txos_dict().values()
wallet_outpoints: List[OutPoint] = [utxo.outpoint for utxo in python_utxos]
number_intersections.append(len(set(outpoints).intersection(set(wallet_outpoints))))
@@ -1707,41 +1874,34 @@ def get_wallet_of_outpoints(outpoints: List[OutPoint], signals: Signals) -> Opti
class ToolsTxUiInfo:
@staticmethod
- def fill_utxo_dict_from_outpoints(
+ def fill_txo_dict_from_outpoints(
txuiinfos: TxUiInfos, outpoints: List[OutPoint], wallets: List[Wallet]
) -> None:
- def get_utxo_and_wallet(outpoint) -> Optional[Tuple[PythonUtxo, Wallet]]:
- for wallet in wallets:
- python_utxo = wallet.utxo_of_outpoint(outpoint)
- if python_utxo:
- return python_utxo, wallet
- logger.warning(f"{txuiinfos.__class__.__name__}: utxo for {outpoint} could not be found")
- return None
+ "Will include the txo even if it is spent already (useful for rbf)"
+ outpoint_dict = {
+ outpoint_str: (python_utxo, wallet)
+ for wallet in wallets
+ for outpoint_str, python_utxo in wallet.get_all_txos_dict().items()
+ }
for outpoint in outpoints:
- res = get_utxo_and_wallet(outpoint)
- if not res:
+ if not str(outpoint) in outpoint_dict:
logger.warning(f"no python_utxo found for outpoint {outpoint} ")
continue
- python_utxo, wallet = res
+ python_utxo, wallet = outpoint_dict[str(outpoint)]
txuiinfos.main_wallet_id = wallet.id
- txuiinfos.utxo_dict[str(outpoint)] = python_utxo
+ txuiinfos.utxo_dict[outpoint] = python_utxo
@staticmethod
def fill_utxo_dict_from_categories(
txuiinfos: TxUiInfos, categories: List[str], wallets: List[Wallet]
) -> None:
+ "Will only include UTXOs, (not usefull for rbf)"
for wallet in wallets:
- for utxo in wallet.get_all_txos():
- if utxo.is_spent_by_txid:
- continue
- if (
- wallet.labels.get_category(
- wallet.bdkwallet.get_address_of_txout(TxOut.from_bdk(utxo.txout))
- )
- in categories
- ):
- txuiinfos.utxo_dict[str(utxo.outpoint)] = utxo
+ for utxo in wallet.get_all_utxos():
+ address = utxo.address
+ if wallet.labels.get_category(address) in categories:
+ txuiinfos.utxo_dict[utxo.outpoint] = utxo
@staticmethod
def get_likely_source_wallet(txuiinfos: TxUiInfos, signals: Signals) -> Optional[Wallet]:
@@ -1754,7 +1914,7 @@ def get_likely_source_wallet(txuiinfos: TxUiInfos, signals: Signals) -> Optional
if wallet:
return wallet
- input_outpoints = [OutPoint.from_str(outpoint) for outpoint in txuiinfos.utxo_dict.keys()]
+ input_outpoints = [outpoint for outpoint in txuiinfos.utxo_dict.keys()]
return get_wallet_of_outpoints(input_outpoints, signals)
@staticmethod
@@ -1788,35 +1948,15 @@ def from_tx(
txinfos = TxUiInfos()
# inputs
- ToolsTxUiInfo.fill_utxo_dict_from_outpoints(txinfos, outpoints, wallets=wallets)
+ ToolsTxUiInfo.fill_txo_dict_from_outpoints(txinfos, outpoints, wallets=wallets)
txinfos.spend_all_utxos = True
# outputs
checked_max_amount = len(tx.output()) == 1 # if there is only 1 recipient, there is no change address
for txout in tx.output():
out_address = robust_address_str_from_script(txout.script_pubkey, network)
- if out_address is None:
- logger.warning(f"No addres of {txout} could be calculated")
- continue
-
txinfos.recipients.append(
Recipient(out_address, txout.value, checked_max_amount=checked_max_amount)
)
# fee rate
txinfos.fee_rate = fee_info.fee_rate() if fee_info else None
return txinfos
-
- @staticmethod
- def from_wallets(
- txid: str,
- wallets: List[Wallet],
- ) -> TxUiInfos:
- def get_wallet_tx_details(txid) -> bdk.TransactionDetails:
- for wallet in wallets:
- tx_details = wallet.get_tx(txid)
- if tx_details:
- return wallet, tx_details
- return None, None
-
- wallet, tx_details = get_wallet_tx_details(txid)
- fee_info = FeeInfo(tx_details.fee, tx_details.transaction.vsize(), is_estimated=False)
- return ToolsTxUiInfo.from_tx(tx_details.transaction, fee_info, wallet.network, wallets)
diff --git a/poetry.lock b/poetry.lock
index f9df2ca..5464120 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -92,17 +92,17 @@ chardet = ">=3.0.2"
[[package]]
name = "bitcoin-nostr-chat"
-version = "0.2.5"
+version = "0.3.5"
description = "A Nostr Chat with participant discovery"
optional = false
python-versions = "<3.13,>=3.9"
files = [
- {file = "bitcoin_nostr_chat-0.2.5-py3-none-any.whl", hash = "sha256:1cdb526a07ac83e1d939d999067432fca860ecf42ea62751fead19b5aaff892c"},
- {file = "bitcoin_nostr_chat-0.2.5.tar.gz", hash = "sha256:13407500d77a3d5985fbc9999ecf85efc573918fad35b969f3cb895abaf961c8"},
+ {file = "bitcoin_nostr_chat-0.3.5-py3-none-any.whl", hash = "sha256:b38895b25d59bbe043d4fd19f2375cd62ea71911999de1c1de72c75e6e90bc39"},
+ {file = "bitcoin_nostr_chat-0.3.5.tar.gz", hash = "sha256:73cdadd50ee1f31abc2aa7195c5f654cfac416d592b88636c9df3afe895331cd"},
]
[package.dependencies]
-bitcoin-qr-tools = ">=0.10.9,<0.11.0"
+bitcoin-qr-tools = ">=0.14.5"
cbor2 = ">=5.6.3,<6.0.0"
nostr-sdk = ">=0.32.1,<0.33.0"
pyqt6 = ">=6.6.1,<7.0.0"
@@ -110,13 +110,13 @@ requests = ">=2.31.0,<3.0.0"
[[package]]
name = "bitcoin-qr-tools"
-version = "0.10.9"
+version = "0.14.6"
description = "Python bitcoin qr reader and generator"
optional = false
python-versions = "<3.13,>=3.9"
files = [
- {file = "bitcoin_qr_tools-0.10.9-py3-none-any.whl", hash = "sha256:e17153faefa2e4dd2190cd43312a55654dde9086b711c29c3d49ad1f197680bc"},
- {file = "bitcoin_qr_tools-0.10.9.tar.gz", hash = "sha256:6dd663b72fc6dd5817b6248fee91e759ccbea8276916c5edc206c07ea8497796"},
+ {file = "bitcoin_qr_tools-0.14.6-py3-none-any.whl", hash = "sha256:4cb655cc9c6b795cd6c1b1c926bec6041de708d7ccb6eb215a4b0946b5bcc1ff"},
+ {file = "bitcoin_qr_tools-0.14.6.tar.gz", hash = "sha256:aa81bdb83b3caf124b4039bbb8524b843416ce6f6dc4937a3efffd531f5dbb7f"},
]
[package.dependencies]
@@ -125,9 +125,9 @@ bdkpython = ">=0.31.0,<0.32.0"
hwi = ">=3.0.0,<4.0.0"
mss = ">=9.0.1,<10.0.0"
numpy = ">=2.0.1,<3.0.0"
-opencv-python-headless = ">=4.9.0.80"
+opencv-python-headless = ">=4.10.0.84,<5.0.0.0"
pillow = ">=10.4.0,<11.0.0"
-pygame = ">=2.5.2,<3.0.0"
+pygame = ">=2.6.0,<3.0.0"
pyqrcode = ">=1.2.1,<2.0.0"
pyqt6 = ">=6.7.0,<7.0.0"
pyzbar = ">=0.1.9,<0.2.0"
@@ -135,13 +135,13 @@ segno = "1.6.1"
[[package]]
name = "bitcoin-usb"
-version = "0.2.1"
+version = "0.5.3"
description = "Wrapper around hwi, such that one can sign bdk PSBTs directly"
optional = false
python-versions = "<3.13,>=3.8.1"
files = [
- {file = "bitcoin_usb-0.2.1-py3-none-any.whl", hash = "sha256:a8df5dd5120fe040ffd7750593864e1d176198c01299c435de739d11399846de"},
- {file = "bitcoin_usb-0.2.1.tar.gz", hash = "sha256:2acdc9343b01ec361506c93104bd96c1c27763f490e2e4f575429330e0978752"},
+ {file = "bitcoin_usb-0.5.3-py3-none-any.whl", hash = "sha256:9ecaa1e46be97b163e0468c81efe4aec22fda6b2593c14204334f0479fb65731"},
+ {file = "bitcoin_usb-0.5.3.tar.gz", hash = "sha256:e6b69a23521a2c49dcba0f29befc96298caa6991b50739f1c5d95e8537226d65"},
]
[package.dependencies]
@@ -150,6 +150,7 @@ hwi = ">=3.0.0,<4.0.0"
mnemonic = ">=0.21,<0.22"
pyqt6 = ">=6.6.1,<7.0.0"
python-bitcointx = "1.1.4"
+requests = ">=2.32.3,<3.0.0"
[[package]]
name = "briefcase"
@@ -186,13 +187,13 @@ docs = ["furo (==2024.5.6)", "pyenchant (==3.2.2)", "sphinx (==7.1.2)", "sphinx
[[package]]
name = "build"
-version = "1.2.1"
+version = "1.2.2.post1"
description = "A simple, correct Python build frontend"
optional = false
python-versions = ">=3.8"
files = [
- {file = "build-1.2.1-py3-none-any.whl", hash = "sha256:75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4"},
- {file = "build-1.2.1.tar.gz", hash = "sha256:526263f4870c26f26c433545579475377b2b7588b6f1eac76a001e873ae3e19d"},
+ {file = "build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5"},
+ {file = "build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"},
]
[package.dependencies]
@@ -211,48 +212,55 @@ virtualenv = ["virtualenv (>=20.0.35)"]
[[package]]
name = "cbor2"
-version = "5.6.4"
+version = "5.6.5"
description = "CBOR (de)serializer with extensive tag support"
optional = false
python-versions = ">=3.8"
files = [
- {file = "cbor2-5.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c40c68779a363f47a11ded7b189ba16767391d5eae27fac289e7f62b730ae1fc"},
- {file = "cbor2-5.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0625c8d3c487e509458459de99bf052f62eb5d773cc9fc141c6a6ea9367726d"},
- {file = "cbor2-5.6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de7137622204168c3a57882f15dd09b5135bda2bcb1cf8b56b58d26b5150dfca"},
- {file = "cbor2-5.6.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3545e1e62ec48944b81da2c0e0a736ca98b9e4653c2365cae2f10ae871e9113"},
- {file = "cbor2-5.6.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6749913cd00a24eba17406a0bfc872044036c30a37eb2fcde7acfd975317e8a"},
- {file = "cbor2-5.6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:57db966ab08443ee54b6f154f72021a41bfecd4ba897fe108728183ad8784a2a"},
- {file = "cbor2-5.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:380e0c7f4db574dcd86e6eee1b0041863b0aae7efd449d49b0b784cf9a481b9b"},
- {file = "cbor2-5.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c763d50a1714e0356b90ad39194fc8ef319356b89fb001667a2e836bfde88e3"},
- {file = "cbor2-5.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58a7ac8861857a9f9b0de320a4808a2a5f68a2599b4c14863e2748d5a4686c99"},
- {file = "cbor2-5.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d715b2f101730335e84a25fe0893e2b6adf049d6d44da123bf243b8c875ffd8"},
- {file = "cbor2-5.6.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f53a67600038cb9668720b309fdfafa8c16d1a02570b96d2144d58d66774318"},
- {file = "cbor2-5.6.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f898bab20c4f42dca3688c673ff97c2f719b1811090430173c94452603fbcf13"},
- {file = "cbor2-5.6.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5e5d50fb9f47d295c1b7f55592111350424283aff4cc88766c656aad0300f11f"},
- {file = "cbor2-5.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:7f9d867dcd814ab8383ad132eb4063e2b69f6a9f688797b7a8ca34a4eadb3944"},
- {file = "cbor2-5.6.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e0860ca88edf8aaec5461ce0e498eb5318f1bcc70d93f90091b7a1f1d351a167"},
- {file = "cbor2-5.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c38a0ed495a63a8bef6400158746a9cb03c36f89aeed699be7ffebf82720bf86"},
- {file = "cbor2-5.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c8d8c2f208c223a61bed48dfd0661694b891e423094ed30bac2ed75032142aa"},
- {file = "cbor2-5.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cd2ce6136e1985da989e5ba572521023a320dcefad5d1fff57fba261de80ca"},
- {file = "cbor2-5.6.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7facce04aed2bf69ef43bdffb725446fe243594c2451921e89cc305bede16f02"},
- {file = "cbor2-5.6.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f9c8ee0d89411e5e039a4f3419befe8b43c0dd8746eedc979e73f4c06fe0ef97"},
- {file = "cbor2-5.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:9b45d554daa540e2f29f1747df9f08f8d98ade65a67b1911791bc193d33a5923"},
- {file = "cbor2-5.6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0a5cb2c16687ccd76b38cfbfdb34468ab7d5635fb92c9dc5e07831c1816bd0a9"},
- {file = "cbor2-5.6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f985f531f7495527153c4f66c8c143e4cf8a658ec9e87b14bc5438e0a8d0911"},
- {file = "cbor2-5.6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d9c7b4bd7c3ea7e5587d4f1bbe073b81719530ddadb999b184074f064896e2"},
- {file = "cbor2-5.6.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d06184dcdc275c389fee3cd0ea80b5e1769763df15f93ecd0bf4c281817365"},
- {file = "cbor2-5.6.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e9ba7116f201860fb4c3e80ef36be63851ec7e4a18af70fea22d09cab0b000bf"},
- {file = "cbor2-5.6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:341468ae58bdedaa05c907ab16e90dd0d5c54d7d1e66698dfacdbc16a31e815b"},
- {file = "cbor2-5.6.4-cp38-cp38-win_amd64.whl", hash = "sha256:bcb4994be1afcc81f9167c220645d878b608cae92e19f6706e770f9bc7bbff6c"},
- {file = "cbor2-5.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41c43abffe217dce70ae51c7086530687670a0995dfc90cc35f32f2cf4d86392"},
- {file = "cbor2-5.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:227a7e68ba378fe53741ed892b5b03fe472b5bd23ef26230a71964accebf50a2"},
- {file = "cbor2-5.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13521b7c9a0551fcc812d36afd03fc554fa4e1b193659bb5d4d521889aa81154"},
- {file = "cbor2-5.6.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4816d290535d20c7b7e2663b76da5b0deb4237b90275c202c26343d8852b8a"},
- {file = "cbor2-5.6.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1e98d370106821335efcc8fbe4136ea26b4747bf29ca0e66512b6c4f6f5cc59f"},
- {file = "cbor2-5.6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:68743a18e16167ff37654a29321f64f0441801dba68359c82dc48173cc6c87e1"},
- {file = "cbor2-5.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:7ba5e9c6ed17526d266a1116c045c0941f710860c5f2495758df2e0d848c1b6d"},
- {file = "cbor2-5.6.4-py3-none-any.whl", hash = "sha256:fe411c4bf464f5976605103ebcd0f60b893ac3e4c7c8d8bc8f4a0cb456e33c60"},
- {file = "cbor2-5.6.4.tar.gz", hash = "sha256:1c533c50dde86bef1c6950602054a0ffa3c376e8b0e20c7b8f5b108793f6983e"},
+ {file = "cbor2-5.6.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e16c4a87fc999b4926f5c8f6c696b0d251b4745bc40f6c5aee51d69b30b15ca2"},
+ {file = "cbor2-5.6.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87026fc838370d69f23ed8572939bd71cea2b3f6c8f8bb8283f573374b4d7f33"},
+ {file = "cbor2-5.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88f029522aec5425fc2f941b3df90da7688b6756bd3f0472ab886d21208acbd"},
+ {file = "cbor2-5.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d15b638539b68aa5d5eacc56099b4543a38b2d2c896055dccf7e83d24b7955"},
+ {file = "cbor2-5.6.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:47261f54a024839ec649b950013c4de5b5f521afe592a2688eebbe22430df1dc"},
+ {file = "cbor2-5.6.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:559dcf0d897260a9e95e7b43556a62253e84550b77147a1ad4d2c389a2a30192"},
+ {file = "cbor2-5.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:5b856fda4c50c5bc73ed3664e64211fa4f015970ed7a15a4d6361bd48462feaf"},
+ {file = "cbor2-5.6.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:863e0983989d56d5071270790e7ed8ddbda88c9e5288efdb759aba2efee670bc"},
+ {file = "cbor2-5.6.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cff06464b8f4ca6eb9abcba67bda8f8334a058abc01005c8e616728c387ad32"},
+ {file = "cbor2-5.6.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c7dbcdc59ea7f5a745d3e30ee5e6b6ff5ce7ac244aa3de6786391b10027bb3"},
+ {file = "cbor2-5.6.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34cf5ab0dc310c3d0196caa6ae062dc09f6c242e2544bea01691fe60c0230596"},
+ {file = "cbor2-5.6.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6797b824b26a30794f2b169c0575301ca9b74ae99064e71d16e6ba0c9057de51"},
+ {file = "cbor2-5.6.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73b9647eed1493097db6aad61e03d8f1252080ee041a1755de18000dd2c05f37"},
+ {file = "cbor2-5.6.5-cp311-cp311-win_amd64.whl", hash = "sha256:6e14a1bf6269d25e02ef1d4008e0ce8880aa271d7c6b4c329dba48645764f60e"},
+ {file = "cbor2-5.6.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e25c2aebc9db99af7190e2261168cdde8ed3d639ca06868e4f477cf3a228a8e9"},
+ {file = "cbor2-5.6.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fde21ac1cf29336a31615a2c469a9cb03cf0add3ae480672d4d38cda467d07fc"},
+ {file = "cbor2-5.6.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8947c102cac79d049eadbd5e2ffb8189952890df7cbc3ee262bbc2f95b011a9"},
+ {file = "cbor2-5.6.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38886c41bebcd7dca57739439455bce759f1e4c551b511f618b8e9c1295b431b"},
+ {file = "cbor2-5.6.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae2b49226224e92851c333b91d83292ec62eba53a19c68a79890ce35f1230d70"},
+ {file = "cbor2-5.6.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2764804ffb6553283fc4afb10a280715905a4cea4d6dc7c90d3e89c4a93bc8d"},
+ {file = "cbor2-5.6.5-cp312-cp312-win_amd64.whl", hash = "sha256:a3ac50485cf67dfaab170a3e7b527630e93cb0a6af8cdaa403054215dff93adf"},
+ {file = "cbor2-5.6.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0d0a9c5aabd48ecb17acf56004a7542a0b8d8212be52f3102b8218284bd881e"},
+ {file = "cbor2-5.6.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61ceb77e6aa25c11c814d4fe8ec9e3bac0094a1f5bd8a2a8c95694596ea01e08"},
+ {file = "cbor2-5.6.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97a7e409b864fecf68b2ace8978eb5df1738799a333ec3ea2b9597bfcdd6d7d2"},
+ {file = "cbor2-5.6.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6d69f38f7d788b04c09ef2b06747536624b452b3c8b371ab78ad43b0296fab"},
+ {file = "cbor2-5.6.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f91e6d74fa6917df31f8757fdd0e154203b0dd0609ec53eb957016a2b474896a"},
+ {file = "cbor2-5.6.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5ce13a27ef8fddf643fc17a753fe34aa72b251d03c23da6a560c005dc171085b"},
+ {file = "cbor2-5.6.5-cp313-cp313-win_amd64.whl", hash = "sha256:54c72a3207bb2d4480c2c39dad12d7971ce0853a99e3f9b8d559ce6eac84f66f"},
+ {file = "cbor2-5.6.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4586a4f65546243096e56a3f18f29d60752ee9204722377021b3119a03ed99ff"},
+ {file = "cbor2-5.6.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d1a18b3a58dcd9b40ab55c726160d4a6b74868f2a35b71f9e726268b46dc6a2"},
+ {file = "cbor2-5.6.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a83b76367d1c3e69facbcb8cdf65ed6948678e72f433137b41d27458aa2a40cb"},
+ {file = "cbor2-5.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90bfa36944caccec963e6ab7e01e64e31cc6664535dc06e6295ee3937c999cbb"},
+ {file = "cbor2-5.6.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:37096663a5a1c46a776aea44906cbe5fa3952f29f50f349179c00525d321c862"},
+ {file = "cbor2-5.6.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:93676af02bd9a0b4a62c17c5b20f8e9c37b5019b1a24db70a2ee6cb770423568"},
+ {file = "cbor2-5.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:8f747b7a9aaa58881a0c5b4cd4a9b8fb27eca984ed261a769b61de1f6b5bd1e6"},
+ {file = "cbor2-5.6.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:94885903105eec66d7efb55f4ce9884fdc5a4d51f3bd75b6fedc68c5c251511b"},
+ {file = "cbor2-5.6.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fe11c2eb518c882cfbeed456e7a552e544893c17db66fe5d3230dbeaca6b615c"},
+ {file = "cbor2-5.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66dd25dd919cddb0b36f97f9ccfa51947882f064729e65e6bef17c28535dc459"},
+ {file = "cbor2-5.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa61a02995f3a996c03884cf1a0b5733f88cbfd7fa0e34944bf678d4227ee712"},
+ {file = "cbor2-5.6.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:824f202b556fc204e2e9a67d6d6d624e150fbd791278ccfee24e68caec578afd"},
+ {file = "cbor2-5.6.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7488aec919f8408f9987a3a32760bd385d8628b23a35477917aa3923ff6ad45f"},
+ {file = "cbor2-5.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:a34ee99e86b17444ecbe96d54d909dd1a20e2da9f814ae91b8b71cf1ee2a95e4"},
+ {file = "cbor2-5.6.5-py3-none-any.whl", hash = "sha256:3038523b8fc7de312bb9cdcbbbd599987e64307c4db357cd2030c472a6c7d468"},
+ {file = "cbor2-5.6.5.tar.gz", hash = "sha256:b682820677ee1dbba45f7da11898d2720f92e06be36acec290867d5ebf3d7e09"},
]
[package.extras]
@@ -262,89 +270,89 @@ test = ["coverage (>=7)", "hypothesis", "pytest"]
[[package]]
name = "certifi"
-version = "2024.7.4"
+version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
- {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
- {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
+ {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
+ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "cffi"
-version = "1.17.0"
+version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
- {file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"},
- {file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"},
- {file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"},
- {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"},
- {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"},
- {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"},
- {file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"},
- {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"},
- {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"},
- {file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"},
- {file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"},
- {file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"},
- {file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"},
- {file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"},
- {file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"},
- {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"},
- {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"},
- {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"},
- {file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"},
- {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"},
- {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"},
- {file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"},
- {file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"},
- {file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"},
- {file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"},
- {file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"},
- {file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"},
- {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"},
- {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"},
- {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"},
- {file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"},
- {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"},
- {file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"},
- {file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"},
- {file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"},
- {file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"},
- {file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"},
- {file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"},
- {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"},
- {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"},
- {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"},
- {file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"},
- {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"},
- {file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"},
- {file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"},
- {file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"},
- {file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"},
- {file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"},
- {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"},
- {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"},
- {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"},
- {file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"},
- {file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"},
- {file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"},
- {file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"},
- {file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"},
- {file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"},
- {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"},
- {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"},
- {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"},
- {file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"},
- {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"},
- {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"},
- {file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"},
- {file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"},
- {file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"},
- {file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"},
+ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
+ {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
+ {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
+ {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
+ {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
+ {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
+ {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
+ {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
+ {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
+ {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
+ {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
+ {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
+ {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
+ {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
+ {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
+ {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
+ {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
+ {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
+ {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
+ {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
+ {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
+ {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
+ {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
+ {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
+ {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
+ {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
+ {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
+ {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
+ {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
+ {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
+ {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
+ {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
+ {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
+ {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
+ {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
+ {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
+ {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
+ {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
+ {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
+ {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
+ {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
+ {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
+ {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
+ {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
+ {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
+ {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
+ {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
+ {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
+ {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
+ {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
+ {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
+ {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
+ {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
+ {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
+ {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
+ {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
+ {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
+ {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
+ {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
+ {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
+ {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
+ {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
+ {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
+ {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
+ {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
+ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
+ {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
[package.dependencies]
@@ -374,101 +382,116 @@ files = [
[[package]]
name = "charset-normalizer"
-version = "3.3.2"
+version = "3.4.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7.0"
files = [
- {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
- {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
- {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
- {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
- {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
- {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
- {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
- {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"},
+ {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"},
+ {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"},
+ {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"},
+ {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"},
+ {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"},
+ {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"},
+ {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"},
+ {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"},
+ {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"},
]
[[package]]
@@ -519,43 +542,38 @@ rich = "*"
[[package]]
name = "cryptography"
-version = "42.0.8"
+version = "43.0.1"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = ">=3.7"
files = [
- {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"},
- {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"},
- {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"},
- {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"},
- {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"},
- {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"},
- {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"},
- {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"},
- {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"},
- {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"},
- {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"},
- {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"},
- {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"},
- {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"},
- {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"},
- {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"},
- {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"},
- {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"},
- {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"},
- {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"},
- {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"},
- {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"},
- {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"},
- {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"},
- {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"},
- {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"},
- {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"},
- {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"},
- {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"},
- {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"},
- {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"},
- {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"},
+ {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
+ {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
+ {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
+ {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
+ {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
+ {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
+ {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
+ {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
+ {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
+ {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
+ {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
+ {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
+ {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
+ {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
+ {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
+ {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
+ {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
+ {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
+ {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
+ {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
+ {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
+ {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
+ {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
+ {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
+ {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
+ {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
+ {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
]
[package.dependencies]
@@ -568,7 +586,7 @@ nox = ["nox"]
pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -584,13 +602,13 @@ files = [
[[package]]
name = "distlib"
-version = "0.3.8"
+version = "0.3.9"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
- {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
- {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
+ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
+ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
]
[[package]]
@@ -710,69 +728,75 @@ test = ["pytest (>=6)"]
[[package]]
name = "filelock"
-version = "3.15.4"
+version = "3.16.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
- {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
- {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
+ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
+ {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
]
[package.extras]
-docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
-typing = ["typing-extensions (>=4.8)"]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
+typing = ["typing-extensions (>=4.12.2)"]
[[package]]
name = "fonttools"
-version = "4.53.1"
+version = "4.54.1"
description = "Tools to manipulate font files"
optional = false
python-versions = ">=3.8"
files = [
- {file = "fonttools-4.53.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0679a30b59d74b6242909945429dbddb08496935b82f91ea9bf6ad240ec23397"},
- {file = "fonttools-4.53.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8bf06b94694251861ba7fdeea15c8ec0967f84c3d4143ae9daf42bbc7717fe3"},
- {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b96cd370a61f4d083c9c0053bf634279b094308d52fdc2dd9a22d8372fdd590d"},
- {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1c7c5aa18dd3b17995898b4a9b5929d69ef6ae2af5b96d585ff4005033d82f0"},
- {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e013aae589c1c12505da64a7d8d023e584987e51e62006e1bb30d72f26522c41"},
- {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9efd176f874cb6402e607e4cc9b4a9cd584d82fc34a4b0c811970b32ba62501f"},
- {file = "fonttools-4.53.1-cp310-cp310-win32.whl", hash = "sha256:c8696544c964500aa9439efb6761947393b70b17ef4e82d73277413f291260a4"},
- {file = "fonttools-4.53.1-cp310-cp310-win_amd64.whl", hash = "sha256:8959a59de5af6d2bec27489e98ef25a397cfa1774b375d5787509c06659b3671"},
- {file = "fonttools-4.53.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da33440b1413bad53a8674393c5d29ce64d8c1a15ef8a77c642ffd900d07bfe1"},
- {file = "fonttools-4.53.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ff7e5e9bad94e3a70c5cd2fa27f20b9bb9385e10cddab567b85ce5d306ea923"},
- {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e7170d675d12eac12ad1a981d90f118c06cf680b42a2d74c6c931e54b50719"},
- {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee32ea8765e859670c4447b0817514ca79054463b6b79784b08a8df3a4d78e3"},
- {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e08f572625a1ee682115223eabebc4c6a2035a6917eac6f60350aba297ccadb"},
- {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b21952c092ffd827504de7e66b62aba26fdb5f9d1e435c52477e6486e9d128b2"},
- {file = "fonttools-4.53.1-cp311-cp311-win32.whl", hash = "sha256:9dfdae43b7996af46ff9da520998a32b105c7f098aeea06b2226b30e74fbba88"},
- {file = "fonttools-4.53.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4d0096cb1ac7a77b3b41cd78c9b6bc4a400550e21dc7a92f2b5ab53ed74eb02"},
- {file = "fonttools-4.53.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d92d3c2a1b39631a6131c2fa25b5406855f97969b068e7e08413325bc0afba58"},
- {file = "fonttools-4.53.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3b3c8ebafbee8d9002bd8f1195d09ed2bd9ff134ddec37ee8f6a6375e6a4f0e8"},
- {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f029c095ad66c425b0ee85553d0dc326d45d7059dbc227330fc29b43e8ba60"},
- {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f5e6c3510b79ea27bb1ebfcc67048cde9ec67afa87c7dd7efa5c700491ac7f"},
- {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f677ce218976496a587ab17140da141557beb91d2a5c1a14212c994093f2eae2"},
- {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e6ceba2a01b448e36754983d376064730690401da1dd104ddb543519470a15f"},
- {file = "fonttools-4.53.1-cp312-cp312-win32.whl", hash = "sha256:791b31ebbc05197d7aa096bbc7bd76d591f05905d2fd908bf103af4488e60670"},
- {file = "fonttools-4.53.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ed170b5e17da0264b9f6fae86073be3db15fa1bd74061c8331022bca6d09bab"},
- {file = "fonttools-4.53.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c818c058404eb2bba05e728d38049438afd649e3c409796723dfc17cd3f08749"},
- {file = "fonttools-4.53.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:651390c3b26b0c7d1f4407cad281ee7a5a85a31a110cbac5269de72a51551ba2"},
- {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54f1bba2f655924c1138bbc7fa91abd61f45c68bd65ab5ed985942712864bbb"},
- {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9cd19cf4fe0595ebdd1d4915882b9440c3a6d30b008f3cc7587c1da7b95be5f"},
- {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2af40ae9cdcb204fc1d8f26b190aa16534fcd4f0df756268df674a270eab575d"},
- {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:35250099b0cfb32d799fb5d6c651220a642fe2e3c7d2560490e6f1d3f9ae9169"},
- {file = "fonttools-4.53.1-cp38-cp38-win32.whl", hash = "sha256:f08df60fbd8d289152079a65da4e66a447efc1d5d5a4d3f299cdd39e3b2e4a7d"},
- {file = "fonttools-4.53.1-cp38-cp38-win_amd64.whl", hash = "sha256:7b6b35e52ddc8fb0db562133894e6ef5b4e54e1283dff606fda3eed938c36fc8"},
- {file = "fonttools-4.53.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a157d8d26c06e64ace9df037ee93a4938a4606a38cb7ffaf6635e60e253b7a"},
- {file = "fonttools-4.53.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4824c198f714ab5559c5be10fd1adf876712aa7989882a4ec887bf1ef3e00e31"},
- {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:becc5d7cb89c7b7afa8321b6bb3dbee0eec2b57855c90b3e9bf5fb816671fa7c"},
- {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ec3fb43befb54be490147b4a922b5314e16372a643004f182babee9f9c3407"},
- {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73379d3ffdeecb376640cd8ed03e9d2d0e568c9d1a4e9b16504a834ebadc2dfb"},
- {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:02569e9a810f9d11f4ae82c391ebc6fb5730d95a0657d24d754ed7763fb2d122"},
- {file = "fonttools-4.53.1-cp39-cp39-win32.whl", hash = "sha256:aae7bd54187e8bf7fd69f8ab87b2885253d3575163ad4d669a262fe97f0136cb"},
- {file = "fonttools-4.53.1-cp39-cp39-win_amd64.whl", hash = "sha256:e5b708073ea3d684235648786f5f6153a48dc8762cdfe5563c57e80787c29fbb"},
- {file = "fonttools-4.53.1-py3-none-any.whl", hash = "sha256:f1f8758a2ad110bd6432203a344269f445a2907dc24ef6bccfd0ac4e14e0d71d"},
- {file = "fonttools-4.53.1.tar.gz", hash = "sha256:e128778a8e9bc11159ce5447f76766cefbd876f44bd79aff030287254e4752c4"},
+ {file = "fonttools-4.54.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ed7ee041ff7b34cc62f07545e55e1468808691dddfd315d51dd82a6b37ddef2"},
+ {file = "fonttools-4.54.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41bb0b250c8132b2fcac148e2e9198e62ff06f3cc472065dff839327945c5882"},
+ {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7965af9b67dd546e52afcf2e38641b5be956d68c425bef2158e95af11d229f10"},
+ {file = "fonttools-4.54.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:278913a168f90d53378c20c23b80f4e599dca62fbffae4cc620c8eed476b723e"},
+ {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0e88e3018ac809b9662615072dcd6b84dca4c2d991c6d66e1970a112503bba7e"},
+ {file = "fonttools-4.54.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4817f0031206e637d1e685251ac61be64d1adef111060df84fdcbc6ab6c44"},
+ {file = "fonttools-4.54.1-cp310-cp310-win32.whl", hash = "sha256:7e3b7d44e18c085fd8c16dcc6f1ad6c61b71ff463636fcb13df7b1b818bd0c02"},
+ {file = "fonttools-4.54.1-cp310-cp310-win_amd64.whl", hash = "sha256:dd9cc95b8d6e27d01e1e1f1fae8559ef3c02c76317da650a19047f249acd519d"},
+ {file = "fonttools-4.54.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5419771b64248484299fa77689d4f3aeed643ea6630b2ea750eeab219588ba20"},
+ {file = "fonttools-4.54.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:301540e89cf4ce89d462eb23a89464fef50915255ece765d10eee8b2bf9d75b2"},
+ {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ae5091547e74e7efecc3cbf8e75200bc92daaeb88e5433c5e3e95ea8ce5aa7"},
+ {file = "fonttools-4.54.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82834962b3d7c5ca98cb56001c33cf20eb110ecf442725dc5fdf36d16ed1ab07"},
+ {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d26732ae002cc3d2ecab04897bb02ae3f11f06dd7575d1df46acd2f7c012a8d8"},
+ {file = "fonttools-4.54.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58974b4987b2a71ee08ade1e7f47f410c367cdfc5a94fabd599c88165f56213a"},
+ {file = "fonttools-4.54.1-cp311-cp311-win32.whl", hash = "sha256:ab774fa225238986218a463f3fe151e04d8c25d7de09df7f0f5fce27b1243dbc"},
+ {file = "fonttools-4.54.1-cp311-cp311-win_amd64.whl", hash = "sha256:07e005dc454eee1cc60105d6a29593459a06321c21897f769a281ff2d08939f6"},
+ {file = "fonttools-4.54.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:54471032f7cb5fca694b5f1a0aaeba4af6e10ae989df408e0216f7fd6cdc405d"},
+ {file = "fonttools-4.54.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fa92cb248e573daab8d032919623cc309c005086d743afb014c836636166f08"},
+ {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a911591200114969befa7f2cb74ac148bce5a91df5645443371aba6d222e263"},
+ {file = "fonttools-4.54.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93d458c8a6a354dc8b48fc78d66d2a8a90b941f7fec30e94c7ad9982b1fa6bab"},
+ {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5eb2474a7c5be8a5331146758debb2669bf5635c021aee00fd7c353558fc659d"},
+ {file = "fonttools-4.54.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9c563351ddc230725c4bdf7d9e1e92cbe6ae8553942bd1fb2b2ff0884e8b714"},
+ {file = "fonttools-4.54.1-cp312-cp312-win32.whl", hash = "sha256:fdb062893fd6d47b527d39346e0c5578b7957dcea6d6a3b6794569370013d9ac"},
+ {file = "fonttools-4.54.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4564cf40cebcb53f3dc825e85910bf54835e8a8b6880d59e5159f0f325e637e"},
+ {file = "fonttools-4.54.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6e37561751b017cf5c40fce0d90fd9e8274716de327ec4ffb0df957160be3bff"},
+ {file = "fonttools-4.54.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:357cacb988a18aace66e5e55fe1247f2ee706e01debc4b1a20d77400354cddeb"},
+ {file = "fonttools-4.54.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e953cc0bddc2beaf3a3c3b5dd9ab7554677da72dfaf46951e193c9653e515a"},
+ {file = "fonttools-4.54.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58d29b9a294573d8319f16f2f79e42428ba9b6480442fa1836e4eb89c4d9d61c"},
+ {file = "fonttools-4.54.1-cp313-cp313-win32.whl", hash = "sha256:9ef1b167e22709b46bf8168368b7b5d3efeaaa746c6d39661c1b4405b6352e58"},
+ {file = "fonttools-4.54.1-cp313-cp313-win_amd64.whl", hash = "sha256:262705b1663f18c04250bd1242b0515d3bbae177bee7752be67c979b7d47f43d"},
+ {file = "fonttools-4.54.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ed2f80ca07025551636c555dec2b755dd005e2ea8fbeb99fc5cdff319b70b23b"},
+ {file = "fonttools-4.54.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9dc080e5a1c3b2656caff2ac2633d009b3a9ff7b5e93d0452f40cd76d3da3b3c"},
+ {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d152d1be65652fc65e695e5619e0aa0982295a95a9b29b52b85775243c06556"},
+ {file = "fonttools-4.54.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8583e563df41fdecef31b793b4dd3af8a9caa03397be648945ad32717a92885b"},
+ {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d1d353ef198c422515a3e974a1e8d5b304cd54a4c2eebcae708e37cd9eeffb1"},
+ {file = "fonttools-4.54.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:fda582236fee135d4daeca056c8c88ec5f6f6d88a004a79b84a02547c8f57386"},
+ {file = "fonttools-4.54.1-cp38-cp38-win32.whl", hash = "sha256:e7d82b9e56716ed32574ee106cabca80992e6bbdcf25a88d97d21f73a0aae664"},
+ {file = "fonttools-4.54.1-cp38-cp38-win_amd64.whl", hash = "sha256:ada215fd079e23e060157aab12eba0d66704316547f334eee9ff26f8c0d7b8ab"},
+ {file = "fonttools-4.54.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5b8a096e649768c2f4233f947cf9737f8dbf8728b90e2771e2497c6e3d21d13"},
+ {file = "fonttools-4.54.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e10d2e0a12e18f4e2dd031e1bf7c3d7017be5c8dbe524d07706179f355c5dac"},
+ {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31c32d7d4b0958600eac75eaf524b7b7cb68d3a8c196635252b7a2c30d80e986"},
+ {file = "fonttools-4.54.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c39287f5c8f4a0c5a55daf9eaf9ccd223ea59eed3f6d467133cc727d7b943a55"},
+ {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a7a310c6e0471602fe3bf8efaf193d396ea561486aeaa7adc1f132e02d30c4b9"},
+ {file = "fonttools-4.54.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d3b659d1029946f4ff9b6183984578041b520ce0f8fb7078bb37ec7445806b33"},
+ {file = "fonttools-4.54.1-cp39-cp39-win32.whl", hash = "sha256:e96bc94c8cda58f577277d4a71f51c8e2129b8b36fd05adece6320dd3d57de8a"},
+ {file = "fonttools-4.54.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8a4b261c1ef91e7188a30571be6ad98d1c6d9fa2427244c545e2fa0a2494dd7"},
+ {file = "fonttools-4.54.1-py3-none-any.whl", hash = "sha256:37cddd62d83dc4f72f7c3f3c2bcf2697e89a30efb152079896544a93907733bd"},
+ {file = "fonttools-4.54.1.tar.gz", hash = "sha256:957f669d4922f92c171ba01bef7f29410668db09f6c02111e22b2bce446f3285"},
]
[package.extras]
@@ -791,13 +815,13 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
[[package]]
name = "fpdf2"
-version = "2.7.9"
+version = "2.8.1"
description = "Simple & fast PDF generation for Python"
optional = false
python-versions = ">=3.7"
files = [
- {file = "fpdf2-2.7.9-py2.py3-none-any.whl", hash = "sha256:1f176aea4a1cc0fa1da5c799fce8f8bcfe2e936b9d2298a75514c9efc7528bfa"},
- {file = "fpdf2-2.7.9.tar.gz", hash = "sha256:f364c0d816a5e364eeeda9761cf5c961bae8c946f080cf87fed7f38ab773b318"},
+ {file = "fpdf2-2.8.1-py2.py3-none-any.whl", hash = "sha256:02f9e81ea5a0ec723c467de392bc6f4c54ea7859857e6fcee46579bd02bd335f"},
+ {file = "fpdf2-2.8.1.tar.gz", hash = "sha256:8866161396f942c8f7e3f022c4afb5e671b4d044745d4ed13c5d2ccc16ae5091"},
]
[package.dependencies]
@@ -920,13 +944,13 @@ setuptools = ">=19.0"
[[package]]
name = "hwi"
-version = "3.0.0"
+version = "3.1.0"
description = "A library for working with Bitcoin hardware wallets"
optional = false
python-versions = "<3.13,>=3.8"
files = [
- {file = "hwi-3.0.0-py3-none-any.whl", hash = "sha256:0a3c4de5135bce6bdc5317e0e6a521bf6213ab9750d9902a006810b0fb99748e"},
- {file = "hwi-3.0.0.tar.gz", hash = "sha256:2db24cdda6dc04d669b26a12194836ace1f026045745a44db8ee98c637aa779a"},
+ {file = "hwi-3.1.0-py3-none-any.whl", hash = "sha256:21ba92bb06e2f805e2806c686f2c50d02db6826a363b01e44052415755504d6f"},
+ {file = "hwi-3.1.0.tar.gz", hash = "sha256:42e875cbb616a91638fb90679cad93edb5075bf375e92fc1709be9b2a3dfd59c"},
]
[package.dependencies]
@@ -947,13 +971,13 @@ qt = ["pyside2 (>=5.14.0,<6.0.0)"]
[[package]]
name = "identify"
-version = "2.6.0"
+version = "2.6.1"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
- {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
+ {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
+ {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
]
[package.extras]
@@ -961,33 +985,40 @@ license = ["ukkonen"]
[[package]]
name = "idna"
-version = "3.7"
+version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
files = [
- {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
- {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
[[package]]
name = "importlib-metadata"
-version = "8.2.0"
+version = "8.5.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.8"
files = [
- {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"},
- {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"},
+ {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"},
+ {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"},
]
[package.dependencies]
-zipp = ">=0.5"
+zipp = ">=3.20"
[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
+cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=2.2)"]
perf = ["ipython"]
-test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
+test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+type = ["pytest-mypy"]
[[package]]
name = "iniconfig"
@@ -1226,71 +1257,72 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markupsafe"
-version = "2.1.5"
+version = "3.0.1"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.9"
files = [
- {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
- {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
- {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
- {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
- {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
- {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
- {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
- {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"},
+ {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"},
+ {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"},
+ {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"},
+ {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"},
+ {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"},
+ {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"},
]
[[package]]
@@ -1317,15 +1349,19 @@ files = [
[[package]]
name = "mss"
-version = "9.0.1"
+version = "9.0.2"
description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes."
optional = false
python-versions = ">=3.8"
files = [
- {file = "mss-9.0.1-py3-none-any.whl", hash = "sha256:7ee44db7ab14cbea6a3eb63813c57d677a109ca5979d3b76046e4bddd3ca1a0b"},
- {file = "mss-9.0.1.tar.gz", hash = "sha256:6eb7b9008cf27428811fa33aeb35f3334db81e3f7cc2dd49ec7c6e5a94b39f12"},
+ {file = "mss-9.0.2-py3-none-any.whl", hash = "sha256:685fa442cc96d8d88b4eb7aadbcccca7b858e789c9259b603e1ef0e435b60425"},
+ {file = "mss-9.0.2.tar.gz", hash = "sha256:c96a4ec73224da7db22bc07ef3cfaa18f8b86900d1872e29113bbcef0093a21e"},
]
+[package.extras]
+dev = ["build (==1.2.1)", "mypy (==1.11.2)", "ruff (==0.6.3)", "twine (==5.1.1)", "wheel (==0.44.0)"]
+test = ["numpy (==2.1.0)", "pillow (==10.4.0)", "pytest (==8.3.2)", "pytest-cov (==5.0.0)", "pytest-rerunfailures (==14.0.0)", "pyvirtualdisplay (==3.0)", "sphinx (==8.0.2)"]
+
[[package]]
name = "nodeenv"
version = "1.9.1"
@@ -1382,56 +1418,56 @@ files = [
[[package]]
name = "numpy"
-version = "2.0.1"
+version = "2.0.2"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.9"
files = [
- {file = "numpy-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fbb536eac80e27a2793ffd787895242b7f18ef792563d742c2d673bfcb75134"},
- {file = "numpy-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:69ff563d43c69b1baba77af455dd0a839df8d25e8590e79c90fcbe1499ebde42"},
- {file = "numpy-2.0.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1b902ce0e0a5bb7704556a217c4f63a7974f8f43e090aff03fcf262e0b135e02"},
- {file = "numpy-2.0.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:f1659887361a7151f89e79b276ed8dff3d75877df906328f14d8bb40bb4f5101"},
- {file = "numpy-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4658c398d65d1b25e1760de3157011a80375da861709abd7cef3bad65d6543f9"},
- {file = "numpy-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4127d4303b9ac9f94ca0441138acead39928938660ca58329fe156f84b9f3015"},
- {file = "numpy-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e5eeca8067ad04bc8a2a8731183d51d7cbaac66d86085d5f4766ee6bf19c7f87"},
- {file = "numpy-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9adbd9bb520c866e1bfd7e10e1880a1f7749f1f6e5017686a5fbb9b72cf69f82"},
- {file = "numpy-2.0.1-cp310-cp310-win32.whl", hash = "sha256:7b9853803278db3bdcc6cd5beca37815b133e9e77ff3d4733c247414e78eb8d1"},
- {file = "numpy-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:81b0893a39bc5b865b8bf89e9ad7807e16717f19868e9d234bdaf9b1f1393868"},
- {file = "numpy-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75b4e316c5902d8163ef9d423b1c3f2f6252226d1aa5cd8a0a03a7d01ffc6268"},
- {file = "numpy-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6e4eeb6eb2fced786e32e6d8df9e755ce5be920d17f7ce00bc38fcde8ccdbf9e"},
- {file = "numpy-2.0.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1e01dcaab205fbece13c1410253a9eea1b1c9b61d237b6fa59bcc46e8e89343"},
- {file = "numpy-2.0.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:a8fc2de81ad835d999113ddf87d1ea2b0f4704cbd947c948d2f5513deafe5a7b"},
- {file = "numpy-2.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a3d94942c331dd4e0e1147f7a8699a4aa47dffc11bf8a1523c12af8b2e91bbe"},
- {file = "numpy-2.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15eb4eca47d36ec3f78cde0a3a2ee24cf05ca7396ef808dda2c0ddad7c2bde67"},
- {file = "numpy-2.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b83e16a5511d1b1f8a88cbabb1a6f6a499f82c062a4251892d9ad5d609863fb7"},
- {file = "numpy-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f87fec1f9bc1efd23f4227becff04bd0e979e23ca50cc92ec88b38489db3b55"},
- {file = "numpy-2.0.1-cp311-cp311-win32.whl", hash = "sha256:36d3a9405fd7c511804dc56fc32974fa5533bdeb3cd1604d6b8ff1d292b819c4"},
- {file = "numpy-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:08458fbf403bff5e2b45f08eda195d4b0c9b35682311da5a5a0a0925b11b9bd8"},
- {file = "numpy-2.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bf4e6f4a2a2e26655717a1983ef6324f2664d7011f6ef7482e8c0b3d51e82ac"},
- {file = "numpy-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6fddc5fe258d3328cd8e3d7d3e02234c5d70e01ebe377a6ab92adb14039cb4"},
- {file = "numpy-2.0.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5daab361be6ddeb299a918a7c0864fa8618af66019138263247af405018b04e1"},
- {file = "numpy-2.0.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:ea2326a4dca88e4a274ba3a4405eb6c6467d3ffbd8c7d38632502eaae3820587"},
- {file = "numpy-2.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529af13c5f4b7a932fb0e1911d3a75da204eff023ee5e0e79c1751564221a5c8"},
- {file = "numpy-2.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6790654cb13eab303d8402354fabd47472b24635700f631f041bd0b65e37298a"},
- {file = "numpy-2.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbab9fc9c391700e3e1287666dfd82d8666d10e69a6c4a09ab97574c0b7ee0a7"},
- {file = "numpy-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99d0d92a5e3613c33a5f01db206a33f8fdf3d71f2912b0de1739894668b7a93b"},
- {file = "numpy-2.0.1-cp312-cp312-win32.whl", hash = "sha256:173a00b9995f73b79eb0191129f2455f1e34c203f559dd118636858cc452a1bf"},
- {file = "numpy-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:bb2124fdc6e62baae159ebcfa368708867eb56806804d005860b6007388df171"},
- {file = "numpy-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfc085b28d62ff4009364e7ca34b80a9a080cbd97c2c0630bb5f7f770dae9414"},
- {file = "numpy-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8fae4ebbf95a179c1156fab0b142b74e4ba4204c87bde8d3d8b6f9c34c5825ef"},
- {file = "numpy-2.0.1-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:72dc22e9ec8f6eaa206deb1b1355eb2e253899d7347f5e2fae5f0af613741d06"},
- {file = "numpy-2.0.1-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:ec87f5f8aca726117a1c9b7083e7656a9d0d606eec7299cc067bb83d26f16e0c"},
- {file = "numpy-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f682ea61a88479d9498bf2091fdcd722b090724b08b31d63e022adc063bad59"},
- {file = "numpy-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8efc84f01c1cd7e34b3fb310183e72fcdf55293ee736d679b6d35b35d80bba26"},
- {file = "numpy-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3fdabe3e2a52bc4eff8dc7a5044342f8bd9f11ef0934fcd3289a788c0eb10018"},
- {file = "numpy-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:24a0e1befbfa14615b49ba9659d3d8818a0f4d8a1c5822af8696706fbda7310c"},
- {file = "numpy-2.0.1-cp39-cp39-win32.whl", hash = "sha256:f9cf5ea551aec449206954b075db819f52adc1638d46a6738253a712d553c7b4"},
- {file = "numpy-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9e81fa9017eaa416c056e5d9e71be93d05e2c3c2ab308d23307a8bc4443c368"},
- {file = "numpy-2.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:61728fba1e464f789b11deb78a57805c70b2ed02343560456190d0501ba37b0f"},
- {file = "numpy-2.0.1-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:12f5d865d60fb9734e60a60f1d5afa6d962d8d4467c120a1c0cda6eb2964437d"},
- {file = "numpy-2.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eacf3291e263d5a67d8c1a581a8ebbcfd6447204ef58828caf69a5e3e8c75990"},
- {file = "numpy-2.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2c3a346ae20cfd80b6cfd3e60dc179963ef2ea58da5ec074fd3d9e7a1e7ba97f"},
- {file = "numpy-2.0.1.tar.gz", hash = "sha256:485b87235796410c3519a699cfe1faab097e509e90ebb05dcd098db2ae87e7b3"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"},
+ {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"},
+ {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"},
+ {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"},
+ {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"},
+ {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"},
+ {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"},
+ {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"},
+ {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"},
+ {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"},
+ {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"},
+ {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"},
+ {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"},
+ {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"},
+ {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"},
+ {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"},
+ {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"},
+ {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"},
+ {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"},
+ {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"},
+ {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"},
+ {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"},
+ {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"},
+ {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"},
+ {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"},
+ {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"},
+ {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"},
+ {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"},
+ {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"},
+ {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"},
+ {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"},
]
[[package]]
@@ -1579,19 +1615,19 @@ files = [
[[package]]
name = "platformdirs"
-version = "4.2.2"
+version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
- {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
- {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
+ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
+ {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
]
[package.extras]
-docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
-type = ["mypy (>=1.8)"]
+docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
+type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
@@ -1628,22 +1664,22 @@ virtualenv = ">=20.10.0"
[[package]]
name = "protobuf"
-version = "4.25.4"
+version = "4.25.5"
description = ""
optional = false
python-versions = ">=3.8"
files = [
- {file = "protobuf-4.25.4-cp310-abi3-win32.whl", hash = "sha256:db9fd45183e1a67722cafa5c1da3e85c6492a5383f127c86c4c4aa4845867dc4"},
- {file = "protobuf-4.25.4-cp310-abi3-win_amd64.whl", hash = "sha256:ba3d8504116a921af46499471c63a85260c1a5fc23333154a427a310e015d26d"},
- {file = "protobuf-4.25.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:eecd41bfc0e4b1bd3fa7909ed93dd14dd5567b98c941d6c1ad08fdcab3d6884b"},
- {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:4c8a70fdcb995dcf6c8966cfa3a29101916f7225e9afe3ced4395359955d3835"},
- {file = "protobuf-4.25.4-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:3319e073562e2515c6ddc643eb92ce20809f5d8f10fead3332f71c63be6a7040"},
- {file = "protobuf-4.25.4-cp38-cp38-win32.whl", hash = "sha256:7e372cbbda66a63ebca18f8ffaa6948455dfecc4e9c1029312f6c2edcd86c4e1"},
- {file = "protobuf-4.25.4-cp38-cp38-win_amd64.whl", hash = "sha256:051e97ce9fa6067a4546e75cb14f90cf0232dcb3e3d508c448b8d0e4265b61c1"},
- {file = "protobuf-4.25.4-cp39-cp39-win32.whl", hash = "sha256:90bf6fd378494eb698805bbbe7afe6c5d12c8e17fca817a646cd6a1818c696ca"},
- {file = "protobuf-4.25.4-cp39-cp39-win_amd64.whl", hash = "sha256:ac79a48d6b99dfed2729ccccee547b34a1d3d63289c71cef056653a846a2240f"},
- {file = "protobuf-4.25.4-py3-none-any.whl", hash = "sha256:bfbebc1c8e4793cfd58589acfb8a1026be0003e852b9da7db5a4285bde996978"},
- {file = "protobuf-4.25.4.tar.gz", hash = "sha256:0dc4a62cc4052a036ee2204d26fe4d835c62827c855c8a03f29fe6da146b380d"},
+ {file = "protobuf-4.25.5-cp310-abi3-win32.whl", hash = "sha256:5e61fd921603f58d2f5acb2806a929b4675f8874ff5f330b7d6f7e2e784bbcd8"},
+ {file = "protobuf-4.25.5-cp310-abi3-win_amd64.whl", hash = "sha256:4be0571adcbe712b282a330c6e89eae24281344429ae95c6d85e79e84780f5ea"},
+ {file = "protobuf-4.25.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:b2fde3d805354df675ea4c7c6338c1aecd254dfc9925e88c6d31a2bcb97eb173"},
+ {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:919ad92d9b0310070f8356c24b855c98df2b8bd207ebc1c0c6fcc9ab1e007f3d"},
+ {file = "protobuf-4.25.5-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:fe14e16c22be926d3abfcb500e60cab068baf10b542b8c858fa27e098123e331"},
+ {file = "protobuf-4.25.5-cp38-cp38-win32.whl", hash = "sha256:98d8d8aa50de6a2747efd9cceba361c9034050ecce3e09136f90de37ddba66e1"},
+ {file = "protobuf-4.25.5-cp38-cp38-win_amd64.whl", hash = "sha256:b0234dd5a03049e4ddd94b93400b67803c823cfc405689688f59b34e0742381a"},
+ {file = "protobuf-4.25.5-cp39-cp39-win32.whl", hash = "sha256:abe32aad8561aa7cc94fc7ba4fdef646e576983edb94a73381b03c53728a626f"},
+ {file = "protobuf-4.25.5-cp39-cp39-win_amd64.whl", hash = "sha256:7a183f592dc80aa7c8da7ad9e55091c4ffc9497b3054452d629bb85fa27c2a45"},
+ {file = "protobuf-4.25.5-py3-none-any.whl", hash = "sha256:0aebecb809cae990f8129ada5ca273d9d670b76d9bfc9b1809f0a9c02b7dbf41"},
+ {file = "protobuf-4.25.5.tar.gz", hash = "sha256:7f8249476b4a9473645db7f8ab42b02fe1488cbe5fb72fddd445e0665afd8584"},
]
[[package]]
@@ -1697,68 +1733,54 @@ files = [
[[package]]
name = "pygame"
-version = "2.6.0"
+version = "2.6.1"
description = "Python Game Development"
optional = false
python-versions = ">=3.6"
files = [
- {file = "pygame-2.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5707aa9d029752495b3eddc1edff62e0e390a02f699b0f1ce77fe0b8c70ea4f"},
- {file = "pygame-2.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3ed0547368733b854c0d9981c982a3cdfabfa01b477d095c57bf47f2199da44"},
- {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6050f3e95f1f16602153d616b52619c6a2041cee7040eb529f65689e9633fc3e"},
- {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89be55b7e9e22e0eea08af9d6cfb97aed5da780f0b3a035803437d481a16d972"},
- {file = "pygame-2.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d65fb222eea1294cfc8206d9e5754d476a1673eb2783c03c4f70e0455320274"},
- {file = "pygame-2.6.0-cp310-cp310-win32.whl", hash = "sha256:71eebb9803cb350298de188fb7cdd3ebf13299f78d59a71c7e81efc649aae348"},
- {file = "pygame-2.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:1551852a2cd5b4139a752888f6cbeeb4a96fc0fe6e6f3f8b9d9784eb8fceab13"},
- {file = "pygame-2.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6e5e6c010b1bf429388acf4d41d7ab2f7ad8fbf241d0db822102d35c9a2eb84"},
- {file = "pygame-2.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99902f4a2f6a338057200d99b5120a600c27a9f629ca012a9b0087c045508d08"},
- {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a284664978a1989c1e31a0888b2f70cfbcbafdfa3bb310e750b0d3366416225"},
- {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:829623cee298b3dbaa1dd9f52c3051ae82f04cad7708c8c67cb9a1a4b8fd3c0b"},
- {file = "pygame-2.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6acf7949ed764487d51123f4f3606e8f76b0df167fef12ef73ef423c35fdea39"},
- {file = "pygame-2.6.0-cp311-cp311-win32.whl", hash = "sha256:3f809560c99bd1fb4716610eca0cd36412528f03da1a63841a347b71d0c604ee"},
- {file = "pygame-2.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6897ab87f9193510a774a3483e00debfe166f340ca159f544ef99807e2a44ec4"},
- {file = "pygame-2.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b834711ebc8b9d0c2a5f9bfae4403dd277b2c61bcb689e1aa630d01a1ebcf40a"},
- {file = "pygame-2.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b5ac288655e8a31a303cc286e79cc57979ed2ba19c3a14042d4b6391c1d3bed2"},
- {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d666667b7826b0a7921b8ce0a282ba5281dfa106976c1a3b24e32a0af65ad3b1"},
- {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd8848a37a7cee37854c7efb8d451334477c9f8ce7ac339c079e724dc1334a76"},
- {file = "pygame-2.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:315e7b3c1c573984f549ac5da9778ac4709b3b4e3a4061050d94eab63fa4fe31"},
- {file = "pygame-2.6.0-cp312-cp312-win32.whl", hash = "sha256:e44bde0840cc21a91c9d368846ac538d106cf0668be1a6030f48df139609d1e8"},
- {file = "pygame-2.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c429824b1f881a7a5ce3b5c2014d3d182aa45a22cea33c8347a3971a5446907"},
- {file = "pygame-2.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b832200bd8b6fc485e087bf3ef7ec1a21437258536413a5386088f5dcd3a9870"},
- {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:098029d01a46ea4e30620dfb7c28a577070b456c8fc96350dde05f85c0bf51b5"},
- {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a858bbdeac5ec473ec9e726c55fb8fbdc2f4aad7c55110e899883738071c7c9b"},
- {file = "pygame-2.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f908762941fd99e1f66d1211d26383184f6045c45673443138b214bf48a89aa"},
- {file = "pygame-2.6.0-cp36-cp36m-win32.whl", hash = "sha256:4a63daee99d050f47d6ec7fa7dbd1c6597b8f082cdd58b6918d382d2bc31262d"},
- {file = "pygame-2.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:ace471b3849d68968e5427fc01166ef5afaf552a5c442fc2c28d3b7226786f55"},
- {file = "pygame-2.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fea019713d0c89dfd5909225aa933010100035d1cd30e6c936e8b6f00529fb80"},
- {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:249dbf2d51d9f0266009a380ccf0532e1a57614a1528bb2f89a802b01d61f93e"},
- {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb51533ee3204e8160600b0de34eaad70eb913a182c94a7777b6051e8fc52f1"},
- {file = "pygame-2.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f637636a44712e94e5601ec69160a080214626471983dfb0b5b68aa0c61563d"},
- {file = "pygame-2.6.0-cp37-cp37m-win32.whl", hash = "sha256:e432156b6f346f4cc6cab03ce9657600093390f4c9b10bf458716b25beebfe33"},
- {file = "pygame-2.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a0194652db7874bdde7dfc69d659ca954544c012e04ae527151325bfb970f423"},
- {file = "pygame-2.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eae3ee62cc172e268121d5bd9dc406a67094d33517de3a91de3323d6ae23eb02"},
- {file = "pygame-2.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f6a58b0a5a8740a3c2cf6fc5366888bd4514561253437f093c12a9ab4fb3ecae"},
- {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c71da36997dc7b9b4ee973fa3a5d4a6cfb2149161b5b1c08b712d2f13a63ccfe"},
- {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b86771801a7fc10d9a62218f27f1d5c13341c3a27394aa25578443a9cd199830"},
- {file = "pygame-2.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4928f3acf5a9ce5fbab384c21f1245304535ffd5fb167ae92a6b4d3cdb55a3b6"},
- {file = "pygame-2.6.0-cp38-cp38-win32.whl", hash = "sha256:4faab2df9926c4d31215986536b112f0d76f711cf02f395805f1ff5df8fd55fc"},
- {file = "pygame-2.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:afbb8d97aed93dfb116fe105603dacb68f8dab05b978a40a9e4ab1b6c1f683fd"},
- {file = "pygame-2.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d11f3646b53819892f4a731e80b8589a9140343d0d4b86b826802191b241228c"},
- {file = "pygame-2.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5ef92ed93c354eabff4b85e457d4d6980115004ec7ff52a19fd38b929c3b80fb"},
- {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc1795f2e36302882546faacd5a0191463c4f4ae2b90e7c334a7733aa4190d2"},
- {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e92294fcc85c4955fe5bc6a0404e4cc870808005dc8f359e881544e3cc214108"},
- {file = "pygame-2.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0cb7bdf3ee0233a3ac02ef777c01dfe315e6d4670f1312c83b91c1ef124359a"},
- {file = "pygame-2.6.0-cp39-cp39-win32.whl", hash = "sha256:ac906478ae489bb837bf6d2ae1eb9261d658aa2c34fa5b283027a04149bda81a"},
- {file = "pygame-2.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:92cf12a9722f6f0bdc5520d8925a8f085cff9c054a2ea462fc409cba3781be27"},
- {file = "pygame-2.6.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:a6636f452fdaddf604a060849feb84c056930b6a3c036214f607741f16aac942"},
- {file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dc242dc15d067d10f25c5b12a1da48ca9436d8e2d72353eaf757e83612fba2f"},
- {file = "pygame-2.6.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f82df23598a281c8c342d3c90be213c8fe762a26c15815511f60d0aac6e03a70"},
- {file = "pygame-2.6.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ed2539bb6bd211fc570b1169dc4a64a74ec5cd95741e62a0ab46bd18fe08e0d"},
- {file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:904aaf29710c6b03a7e1a65b198f5467ed6525e8e60bdcc5e90ff8584c1d54ea"},
- {file = "pygame-2.6.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcd28f96f0fffd28e71a98773843074597e10d7f55a098e2e5bcb2bef1bdcbf5"},
- {file = "pygame-2.6.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fad1ab33443ecd4f958dbbb67fc09fcdc7a37e26c34054e3296fb7e26ad641e"},
- {file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e909186d4d512add39b662904f0f79b73028fbfc4fbfdaf6f9412aed4e500e9c"},
- {file = "pygame-2.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79abcbf6d12fce51a955a0652ccd50b6d0a355baa27799535eaf21efb43433dd"},
- {file = "pygame-2.6.0.tar.gz", hash = "sha256:722d33ae676aa8533c1f955eded966411298831346b8d51a77dad22e46ba3e35"},
+ {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:816e85000c5d8b02a42b9834f761a5925ef3377d2924e3a7c4c143d2990ce5b8"},
+ {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a78fd030d98faab4a8e27878536fdff7518d3e062a72761c552f624ebba5a5f"},
+ {file = "pygame-2.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da3ad64d685f84a34ebe5daacb39fff14f1251acb34c098d760d63fee768f50c"},
+ {file = "pygame-2.6.1-cp310-cp310-win32.whl", hash = "sha256:9dd5c054d4bd875a8caf978b82672f02bec332f52a833a76899220c460bb4b58"},
+ {file = "pygame-2.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:00827aba089355925902d533f9c41e79a799641f03746c50a374dc5c3362e43d"},
+ {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c31dbdb5d0217f32764797d21c2752e258e5fb7e895326538d82b5f75a0cd856"},
+ {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:173badf82fa198e6888017bea40f511cb28e69ecdd5a72b214e81e4dcd66c3b1"},
+ {file = "pygame-2.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce8cc108b92de9b149b344ad2e25eedbe773af0dc41dfb24d1f07f679b558c60"},
+ {file = "pygame-2.6.1-cp311-cp311-win32.whl", hash = "sha256:811e7b925146d8149d79193652cbb83e0eca0aae66476b1cb310f0f4226b8b5c"},
+ {file = "pygame-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:91476902426facd4bb0dad4dc3b2573bc82c95c71b135e0daaea072ed528d299"},
+ {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47a6938de93fa610accd4969e638c2aebcb29b2fca518a84c3a39d91ab47116"},
+ {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33006f784e1c7d7e466fcb61d5489da59cc5f7eb098712f792a225df1d4e229d"},
+ {file = "pygame-2.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1206125f14cae22c44565c9d333607f1d9f59487b1f1432945dfc809aeaa3e88"},
+ {file = "pygame-2.6.1-cp312-cp312-win32.whl", hash = "sha256:84fc4054e25262140d09d39e094f6880d730199710829902f0d8ceae0213379e"},
+ {file = "pygame-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9e7396be0d9633831c3f8d5d82dd63ba373ad65599628294b7a4f8a5a01a65"},
+ {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b"},
+ {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b"},
+ {file = "pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c"},
+ {file = "pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e"},
+ {file = "pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a"},
+ {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bede70ec708057e305815d6546012669226d1d80566785feca9b044216062e7"},
+ {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f84f15d146d6aa93254008a626c56ef96fed276006202881a47b29757f0cd65a"},
+ {file = "pygame-2.6.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14f9dda45469b254c0f15edaaeaa85d2cc072ff6a83584a265f5d684c7f7efd8"},
+ {file = "pygame-2.6.1-cp36-cp36m-win32.whl", hash = "sha256:28b43190436037e428a5be28fc80cf6615304fd528009f2c688cc828f4ff104b"},
+ {file = "pygame-2.6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4b8f04fceddd9a3ac30778d11f0254f59efcd1c382d5801271113cea8b4f2f3"},
+ {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b46e68cd168f44d0224c670bb72186688fc692d7079715f79d04096757d703d0"},
+ {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b11356ac96261162d54a2c2b41a41978f00525631b01ec9c4fe26b01c66595"},
+ {file = "pygame-2.6.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:325a84d072d52e3c2921eff02f87c6a74b7e77d71db3bdf53801c6c975f1b6c4"},
+ {file = "pygame-2.6.1-cp37-cp37m-win32.whl", hash = "sha256:2a615d78b2364e86f541458ff41c2a46181b9a1e9eabd97b389282fdf04efbb3"},
+ {file = "pygame-2.6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:94afd1177680d92f9214c54966ad3517d18210c4fbc5d84a0192d218e93647e0"},
+ {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac3f033d2be4a9e23660a96afe2986df3a6916227538a6a0061bc218c5088507"},
+ {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1bf7ab5311bbced70320f1a56701650b4c18231343ae5af42111eea91e0949a"},
+ {file = "pygame-2.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21160d9093533eb831f1b708e630706e5ac16b30750571ec27bc3b8364814f38"},
+ {file = "pygame-2.6.1-cp38-cp38-win32.whl", hash = "sha256:7bffdd3eaf394d9645331d1c3a5df9d782ebcc3c5a78f3b657c7879a828dd111"},
+ {file = "pygame-2.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:818b4eaec9c4acb6ac64805d4ca8edd4062bebca77bd815c18739fe2842c97e9"},
+ {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d09fd950725d187aa5207c0cb8eb9ab0d2f8ce9ab8d189c30eeb470e71b617e"},
+ {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:163e66de169bd5670c86e27d0b74aad0d2d745e3b63cf4e7eb5b2bff1231ca8d"},
+ {file = "pygame-2.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6e8d0547f30ddc845f4fd1e33070ef548233ad0dbf21f7ecea768883d1bbdc"},
+ {file = "pygame-2.6.1-cp39-cp39-win32.whl", hash = "sha256:d29eb9a93f12aa3d997b6e3c447ac85b2a4b142ab2548441523a8fcf5e216042"},
+ {file = "pygame-2.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:6582aa71a681e02e55d43150a9ab41394e6bf4d783d2962a10aea58f424be060"},
+ {file = "pygame-2.6.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:4a8ea113b1bf627322a025a1a5a87e3818a7f55ab3a4077ff1ae5c8c60576614"},
+ {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17498a2b043bc0e795faedef1b081199c688890200aef34991c1941caa2d2c89"},
+ {file = "pygame-2.6.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7103c60939bbc1e05cfc7ba3f1d2ad3bbf103b7828b82a7166a9ab6f51950146"},
]
[[package]]
@@ -1787,13 +1809,13 @@ files = [
[[package]]
name = "pyproject-hooks"
-version = "1.1.0"
+version = "1.2.0"
description = "Wrappers to call pyproject.toml-based build backend hooks."
optional = false
python-versions = ">=3.7"
files = [
- {file = "pyproject_hooks-1.1.0-py3-none-any.whl", hash = "sha256:7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2"},
- {file = "pyproject_hooks-1.1.0.tar.gz", hash = "sha256:4b37730834edbd6bd37f26ece6b44802fb1c1ee2ece0e54ddff8bfc06db86965"},
+ {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"},
+ {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"},
]
[[package]]
@@ -1838,6 +1860,7 @@ optional = false
python-versions = ">=3.8"
files = [
{file = "PyQt6_Charts-6.7.0-cp38-abi3-macosx_10_14_universal2.whl", hash = "sha256:bb28fc14771a2dfa8d9bfc41e37f704902e4e50400055bdc9d1f4b07e33d294e"},
+ {file = "PyQt6_Charts-6.7.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c5d04a37fc6db06a95e6ad621f76cf6379e24fc73caec44e8a0a68e084337f5"},
{file = "PyQt6_Charts-6.7.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2808c22372a7ae365103a8ba3160dbaf025dd6dc744f8aa0e8b6dc97ef1afd83"},
{file = "PyQt6_Charts-6.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:aabd0e796d7a3b23d234afde3bcf764490814e8db220db9bf0a5bbb5ad542887"},
{file = "PyQt6_Charts-6.7.0.tar.gz", hash = "sha256:c4f7cf369928f7bf032e4e33f718d3b8fe66da176d4959fe30735a970d86f35c"},
@@ -1850,30 +1873,30 @@ PyQt6-sip = ">=13.6,<14"
[[package]]
name = "pyqt6-charts-qt6"
-version = "6.7.2"
+version = "6.7.3"
description = "The subset of a Qt installation needed by PyQt6-Charts."
optional = false
python-versions = "*"
files = [
- {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:9b8541766b65672087cbbcf1277423268b44f4f1c7a70f32a62a5482b52cd255"},
- {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bff6cd3f2ab1620a6b54dfb27e8b05f9ffb5c13f9f906f68f9c78775c78a0046"},
- {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9ce9f0399bcdc6ce42b53aae03cfe04f933357a5cc6d0872f6589c3e6ccdc711"},
- {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0bc1c2bfd36895f9eb080c4db3a4061c01f492fcc1178d475c460b3de4dd7e36"},
- {file = "PyQt6_Charts_Qt6-6.7.2-py3-none-win_amd64.whl", hash = "sha256:d41cc07204218c9364c0eec8c55ee5fb49ad3cbea46ab82f5c655f48a771f185"},
+ {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:b5ed653a02aba4efb3e1424f582e8d274e2713c94bc3e60716a869bc1fd57057"},
+ {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:44afc9c0722116ffd84bc87652f3442be463839e312dd470840e7374b4941443"},
+ {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:394f3ac05773d3e2b3fcd31713b0db33893c969f969bef8830f565fb7c903cf3"},
+ {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:85ae61ef79700e37bbf55aff0599eab5e116e77c89d70b82f3d4b2df8ecd7fa9"},
+ {file = "PyQt6_Charts_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:9286f21fb0589d54e701b8bf91967f38d1a5941e8f456361db0d49e3d14d4bd6"},
]
[[package]]
name = "pyqt6-qt6"
-version = "6.7.2"
+version = "6.7.3"
description = "The subset of a Qt installation needed by PyQt6."
optional = false
python-versions = "*"
files = [
- {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:065415589219a2f364aba29d6a98920bb32810286301acbfa157e522d30369e3"},
- {file = "PyQt6_Qt6-6.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f817efa86a0e8eda9152c85b73405463fbf3266299090f32bbb2266da540ead"},
- {file = "PyQt6_Qt6-6.7.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:05f2c7d195d316d9e678a92ecac0252a24ed175bd2444cc6077441807d756580"},
- {file = "PyQt6_Qt6-6.7.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fc93945eaef4536d68bd53566535efcbe78a7c05c2a533790a8fd022bac8bfaa"},
- {file = "PyQt6_Qt6-6.7.2-py3-none-win_amd64.whl", hash = "sha256:b2d7e5ddb1b9764cd60f1d730fa7bf7a1f0f61b2630967c81761d3d0a5a8a2e0"},
+ {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:f517a93b6b1a814d4aa6587adc312e812ebaf4d70415bb15cfb44268c5ad3f5f"},
+ {file = "PyQt6_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8551732984fb36a5f4f3db51eafc4e8e6caf18617365830285306f2db17a94c2"},
+ {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:50c7482bcdcf2bb78af257fb10ed8b582f8daf91d829782393bc50ac5a0a900c"},
+ {file = "PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb525fdd393332de60887953029276a44de480fce1d785251ae639580f5e7246"},
+ {file = "PyQt6_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:36ea0892b8caeb983af3f285f45fb8dfbb93cfd972439f4e01b7efb2868f6230"},
]
[[package]]
@@ -1922,13 +1945,13 @@ cp2110 = ["hidapi"]
[[package]]
name = "pytest"
-version = "8.3.2"
+version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
- {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
- {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
+ {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
+ {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
@@ -2002,13 +2025,13 @@ six = ">=1.5"
[[package]]
name = "python-gnupg"
-version = "0.5.2"
+version = "0.5.3"
description = "A wrapper for the Gnu Privacy Guard (GPG or GnuPG)"
optional = false
python-versions = "*"
files = [
- {file = "python-gnupg-0.5.2.tar.gz", hash = "sha256:01d8013931c9fa3f45824bbea7054c03d6e11f258a72e7e086e168dbcb91854c"},
- {file = "python_gnupg-0.5.2-py2.py3-none-any.whl", hash = "sha256:72ce142af6da7f07e433fef148b445fb3e07854acd2f88739008838745c0e9f5"},
+ {file = "python-gnupg-0.5.3.tar.gz", hash = "sha256:290d8ddb9cd63df96cfe9284b9b265f19fd6e145e5582dc58fd7271f026d0a47"},
+ {file = "python_gnupg-0.5.3-py2.py3-none-any.whl", hash = "sha256:2f8a4c6f63766feca6cc1416408f8b84e1b914fe7b54514e570fc5cbe92e9248"},
]
[[package]]
@@ -2159,18 +2182,19 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
-version = "13.7.1"
+version = "13.9.2"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
-python-versions = ">=3.7.0"
+python-versions = ">=3.8.0"
files = [
- {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
- {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
+ {file = "rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"},
+ {file = "rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
@@ -2202,19 +2226,23 @@ files = [
[[package]]
name = "setuptools"
-version = "72.2.0"
+version = "75.1.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
- {file = "setuptools-72.2.0-py3-none-any.whl", hash = "sha256:f11dd94b7bae3a156a95ec151f24e4637fb4fa19c878e4d191bfb8b2d82728c4"},
- {file = "setuptools-72.2.0.tar.gz", hash = "sha256:80aacbf633704e9c8bfa1d99fa5dd4dc59573efcf9e4042c13d3bcef91ac2ef9"},
+ {file = "setuptools-75.1.0-py3-none-any.whl", hash = "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2"},
+ {file = "setuptools-75.1.0.tar.gz", hash = "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538"},
]
[package.extras]
-core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"]
+core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
+cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
-test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"]
[[package]]
name = "six"
@@ -2265,24 +2293,24 @@ files = [
[[package]]
name = "tomli"
-version = "2.0.1"
+version = "2.0.2"
description = "A lil' TOML parser"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
files = [
- {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
- {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+ {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
+ {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
]
[[package]]
name = "tomli-w"
-version = "1.0.0"
+version = "1.1.0"
description = "A lil' TOML writer"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.9"
files = [
- {file = "tomli_w-1.0.0-py3-none-any.whl", hash = "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463"},
- {file = "tomli_w-1.0.0.tar.gz", hash = "sha256:f463434305e0336248cac9c2dc8076b707d8a12d019dd349f5c1e382dd1ae1b9"},
+ {file = "tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7"},
+ {file = "tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33"},
]
[[package]]
@@ -2318,13 +2346,13 @@ files = [
[[package]]
name = "translate-toolkit"
-version = "3.13.3"
+version = "3.13.5"
description = "Tools and API for translation and localization engineering."
optional = false
python-versions = ">=3.8"
files = [
- {file = "translate_toolkit-3.13.3-py3-none-any.whl", hash = "sha256:efabe2b974243da53cfdc31082d81f536008607a4ad4e65a3098a85b44ae2d6e"},
- {file = "translate_toolkit-3.13.3.tar.gz", hash = "sha256:5bd73841a0ae99dbb583489879a4fa742860b3faa75ef2bb9d4f06f9e3195d75"},
+ {file = "translate_toolkit-3.13.5-py3-none-any.whl", hash = "sha256:f2a549973ca50ad56d299050401ee55bfd8265dd6f7c9307c77ece2f0e8e2ca8"},
+ {file = "translate_toolkit-3.13.5.tar.gz", hash = "sha256:53c59c919e52a9787c0d1d7ccd34df9508e9f58bb84d1d52d27a9bda5203d768"},
]
[package.dependencies]
@@ -2332,30 +2360,30 @@ lxml = ">=4.6.3"
wcwidth = ">=0.2.10"
[package.extras]
-all = ["BeautifulSoup4 (>=4.3)", "aeidon (==1.15)", "charset-normalizer (==3.3.2)", "cheroot (==10.0.1)", "fluent.syntax (==0.19.0)", "iniparse (==0.5)", "mistletoe (==1.4.0)", "phply (==1.2.6)", "pyenchant (==3.2.2)", "pyparsing (==3.1.2)", "python-Levenshtein (>=0.12)", "ruamel.yaml (==0.18.6)", "vobject (==0.9.7)"]
+all = ["BeautifulSoup4 (>=4.10.0)", "aeidon (==1.15)", "charset-normalizer (==3.3.2)", "cheroot (==10.0.1)", "fluent.syntax (==0.19.0)", "iniparse (==0.5)", "mistletoe (==1.4.0)", "phply (==1.2.6)", "pyenchant (==3.2.2)", "pyparsing (==3.1.4)", "python-Levenshtein (>=0.21.0)", "ruamel.yaml (==0.18.6)", "vobject (==0.9.8)"]
chardet = ["charset-normalizer (==3.3.2)"]
fluent = ["fluent.syntax (==0.19.0)"]
-ical = ["vobject (==0.9.7)"]
+ical = ["vobject (==0.9.8)"]
ini = ["iniparse (==0.5)"]
-levenshtein = ["python-Levenshtein (>=0.12)"]
+levenshtein = ["python-Levenshtein (>=0.21.0)"]
markdown = ["mistletoe (==1.4.0)"]
php = ["phply (==1.2.6)"]
-rc = ["pyparsing (==3.1.2)"]
+rc = ["pyparsing (==3.1.4)"]
spellcheck = ["pyenchant (==3.2.2)"]
subtitles = ["aeidon (==1.15)"]
tmserver = ["cheroot (==10.0.1)"]
-trados = ["BeautifulSoup4 (>=4.3)"]
+trados = ["BeautifulSoup4 (>=4.10.0)"]
yaml = ["ruamel.yaml (==0.18.6)"]
[[package]]
name = "types-python-dateutil"
-version = "2.9.0.20240316"
+version = "2.9.0.20241003"
description = "Typing stubs for python-dateutil"
optional = false
python-versions = ">=3.8"
files = [
- {file = "types-python-dateutil-2.9.0.20240316.tar.gz", hash = "sha256:5d2f2e240b86905e40944dd787db6da9263f0deabef1076ddaed797351ec0202"},
- {file = "types_python_dateutil-2.9.0.20240316-py3-none-any.whl", hash = "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b"},
+ {file = "types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446"},
+ {file = "types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d"},
]
[[package]]
@@ -2371,13 +2399,13 @@ files = [
[[package]]
name = "urllib3"
-version = "2.2.2"
+version = "2.2.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.8"
files = [
- {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"},
- {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"},
+ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
+ {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
[package.extras]
@@ -2388,13 +2416,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
-version = "20.26.3"
+version = "20.26.6"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
- {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
- {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
+ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"},
+ {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"},
]
[package.dependencies]
@@ -2433,20 +2461,24 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"]
[[package]]
name = "zipp"
-version = "3.20.0"
+version = "3.20.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.8"
files = [
- {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"},
- {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"},
+ {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"},
+ {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"},
]
[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
+cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.9,<3.11"
-content-hash = "93dc1854807438addd20414076be5717118c36f091a91f558d49883670654f77"
+content-hash = "6230851e56531063fb041b948c29c763e1dca319daf21b30744ae7bacfd6b088"
diff --git a/pyproject.toml b/pyproject.toml
index fed3d01..7661350 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,14 +5,12 @@ build-backend = "setuptools.build_meta"
[tool.black]
line-length = 110
-[tool.mypy]
-no_implicit_optional = false
-ignore_missing_imports = true
-show_error_codes = true
+
+
[tool.poetry]
name = "bitcoin-safe"
-version = "0.7.4a0"
+version = "1.0.0b0"
description = "Long-term Bitcoin savings made Easy"
authors = [ "andreasgriffin ",]
license = "GPL-3.0"
@@ -25,19 +23,19 @@ source = "init"
[tool.briefcase]
project_name = "Bitcoin-Safe"
bundle = "org.bitcoin-safe"
-version = "0.7.4a0"
+version = "1.0.0b0"
url = "https://bitcoin-safe.org"
license = "GPL-3.0"
author = "Andreas Griffin"
author_email = "andreasgriffin@proton.me"
[tool.poetry.dependencies]
-python = ">=3.9,<3.11"
fpdf2 = "^2.7.4"
+python = ">=3.9,<3.11"
requests = "^2.31.0"
pyyaml = "^6.0"
bdkpython = "^0.31.0"
-cryptography = "^42.0.2"
+cryptography = "^43.0.1"
hwi = ">=2.3.1"
appdirs = "^1.4.4"
reportlab = "4.0.8"
@@ -46,10 +44,13 @@ pyqt6 = "^6.6.1"
pyqt6-charts = "^6.6.0"
electrumsv-secp256k1 = "^18.0.0"
python-gnupg = "^0.5.2"
-bitcoin-qr-tools = "^0.10.9"
-bitcoin-nostr-chat = "^0.2.5"
-bitcoin-usb = "^0.2.1"
-numpy = "^2.0.1"
+# bitcoin-qr-tools = {path="../bitcoin-qr-tools"} # "^0.10.15"
+# bitcoin-nostr-chat = {path="../bitcoin-nostr-chat"} # "^0.2.6"
+# bitcoin-usb = {path="../bitcoin-usb"} # "^0.3.3"
+bitcoin-qr-tools = "^0.14.6"
+bitcoin-nostr-chat = "^0.3.5"
+bitcoin-usb = "^0.5.3"
+numpy = "^2.0.1"
[tool.briefcase.app.bitcoin-safe]
formal_name = "Bitcoin-Safe"
@@ -57,17 +58,18 @@ description = "A bitcoin wallet for the entire family."
long_description = "More details about the app should go here.\n"
sources = [ "bitcoin_safe",]
test_sources = [ "tests",]
-test_requires = [ "pytest",]
-requires = ["appdirs==1.4.4", "arrow==1.3.0", "asn1crypto==1.5.1", "base58==2.1.1", "bdkpython==0.31.0", "binaryornot==0.4.4", "bitcoin-nostr-chat==0.2.5", "bitcoin-qr-tools==0.10.9", "bitcoin-usb==0.2.1", "briefcase==0.3.19", "build==1.2.1", "cbor2==5.6.4", "certifi==2024.7.4", "cffi==1.17.0", "cfgv==3.4.0", "chardet==5.2.0", "charset-normalizer==3.3.2", "click==8.1.7", "colorama==0.4.6", "cookiecutter==2.6.0", "cryptography==42.0.8", "defusedxml==0.7.1", "distlib==0.3.8", "dmgbuild==1.6.2", "ds-store==1.3.1", "ecdsa==0.19.0", "electrumsv-secp256k1==18.0.0", "exceptiongroup==1.2.2", "filelock==3.15.4", "fonttools==4.53.1", "fpdf2==2.7.9", "gitdb==4.0.11", "gitpython==3.1.43", "hidapi==0.14.0.post2", "hwi==3.0.0", "identify==2.6.0", "idna==3.7", "importlib-metadata==8.2.0", "iniconfig==2.0.0", "jinja2==3.1.4", "libusb1==3.1.0", "lxml==5.3.0", "mac-alias==2.2.2", "markdown-it-py==3.0.0", "markupsafe==2.1.5", "mdurl==0.1.2", "mnemonic==0.21", "mss==9.0.1", "nodeenv==1.9.1", "noiseprotocol==0.3.1", "nostr-sdk==0.32.2", "numpy==2.0.1", "opencv-python-headless==4.10.0.84", "packaging==24.1", "pillow==10.4.0", "pip==24.2", "platformdirs==4.2.2", "pluggy==1.5.0", "pre-commit==3.8.0", "protobuf==4.25.4", "psutil==5.9.8", "pyaes==1.6.1", "pycparser==2.22", "pygame==2.6.0", "pygments==2.18.0", "pyprof2calltree==1.4.5", "pyproject-hooks==1.1.0", "pyqrcode==1.2.1", "pyqt6==6.7.1", "pyqt6-charts==6.7.0", "pyqt6-charts-qt6==6.7.2", "pyqt6-qt6==6.7.2", "pyqt6-sip==13.8.0", "pyserial==3.5", "pytest==8.3.2", "pytest-qt==4.4.0", "pytest-xvfb==3.0.0", "python-bitcointx==1.1.4", "python-dateutil==2.9.0.post0", "python-gnupg==0.5.2", "python-slugify==8.0.4", "pyvirtualdisplay==3.0", "pyyaml==6.0.2", "pyzbar==0.1.9", "reportlab==4.0.8", "requests==2.32.3", "rich==13.7.1", "segno==1.6.1", "semver==3.0.2", "setuptools==72.2.0", "six==1.16.0", "smmap==5.0.1", "snakeviz==2.2.0", "text-unidecode==1.3", "tomli==2.0.1", "tomli-w==1.0.0", "tomlkit==0.13.2", "tornado==6.4.1", "translate-toolkit==3.13.3", "types-python-dateutil==2.9.0.20240316", "typing-extensions==4.12.2", "urllib3==2.2.2", "virtualenv==20.26.3", "wcwidth==0.2.13", "wheel==0.44.0", "zipp==3.20.0"]
-icon = "tools/resources/icon"
+test_requires = [ "pytest",]
resources = [ "bitcoin_safe/gui/locales/*.qm",]
+requires = ["appdirs==1.4.4", "arrow==1.3.0", "asn1crypto==1.5.1", "base58==2.1.1", "bdkpython==0.31.0", "binaryornot==0.4.4", "bitcoin-nostr-chat==0.3.5", "../bitcoin-qr-tools", "bitcoin-usb==0.5.3", "briefcase==0.3.19", "build==1.2.2.post1", "cbor2==5.6.5", "certifi==2024.8.30", "cffi==1.17.1", "cfgv==3.4.0", "chardet==5.2.0", "charset-normalizer==3.4.0", "click==8.1.7", "colorama==0.4.6", "cookiecutter==2.6.0", "coverage==7.6.2", "cryptography==43.0.1", "defusedxml==0.7.1", "distlib==0.3.9", "dmgbuild==1.6.2", "ds-store==1.3.1", "ecdsa==0.19.0", "electrumsv-secp256k1==18.0.0", "exceptiongroup==1.2.2", "filelock==3.16.1", "fonttools==4.54.1", "fpdf2==2.8.1", "gitdb==4.0.11", "gitpython==3.1.43", "hidapi==0.14.0.post2", "hwi==3.1.0", "identify==2.6.1", "idna==3.10", "importlib-metadata==8.5.0", "iniconfig==2.0.0", "jinja2==3.1.4", "libusb1==3.1.0", "lxml==5.3.0", "mac-alias==2.2.2", "markdown-it-py==3.0.0", "markupsafe==3.0.1", "mdurl==0.1.2", "mnemonic==0.21", "mss==9.0.2", "nodeenv==1.9.1", "noiseprotocol==0.3.1", "nostr-sdk==0.32.2", "numpy==2.0.2", "opencv-python-headless==4.10.0.84", "packaging==24.1", "pillow==10.4.0", "pip==24.2", "platformdirs==4.3.6", "pluggy==1.5.0", "pre-commit==3.8.0", "protobuf==4.25.5", "psutil==5.9.8", "pyaes==1.6.1", "pycparser==2.22", "pygame==2.6.1", "pygments==2.18.0", "pyprof2calltree==1.4.5", "pyproject-hooks==1.2.0", "pyqrcode==1.2.1", "pyqt6==6.7.1", "pyqt6-charts==6.7.0", "pyqt6-charts-qt6==6.7.3", "pyqt6-qt6==6.7.3", "pyqt6-sip==13.8.0", "pyserial==3.5", "pytest==8.3.3", "pytest-cov==5.0.0", "pytest-qt==4.4.0", "pytest-xvfb==3.0.0", "python-bitcointx==1.1.4", "python-dateutil==2.9.0.post0", "python-gnupg==0.5.3", "python-slugify==8.0.4", "pyvirtualdisplay==3.0", "pyyaml==6.0.2", "pyzbar==0.1.9", "reportlab==4.0.8", "requests==2.32.3", "rich==13.9.2", "segno==1.6.1", "semver==3.0.2", "setuptools==75.1.0", "six==1.16.0", "smmap==5.0.1", "snakeviz==2.2.0", "text-unidecode==1.3", "tomli==2.0.2", "tomli-w==1.1.0", "tomlkit==0.13.2", "tornado==6.4.1", "translate-toolkit==3.13.5", "types-python-dateutil==2.9.0.20241003", "typing-extensions==4.12.2", "urllib3==2.2.3", "virtualenv==20.26.6", "wcwidth==0.2.13", "wheel==0.44.0", "zipp==3.20.2"]
+
+
[tool.poetry.group.dev.dependencies]
pytest = "^8.2.2"
pytest-qt = ">=4.4.0"
briefcase = "0.3.19"
requests = "^2.31.0"
-pre-commit = "^3.6.2"
+pre-commit = "^3.8.0"
python-gnupg = "^0.5.2"
translate-toolkit = "^3.12.2"
snakeviz = "^2.2.0"
@@ -106,7 +108,7 @@ NSCameraUsageDescription = "This application supports scanning QR-codes."
manylinux = "manylinux_2_28"
icon = "tools/resources/icon"
resources = [ "tools/resources/icon/*.png", "tools/resources/icon/*.svg",]
-version = "0.7.4a0"
+version = "1.0.0b0"
[tool.briefcase.app.bitcoin-safe.linux.flatpak]
bundle = "org.bitcoinsafe"
@@ -114,7 +116,7 @@ flatpak_runtime = "org.kde.Platform"
flatpak_runtime_version = "6.6"
flatpak_sdk = "org.kde.Sdk"
-version = "0.7.4a0"
+version = "1.0.0b0"
[tool.briefcase.app.bitcoin-safe.linux.system.debian]
diff --git a/tests/__init__.py b/tests/__init__.py
index 8d1c8b6..8b13789 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1 +1 @@
-
+
diff --git a/tests/fill_regtest_wallet.py b/tests/fill_regtest_wallet.py
index c5545b4..d3ab5d5 100644
--- a/tests/fill_regtest_wallet.py
+++ b/tests/fill_regtest_wallet.py
@@ -74,80 +74,10 @@ def send_rpc_command(ip: str, port: Union[str, int], username: str, password: st
return response.json()
-# Initialize bdk and configurations
-network = bdk.Network.REGTEST
-
-
-# Set up argument parsing
-parser = argparse.ArgumentParser(description="Bitcoin Wallet Operations")
-parser.add_argument("-s", "--seed", help="Mnemonic seed phrase", type=str, default="")
-parser.add_argument("-d", "--descriptor", help="Descriptor", type=str, default="")
-parser.add_argument("-m", "--mine", type=int, default=101)
-parser.add_argument("-tx", "--transactions", type=int, default=20)
-parser.add_argument("--always_new_addresses", action="store_true")
-args = parser.parse_args()
-
-# Use provided mnemonic or generate a new one
-mnemonic = bdk.Mnemonic.from_string(args.seed) if args.seed else None
-if mnemonic:
- print(f"Mnemonic: {mnemonic.as_string()}")
-db_config = bdk.DatabaseConfig.MEMORY()
-
-
-gap = 20
-
-rpc_ip = "127.0.0.1:18443"
-rpc_username = "admin1"
-rpc_password = "123"
-# RPC Blockchain Configuration
-blockchain_config = bdk.BlockchainConfig.RPC(
- bdk.RpcConfig(
- rpc_ip,
- bdk.Auth.USER_PASS(rpc_username, rpc_password),
- network,
- "new0-51117772c02f89651e192a79b2deac8d332cc1a5b67bb21e931d2395e5455c1a9b7c",
- bdk.RpcSyncParams(0, 0, False, 10),
- )
-)
-blockchain_config = bdk.BlockchainConfig.ESPLORA(
- bdk.EsploraConfig("http://127.0.0.1:3000", None, 1, gap * 2, 20)
-)
-
-
-blockchain = bdk.Blockchain(blockchain_config)
-
-# Create Wallet
-if mnemonic:
- descriptor = bdk.Descriptor.new_bip84(
- secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""),
- keychain=bdk.KeychainKind.EXTERNAL,
- network=network,
- )
- change_descriptor = bdk.Descriptor.new_bip84(
- secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""),
- keychain=bdk.KeychainKind.INTERNAL,
- network=network,
- )
- wallet = bdk.Wallet(
- descriptor=descriptor,
- change_descriptor=change_descriptor,
- network=network,
- database_config=db_config,
- )
-if descriptor:
- descriptor = bdk.Descriptor(args.descriptor, network)
- wallet = bdk.Wallet(
- descriptor=descriptor,
- change_descriptor=None,
- network=network,
- database_config=db_config,
- )
-
-
-def mine_coins(wallet, blocks=101):
+def mine_coins(rpc_ip, rpc_username, rpc_password, wallet, blocks=101, always_new_addresses=False):
"""Mine some blocks to generate coins for the wallet."""
address = wallet.get_address(
- bdk.AddressIndex.NEW() if args.always_new_addresses else bdk.AddressIndex.LAST_UNUSED()
+ bdk.AddressIndex.NEW() if always_new_addresses else bdk.AddressIndex.LAST_UNUSED()
).address.as_string()
print(f"Mining {blocks} blocks to {address}")
ip, port = rpc_ip.split(":")
@@ -162,12 +92,12 @@ def mine_coins(wallet, blocks=101):
print(response)
-def extend_tip(n):
+def extend_tip(wallet, n):
return [wallet.get_address(bdk.AddressIndex.NEW()) for i in range(n)]
-def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10):
- if args.always_new_addresses:
+def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10, always_new_addresses=False):
+ if always_new_addresses:
address_indices = [wallet.get_address(bdk.AddressIndex.NEW()).index for _ in range(n)]
else:
tip = wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).index
@@ -182,14 +112,16 @@ def generate_random_own_addresses_info(wallet: bdk.Wallet, n=10):
# Function to create complex transactions
-def create_complex_transactions(wallet: bdk.Wallet, blockchain, n=300):
+def create_complex_transactions(
+ rpc_ip, rpc_username, rpc_password, wallet: bdk.Wallet, blockchain, n=300, always_new_addresses=True
+):
for i in range(n):
try:
# Build the transaction
tx_builder = bdk.TxBuilder().fee_rate(1.0).enable_rbf()
recieve_address_infos: List[bdk.AddressInfo] = generate_random_own_addresses_info(
- wallet, randint(1, 10)
+ wallet, randint(1, 10), always_new_addresses=always_new_addresses
)
for recieve_address_info in recieve_address_infos:
amount = randint(10000, 1000000) # Random amount
@@ -208,7 +140,14 @@ def create_complex_transactions(wallet: bdk.Wallet, blockchain, n=300):
f"Broadcast tx {final_tx.txid()} to addresses {[recieve_address_info.index for recieve_address_info in recieve_address_infos]}"
)
if np.random.random() < 0.2:
- mine_coins(wallet, blocks=1)
+ mine_coins(
+ rpc_ip,
+ rpc_username,
+ rpc_password,
+ wallet,
+ blocks=1,
+ always_new_addresses=always_new_addresses,
+ )
wallet.sync(blockchain, None)
except Exception:
@@ -222,10 +161,87 @@ def update(progress: float, message: str):
def main():
- # ... [existing wallet setup code] ...
+
+ # Initialize bdk and configurations
+ network = bdk.Network.REGTEST
+
+ # Set up argument parsing
+ parser = argparse.ArgumentParser(description="Bitcoin Wallet Operations")
+ parser.add_argument("-s", "--seed", help="Mnemonic seed phrase", type=str, default="")
+ parser.add_argument("-d", "--descriptor", help="Descriptor", type=str, default="")
+ parser.add_argument("-m", "--mine", type=int, default=0)
+ parser.add_argument("-tx", "--transactions", type=int, default=20)
+ parser.add_argument("--always_new_addresses", action="store_true")
+ args = parser.parse_args()
+
+ db_config = bdk.DatabaseConfig.MEMORY()
+
+ gap = 20
+
+ rpc_ip = "127.0.0.1:18443"
+ rpc_username = "admin1"
+ rpc_password = "123"
+ # RPC Blockchain Configuration
+ blockchain_config = bdk.BlockchainConfig.RPC(
+ bdk.RpcConfig(
+ rpc_ip,
+ bdk.Auth.USER_PASS(rpc_username, rpc_password),
+ network,
+ "new0-51117772c02f89651e192a79b2deac8d332cc1a5b67bb21e931d2395e5455c1a9b7c",
+ bdk.RpcSyncParams(0, 0, False, 10),
+ )
+ )
+ blockchain_config = bdk.BlockchainConfig.ESPLORA(
+ bdk.EsploraConfig("http://127.0.0.1:3000", None, 1, gap * 2, 20)
+ )
+
+ blockchain = bdk.Blockchain(blockchain_config)
+
+ # Create Wallet
+ if args.descriptor:
+ descriptor = bdk.Descriptor(args.descriptor, network)
+ wallet = bdk.Wallet(
+ descriptor=descriptor,
+ change_descriptor=None,
+ network=network,
+ database_config=db_config,
+ )
+
+ if args.seed:
+
+ # Use provided mnemonic or generate a new one
+ mnemonic = bdk.Mnemonic.from_string(args.seed) if args.seed else None
+ if mnemonic:
+ print(f"Mnemonic: {mnemonic.as_string()}")
+ descriptor = bdk.Descriptor.new_bip84(
+ secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""),
+ keychain=bdk.KeychainKind.EXTERNAL,
+ network=network,
+ )
+ change_descriptor = bdk.Descriptor.new_bip84(
+ secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""),
+ keychain=bdk.KeychainKind.INTERNAL,
+ network=network,
+ )
+ wallet = bdk.Wallet(
+ descriptor=descriptor,
+ change_descriptor=change_descriptor,
+ network=network,
+ database_config=db_config,
+ )
+
+ if not wallet:
+ raise Exception("A wallet cannot be defined")
# Mine some blocks to get coins
- mine_coins(wallet, blocks=args.mine)
+ mine_coins(
+ rpc_ip,
+ rpc_username,
+ rpc_password,
+ wallet,
+ blocks=args.mine,
+ always_new_addresses=args.always_new_addresses,
+ )
if args.mine:
time.sleep(5)
@@ -237,8 +253,16 @@ def main():
print(wallet.get_balance())
# create transactions
- extend_tip(gap // 5)
- create_complex_transactions(wallet, blockchain, args.transactions)
+ extend_tip(wallet, gap // 5)
+ create_complex_transactions(
+ rpc_ip,
+ rpc_username,
+ rpc_password,
+ wallet,
+ blockchain,
+ n=args.transactions,
+ always_new_addresses=args.always_new_addresses,
+ )
if __name__ == "__main__":
diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py
index 8d1c8b6..8b13789 100644
--- a/tests/gui/__init__.py
+++ b/tests/gui/__init__.py
@@ -1 +1 @@
-
+
diff --git a/tests/gui/qt/__init__.py b/tests/gui/qt/__init__.py
index 8d1c8b6..8b13789 100644
--- a/tests/gui/qt/__init__.py
+++ b/tests/gui/qt/__init__.py
@@ -1 +1 @@
-
+
diff --git a/tests/gui/qt/taglist/test_main.py b/tests/gui/qt/taglist/test_main.py
new file mode 100644
index 0000000..cd9b0d4
--- /dev/null
+++ b/tests/gui/qt/taglist/test_main.py
@@ -0,0 +1,496 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import logging
+
+from PyQt6.QtCore import QMimeData, QPoint, QPointF, Qt
+from PyQt6.QtGui import QDragEnterEvent
+from PyQt6.QtWidgets import QApplication
+
+from bitcoin_safe.gui.qt.taglist.main import (
+ AddressDragInfo,
+ CustomDelegate,
+ CustomListWidget,
+ CustomListWidgetItem,
+ DeleteButton,
+ TagEditor,
+ clean_tag,
+ hash_color,
+ hash_string,
+ qbytearray_to_str,
+ rescale,
+ str_to_qbytearray,
+)
+
+logger = logging.getLogger(__name__)
+
+
+import hashlib
+
+from PyQt6.QtCore import QMimeData, QModelIndex, QPoint, QPointF, Qt
+from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent
+from PyQt6.QtWidgets import QApplication
+
+
+def test_clean_tag():
+ assert clean_tag(" hello ") == "hello"
+ assert clean_tag("world") == "world"
+ assert clean_tag("PYTHON ") == "PYTHON"
+ assert clean_tag(" multiple words ") == "multiple words"
+
+
+def test_hash_string():
+ text = "test"
+ expected_hash = hashlib.sha256(text.encode()).hexdigest()
+ assert hash_string(text) == expected_hash
+
+
+def test_rescale():
+ assert rescale(5, 0, 10, 0, 100) == 50
+ assert rescale(0, 0, 10, 0, 100) == 0
+ assert rescale(10, 0, 10, 0, 100) == 100
+ assert rescale(5, 0, 10, 100, 200) == 150
+
+
+def test_hash_color():
+ color = hash_color("test")
+ assert isinstance(color, QColor)
+ # Check that the color components are within expected ranges
+ r = color.red()
+ g = color.green()
+ b = color.blue()
+ assert 100 <= r <= 255
+ assert 100 <= g <= 255
+ assert 100 <= b <= 255
+
+
+def test_address_drag_info():
+ tags = ["tag1", "tag2"]
+ addresses = ["address1", "address2"]
+ adi = AddressDragInfo(tags, addresses)
+ assert adi.tags == tags
+ assert adi.addresses == addresses
+ assert repr(adi) == f"AddressDragInfo({tags}, {addresses})"
+
+
+def test_custom_list_widget_item(qapp: QApplication):
+ item_text = "ItemText"
+ sub_text = "SubText"
+ item = CustomListWidgetItem(item_text, sub_text)
+ assert item.text() == item_text
+ assert item.subtext == sub_text
+ color = item.data(Qt.ItemDataRole.UserRole + 1)
+ assert isinstance(color, QColor)
+ stored_subtext = item.data(Qt.ItemDataRole.UserRole + 2)
+ assert stored_subtext == sub_text
+
+
+def test_custom_delegate(qapp: QApplication):
+ parent = None
+ delegate = CustomDelegate(parent)
+ assert delegate.currentlyEditingIndex == QModelIndex()
+ assert isinstance(delegate.imageCache, dict)
+
+
+def test_delete_button(qapp: QApplication):
+ button = DeleteButton()
+ assert button.acceptDrops()
+ # Test that the signals exist
+ assert hasattr(button, "signal_delete_item")
+ assert hasattr(button, "signal_addresses_dropped")
+
+
+def test_custom_list_widget(qapp: QApplication):
+ widget = CustomListWidget()
+ # Test that the widget initializes properly
+ assert widget.count() == 0
+ # Test adding items
+ item = widget.add("TestItem", "SubText")
+ assert widget.count() == 1
+ assert item.text() == "TestItem"
+ assert item.subtext == "SubText"
+ # Test get_selected
+ widget.setAllSelection(True)
+ selected = widget.get_selected()
+ assert selected == ["TestItem"]
+
+
+def test_tag_editor(qapp: QApplication):
+ tags = ["Tag1", "Tag2"]
+ sub_texts = ["Sub1", "Sub2"]
+ editor = TagEditor(tags=tags, sub_texts=sub_texts)
+ # Test that the editor initializes properly
+ assert editor.list_widget.count() == 2
+ item1 = editor.list_widget.item(0)
+ item2 = editor.list_widget.item(1)
+ assert item1
+ assert item2
+ assert item1.text() == "Tag1"
+ assert isinstance(item1, CustomListWidgetItem)
+ assert item1.subtext == "Sub1"
+ assert item2.text() == "Tag2"
+ assert isinstance(item2, CustomListWidgetItem)
+ assert item2.subtext == "Sub2"
+
+ # Test adding a new tag
+ editor.input_field.setText("NewTag")
+ editor.add_new_tag_from_input_field()
+ assert editor.list_widget.count() == 3
+ new_item = editor.list_widget.item(2)
+ assert new_item
+ assert new_item.text() == "NewTag"
+ assert isinstance(new_item, CustomListWidgetItem)
+ assert new_item.subtext is None
+
+
+def test_tag_exists(qapp: QApplication):
+ editor = TagEditor()
+ editor.add("TestTag")
+ assert editor.tag_exists("TestTag")
+ assert not editor.tag_exists("OtherTag")
+
+
+def test_list_widget_delete_item(qapp: QApplication):
+ widget = CustomListWidget()
+ widget.add("TestItem")
+ assert widget.count() == 1
+ widget.delete_item("TestItem")
+ assert widget.count() == 0
+
+
+def test_list_widget_recreate(qapp: QApplication):
+ widget = CustomListWidget()
+ tags = ["Tag1", "Tag2", "Tag3"]
+ sub_texts = ["Sub1", "Sub2", "Sub3"]
+ widget.recreate(tags, sub_texts)
+ assert widget.count() == 3
+ for i, (tag, sub_text) in enumerate(zip(tags, sub_texts)):
+ item = widget.item(i)
+ assert item
+ assert item.text() == tag
+ assert isinstance(item, CustomListWidgetItem)
+ assert item.subtext == sub_text
+
+
+def test_custom_list_widget_item_mime_data(qapp: QApplication):
+ item = CustomListWidgetItem("TestItem")
+ mime_data = item.mimeData()
+ assert mime_data.hasFormat("application/json")
+ data = qbytearray_to_str(mime_data.data("application/json"))
+ import json
+
+ d = json.loads(data)
+ assert d["type"] == "drag_tag"
+ assert d["tag"] == "TestItem"
+
+
+def test_list_widget_get_items(qapp: QApplication):
+ widget = CustomListWidget()
+ widget.add("Item1")
+ widget.add("Item2")
+ items = list(widget.get_items())
+ assert len(items) == 2
+ assert items[0].text() == "Item1"
+ assert items[1].text() == "Item2"
+
+
+def test_list_widget_get_item_texts(qapp: QApplication):
+ widget = CustomListWidget()
+ widget.add("Item1")
+ widget.add("Item2")
+ texts = list(widget.get_item_texts())
+ assert texts == ["Item1", "Item2"]
+
+
+def test_tag_editor_add_existing_tag(qapp: QApplication):
+ editor = TagEditor()
+ editor.add("TestTag")
+ item = editor.add("TestTag")
+ assert item is None # Should not add duplicate
+ assert editor.list_widget.count() == 1
+
+
+def test_custom_list_widget_add_multiple_items(qapp: QApplication):
+ widget = CustomListWidget()
+ items = ["Item1", "Item2", "Item3"]
+ for item_text in items:
+ widget.add(item_text)
+ assert widget.count() == len(items)
+ for i, item_text in enumerate(items):
+ item = widget.item(i)
+ assert item
+ assert item.text() == item_text
+
+
+def test_custom_list_widget_remove_multiple_items(qapp: QApplication):
+ widget = CustomListWidget()
+ items = ["Item1", "Item2", "Item3"]
+ for item_text in items:
+ widget.add(item_text)
+ # Remove items
+ widget.delete_item("Item1")
+ widget.delete_item("Item2")
+ assert widget.count() == 1
+ remaining_item = widget.item(0)
+ assert remaining_item
+ assert remaining_item.text() == "Item3"
+
+
+def test_custom_list_widget_remove_nonexistent_item(qapp: QApplication):
+ widget = CustomListWidget()
+ widget.add("Item1")
+ assert widget.count() == 1
+ # Try to remove an item that does not exist
+ widget.delete_item("NonExistentItem")
+ # Count should remain the same
+ assert widget.count() == 1
+ item = widget.item(0)
+ assert item
+ assert item.text() == "Item1"
+
+
+def test_delete_button_drag_drop_events(qapp: QApplication, qtbot):
+
+ button = DeleteButton()
+ # We need to show the button for events to work properly
+ button.show()
+
+ # Create mime data with the correct format
+ mime_data = QMimeData()
+ drag_data = {
+ "type": "drag_tag",
+ "tag": "TestTag",
+ }
+ import json
+
+ mime_data.setData("application/json", str_to_qbytearray(json.dumps(drag_data)))
+
+ # Create a drag enter event
+ pos = QPoint(10, 10)
+ event = QDragEnterEvent(
+ pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier
+ )
+
+ # Call the dragEnterEvent
+ button.dragEnterEvent(event)
+ assert event.isAccepted()
+
+ # Create a drop event
+ pos2 = QPointF(10, 10)
+ drop_event = QDropEvent(
+ pos2, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier
+ )
+
+ # Connect to the signal to capture the emitted value
+ captured = []
+
+ def on_delete_item(tag):
+ captured.append(tag)
+
+ button.signal_delete_item.connect(on_delete_item)
+
+ # Call the dropEvent
+ button.dropEvent(drop_event)
+
+ # Check that the signal was emitted with the correct tag
+ assert len(captured) == 1
+ assert captured[0] == "TestTag"
+
+
+def test_custom_list_widget_drop_event_addresses(qapp: QApplication, qtbot):
+
+ widget = CustomListWidget()
+ widget.show()
+
+ # Add an item to the widget at a known position
+ item = widget.add("TestItem", "SubText")
+ # Ensure the widget is properly laid out
+ widget.updateGeometry()
+ widget.repaint()
+ qtbot.waitExposed(widget)
+
+ # Find the position of the item
+ item_rect = widget.visualItemRect(item)
+ drop_position = item_rect.center()
+
+ # Create mime data with the correct format
+ mime_data = QMimeData()
+ drag_data = {
+ "type": "drag_addresses",
+ "addresses": ["Address1", "Address2"],
+ }
+ import json
+
+ mime_data.setData("application/json", str_to_qbytearray(json.dumps(drag_data)))
+
+ # Create a drag enter event
+ event = QDragEnterEvent(
+ drop_position,
+ Qt.DropAction.CopyAction,
+ mime_data,
+ Qt.MouseButton.LeftButton,
+ Qt.KeyboardModifier.NoModifier,
+ )
+
+ # Call the dragEnterEvent
+ widget.dragEnterEvent(event)
+ assert event.isAccepted()
+
+ # Create a drop event at the item's position
+ drop_event = QDropEvent(
+ drop_position.toPointF(),
+ Qt.DropAction.CopyAction,
+ mime_data,
+ Qt.MouseButton.LeftButton,
+ Qt.KeyboardModifier.NoModifier,
+ )
+
+ # Connect to the signal to capture the emitted value
+ captured = []
+
+ def on_addresses_dropped(address_drag_info):
+ captured.append(address_drag_info)
+
+ widget.signal_addresses_dropped.connect(on_addresses_dropped)
+
+ # Call the dropEvent
+ widget.dropEvent(drop_event)
+
+ # Check that the signal was emitted with the correct AddressDragInfo
+ assert len(captured) == 1
+ assert captured[0].addresses == ["Address1", "Address2"]
+ assert captured[0].tags == ["TestItem"]
+
+
+def test_custom_list_widget_signals(qapp: QApplication, qtbot):
+ widget = CustomListWidget()
+ # Connect to signals to capture emitted values
+ added_tags = []
+ deleted_tags = []
+
+ def on_tag_added(tag):
+ added_tags.append(tag)
+
+ def on_tag_deleted(tag):
+ deleted_tags.append(tag)
+
+ widget.signal_tag_added.connect(on_tag_added)
+ widget.signal_tag_deleted.connect(on_tag_deleted)
+
+ # Add an item
+ widget.add("TestItem")
+ assert added_tags == ["TestItem"]
+ assert widget.count() == 1
+
+ # Delete the item
+ widget.delete_item("TestItem")
+ assert deleted_tags == ["TestItem"]
+ assert widget.count() == 0
+
+
+def test_tag_editor_signals(qapp: QApplication, qtbot):
+ editor = TagEditor()
+ # Connect to signals to capture emitted values
+ added_tags = []
+ deleted_tags = []
+ renamed_tags = []
+
+ def on_tag_added(tag):
+ added_tags.append(tag)
+
+ def on_tag_deleted(tag):
+ deleted_tags.append(tag)
+
+ def on_tag_renamed(old_tag, new_tag):
+ renamed_tags.append((old_tag, new_tag))
+
+ editor.list_widget.signal_tag_added.connect(on_tag_added)
+ editor.list_widget.signal_tag_deleted.connect(on_tag_deleted)
+ editor.list_widget.signal_tag_renamed.connect(on_tag_renamed)
+
+ # Add a tag via the input field
+ editor.input_field.setText("NewTag")
+ editor.add_new_tag_from_input_field()
+ assert added_tags == ["NewTag"]
+ assert editor.list_widget.count() == 1
+
+ # Simulate renaming the tag
+ item = editor.list_widget.item(0)
+ assert item
+ old_text = item.text()
+ new_text = "RenamedTag"
+ # Begin editing the item (this would normally be handled by the delegate)
+ item.setText(new_text)
+ # Simulate the itemChanged signal
+ editor.list_widget.itemChanged.emit(item)
+ # Since signal_tag_renamed is emitted by the delegate during editing, and in this test we're not invoking the delegate's editing process, the signal might not be emitted
+ # So we can simulate the delegate's signal directly
+ delegate = editor.list_widget.itemDelegate()
+ assert isinstance(delegate, CustomDelegate)
+ delegate.signal_tag_renamed.emit(old_text, new_text)
+ assert renamed_tags == [(old_text, new_text)]
+
+ # Delete the tag
+ editor.list_widget.delete_item(new_text)
+ assert deleted_tags == [new_text]
+ assert editor.list_widget.count() == 0
+
+
+def test_delegate_cache_eviction(qapp: QApplication):
+ delegate = CustomDelegate(None)
+ # Set a small cache size for testing
+ delegate.cache_size = 5
+ # Simulate adding items to the cache
+ for i in range(10):
+ key = ("index", i)
+ value = f"image_{i}"
+ delegate.add_to_cache(key, value)
+ # Cache size should not exceed cache_size
+ assert len(delegate.imageCache) <= delegate.cache_size
+
+
+def test_custom_list_widget_drag_enter_event_invalid_mime(qapp: QApplication):
+
+ widget = CustomListWidget()
+ widget.show()
+
+ # Create mime data with an invalid format
+ mime_data = QMimeData()
+ mime_data.setText("Invalid data")
+
+ # Create a drag enter event
+ pos = QPoint(10, 10)
+ event = QDragEnterEvent(
+ pos, Qt.DropAction.CopyAction, mime_data, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier
+ )
+
+ # Call the dragEnterEvent
+ widget.dragEnterEvent(event)
+ # Since the mime data is invalid, the event should not be accepted
+ assert not event.isAccepted()
diff --git a/tests/gui/qt/test_button_edit.py b/tests/gui/qt/test_button_edit.py
new file mode 100644
index 0000000..f8d5d1e
--- /dev/null
+++ b/tests/gui/qt/test_button_edit.py
@@ -0,0 +1,304 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import logging
+from unittest.mock import patch
+
+from PyQt6.QtWidgets import QApplication, QPushButton, QWidget
+from pytestqt.qtbot import QtBot
+
+from bitcoin_safe.gui.qt.buttonedit import (
+ AnalyzerLineEdit,
+ AnalyzerState,
+ AnalyzerTextEdit,
+ ButtonEdit,
+ ButtonsField,
+ SquareButton,
+ icon_path,
+)
+from bitcoin_safe.gui.qt.custom_edits import AnalyzerMessage, BaseAnalyzer
+
+logger = logging.getLogger(__name__)
+
+
+from unittest.mock import MagicMock, patch
+
+from PyQt6.QtCore import QSize, Qt
+from PyQt6.QtGui import QIcon, QResizeEvent
+from PyQt6.QtWidgets import QApplication, QPushButton, QWidget
+
+
+def test_square_button(qapp: QApplication):
+ icon = QIcon()
+ parent = QWidget()
+ button = SquareButton(icon, parent)
+ assert isinstance(button, QPushButton)
+ assert button.maximumSize() == QSize(24, 24)
+ assert button.parent() == parent
+ assert button.icon().cacheKey() == icon.cacheKey()
+
+
+def test_buttons_field_initialization(qapp: QApplication):
+ parent = QWidget()
+ buttons_field = ButtonsField(Qt.AlignmentFlag.AlignBottom, parent)
+ assert buttons_field.parent() == parent
+ assert buttons_field.vertical_align == Qt.AlignmentFlag.AlignBottom
+ assert buttons_field.buttons == []
+ assert buttons_field.grid_layout is not None
+
+
+def test_buttons_field_add_button(qapp: QApplication):
+ buttons_field = ButtonsField()
+ button = QPushButton()
+ buttons_field.append_button(button)
+ assert button in buttons_field.buttons
+ assert buttons_field.grid_layout.count() == 1
+
+
+def test_buttons_field_remove_button(qapp: QApplication):
+ buttons_field = ButtonsField()
+ button = QPushButton()
+ buttons_field.append_button(button)
+ assert buttons_field.grid_layout.count() == 1
+ buttons_field.remove_button(button)
+ assert button not in buttons_field.buttons
+ assert buttons_field.grid_layout.count() == 0 # Now this should be 0
+
+
+def test_buttons_field_clear_buttons(qapp: QApplication):
+ buttons_field = ButtonsField()
+ button1 = QPushButton()
+ button2 = QPushButton()
+ buttons_field.append_button(button1)
+ buttons_field.append_button(button2)
+ buttons_field.clear_buttons()
+ assert buttons_field.buttons == []
+ assert buttons_field.grid_layout.count() == 0
+
+
+def test_buttons_field_resize_event(qapp: QApplication):
+ buttons_field = ButtonsField()
+ for _ in range(10):
+ button = QPushButton()
+ buttons_field.append_button(button)
+ # Simulate resize event
+ event = QResizeEvent(QSize(200, 200), QSize(100, 100))
+ buttons_field.resizeEvent(event)
+ # Check that rearrange_buttons was called (we can mock rearrange_buttons)
+ with patch.object(buttons_field, "rearrange_buttons") as mock_rearrange:
+ buttons_field.resizeEvent(event)
+ mock_rearrange.assert_called_once()
+
+
+def test_button_edit_initialization(qapp: QApplication):
+ button_edit = ButtonEdit()
+ assert isinstance(button_edit.input_field, AnalyzerLineEdit)
+ assert button_edit.button_container is not None
+ assert button_edit.main_layout is not None
+
+
+def test_button_edit_set_text(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setText("Test Text")
+ assert button_edit.input_field.text() == "Test Text"
+
+
+def test_button_edit_get_text(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setText("Test Text")
+ assert button_edit.text() == "Test Text"
+ assert button_edit.input_field.text() == "Test Text"
+
+
+def test_button_edit_set_input_field(qapp: QApplication):
+ button_edit = ButtonEdit()
+ new_input_field = AnalyzerTextEdit()
+ button_edit.set_input_field(new_input_field)
+ assert button_edit.input_field == new_input_field
+ assert button_edit.main_layout.itemAt(0).widget() == new_input_field
+
+
+def test_button_edit_format_as_error(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.format_as_error(True)
+ assert "background-color" in button_edit.input_field.styleSheet()
+ button_edit.format_as_error(False)
+ assert button_edit.input_field.styleSheet() == ""
+
+
+def test_button_edit_format_and_apply_validator_valid(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.input_field.setText("Valid Input")
+ analyzer = BaseAnalyzer()
+ with patch.object(BaseAnalyzer, "analyze", return_value=AnalyzerMessage("", AnalyzerState.Valid)):
+ button_edit.input_field.setAnalyzer(analyzer)
+ button_edit.format_and_apply_validator()
+ assert button_edit.input_field.styleSheet() == ""
+
+
+def test_button_edit_format_and_apply_validator_invalid(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.input_field.setText("Invalid Input")
+ invalid_result = AnalyzerMessage("Error message", AnalyzerState.Invalid)
+ analyzer = BaseAnalyzer()
+ with patch.object(BaseAnalyzer, "analyze", return_value=invalid_result):
+ button_edit.input_field.setAnalyzer(analyzer)
+ button_edit.format_and_apply_validator()
+ assert "background-color" in button_edit.input_field.styleSheet()
+ assert button_edit.toolTip() == "Error message"
+
+
+def test_button_edit_add_pdf_button(qapp: QApplication, qtbot: QtBot):
+ button_edit = ButtonEdit()
+ qtbot.addWidget(button_edit) # Register the widget with qtbot
+ callback = MagicMock()
+ button_edit.add_pdf_buttton(callback)
+ assert button_edit.pdf_button is not None
+ # Simulate button click
+ qtbot.mouseClick(button_edit.pdf_button, Qt.MouseButton.LeftButton)
+ callback.assert_called_once()
+
+
+def test_button_edit_add_open_file_button(qapp: QApplication, qtbot: QtBot):
+ button_edit = ButtonEdit()
+ callback = MagicMock()
+ with patch("PyQt6.QtWidgets.QFileDialog.getOpenFileName", return_value=("file_path", "")):
+ button_edit.add_open_file_button(callback)
+ assert button_edit.open_file_button is not None
+ # Simulate button click
+ qtbot.mouseClick(button_edit.open_file_button, Qt.MouseButton.LeftButton)
+ callback.assert_called_once_with("file_path")
+
+
+def test_button_edit_set_placeholder_text(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setPlaceholderText("Enter text...")
+ assert button_edit.input_field.placeholderText() == "Enter text..."
+
+
+def test_button_edit_set_read_only(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setReadOnly(True)
+ assert button_edit.input_field.isReadOnly()
+ button_edit.setReadOnly(False)
+ assert not button_edit.input_field.isReadOnly()
+
+
+def test_button_edit_update_ui(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.add_copy_button()
+ button_edit.add_pdf_buttton(lambda: None)
+ button_edit.updateUi()
+ assert button_edit.copy_button.toolTip() == "Copy to clipboard"
+ assert button_edit.pdf_button.toolTip() == "Create PDF"
+
+
+def test_button_edit_add_button(qapp: QApplication):
+ button_edit = ButtonEdit()
+ callback = MagicMock()
+ button = button_edit.add_button(icon_path("icon.png"), callback, "Tooltip text")
+ assert button in button_edit.button_container.buttons
+ assert button.toolTip() == "Tooltip text"
+ # Simulate button click
+ button.click()
+ callback.assert_called_once()
+
+
+def test_button_edit_set_plain_text(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setPlainText("Plain Text")
+ assert button_edit.input_field.text() == "Plain Text"
+
+
+def test_button_edit_set_style_sheet(qapp: QApplication):
+ button_edit = ButtonEdit()
+ button_edit.setStyleSheet("background-color: red;")
+ assert button_edit.input_field.styleSheet() == "background-color: red;"
+
+
+def test_buttons_field_rearrange_buttons(qapp: QApplication):
+ buttons_field = ButtonsField()
+ for i in range(5):
+ button = QPushButton(f"Button {i}")
+ buttons_field.append_button(button)
+ # Simulate resize event
+ event = QResizeEvent(QSize(100, 500), QSize(100, 100))
+ buttons_field.resizeEvent(event)
+ # Check that buttons are arranged correctly
+ # Since the rearrangement logic can be complex, we can check the number of items in the grid layout
+ assert buttons_field.grid_layout.count() == 5
+
+
+def test_square_button_click(qapp: QApplication, qtbot: QtBot):
+ icon = QIcon()
+ parent = QWidget()
+ button = SquareButton(icon, parent)
+ callback = MagicMock()
+ button.clicked.connect(callback)
+ # Simulate button click
+ qtbot.mouseClick(button, Qt.MouseButton.LeftButton)
+ callback.assert_called_once()
+
+
+def test_button_edit_set_input_field_text_edit(qapp: QApplication):
+ button_edit = ButtonEdit()
+ text_edit = AnalyzerTextEdit()
+ button_edit.set_input_field(text_edit)
+ assert isinstance(button_edit.input_field, AnalyzerTextEdit)
+ button_edit.setText("Sample Text")
+ assert button_edit.input_field.toPlainText() == "Sample Text"
+ assert button_edit.text() == "Sample Text"
+
+
+def test_button_edit_method_delegation(qapp: QApplication):
+ button_edit = ButtonEdit()
+ # Set placeholder text
+ button_edit.setPlaceholderText("Placeholder")
+ assert button_edit.input_field.placeholderText() == "Placeholder"
+ # Set read-only
+ button_edit.setReadOnly(True)
+ assert button_edit.input_field.isReadOnly()
+
+
+def test_button_edit_format_and_apply_validator_no_analyzer(qapp: QApplication):
+ button_edit = ButtonEdit()
+ with patch.object(button_edit.input_field, "analyzer", return_value=None):
+ button_edit.format_and_apply_validator()
+ assert button_edit.input_field.styleSheet() == ""
+
+
+def test_button_edit_add_reset_button(qapp: QApplication, qtbot: QtBot):
+ button_edit = ButtonEdit()
+ get_reset_text = MagicMock(return_value="Reset Text")
+ reset_button = button_edit.addResetButton(get_reset_text)
+ assert reset_button in button_edit.button_container.buttons
+ # Simulate button click
+ qtbot.mouseClick(reset_button, Qt.MouseButton.LeftButton)
+ get_reset_text.assert_called_once()
+ assert button_edit.text() == "Reset Text"
diff --git a/tests/gui/qt/test_data_tab_widget.py b/tests/gui/qt/test_data_tab_widget.py
new file mode 100644
index 0000000..1a7e0b4
--- /dev/null
+++ b/tests/gui/qt/test_data_tab_widget.py
@@ -0,0 +1,236 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import logging
+
+from bitcoin_safe.gui.qt.data_tab_widget import DataTabWidget
+
+logger = logging.getLogger(__name__)
+
+
+import pytest
+from PyQt6.QtWidgets import QApplication, QWidget
+
+
+@pytest.fixture
+def data_tab_widget(qapp: QApplication) -> DataTabWidget:
+ """Fixture to create a DataTabWidget instance with string data."""
+ widget = DataTabWidget(str)
+ return widget
+
+
+def test_add_tab(data_tab_widget: DataTabWidget):
+ """Test adding tabs and verifying data storage."""
+ widget = data_tab_widget
+ tab1 = QWidget()
+ data1 = "Data for tab 1"
+ index1 = widget.addTab(tab1, description="Tab 1", data=data1)
+ assert index1 == 0
+ assert len(widget._tab_data) == 1
+ assert widget.tabData(index1) == data1
+
+ tab2 = QWidget()
+ data2 = "Data for tab 2"
+ index2 = widget.addTab(tab2, description="Tab 2", data=data2)
+ assert index2 == 1
+ assert len(widget._tab_data) == 2
+ assert widget.tabData(index2) == data2
+
+
+def test_insert_tab(data_tab_widget: DataTabWidget):
+ """Test inserting a tab and verifying data consistency."""
+ widget = data_tab_widget
+ tab1 = QWidget()
+ data1 = "Data for tab 1"
+ widget.addTab(tab1, description="Tab 1", data=data1)
+ tab2 = QWidget()
+ data2 = "Data for tab 2"
+ widget.addTab(tab2, description="Tab 2", data=data2)
+
+ tab_inserted = QWidget()
+ data_inserted = "Data for inserted tab"
+ index_inserted = widget.insertTab(1, tab_inserted, data_inserted, description="Inserted Tab")
+ assert index_inserted == 1
+ assert len(widget._tab_data) == 3
+ assert widget.tabData(index_inserted) == data_inserted
+ assert widget.tabData(0) == data1
+ assert widget.tabData(2) == data2
+
+
+def test_remove_tab(data_tab_widget: DataTabWidget):
+ """Test removing a tab and verifying data consistency."""
+ widget = data_tab_widget
+ tabs = []
+ for i in range(3):
+ tab = QWidget()
+ data = f"Data for tab {i}"
+ widget.addTab(tab, description=f"Tab {i}", data=data)
+ tabs.append((tab, data))
+
+ widget.removeTab(1)
+ assert len(widget._tab_data) == 2
+ assert widget.tabData(0) == "Data for tab 0"
+ assert widget.tabData(1) == "Data for tab 2"
+ assert widget.tabData(2) is None
+
+
+def test_clear_tab_data(data_tab_widget: DataTabWidget):
+ """Test clearing tab data."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab 1", data="Data 1")
+ widget.addTab(QWidget(), description="Tab 2", data="Data 2")
+ assert len(widget._tab_data) == 2
+ widget.clearTabData()
+ assert len(widget._tab_data) == 0
+ assert widget.count() == 2
+ with pytest.raises(KeyError):
+ widget.tabData(0)
+
+
+def test_get_current_tab_data(data_tab_widget: DataTabWidget):
+ """Test retrieving data of the current tab."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab 1", data="Data 1")
+ widget.addTab(QWidget(), description="Tab 2", data="Data 2")
+ widget.setCurrentIndex(1)
+ assert widget.getCurrentTabData() == "Data 2"
+
+
+def test_get_all_tab_data(data_tab_widget: DataTabWidget):
+ """Test retrieving all tab data."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab 1", data="Data 1")
+ widget.addTab(QWidget(), description="Tab 2", data="Data 2")
+ all_data = widget.getAllTabData()
+ assert len(all_data) == 2
+ assert all_data[widget.widget(0)] == "Data 1"
+ assert all_data[widget.widget(1)] == "Data 2"
+
+
+def test_get_data_for_tab(data_tab_widget: DataTabWidget):
+ """Test retrieving data for a specific tab widget."""
+ widget = data_tab_widget
+ tab1 = QWidget()
+ tab2 = QWidget()
+ widget.addTab(tab1, description="Tab 1", data="Data 1")
+ widget.addTab(tab2, description="Tab 2", data="Data 2")
+ assert widget.get_data_for_tab(tab1) == "Data 1"
+ assert widget.get_data_for_tab(tab2) == "Data 2"
+
+
+def test_add_tab_with_position_and_focus(data_tab_widget: DataTabWidget):
+ """Test adding a tab at a specific position with focus."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab 1", data="Data 1")
+ widget.addTab(QWidget(), description="Tab 2", data="Data 2")
+ new_tab = QWidget()
+ widget.add_tab(new_tab, icon=None, description="New Tab", data="New Data", position=1, focus=True)
+ assert len(widget._tab_data) == 3
+ assert widget.currentIndex() == 1
+ assert widget.tabData(1) == "New Data"
+
+
+def test_remove_all_tabs(data_tab_widget: DataTabWidget):
+ """Test removing all tabs and data."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab 1", data="Data 1")
+ widget.addTab(QWidget(), description="Tab 2", data="Data 2")
+ widget.clear()
+ assert widget.count() == 0
+ assert len(widget._tab_data) == 0
+
+
+def test_add_tab_without_data(data_tab_widget: DataTabWidget):
+ """Test adding a tab without associated data."""
+ widget = data_tab_widget
+ tab = QWidget()
+ index = widget.addTab(tab, description="No Data Tab")
+ assert len(widget._tab_data) == 0
+ with pytest.raises(KeyError):
+ widget.tabData(index)
+
+
+def test_insert_tab_without_data(data_tab_widget: DataTabWidget):
+ """Test inserting a tab without associated data."""
+ widget = data_tab_widget
+ tab = QWidget()
+ index = widget.insertTab(0, tab, data=None, description="Inserted No Data Tab")
+ assert len(widget._tab_data) == 0
+ with pytest.raises(KeyError):
+ widget.tabData(index)
+
+
+def test_remove_tab_updates_indices(data_tab_widget: DataTabWidget):
+ """Test that removing a tab updates the indices correctly."""
+ widget = data_tab_widget
+ for i in range(5):
+ widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}")
+ widget.removeTab(2)
+ assert len(widget._tab_data) == 4
+ assert widget.tabData(2) == "Data 3"
+ assert widget.tabData(3) == "Data 4"
+
+
+def test_insert_tab_updates_indices(data_tab_widget: DataTabWidget):
+ """Test that inserting a tab updates the indices correctly."""
+ widget = data_tab_widget
+ for i in range(3):
+ widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}")
+ widget.insertTab(1, QWidget(), data="Inserted Data", description="Inserted Tab")
+ assert len(widget._tab_data) == 4
+ assert widget.tabData(1) == "Inserted Data"
+ assert widget.tabData(2) == "Data 1"
+
+
+def test_set_tab_data(data_tab_widget: DataTabWidget):
+ """Test setting tab data after creation."""
+ widget = data_tab_widget
+ tab = QWidget()
+ index = widget.addTab(tab, description="Tab", data="Initial Data")
+ widget.setTabData(tab, "Updated Data")
+ assert widget.tabData(index) == "Updated Data"
+
+
+def test_invalid_index_access(data_tab_widget: DataTabWidget):
+ """Test accessing data with an invalid index."""
+ widget = data_tab_widget
+ widget.addTab(QWidget(), description="Tab", data="Data")
+ with pytest.raises(KeyError):
+ widget.get_data_for_tab(QWidget())
+
+
+def test_clear_tabs_and_data(data_tab_widget: DataTabWidget):
+ """Test clearing all tabs and data."""
+ widget = data_tab_widget
+ for i in range(3):
+ widget.addTab(QWidget(), description=f"Tab {i}", data=f"Data {i}")
+ widget.clear()
+ widget.clearTabData()
+ assert widget.count() == 0
+ assert len(widget._tab_data) == 0
diff --git a/tests/gui/qt/test_gui_setup_wallet.py b/tests/gui/qt/test_gui_setup_wallet.py
index b06c198..cf174fe 100644
--- a/tests/gui/qt/test_gui_setup_wallet.py
+++ b/tests/gui/qt/test_gui_setup_wallet.py
@@ -37,14 +37,24 @@
from PyQt6 import QtGui
from PyQt6.QtTest import QTest
-from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox, QPushButton
+from PyQt6.QtWidgets import (
+ QApplication,
+ QDialogButtonBox,
+ QMessageBox,
+ QPushButton,
+ QWidget,
+)
from pytestqt.qtbot import QtBot
from bitcoin_safe.config import UserConfig
+from bitcoin_safe.gui.qt.bitcoin_quick_receive import BitcoinQuickReceive
from bitcoin_safe.gui.qt.dialogs import WalletIdDialog
from bitcoin_safe.gui.qt.keystore_ui import SignerUI
-from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet
-from bitcoin_safe.gui.qt.tutorial import (
+from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet
+from bitcoin_safe.gui.qt.tx_signing_steps import HorizontalImporters
+from bitcoin_safe.gui.qt.ui_tx import UITx_Viewer
+from bitcoin_safe.gui.qt.util import MessageType
+from bitcoin_safe.gui.qt.wallet_steps import (
BackupSeed,
BuyHardware,
DistributeSeeds,
@@ -52,34 +62,45 @@
ImportXpubs,
ReceiveTest,
SendTest,
+ StickerTheHardware,
TutorialStep,
- ValidateBackup,
WalletSteps,
)
-from bitcoin_safe.gui.qt.tx_signing_steps import HorizontalImporters
-from bitcoin_safe.gui.qt.ui_tx import UITx_Viewer
from bitcoin_safe.logging_setup import setup_logging # type: ignore
from bitcoin_safe.util import Satoshis
+from ...non_gui.test_signers import test_seeds
from ...test_helpers import test_config # type: ignore
from ...test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore
-from ...test_signers import test_seeds
from .test_helpers import ( # type: ignore
Shutter,
- assert_message_box,
close_wallet,
do_modal_click,
+ get_called_args_message_box,
get_tab_with_title,
get_widget_top_level,
main_window_context,
save_wallet,
test_start_time,
+ type_text_in_edit,
)
logger = logging.getLogger(__name__)
+def enter_text(text: str, widget: QWidget) -> None:
+ """
+ Simulates key-by-key text entry into a specified PyQt widget.
+
+ :param text: The string of text to be entered into the widget.
+ :param widget: The PyQt widget where the text will be entered.
+ """
+ for char in text:
+ QTest.keyClick(widget, char)
+
+
def test_tutorial_wallet_setup(
+ qapp: QApplication,
qtbot: QtBot,
test_start_time: datetime,
test_config: UserConfig,
@@ -94,8 +115,8 @@ def test_tutorial_wallet_setup(
shutter = Shutter(qtbot, name=f"{test_start_time.timestamp()}_{inspect.getframeinfo(frame).function }")
shutter.create_symlink(test_config=test_config)
logger.debug(f"shutter = {shutter}")
- with main_window_context(test_config=test_config) as (app, main_window):
- logger.debug(f"(app, main_window) = {(app, main_window)}")
+ with main_window_context(test_config=test_config) as main_window:
+ logger.debug(f"(app, main_window) = {main_window}")
QTest.qWaitForWindowExposed(main_window) # This will wait until the window is fully exposed
assert main_window.windowTitle() == "Bitcoin Safe - REGTEST"
@@ -126,15 +147,23 @@ def page1() -> None:
page1()
- def page2() -> None:
+ def page_sticker() -> None:
+ shutter.save(main_window)
+ step: StickerTheHardware = wallet_steps.tab_generators[TutorialStep.sticker]
+ assert step.buttonbox_buttons[0].isVisible()
+ step.buttonbox_buttons[0].click()
+
+ page_sticker()
+
+ def page_generate() -> None:
shutter.save(main_window)
step: GenerateSeed = wallet_steps.tab_generators[TutorialStep.generate]
assert step.buttonbox_buttons[0].isVisible()
step.buttonbox_buttons[0].click()
- page2()
+ page_generate()
- def page3() -> None:
+ def page_import() -> None:
shutter.save(main_window)
step: ImportXpubs = wallet_steps.tab_generators[TutorialStep.import_xpub]
@@ -142,36 +171,110 @@ def page3() -> None:
def wrong_entry(dialog: QMessageBox) -> None:
shutter.save(dialog)
- assert (
- dialog.text() == "Please import the public key information from the hardware wallet first"
- )
+ assert dialog.text() == "Please import the complete data for Signer 1!"
dialog.button(QMessageBox.StandardButton.Ok).click()
do_modal_click(step.button_create_wallet, wrong_entry, qtbot, cls=QMessageBox)
# import xpub
- keystore = step.keystore_uis.keystore_uis[0]
+ assert step.keystore_uis
+ keystore = list(step.keystore_uis.getAllTabData().values())[0]
keystore.tabs_import_type.setCurrentWidget(keystore.tab_manual)
shutter.save(main_window)
+ # # fingerprint
+ # type_text_in_edit("0000", keystore.edit_fingerprint.input_field)
+ # shutter.save(main_window)
+ # assert "{ background-color: #ff6c54; }" in edit.input_field.styleSheet()
+
+ # def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None:
+ # shutter.save(dialog)
+ # assert dialog.text() == f"Please import the information from all hardware signers first"
+ # dialog.button(QMessageBox.StandardButton.Ok).click()
+
+ # do_modal_click(step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox)
+ # shutter.save(main_window)
# check that inputting in the wrong field gives an error
- for edit in [keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint]:
- edit.setText(test_seeds[0])
+ for edit, wrong_text, valid_text, error_message in [
+ (
+ keystore.edit_xpub.input_field,
+ "tpub1111",
+ "tpubDDnGNapGEY6AZAdQbfRJgMg9fvz8pUBrLwvyvUqEgcUfgzM6zc2eVK4vY9x9L5FJWdX8WumXuLEDV5zDZnTfbn87vLe9XceCFwTu9so9Kks",
+ "Please import the complete data for Signer 1!",
+ ),
+ (
+ keystore.edit_fingerprint.input_field,
+ "000",
+ "a42c6dd3",
+ "Please import the complete data for Signer 1!",
+ ),
+ ]:
+ type_text_in_edit(wrong_text, edit)
shutter.save(main_window)
- assert "{ background-color: #ff6c54; }" in edit.input_field.styleSheet()
+ assert "{ background-color: #ff6c54; }" in edit.styleSheet()
# check that you cannot go further without import xpub
def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None:
shutter.save(dialog)
- assert dialog.text() == f"{test_seeds[0]} is not a valid public xpub"
+ assert dialog.text() == error_message
dialog.button(QMessageBox.StandardButton.Ok).click()
do_modal_click(
step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox
)
shutter.save(main_window)
+ edit.clear()
+ type_text_in_edit(valid_text, edit)
+
+ # key_origin
+ edit, wrong_text, valid_text, error_message = (
+ keystore.edit_key_origin.input_field,
+ "m/0h00",
+ "m/84h/1h/0h",
+ "Signer 1: Unexpected key origin",
+ )
+ type_text_in_edit(wrong_text, edit)
+ shutter.save(main_window)
+ assert "{ background-color: #ff6c54; }" in edit.styleSheet()
+
+ with patch("bitcoin_safe.gui.qt.keystore_uis.question_dialog") as mock_question:
+ with patch("bitcoin_safe.gui.qt.main.Message") as mock_message:
+
+ # check that you cannot go further without import xpub
+ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None:
+ shutter.save(dialog)
+ assert dialog.text() == error_message
+ dialog.button(QMessageBox.StandardButton.Ignore).click()
+
+ do_modal_click(
+ step.button_create_wallet, wrong_entry_xpub_try_to_proceed, qtbot, cls=QMessageBox
+ )
+
+ QTest.qWait(200)
+
+ # Inspect the call arguments for each call
+ calls = mock_question.call_args_list
+
+ first_call_args = calls[0][0] # args of the first call
+ assert first_call_args == (
+ "The key derivation path m/0h00 of Signer 1 is not the default m/84h/1h/0h for the address type Single Sig (SegWit/p2wpkh). Do you want to proceed anyway?",
+ )
+
+ QTest.qWait(200)
+
+ # Inspect the call arguments for each call
+ calls = mock_message.call_args_list
+
+ first_call_args = calls[0][0] # args of the first call
+ assert first_call_args == ("('Invalid BIP32 path', '0h00')",)
+
+ shutter.save(main_window)
+ edit.clear()
+ type_text_in_edit(valid_text, edit)
# correct entry
+ for edit in [keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint]:
+ edit.setText("")
keystore.edit_seed.setText(test_seeds[0])
shutter.save(main_window)
assert keystore.edit_fingerprint.text().lower() == "5aa39a43"
@@ -201,7 +304,7 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None:
save_button=step.button_create_wallet,
)
- page3()
+ page_import()
######################################################
# now that the qt wallet is created i have to reload the
@@ -210,7 +313,7 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None:
assert qt_wallet
wallet_steps = qt_wallet.wallet_steps
- def page4() -> None:
+ def page_backup() -> None:
shutter.save(main_window)
step: BackupSeed = wallet_steps.tab_generators[TutorialStep.backup_seed]
with patch("bitcoin_safe.pdfrecovery.xdg_open_file") as mock_open:
@@ -218,33 +321,32 @@ def page4() -> None:
step.custom_yes_button.click()
mock_open.assert_called_once()
- temp_file = os.path.join(Path.home(), f"Descriptor and seed backup of {wallet_name}.pdf")
+ temp_file = os.path.join(Path.home(), f"Seed backup of {wallet_name}.pdf")
assert Path(temp_file).exists()
# remove the file again
Path(temp_file).unlink()
- page4()
-
- def page5() -> None:
- shutter.save(main_window)
- step: ValidateBackup = wallet_steps.tab_generators[TutorialStep.validate_backup]
- assert step.custom_yes_button.isVisible()
- step.custom_yes_button.click()
-
- page5()
+ page_backup()
- def page6() -> None:
+ def page_receive() -> None:
shutter.save(main_window)
step: ReceiveTest = wallet_steps.tab_generators[TutorialStep.receive]
- assert step.quick_receive
- address = step.quick_receive.text_edit.input_field.toPlainText()
+ assert isinstance(step.quick_receive, BitcoinQuickReceive)
+ address = step.quick_receive.group_boxes[0].text_edit.input_field.toPlainText()
assert address == "bcrt1q3qt0n3z69sds3u6zxalds3fl67rez4u2wm4hes"
faucet.send(address, amount=amount)
- assert_message_box(
+ called_args_message_box = get_called_args_message_box(
+ "bitcoin_safe.gui.qt.wallet_steps.Message",
step.check_button,
- "Information",
- f"Received {Satoshis(amount, test_config.network).str_with_unit()}",
+ repeat_clicking_until_message_box_called=True,
+ )
+ assert str(called_args_message_box) == str(
+ (
+ "Balance = {amount}".format(
+ amount=Satoshis(amount, network=test_config.network).str_with_unit()
+ ),
+ )
)
assert not step.check_button.isVisible()
assert step.next_button.isVisible()
@@ -252,52 +354,55 @@ def page6() -> None:
step.next_button.click()
shutter.save(main_window)
- page6()
+ page_receive()
- def page7() -> None:
+ def page_send() -> None:
shutter.save(main_window)
step: SendTest = wallet_steps.tab_generators[TutorialStep.send]
assert step.refs.floating_button_box.isVisible()
- assert step.refs.floating_button_box.tutorial_button_prefill.isVisible()
+ assert step.refs.floating_button_box.button_create_tx.isVisible()
+ assert not step.refs.floating_button_box.tutorial_button_prefill.isVisible()
- step.refs.floating_button_box.tutorial_button_prefill.click()
shutter.save(main_window)
assert qt_wallet.tabs.currentWidget() == qt_wallet.send_tab
box = qt_wallet.uitx_creator.recipients.get_recipient_group_boxes()[0]
shutter.save(main_window)
assert [recipient.address for recipient in qt_wallet.uitx_creator.recipients.recipients] == [
- "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5"
+ "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042"
]
- assert box.address == "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5"
+ assert box.address == "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042"
assert (
box.recipient_widget.address_edit.input_field.palette()
.color(QtGui.QPalette.ColorRole.Base)
.name()
== "#8af296"
)
- assert qt_wallet.uitx_creator.recipients.recipients[0].amount == amount
+ fee_info = qt_wallet.uitx_creator.estimate_fee_info(
+ qt_wallet.uitx_creator.fee_group.spin_fee_rate.value()
+ )
+ assert qt_wallet.uitx_creator.recipients.recipients[0].amount == amount - fee_info.fee_amount
assert qt_wallet.uitx_creator.recipients.recipients[0].checked_max_amount
assert step.refs.floating_button_box.button_create_tx.isVisible()
step.refs.floating_button_box.button_create_tx.click()
shutter.save(main_window)
- page7()
+ page_send()
- def page8() -> None:
+ def page_sign() -> None:
shutter.save(main_window)
viewer = main_window.tab_wallets.getCurrentTabData()
assert isinstance(viewer, UITx_Viewer)
assert [recipient.address for recipient in viewer.recipients.recipients] == [
- "bcrt1qmx7ke6j0amadeca65xqxpwh0utju5g3uka2sj5"
+ "bcrt1qz07mxz0pm3mj4jhypc6llm5mtzkcdeu3pnw042"
]
assert [recipient.label for recipient in viewer.recipients.recipients] == ["Send Test"]
assert [recipient.amount for recipient in viewer.recipients.recipients] == [999890]
assert viewer.fee_info
- assert round(viewer.fee_info.fee_rate(), 1) == 2.7
+ assert round(viewer.fee_info.fee_rate(), 1) == 1.3
assert not viewer.fee_group.allow_edit
- assert viewer.fee_group.spin_fee_rate.value() == 2.7
+ assert viewer.fee_group.spin_fee_rate.value() == 1.3
assert viewer.fee_group.approximate_fee_label.isVisible()
assert viewer.button_next.isVisible()
@@ -326,43 +431,58 @@ def page8() -> None:
shutter.save(main_window)
assert viewer.button_send.isVisible()
- viewer.button_send.click()
+
+ with patch("bitcoin_safe.gui.qt.wallet_steps.Message") as mock_message:
+ with qtbot.waitSignal(
+ main_window.signals.wallet_signals[qt_wallet.wallet.id].updated, timeout=10000
+ ): # Timeout after 10 seconds
+ viewer.button_send.click()
+ qtbot.wait(1000)
+ mock_message.assert_called_with(
+ main_window.tr("All Send tests done successfully."), type=MessageType.Info
+ )
# hist list
shutter.save(main_window)
- page8()
+ page_sign()
- def page9() -> None:
+ def page10() -> None:
shutter.save(main_window)
- assert isinstance(main_window.tab_wallets.getCurrentTabData(), QTWallet)
- step: SendTest = wallet_steps.tab_generators[TutorialStep.send]
- assert step.refs.floating_button_box.isVisible()
- assert step.refs.floating_button_box.button_yes_it_is_in_hist.isVisible()
+ step: DistributeSeeds = wallet_steps.tab_generators[TutorialStep.distribute]
+ assert step.buttonbox_buttons[0].isVisible()
+ step.buttonbox_buttons[0].click()
- # because updating the cache is threaded by default, I have to force a nonthreaded update
- qt_wallet.refresh_caches_and_ui_lists(threaded=False)
+ shutter.save(main_window)
- assert len(qt_wallet.wallet.bdkwallet.list_transactions()) == 2
- assert len(qt_wallet.wallet.sorted_delta_list_transactions()) == 2
+ page10()
- assert step.refs.floating_button_box.button_yes_it_is_in_hist.isVisible()
- step.refs.floating_button_box.button_yes_it_is_in_hist.click()
- shutter.save(main_window)
+ def do_close_wallet() -> None:
- page9()
+ close_wallet(
+ shutter=shutter,
+ test_config=test_config,
+ wallet_name=wallet_name,
+ qtbot=qtbot,
+ main_window=main_window,
+ )
- def page10() -> None:
shutter.save(main_window)
- step: DistributeSeeds = wallet_steps.tab_generators[TutorialStep.distribute]
- assert step.buttonbox_buttons[0].isVisible()
- step.buttonbox_buttons[0].click()
+ do_close_wallet()
+
+ def check_that_it_is_in_recent_wallets() -> None:
+ assert any(
+ [
+ (wallet_name in name)
+ for name in main_window.config.recently_open_wallets[main_window.config.network]
+ ]
+ )
shutter.save(main_window)
- page10()
+ check_that_it_is_in_recent_wallets()
# end
shutter.save(main_window)
diff --git a/tests/gui/qt/test_gui_setup_wallet_custom.py b/tests/gui/qt/test_gui_setup_wallet_custom.py
index c3a4111..b32ac8c 100644
--- a/tests/gui/qt/test_gui_setup_wallet_custom.py
+++ b/tests/gui/qt/test_gui_setup_wallet_custom.py
@@ -36,13 +36,13 @@
import bdkpython as bdk
from bitcoin_usb.address_types import AddressTypes
from PyQt6.QtTest import QTest
-from PyQt6.QtWidgets import QDialogButtonBox
+from PyQt6.QtWidgets import QApplication, QDialogButtonBox
from pytestqt.qtbot import QtBot
from bitcoin_safe.config import UserConfig
from bitcoin_safe.gui.qt.block_change_signals import BlockChangesSignals
from bitcoin_safe.gui.qt.dialogs import WalletIdDialog
-from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet
+from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet
from bitcoin_safe.logging_setup import setup_logging # type: ignore
from tests.gui.qt.test_gui_setup_wallet import (
close_wallet,
@@ -54,7 +54,6 @@
from ...test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore
from .test_helpers import ( # type: ignore
Shutter,
- assert_message_box,
close_wallet,
do_modal_click,
get_tab_with_title,
@@ -68,6 +67,7 @@
def test_custom_wallet_setup_custom_single_sig(
+ qapp: QApplication,
qtbot: QtBot,
test_start_time: datetime,
test_config: UserConfig,
@@ -81,13 +81,13 @@ def test_custom_wallet_setup_custom_single_sig(
shutter = Shutter(qtbot, name=f"{test_start_time.timestamp()}_{inspect.getframeinfo(frame).function }")
shutter.create_symlink(test_config=test_config)
- with main_window_context(test_config=test_config) as (app, main_window):
+ with main_window_context(test_config=test_config) as main_window:
QTest.qWaitForWindowExposed(main_window) # This will wait until the window is fully exposed
assert main_window.windowTitle() == "Bitcoin Safe - REGTEST"
shutter.save(main_window)
- w = main_window.welcome_screen.pushButton_custom_wallet
+ button = main_window.welcome_screen.pushButton_custom_wallet
def on_wallet_id_dialog(dialog: WalletIdDialog) -> None:
shutter.save(dialog)
@@ -97,7 +97,7 @@ def on_wallet_id_dialog(dialog: WalletIdDialog) -> None:
dialog.buttonbox.button(QDialogButtonBox.StandardButton.Ok).click()
shutter.save(main_window)
- do_modal_click(w, on_wallet_id_dialog, qtbot, cls=WalletIdDialog)
+ do_modal_click(button, on_wallet_id_dialog, qtbot, cls=WalletIdDialog)
w = get_tab_with_title(main_window.tab_wallets, title=wallet_name)
qt_proto_wallet = main_window.tab_wallets.get_data_for_tab(w)
@@ -115,7 +115,7 @@ def check_consistent() -> None:
signers = qt_proto_wallet.wallet_descriptor_ui.spin_signers.value()
qt_proto_wallet.wallet_descriptor_ui.spin_req.value()
- assert signers == len(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis)
+ assert signers == qt_proto_wallet.wallet_descriptor_ui.keystore_uis.count()
for i in range(signers):
assert qt_proto_wallet.wallet_descriptor_ui.keystore_uis.tabText(
i
@@ -141,7 +141,7 @@ def page1() -> None:
qt_proto_wallet.wallet_descriptor_ui.comboBox_address_type.currentData() == AddressTypes.p2wsh
)
assert qt_proto_wallet.wallet_descriptor_ui.spin_gap.value() == 20
- assert len(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis) == 5
+ assert qt_proto_wallet.wallet_descriptor_ui.keystore_uis.count() == 5
shutter.save(main_window)
check_consistent()
@@ -166,7 +166,7 @@ def change_to_single_sig() -> None:
change_to_single_sig()
def do_save_wallet() -> None:
- key = qt_proto_wallet.wallet_descriptor_ui.keystore_uis.keystore_uis[0]
+ key = list(qt_proto_wallet.wallet_descriptor_ui.keystore_uis.getAllTabData().values())[0]
key.tabs_import_type.setCurrentWidget(key.tab_manual)
shutter.save(main_window)
@@ -198,7 +198,11 @@ def do_save_wallet() -> None:
do_save_wallet()
- main_window.tab_wallets.get_data_for_tab(w)
+ # get the new qt wallet
+ qt_wallet = main_window.tab_wallets.get_data_for_tab(
+ get_tab_with_title(main_window.tab_wallets, title=wallet_name)
+ )
+ assert isinstance(qt_wallet, QTWallet)
def do_close_wallet() -> None:
@@ -214,14 +218,17 @@ def do_close_wallet() -> None:
do_close_wallet()
- def do_open_wallet() -> None:
- assert (
- wallet_name in list(main_window.config.recently_open_wallets[main_window.config.network])[0]
+ def check_that_it_is_in_recent_wallets() -> None:
+ assert any(
+ [
+ (wallet_name in name)
+ for name in main_window.config.recently_open_wallets[main_window.config.network]
+ ]
)
shutter.save(main_window)
- do_open_wallet()
+ check_that_it_is_in_recent_wallets()
# end
shutter.save(main_window)
diff --git a/tests/gui/qt/test_helpers.py b/tests/gui/qt/test_helpers.py
index aad7381..a8cb4ed 100644
--- a/tests/gui/qt/test_helpers.py
+++ b/tests/gui/qt/test_helpers.py
@@ -36,18 +36,21 @@
from datetime import datetime
from pathlib import Path
from time import sleep
-from typing import Callable, Generator, Optional, Tuple, Type, TypeVar, Union
+from typing import Any, Callable, Generator, List, Optional, Type, TypeVar, Union
from unittest.mock import patch
import pytest
from PyQt6 import QtCore
+from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import (
QApplication,
QFileDialog,
+ QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QTabWidget,
+ QTextEdit,
QWidget,
)
from pytestqt.qtbot import QtBot
@@ -78,27 +81,14 @@ def test_start_time() -> datetime:
@contextmanager
-def application_context() -> Generator[QApplication, None, None]:
- """Context manager that manages the QApplication lifecycle."""
- app = QApplication.instance()
- if app is None:
- app = QApplication([])
+def main_window_context(test_config: UserConfig) -> Generator[MainWindow, None, None]:
+ """Context manager that manages the MainWindow lifecycle."""
+ window = MainWindow(config=test_config)
+ window.show()
try:
- yield app
+ yield window
finally:
- app.quit()
-
-
-@contextmanager
-def main_window_context(test_config: UserConfig) -> Generator[Tuple[QApplication, MainWindow], None, None]:
- """Context manager that manages the MainWindow lifecycle."""
- with application_context() as app:
- window = MainWindow(config=test_config)
- window.show()
- try:
- yield app, window
- finally:
- window.close()
+ window.close()
# Define a Type Variable
@@ -154,7 +144,7 @@ def create_symlink(self, test_config: UserConfig) -> None:
link_name.symlink_to(test_config.config_dir)
-def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]:
+def _get_widget_top_level(cls: Type[T], title: str | None = None) -> Optional[T]:
"""
Find the top-level widget of the specified class and title among the active widgets.
@@ -165,8 +155,8 @@ def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]:
Returns:
QWidget or False: The widget if found, otherwise False.
"""
- QApplication.processEvents()
for widget in QApplication.topLevelWidgets():
+ logger.debug(str(widget))
# Check instance and, if a title is provided, whether the title matches
if (
isinstance(widget, cls)
@@ -181,7 +171,7 @@ def _get_widget_top_level(cls: Type[T], title: str = None) -> Optional[T]:
def get_widget_top_level(
- cls: Type[T], qtbot: QtBot, title: str = None, wait: bool = True, timeout: int = 10000
+ cls: Type[T], qtbot: QtBot, title: str | None = None, wait: bool = True, timeout: int = 10000
) -> Optional[T]:
"""
Find the top-level widget of the specified class and title among the active widgets.
@@ -205,6 +195,7 @@ def do_modal_click(
button: QtCore.Qt.MouseButton = QtCore.Qt.MouseButton.LeftButton,
cls: Type[T] = QMessageBox,
timeout=5000,
+ timer_delay=200,
) -> None:
def click() -> None:
print("\nwaiting for is_dialog_open")
@@ -216,23 +207,28 @@ def click() -> None:
print("Do on_open")
on_open(dialog)
- QtCore.QTimer.singleShot(200, click)
+ QtCore.QTimer.singleShot(timer_delay, click)
if callable(click_pushbutton):
click_pushbutton()
else:
qtbot.mouseClick(click_pushbutton, button)
-def assert_message_box(click_pushbutton: QPushButton, tile: str, message_text: str) -> None:
- with patch("bitcoin_safe.gui.qt.util.QMessageBox") as mock_msgbox:
- while not mock_msgbox.called:
+def get_called_args_message_box(
+ patch_str: str,
+ click_pushbutton: QPushButton,
+ repeat_clicking_until_message_box_called=False,
+) -> List[Any]:
+ with patch(patch_str) as mock_message:
+ while not mock_message.called:
click_pushbutton.click()
QApplication.processEvents()
sleep(0.2)
+ if not repeat_clicking_until_message_box_called:
+ break
- called_args, called_kwargs = mock_msgbox.call_args
- assert called_args[1] == tile
- assert called_args[2] == message_text
+ called_args, called_kwargs = mock_message.call_args
+ return called_args
def simulate_user_response(
@@ -248,6 +244,27 @@ def simulate_user_response(
return None
+def type_text_in_edit(text: str, edit: Union[QLineEdit, QTextEdit]) -> None:
+ """
+ Simulate typing text into a QLineEdit or QTextEdit widget.
+
+ :param text: The text to type into the edit widget.
+ :param edit: The QLineEdit or QTextEdit widget where the text will be typed.
+ """
+ edit.setFocus()
+ QApplication.processEvents()
+
+ # Ensure the widget has focus
+ if not edit.hasFocus():
+ edit.setFocus()
+ QApplication.processEvents()
+
+ # Simulate typing each character
+ for char in text:
+ QTest.keyClick(edit, char)
+ QApplication.processEvents()
+
+
def get_tab_with_title(tabs: QTabWidget, title: str) -> Optional[QWidget]:
"""
Returns the tab with the specified title from a QTabWidget.
diff --git a/tests/non_gui/__init__.py b/tests/non_gui/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/non_gui/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/non_gui/test_keystore.py b/tests/non_gui/test_keystore.py
new file mode 100644
index 0000000..9acca1c
--- /dev/null
+++ b/tests/non_gui/test_keystore.py
@@ -0,0 +1,139 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import logging
+
+import bdkpython as bdk
+
+from bitcoin_safe.config import UserConfig
+from bitcoin_safe.keystore import KeyStore
+from tests.non_gui.test_wallet import create_test_seed_keystores
+
+from ..test_helpers import test_config # type: ignore
+
+logger = logging.getLogger(__name__)
+
+
+def test_dump(test_config: UserConfig):
+ "Tests if dump works correctly"
+ network = bdk.Network.REGTEST
+
+ keystore = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i+41}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+
+ keystore_restored = KeyStore.from_dump(keystore.dump())
+ assert keystore.is_equal(keystore_restored)
+
+
+def test_is_equal():
+ network = bdk.Network.REGTEST
+
+ keystore = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+
+ # xpub
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.xpub += " "
+ assert not keystore.is_equal(keystore2)
+
+ # fingerprint
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.fingerprint = keystore2.fingerprint.lower()
+ assert not keystore.is_equal(keystore2)
+ keystore2.fingerprint = keystore2.format_fingerprint(keystore2.fingerprint)
+ assert keystore.is_equal(keystore2)
+
+ # key_origin
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.key_origin = keystore2.key_origin.replace("h", "'")
+ assert not keystore.is_equal(keystore2)
+ keystore2.key_origin = keystore2.format_key_origin(keystore2.key_origin)
+ assert keystore.is_equal(keystore2)
+
+ # label
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.label = "a"
+ assert not keystore.is_equal(keystore2)
+
+ # network
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.network = bdk.Network.BITCOIN
+ assert not keystore.is_equal(keystore2)
+
+ # mnemonic
+ keystore2 = create_test_seed_keystores(
+ signers=2,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[1]
+ assert not keystore.is_equal(keystore2)
+
+ # description
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.description = "ddd"
+ assert not keystore.is_equal(keystore2)
+
+ # derivation_path
+ keystore2 = create_test_seed_keystores(
+ signers=1,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ network=network,
+ )[0]
+ keystore2.derivation_path = keystore2.key_origin
+ assert not keystore.is_equal(keystore2)
diff --git a/tests/test_labels.py b/tests/non_gui/test_labels.py
similarity index 91%
rename from tests/test_labels.py
rename to tests/non_gui/test_labels.py
index ed68e64..2ab6975 100644
--- a/tests/test_labels.py
+++ b/tests/non_gui/test_labels.py
@@ -37,7 +37,7 @@ def test_label_export():
labels = Labels()
timestamp = datetime.datetime(2000, 1, 1, 0, 0, 0).timestamp()
labels.set_addr_label("some_address", "my label", timestamp=timestamp)
- labels.set_addr_category("some_address", "category 0")
+ labels.set_addr_category("some_address", "category 0", timestamp=timestamp)
assert labels.dump()["__class__"] == "Labels"
assert labels.dump()["categories"] == ["category 0"]
@@ -85,14 +85,16 @@ def test_dumps_data():
a = labels2.data.get("some_address")
assert isinstance(a, Label)
- assert a.timestamp == timestamp
+ # the 2. assignment set_addr_category("some_address", "category 0" )
+ # should update the timestamp. therefore is should NOT be the old timestamp
+ assert a.timestamp != timestamp
def test_preservebip329_keys_for_single_label():
import json
labels = Labels()
- labels.set_addr_category("some_address", "category 0")
+ labels.set_addr_category("some_address", "category 0", timestamp=0)
serialized_labels = labels.dumps_data_jsonlines()
@@ -102,7 +104,7 @@ def test_preservebip329_keys_for_single_label():
assert (
serialized_labels
- == '{"__class__": "Label", "VERSION": "0.0.1", "type": "addr", "ref": "some_address", "label": null, "category": "category 0"}'
+ == '{"__class__": "Label", "VERSION": "0.0.2", "type": "addr", "ref": "some_address", "label": null, "category": "category 0", "timestamp": 0}'
)
@@ -111,7 +113,7 @@ def test_label_bip329_import():
labels = Labels()
labels.set_addr_label("some_address", "my label", timestamp=timestamp)
- labels.set_addr_category("some_address", "category 0")
+ labels.set_addr_category("some_address", "category 0", timestamp=timestamp)
s = labels.export_bip329_jsonlines()
assert s == '{"type": "addr", "ref": "some_address", "label": "my label #category 0"}'
@@ -166,7 +168,7 @@ def test_import():
s = """
{"type": "tx", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd", "label": "Transaction", "origin": "wpkh([d34db33f/84'/0'/0'])"}
- {"type": "addr", "ref": "bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c", "label": "Address"}
+ {"type": "addr", "ref": "tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f", "label": "Address"}
{"type": "pubkey", "ref": "0283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f448", "label": "Public Key"}
{"type": "input", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:0", "label": "Input"}
{"type": "output", "ref": "f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd:1", "label": "Output", "spendable": "false"}
@@ -182,7 +184,7 @@ def test_import():
assert (
labels.get_label("f91d0a8a78462bc59398f2c5d7a84fcff491c26ba54c4833478b202796c8aafd") == "Transaction"
)
- assert labels.get_label("bc1q34aq5drpuwy3wgl9lhup9892qp6svr8ldzyy7c") == "Address"
+ assert labels.get_label("tb1q6xhxcrzmjwf6ce5jlj08gyrmu4eq3zwpv0ss3f") == "Address"
assert (
labels.get_label("0283409659355b6d1cc3c32decd5d561abaac86c37a353b52895a5e6c196d6f448") == "Public Key"
)
diff --git a/tests/test_mail_handler.py b/tests/non_gui/test_mail_handler.py
similarity index 100%
rename from tests/test_mail_handler.py
rename to tests/non_gui/test_mail_handler.py
diff --git a/tests/test_migration.py b/tests/non_gui/test_migration.py
similarity index 96%
rename from tests/test_migration.py
rename to tests/non_gui/test_migration.py
index e4bde1d..056457e 100644
--- a/tests/test_migration.py
+++ b/tests/non_gui/test_migration.py
@@ -27,6 +27,8 @@
# SOFTWARE.
+from pathlib import Path
+
import bdkpython as bdk
import pytest
@@ -59,7 +61,7 @@ def test_011(config: UserConfig):
def test_config010(config: UserConfig):
file_path = "tests/data/config_0.1.0.conf"
- config = UserConfig.from_file(file_path=file_path)
+ config = UserConfig.from_file(file_path=Path(file_path))
assert config.last_wallet_files == {"Network.REGTEST": [".config/bitcoin_safe/REGTEST/Coldcard.wallet"]}
assert config.data_dir == rel_home_path_to_abs_path(".local/share/bitcoin_safe")
diff --git a/tests/test_psbt_util.py b/tests/non_gui/test_psbt_util.py
similarity index 97%
rename from tests/test_psbt_util.py
rename to tests/non_gui/test_psbt_util.py
index d623403..a54aeef 100644
--- a/tests/test_psbt_util.py
+++ b/tests/non_gui/test_psbt_util.py
@@ -29,7 +29,8 @@
import bdkpython as bdk
-from bitcoin_safe.psbt_util import SimplePSBT
+from bitcoin_safe.psbt_util import SimpleOutput, SimplePSBT
+from bitcoin_safe.pythonbdk_types import TxOut
p2wsh_psbt_0_2of3 = bdk.PartiallySignedTransaction(
"cHNidP8BAIkBAAAAATqahH4QTEKfxm6qlALcWC5h8D9bjKFoW0VRfm4auf4aAAAAAAD9////AvQBAAAAAAAAIgAgsCBsnrRoOkUsY175u3Fa6vNXXwsSNbf4mDWFFvXODJH0AQAAAAAAACIAIPVnTHBKqnziIq5ov/TvQ8nNJYQ1MakbfdY7VMXIJbnpR8EmAAABAH0BAAAAAYMWmPX/X+Jq1QzTenGMmtvdeaMYEKYf7Nli0gzb+7C0AAAAAAD9////AugDAAAAAAAAIgAgHWI4I8UK5PLP+DtAXdlRI8Sts/PIRh1ksMD6iKlk/r6/GgAAAAAAABYAFNiY7EiZrTSaq0ipS+jFKXBQep4ON8EmAAEBK+gDAAAAAAAAIgAgHWI4I8UK5PLP+DtAXdlRI8Sts/PIRh1ksMD6iKlk/r4BBWlSIQIyOXzeZut4A5aUyMNWJy0Opx5iGruvdPBowW71rVQ1piEDDuRS5miVqUzK3RnF0adROAfU5jFNecF4zZ5TPebcRUMhAxU1ObeArGZ6bGPcb/KWg98LPu3Jj5wzMr9mDNI31ta0U64iBgIyOXzeZut4A5aUyMNWJy0Opx5iGruvdPBowW71rVQ1phixB43FVAAAgAEAAIAAAACAAAAAABUAAAAiBgMO5FLmaJWpTMrdGcXRp1E4B9TmMU15wXjNnlM95txFQxjRua98VAAAgAEAAIAAAACAAAAAABUAAAAiBgMVNTm3gKxmemxj3G/yloPfCz7tyY+cMzK/ZgzSN9bWtBiBe43+VAAAgAEAAIAAAACAAAAAABUAAAAAAQFpUiECwFSVDN1wlaOC4Xh3Vz8f1Fe1R3C9BnOEctx14BcM/vAhAvWDA1HgThJW6S0Buq4+ribWkdx/+Mq1qsmRr4XPMC1BIQNmWAeip+z4mEdQsVP1K0vLgB/pAvW5A/Vf5wi3tfahM1OuIgICwFSVDN1wlaOC4Xh3Vz8f1Fe1R3C9BnOEctx14BcM/vAYgXuN/lQAAIABAACAAAAAgAEAAAAVAAAAIgIC9YMDUeBOElbpLQG6rj6uJtaR3H/4yrWqyZGvhc8wLUEYsQeNxVQAAIABAACAAAAAgAEAAAAVAAAAIgIDZlgHoqfs+JhHULFT9StLy4Af6QL1uQP1X+cIt7X2oTMY0bmvfFQAAIABAACAAAAAgAEAAAAVAAAAAAEBaVIhAibQDjOdARwmI9G/ZnarEd23QZ/bskSSk5pzTsSbppqXIQNVWIlGZfiE5uzg9WV4Kkn7P+sdkX4mXCalj4wWRNH1dCED5H+E6OnZns/lomlsiSKclAcFlG7AZROwRk/voGCezotTriICAibQDjOdARwmI9G/ZnarEd23QZ/bskSSk5pzTsSbppqXGLEHjcVUAACAAQAAgAAAAIAAAAAAFAAAACICA1VYiUZl+ITm7OD1ZXgqSfs/6x2RfiZcJqWPjBZE0fV0GNG5r3xUAACAAQAAgAAAAIAAAAAAFAAAACICA+R/hOjp2Z7P5aJpbIkinJQHBZRuwGUTsEZP76Bgns6LGIF7jf5UAACAAQAAgAAAAIAAAAAAFAAAAAA="
@@ -133,3 +134,14 @@ def test_psbt_optional_fields():
def test_p2sh():
psbt = SimplePSBT.from_psbt(p2sh_0_2of3)
psbt
+
+
+def test_to_txout():
+ output_data = {"value": 1000, "script_pubkey": ""}
+ unsigned_tx = {"value": 1000, "script_pubkey": ""}
+
+ simple_output = SimpleOutput.from_output(output_data, unsigned_tx)
+
+ txout = simple_output.to_txout()
+
+ assert isinstance(txout, TxOut) # Should return a TxOut object
diff --git a/tests/test_signature_manager.py b/tests/non_gui/test_signature_manager.py
similarity index 100%
rename from tests/test_signature_manager.py
rename to tests/non_gui/test_signature_manager.py
diff --git a/tests/test_signers.py b/tests/non_gui/test_signers.py
similarity index 94%
rename from tests/test_signers.py
rename to tests/non_gui/test_signers.py
index 9eb0d47..887b211 100644
--- a/tests/test_signers.py
+++ b/tests/non_gui/test_signers.py
@@ -29,6 +29,7 @@
import logging
from dataclasses import dataclass
+from pathlib import Path
from typing import List, Literal
from uuid import uuid4
@@ -41,8 +42,18 @@
from bitcoin_safe.signer import SignatureImporterClipboard
from bitcoin_safe.util import hex_to_serialized, serialized_to_hex
+from ..test_helpers import test_config # type: ignore
+
logger = logging.getLogger(__name__)
-from .test_setup_bitcoin_core import BITCOIN_PORT, RPC_PASSWORD, RPC_USER
+from ..test_helpers import test_config # type: ignore
+from ..test_setup_bitcoin_core import ( # type: ignore
+ BITCOIN_PORT,
+ RPC_PASSWORD,
+ RPC_USER,
+ Faucet,
+ bitcoin_core,
+ faucet,
+)
test_seeds = """peanut all ghost appear daring exotic choose disease bird ready love salad
chair useful hammer word edge hat title drastic priority chalk city gentle
@@ -154,7 +165,7 @@ class PyTestBDKSetup:
wallets: List[bdk.Wallet]
-def get_blockchain_config(network: bdk.Network) -> bdk.BlockchainConfig.RPC:
+def get_blockchain_config(bitcoin_core: Path, network: bdk.Network) -> bdk.BlockchainConfig.RPC:
return bdk.BlockchainConfig.RPC(
bdk.RpcConfig(
f"127.0.0.1:{BITCOIN_PORT}",
@@ -166,8 +177,8 @@ def get_blockchain_config(network: bdk.Network) -> bdk.BlockchainConfig.RPC:
)
-def pytest_bdk_setup_multisig(m=2, n=3, network=bdk.Network.REGTEST) -> PyTestBDKSetup:
- blockchain_config = get_blockchain_config(network=network)
+def pytest_bdk_setup_multisig(bitcoin_core: Path, m=2, n=3, network=bdk.Network.REGTEST) -> PyTestBDKSetup:
+ blockchain_config = get_blockchain_config(bitcoin_core, network=network)
blockchain = bdk.Blockchain(blockchain_config)
@@ -227,15 +238,15 @@ def gen_multisig_descriptor_str(
)
-def pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST) -> PyTestBDKSetup:
+def pytest_bdk_setup_single_sig(bitcoin_core: Path, network=bdk.Network.REGTEST) -> PyTestBDKSetup:
logger.debug("pytest_bdk_setup_single_sig start")
- blockchain_config = get_blockchain_config(network=network)
+ blockchain_config = get_blockchain_config(bitcoin_core, network=network)
logger.debug(f"blockchain_config = {blockchain_config}")
blockchain = bdk.Blockchain(blockchain_config)
logger.debug(f"blockchain = {blockchain}")
- mnemonic = bdk.Mnemonic.from_string(test_seeds[0])
+ mnemonic = bdk.Mnemonic.from_string(test_seeds[50])
logger.debug(f"mnemonic = {mnemonic}")
descriptor = bdk.Descriptor.new_bip84(
@@ -257,15 +268,15 @@ def pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST) -> PyTestBDKSetup:
@pytest.fixture
-def pytest_2_of_3_multisig_wallets() -> PyTestBDKSetup:
+def pytest_2_of_3_multisig_wallets(bitcoin_core: Path) -> PyTestBDKSetup:
logger.debug("prepare fixture pytest_2_of_3_multisig_wallets")
- return pytest_bdk_setup_multisig(m=2, n=3, network=bdk.Network.REGTEST)
+ return pytest_bdk_setup_multisig(bitcoin_core, m=2, n=3, network=bdk.Network.REGTEST)
@pytest.fixture
-def pytest_siglesig_wallet() -> PyTestBDKSetup:
+def pytest_siglesig_wallet(bitcoin_core: Path) -> PyTestBDKSetup:
logger.debug("prepare fixture pytest_siglesig_wallet")
- return pytest_bdk_setup_single_sig(network=bdk.Network.REGTEST)
+ return pytest_bdk_setup_single_sig(bitcoin_core, network=bdk.Network.REGTEST)
def test_signer_finalizes_ofn_final_sig_receive(
@@ -274,7 +285,6 @@ def test_signer_finalizes_ofn_final_sig_receive(
signer = SignatureImporterClipboard(
network=pytest_siglesig_wallet.network,
- blockchain=pytest_siglesig_wallet.blockchain,
)
psbt_1_sig_2_of_3 = "cHNidP8BAIkBAAAAATuuOwH+YN3lM9CHZuaxhXU+P/xWQQUpwldxTxng2/NWAAAAAAD9////AhAnAAAAAAAAIgAgbnxIFWJ84RPQEHQJIBWYVALEGgr6e99xVLT2DDykpha+kQ0AAAAAACIAIH+2seEetNM9J6mtfXwz2EwP7E1gqjpvr0HHI97D3b5IcwAAAAABAP2HAQEAAAAAAQHSc/5077HT+IqRaNwhhb9WuzlFYINsZk1BxhahFNsqlQAAAAAA/f///wKYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1oIYBAAAAAAAiACBYU+aHAWVhSe4DMfwzQhq9NzO6smI694/A7MoURBK4nAQARzBEAiBFFZVQQjC5SlDRCAuC5AkoQgMXyrG54gp71Ro2W6g0fgIgTbg94g7liL0T7DwEeWqOiJfurgpuTv1Q+7bAzFlV/yQBRzBEAiB76jOyWL28VWQzn32ITyy4JlRYAASEaPB9C7mANDLtzAIgCjyov+Y9xRQicB2+v0iDA09RcC7hQHzLxXA9klITMXkBaVIhApBlhYUDvuGXybpbsvXzcXHMb+NikjYe3kqp8xvXMoeJIQPPm4n6VeT9fEoPYLoiy9a3O0mxnSA3wNRunj9xLxmoXSED6TWmEfTbB6zewl0TlxSPr3xmEqifQu5Ou9xoOocqvQlTrnMAAAABASuYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1IgIDXJ+vqtLyk8wiixL5TFlcG0vz7s5VVW7BnzHKELejo1JHMEQCIHRCI5/HJ4+/1h8950fcaTEc3H0wkKs8wmASocGCmJaNAiAnaabE/m0JtZLa0QCQqXPHp3xnI3GkvdpjG0Q7wjqOagEBBWlSIQKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWSEDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEhA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSU64iBgKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWRwll+QpMAAAgAEAAIAAAACAAgAAgAEAAAADAAAAIgYDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEcJuv5KjAAAIABAACAAAAAgAIAAIABAAAAAwAAACIGA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSHPTklXQwAACAAQAAgAAAAIACAACAAQAAAAMAAAAAAQFpUiECyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0hAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZIQN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWFOuIgICyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0c9OSVdDAAAIABAACAAAAAgAIAAIAAAAAABwAAACICAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZHCWX5CkwAACAAQAAgAAAAIACAACAAAAAAAcAAAAiAgN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWBwm6/kqMAAAgAEAAIAAAACAAgAAgAAAAAAHAAAAAAEBaVIhAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRIQM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7yEDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfpTriICAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRHCbr+SowAACAAQAAgAAAAIACAACAAQAAAAQAAAAiAgM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7xz05JV0MAAAgAEAAIAAAACAAgAAgAEAAAAEAAAAIgIDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfocJZfkKTAAAIABAACAAAAAgAIAAIABAAAABAAAAAA="
@@ -301,7 +311,6 @@ def test_signer_recognizes_finalized_tx_received(
signer = SignatureImporterClipboard(
network=pytest_siglesig_wallet.network,
- blockchain=pytest_siglesig_wallet.blockchain,
)
psbt_1_sig_2_of_3 = "cHNidP8BAIkBAAAAATuuOwH+YN3lM9CHZuaxhXU+P/xWQQUpwldxTxng2/NWAAAAAAD9////AhAnAAAAAAAAIgAgbnxIFWJ84RPQEHQJIBWYVALEGgr6e99xVLT2DDykpha+kQ0AAAAAACIAIH+2seEetNM9J6mtfXwz2EwP7E1gqjpvr0HHI97D3b5IcwAAAAABAP2HAQEAAAAAAQHSc/5077HT+IqRaNwhhb9WuzlFYINsZk1BxhahFNsqlQAAAAAA/f///wKYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1oIYBAAAAAAAiACBYU+aHAWVhSe4DMfwzQhq9NzO6smI694/A7MoURBK4nAQARzBEAiBFFZVQQjC5SlDRCAuC5AkoQgMXyrG54gp71Ro2W6g0fgIgTbg94g7liL0T7DwEeWqOiJfurgpuTv1Q+7bAzFlV/yQBRzBEAiB76jOyWL28VWQzn32ITyy4JlRYAASEaPB9C7mANDLtzAIgCjyov+Y9xRQicB2+v0iDA09RcC7hQHzLxXA9klITMXkBaVIhApBlhYUDvuGXybpbsvXzcXHMb+NikjYe3kqp8xvXMoeJIQPPm4n6VeT9fEoPYLoiy9a3O0mxnSA3wNRunj9xLxmoXSED6TWmEfTbB6zewl0TlxSPr3xmEqifQu5Ou9xoOocqvQlTrnMAAAABASuYuQ0AAAAAACIAIKteOph2G5lDpTD98oWJkrif3i6FX/eHTr2kmU4KN1w1IgIDXJ+vqtLyk8wiixL5TFlcG0vz7s5VVW7BnzHKELejo1JHMEQCIHRCI5/HJ4+/1h8950fcaTEc3H0wkKs8wmASocGCmJaNAiAnaabE/m0JtZLa0QCQqXPHp3xnI3GkvdpjG0Q7wjqOagEBBWlSIQKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWSEDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEhA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSU64iBgKi/d4Q8/DAD7tLY2kHUUIGTfBkO74RcE6u0gmLwiAjWRwll+QpMAAAgAEAAIAAAACAAgAAgAEAAAADAAAAIgYDFMD9m9xxfSIcwmc3SXiciTV6v10693MSc79LQ15SBZEcJuv5KjAAAIABAACAAAAAgAIAAIABAAAAAwAAACIGA1yfr6rS8pPMIosS+UxZXBtL8+7OVVVuwZ8xyhC3o6NSHPTklXQwAACAAQAAgAAAAIACAACAAQAAAAMAAAAAAQFpUiECyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0hAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZIQN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWFOuIgICyEiwHFxXNTRDyxekTGCOqDJF/UGPswuW6++eVUyngD0c9OSVdDAAAIABAACAAAAAgAIAAIAAAAAABwAAACICAx0p4dgmMCecStltCitwPnXRHeo7uMy260unWne4hkSZHCWX5CkwAACAAQAAgAAAAIACAACAAAAAAAcAAAAiAgN3gQY8fBis7zaMg6PPUpUBmqVTFeHL88ZtrkmGYIItWBwm6/kqMAAAgAEAAIAAAACAAgAAgAAAAAAHAAAAAAEBaVIhAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRIQM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7yEDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfpTriICAqXzS83sSX2eRvvkhFWsqQprOcOIP/BMZkTh5Hutt8cRHCbr+SowAACAAQAAgAAAAIACAACAAQAAAAQAAAAiAgM3O68WgyPcey73e1N32j7PXt+AzbKwxP1dpkVWJ9Fi7xz05JV0MAAAgAEAAIAAAACAAgAAgAEAAAAEAAAAIgIDZB1itfxzFAcc/Qm7O3pZgudvIgEFiFtdODQ/QemSNfocJZfkKTAAAIABAACAAAAAgAIAAIABAAAABAAAAAA="
diff --git a/tests/test_wallet.py b/tests/non_gui/test_wallet.py
similarity index 95%
rename from tests/test_wallet.py
rename to tests/non_gui/test_wallet.py
index 8babbbc..049700d 100644
--- a/tests/test_wallet.py
+++ b/tests/non_gui/test_wallet.py
@@ -38,7 +38,7 @@
from bitcoin_safe.keystore import KeyStore
from bitcoin_safe.wallet import ProtoWallet, Wallet, WalletInputsInconsistentError
-from .test_helpers import test_config # type: ignore
+from ..test_helpers import test_config # type: ignore
from .test_signers import test_seeds
logger = logging.getLogger(__name__)
@@ -382,3 +382,26 @@ def test_mixed_keystores_is_consistent(test_config: UserConfig):
)
assert wallet.is_multisig()
+
+
+def test_wallet_dump_and_restore(test_config: UserConfig):
+ "Tests if dump works correctly"
+ network = bdk.Network.REGTEST
+
+ protowallet = create_multisig_protowallet(
+ threshold=2,
+ signers=5,
+ key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)],
+ wallet_id="some id",
+ network=network,
+ )
+ wallet = Wallet.from_protowallet(protowallet=protowallet, config=test_config)
+ dump = wallet.dump()
+
+ restored_wallet = Wallet.from_dump(dct=dump, class_kwargs={"Wallet": {"config": test_config}})
+
+ assert wallet.is_essentially_equal(restored_wallet)
+
+ assert len(wallet.keystores) == len(restored_wallet.keystores)
+ for org_keystore, restored_keystore in zip(wallet.keystores, restored_wallet.keystores):
+ assert org_keystore.is_equal(restored_keystore)
diff --git a/tests/non_gui/test_wallet_coin_select.py b/tests/non_gui/test_wallet_coin_select.py
new file mode 100644
index 0000000..2d12489
--- /dev/null
+++ b/tests/non_gui/test_wallet_coin_select.py
@@ -0,0 +1,936 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import logging
+import random
+from dataclasses import dataclass
+from pathlib import Path
+
+import bdkpython as bdk
+import numpy as np
+import pytest
+from bitcoin_usb.address_types import DescriptorInfo
+
+from bitcoin_safe.config import UserConfig
+from bitcoin_safe.keystore import KeyStore
+from bitcoin_safe.logging_setup import setup_logging # type: ignore
+from bitcoin_safe.pythonbdk_types import Recipient
+from bitcoin_safe.tx import TxUiInfos, transaction_to_dict
+from bitcoin_safe.wallet import Wallet
+
+from ..test_helpers import test_config # type: ignore
+from ..test_setup_bitcoin_core import Faucet, bitcoin_core, faucet # type: ignore
+from .test_signers import test_seeds # type: ignore
+
+logger = logging.getLogger(__name__)
+import logging
+
+
+def compare_dicts(d1, d2, ignore_value="value_to_be_ignored") -> bool:
+ """
+ Recursively compares two dictionaries (or lists), allowing for arbitrary depth.
+ If a value matches `ignore_value`, the corresponding value in the other structure
+ does not affect the outcome of the comparison.
+
+ Args:
+ d1 (dict | list): First structure for comparison.
+ d2 (dict | list): Second structure for comparison.
+ ignore_value (any): A value in either structure that should be ignored during comparison.
+
+ Returns:
+ bool: True if the structures are identical (considering ignore_value), False otherwise.
+ """
+ # Handle ignore_value in direct comparison
+ if d1 == ignore_value or d2 == ignore_value:
+ return True
+
+ # Check the type of d1 and d2
+ if type(d1) != type(d2):
+ logger.debug(f"Type mismatch: {type(d1)} != {type(d2)}")
+ return False
+
+ # If both are dictionaries
+ if isinstance(d1, dict):
+ if d1.keys() != d2.keys():
+ logger.debug(f"Dictionary keys mismatch: {d1.keys()} != {d2.keys()}")
+ return False
+ for key in d1:
+ if not compare_dicts(d1[key], d2[key], ignore_value):
+ logger.debug(f"Mismatch at key {key}: {d1[key]} != {d2[key]}")
+ return False
+ return True
+
+ # If both are lists
+ if isinstance(d1, list):
+ if len(d1) != len(d2):
+ logger.debug(f"List length mismatch: {len(d1)} != {len(d2)}")
+ return False
+ for i in range(len(d1)):
+ if not compare_dicts(d1[i], d2[i], ignore_value):
+ logger.debug(f"Mismatch at index {i}: {d1[i]} != {d2[i]}")
+ return False
+ return True
+
+ # Direct value comparison
+ if d1 != d2:
+ logger.debug(f"Value mismatch: {d1} != {d2}")
+ return False
+ return True
+
+
+@dataclass
+class TestWalletConfig:
+ utxo_value_private: int
+ utxo_value_kyc: int
+ num_private: int
+ num_kyc: int
+
+
+@dataclass
+class TestCoinControlConfig:
+ opportunistic_merge_utxos: bool
+ python_random_seed: int = 0
+
+
+@pytest.fixture(
+ scope="session",
+ params=[
+ TestWalletConfig(utxo_value_private=1_000_000, num_private=5, utxo_value_kyc=2_000_000, num_kyc=1)
+ ],
+)
+def test_wallet_config(request) -> TestWalletConfig:
+ return request.param
+
+
+@pytest.fixture(
+ scope="session",
+ params=[
+ TestCoinControlConfig(opportunistic_merge_utxos=True),
+ TestCoinControlConfig(opportunistic_merge_utxos=False),
+ ],
+)
+def test_coin_control_config(request) -> TestCoinControlConfig:
+ return request.param
+
+
+# params
+# [(utxo_value_private, utxo_value_kyc)]
+@pytest.fixture(scope="session")
+def test_funded_wallet(
+ test_config: UserConfig,
+ bitcoin_core: Path,
+ faucet: Faucet,
+ test_wallet_config: TestWalletConfig,
+ wallet_name="test_tutorial_wallet_setup",
+) -> Wallet:
+
+ descriptor_str = "wpkh([5aa39a43/84'/1'/0']tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#xaf9qzlf"
+
+ descriptor_info = DescriptorInfo.from_str(descriptor_str=descriptor_str)
+ keystore = KeyStore(
+ xpub=descriptor_info.spk_providers[0].xpub,
+ fingerprint=descriptor_info.spk_providers[0].fingerprint,
+ key_origin=descriptor_info.spk_providers[0].key_origin,
+ label="test",
+ network=test_config.network,
+ )
+ wallet = Wallet(
+ id=wallet_name,
+ descriptor_str=descriptor_str,
+ keystores=[keystore],
+ network=test_config.network,
+ config=test_config,
+ )
+
+ # fund the wallet
+ addresses_private = [
+ wallet.get_address(force_new=True).address.as_string() for i in range(test_wallet_config.num_private)
+ ]
+ for address in addresses_private:
+ wallet.labels.set_addr_category(address, "Private")
+ faucet.send(address, amount=test_wallet_config.utxo_value_private)
+
+ addresses_kyc = [
+ wallet.get_address(force_new=True).address.as_string() for i in range(test_wallet_config.num_kyc)
+ ]
+ for address in addresses_kyc:
+ wallet.labels.set_addr_category(address, "KYC")
+ faucet.send(address, amount=test_wallet_config.utxo_value_kyc)
+
+ faucet.mine()
+ wallet.sync()
+
+ return wallet
+
+
+###############################
+###### Manual coin selection , spend_all_utxos=True
+###############################
+# no max amounts
+# change address must be created
+def test_manual_coin_selection(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+ test_coin_control_config: TestCoinControlConfig,
+) -> None:
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = True
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000, 25_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts]
+ recipients = [
+ Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts)
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1332
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1232,
+ "size": 308,
+ "vsize": 308,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 1
+
+
+###############################
+###### Manual coin selection , spend_all_utxos=True
+###############################
+# 1 max amount
+# no change address must be created
+def test_manual_coin_selection_1_max(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+ test_coin_control_config: TestCoinControlConfig,
+) -> None:
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = True
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(2)]
+ recipients = [
+ Recipient(address=addresses[0], amount=recpient_amounts[0]),
+ Recipient(
+ address=addresses[1],
+ checked_max_amount=True,
+ amount=500, # 500 is just to stay above the dust limit
+ ),
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1239
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1108,
+ "size": 277,
+ "vsize": 277,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 0
+
+
+###############################
+###### Manual coin selection , spend_all_utxos=True
+###############################
+# 2 max amount
+# no change address must be created
+def test_manual_coin_selection_2_max(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+ test_coin_control_config: TestCoinControlConfig,
+) -> None:
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = True
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000]
+ estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // 2
+ addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(3)]
+ recipients = [
+ Recipient(address=addresses[0], amount=recpient_amounts[0]),
+ Recipient(
+ address=addresses[1],
+ checked_max_amount=True,
+ amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively
+ ),
+ Recipient(
+ address=addresses[2],
+ checked_max_amount=True,
+ amount=500, # 500 is just to stay above the dust limit
+ ),
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1332
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1232,
+ "size": 308,
+ "vsize": 308,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+
+ expected_output_values = sorted(
+ recpient_amounts
+ + [estimated_max_amount_of_first_max]
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ - estimated_max_amount_of_first_max
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 0
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=False
+# no max amount
+# 1 change address must be created
+def test_category_coin_selection(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False)
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = False
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000, 25_000, 35_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts]
+ recipients = [
+ Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts)
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 609
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 700,
+ "size": 175,
+ "vsize": 175,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # only 1 input is needed
+ num_inputs_needed = 1 + sum(recpient_amounts) // test_wallet_config.utxo_value_private
+ assert len(tx_dict["input"]) == num_inputs_needed
+
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ num_inputs_needed * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 1
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=False
+# 1 max amount
+# no change address must be created
+def test_category_coin_selection_1_max(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False)
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = False
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(2)]
+ recipients = [
+ Recipient(address=addresses[0], amount=recpient_amounts[0]),
+ Recipient(
+ address=addresses[1],
+ checked_max_amount=True,
+ amount=500, # 500 is just to stay above the dust limit
+ ),
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1239
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1108,
+ "size": 277,
+ "vsize": 277,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # only 1 input is needed
+ assert len(tx_dict["input"]) == test_wallet_config.num_private
+
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 0
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=False
+# 2 max amount
+# no change address must be created
+def test_category_coin_selection_2_max(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False)
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = True
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000]
+ estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // 2
+ addresses = [wallet.get_address(force_new=True).address.as_string() for i in range(3)]
+ recipients = [
+ Recipient(address=addresses[0], amount=recpient_amounts[0]),
+ Recipient(
+ address=addresses[1],
+ checked_max_amount=True,
+ amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively
+ ),
+ Recipient(
+ address=addresses[2],
+ checked_max_amount=True,
+ amount=500, # 500 is just to stay above the dust limit
+ ),
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1332
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1232,
+ "size": 308,
+ "vsize": 308,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [estimated_max_amount_of_first_max]
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ - estimated_max_amount_of_first_max
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 0
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=False
+# 6 max amount
+# no change address must be created
+def test_category_coin_selection_6_max(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=False)
+ wallet = test_funded_wallet
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = True
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000]
+ num_max_recipients = 6
+ addresses = [
+ wallet.get_address(force_new=True).address.as_string() for i in range(num_max_recipients + 1)
+ ]
+ estimated_max_amount_of_first_max = (input_value - sum(recpient_amounts)) // num_max_recipients
+ recipients = [Recipient(address=addresses[0], amount=recpient_amounts[0])] + [
+ Recipient(
+ address=address,
+ checked_max_amount=True,
+ amount=estimated_max_amount_of_first_max, # bdk cant handle multiple max amounts natively
+ )
+ for address in addresses[1:]
+ ]
+
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 1704
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 1728,
+ "size": 432,
+ "vsize": 432,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ - estimated_max_amount_of_first_max * (num_max_recipients - 1)
+ ]
+ + [estimated_max_amount_of_first_max] * (num_max_recipients - 1)
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 0
+
+
+#######################################################################################################################################################################################################################################################################################
+## now opportunistic_merge_utxos=True
+##################################################################################################################################################################################################################################################################################################################################################################################################################################################
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=True
+# no max amount
+# 1 change address must be created
+def test_category_coin_selection_opportunistic(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=True, python_random_seed=1)
+ wallet = test_funded_wallet
+
+ random.seed(test_coin_control_config.python_random_seed)
+ np.random.seed(test_coin_control_config.python_random_seed)
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = False
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000, 25_000, 35_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts]
+ recipients = [
+ Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts)
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 813
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 864,
+ "size": 216,
+ "vsize": 216,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # the python_random_seed=2 leads to 2 inputs being chosen, even though only 1 is needed
+ num_inputs_chosen = 2
+ assert len(tx_dict["input"]) == num_inputs_chosen
+
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ num_inputs_chosen * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 1
+
+
+###############################
+###### Category coin selection , spend_all_utxos=False
+###############################
+# opportunistic_merge_utxos=True
+# no max amount
+# 1 change address must be created
+# different seed
+def test_category_coin_selection_opportunistic_different_seed(
+ test_funded_wallet: Wallet,
+ test_wallet_config: TestWalletConfig,
+) -> None:
+ test_coin_control_config = TestCoinControlConfig(opportunistic_merge_utxos=True, python_random_seed=42)
+ wallet = test_funded_wallet
+
+ random.seed(test_coin_control_config.python_random_seed)
+ np.random.seed(test_coin_control_config.python_random_seed)
+
+ txinfos = TxUiInfos()
+ txinfos.spend_all_utxos = False
+ txinfos.utxo_dict = {
+ str(utxo.outpoint): utxo
+ for utxo in wallet.get_all_utxos()
+ if wallet.labels.get_category(utxo.address) == "Private"
+ }
+ input_value = sum([utxo.txout.value for utxo in txinfos.utxo_dict.values()])
+ assert input_value == test_wallet_config.num_private * test_wallet_config.utxo_value_private
+ assert len(txinfos.utxo_dict) == test_wallet_config.num_private
+ txinfos.fee_rate = 3
+ txinfos.opportunistic_merge_utxos = test_coin_control_config.opportunistic_merge_utxos
+ txinfos.main_wallet_id = wallet.id
+
+ recpient_amounts = [15_000, 25_000, 35_000]
+ addresses = [wallet.get_address(force_new=True).address.as_string() for amount in recpient_amounts]
+ recipients = [
+ Recipient(address=address, amount=amount) for address, amount in zip(addresses, recpient_amounts)
+ ]
+ txinfos.recipients = recipients
+
+ builder_infos = wallet.create_psbt(txinfos)
+ psbt: bdk.PartiallySignedTransaction = builder_infos.builder_result.psbt
+
+ tx_dict = transaction_to_dict(psbt.extract_tx(), wallet.network)
+ assert psbt.fee_amount() == 609
+ assert compare_dicts(
+ tx_dict,
+ {
+ "txid": "value_to_be_ignored",
+ "weight": 700,
+ "size": 175,
+ "vsize": 175,
+ "serialize": "value_to_be_ignored",
+ "is_coin_base": False,
+ "is_explicitly_rbf": True,
+ "is_lock_time_enabled": True,
+ "version": 1,
+ "lock_time": "value_to_be_ignored",
+ "input": "value_to_be_ignored",
+ "output": "value_to_be_ignored",
+ },
+ )
+ # the python_random_seed=42 leads to 1 inputs being chosen
+ num_inputs_chosen = 1
+ assert len(tx_dict["input"]) == num_inputs_chosen
+
+ # bdk gives random sorting, so i have to compare sorted lists
+ # if utxo_value_private != utxo_value_kyc, this check will implicitly
+ # also check if the correct coin categories were selected
+ expected_output_values = sorted(
+ recpient_amounts
+ + [
+ num_inputs_chosen * test_wallet_config.utxo_value_private
+ - psbt.fee_amount()
+ - sum(recpient_amounts)
+ ]
+ )
+ values = sorted([output["value"] for output in tx_dict["output"]])
+ assert values == expected_output_values
+
+ # check that the recipient addresses are correct
+ output_addresses = [output["address"] for output in tx_dict["output"]]
+ assert len(set(output_addresses) - set(addresses)) == 1
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index d3e59bc..9e68e74 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -61,4 +61,5 @@ def test_config() -> TestConfig:
config.network_config.rpc_port = BITCOIN_PORT
config.network_config.rpc_username = RPC_USER
config.network_config.rpc_password = RPC_PASSWORD
+
return config
diff --git a/tests/test_setup_bitcoin_core.py b/tests/test_setup_bitcoin_core.py
index ac1c476..70334f6 100644
--- a/tests/test_setup_bitcoin_core.py
+++ b/tests/test_setup_bitcoin_core.py
@@ -344,20 +344,30 @@ def update(progress: float, message: str):
logger.debug(f"faucet syncing {progress, message}")
progress = bdk.Progress()
- progress.update = update
+ progress.update = update # type: ignore
self.wallet.sync(self.blockchain, progress)
- def initial_mine(self):
+ def mine(self, blocks=1, address=None):
+ address = (
+ address
+ if address
+ else self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string()
+ )
block_hashes = mine_blocks(
self.bitcoin_core,
- 200,
- address=self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string(),
+ blocks,
+ address=address,
)
self.sync()
balance = self.wallet.get_balance()
logger.debug(f"Faucet Wallet balance is: {balance.total}")
+ def initial_mine(self):
+ self.mine(
+ blocks=200, address=self.wallet.get_address(bdk.AddressIndex.LAST_UNUSED()).address.as_string()
+ )
+
@pytest.fixture(scope="session")
def faucet(bitcoin_core: Path) -> Faucet:
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/build.py b/tools/build.py
index a321e5f..d21250a 100644
--- a/tools/build.py
+++ b/tools/build.py
@@ -28,137 +28,22 @@
import argparse
-import csv
import logging
-import operator
import os
import platform
-import shlex
import shutil
-import subprocess
from pathlib import Path
-from subprocess import CompletedProcess
-from typing import List, Literal, Tuple, Union
+from typing import List, Literal
import tomlkit
+from translation_handler import TranslationHandler, run_local
from bitcoin_safe import __version__
from bitcoin_safe.signature_manager import KnownGPGKeys, SignatureSigner
+from tools.dependency_check import DependencyCheck
logger = logging.getLogger(__name__)
-
-
-def run_local(cmd) -> CompletedProcess:
- completed_process = subprocess.run(shlex.split(cmd), check=True)
- return completed_process
-
-
-# https://www.fincher.org/Utilities/CountryLanguageList.shtml
-class TranslationHandler:
- def __init__(
- self,
- module_name,
- languages=["zh_CN", "es_ES", "ru_RU", "hi_IN", "pt_PT", "ja_JP", "ar_AE", "it_IT"],
- prefix="app",
- ) -> None:
- self.module_name = module_name
- self.ts_folder = Path(module_name) / "gui" / "locales"
- self.prefix = prefix
- self.languages = languages
-
- def delete_po_files(self):
- for file in self.ts_folder.glob("*.po"):
- file.unlink()
-
- def get_all_python_files(self) -> List[str]:
- project_dir = Path(self.module_name)
- python_files = [str(file) for file in project_dir.rglob("*.py")]
- return python_files
-
- def get_all_ts_files(self) -> List[str]:
- python_files = [str(file) for file in self.ts_folder.rglob("*.ts")]
- return python_files
-
- def _ts_file(self, language: str) -> Path:
- return self.ts_folder / f"{self.prefix}_{language}.ts"
-
- @staticmethod
- def sort_csv(input_file: Path, output_file: Path, sort_columns: Union[Tuple[str, ...], List[str]]):
- """
- Sorts a CSV file by specified columns and writes the sorted data to another CSV file.
-
- Parameters:
- input_file (Path): The input CSV file path.
- output_file (Path): The output CSV file path.
- sort_columns (Tuple[str, ...]): A tuple of column names to sort the CSV data by (in priority order).
- """
- # Read the CSV file into a list of dictionaries
- with open(str(input_file), mode="r", newline="", encoding="utf-8") as infile:
- reader = csv.DictReader(infile)
- rows = list(reader)
-
- # Validate that all sort columns are in the fieldnames
- fieldnames = reader.fieldnames
- assert fieldnames
- for col in sort_columns:
- if col not in fieldnames:
- raise ValueError(f"Column '{col}' not found in CSV file")
-
- # Sort the rows by the specified columns (in priority order)
- sorted_rows = sorted(rows, key=operator.itemgetter(*sort_columns))
-
- # Write the sorted data to the output CSV file
- with open(str(output_file), mode="w", newline="", encoding="utf-8") as outfile:
- writer = csv.DictWriter(outfile, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
- writer.writeheader()
- writer.writerows(sorted_rows)
-
- def update_translations_from_py(self):
- for language in self.languages:
- ts_file = self._ts_file(language)
- run_local(
- f"pylupdate6 {' '.join(self.get_all_python_files())} -no-obsolete -ts {ts_file}"
- ) # -no-obsolete
- run_local(f"ts2po {ts_file} -o {ts_file.with_suffix('.po')}")
- run_local(f"po2csv {ts_file.with_suffix('.po')} -o {ts_file.with_suffix('.csv')}")
- self.sort_csv(
- ts_file.with_suffix(".csv"),
- ts_file.with_suffix(".csv"),
- sort_columns=["target", "location", "source"],
- )
-
- self.delete_po_files()
- self.compile()
-
- @staticmethod
- def quote_csv(input_file, output_file):
- # Read the CSV content from the input file
- with open(input_file, newline="") as infile:
- reader = csv.reader(infile)
- rows = list(reader)
-
- # Write the CSV content with quotes around each item to the output file
- with open(output_file, "w", newline="") as outfile:
- writer = csv.writer(outfile, quoting=csv.QUOTE_ALL)
- writer.writerows(rows)
-
- def csv_to_ts(self):
- for language in self.languages:
- ts_file = self._ts_file(language)
-
- # csv2po cannot handle partially quoted files
- self.sort_csv(
- ts_file.with_suffix(".csv"),
- ts_file.with_suffix(".csv"),
- sort_columns=["location", "source", "target"],
- )
- run_local(f"csv2po {ts_file.with_suffix('.csv')} -o {ts_file.with_suffix('.po')}")
- run_local(f"po2ts {ts_file.with_suffix('.po')} -o {ts_file}")
- self.delete_po_files()
- self.compile()
-
- def compile(self):
- run_local(f"/usr/lib/qt6/bin/lrelease {' '.join(self.get_all_ts_files())}")
+logging.basicConfig(level=logging.DEBUG)
class Builder:
@@ -192,30 +77,30 @@ def update_briefcase_requires(
# which is needed for bitcointx.
# bitcointx and with it the prebuild libsecp256k1 is not used for anything security critical
# key derivation with bitcointx is restricted to testnet/regtest/signet
- # and the PSBTFinalizer using bitcointx is safe because it handles no key material
+ # and the PSBTTools using bitcointx is safe because it handles no key material
additional_requires=[],
):
+
# Load pyproject.toml
with open(pyproject_path, "r") as file:
pyproject_data = tomlkit.load(file)
# Load and parse poetry lock file
with open(poetry_lock_path, "r") as file:
- poetry_lock_content = file.read()
+ poetry_lock_data = tomlkit.load(file)
briefcase_requires = []
- packages = poetry_lock_content.split("[[package]]")
- for package in packages[1:]: # Skip the first part as it's before the first package
- lines = package.split("\n")
- name = version = None
- for line in lines:
- if line.strip().startswith("name ="):
- name = line.split('"')[1].strip()
- elif line.strip().startswith("version ="):
- version = line.split('"')[1].strip()
- if name and version:
+ # Extract packages from the lock file
+ for package in poetry_lock_data["package"]:
+ name = package["name"]
+ version = package["version"]
+ if package.get("source"):
+ briefcase_requires.append(package.get("source", {}).get("url"))
+ else:
briefcase_requires.append(f"{name}=={version}")
- briefcase_requires += additional_requires
+
+ # Append any additional requires
+ briefcase_requires.extend(additional_requires)
# Ensure the structure exists before updating it
pyproject_data.setdefault("tool", {}).setdefault("briefcase", {}).setdefault("app", {}).setdefault(
@@ -231,6 +116,12 @@ def update_briefcase_requires(
with open(pyproject_path, "w") as file:
tomlkit.dump(pyproject_data, file)
+ if platform.system() == "Linux":
+ DependencyCheck.check_local_files_match_lockfile()
+ else:
+ pass
+ # the whl files build in Windows have a different checksum, and therefore the check will fail
+
def briefcase_appimage(self):
run_local("poetry run briefcase -u package linux appimage")
@@ -249,9 +140,6 @@ def briefcase_flatpak(self):
run_local("poetry run briefcase package linux flatpak")
def package_application(self, targets: List[Literal["windows", "mac", "appimage", "deb", "flatpak"]]):
- if self.version is None:
- print("Version could not be determined.")
- return
shutil.rmtree("build")
self.update_briefcase_requires()
@@ -419,7 +307,7 @@ def get_default_targets() -> List[str]:
return [
"appimage",
# "flatpak",
- "deb",
+ # "deb",
]
elif platform.system() == "Darwin":
return ["mac"]
diff --git a/tools/dependency_check.py b/tools/dependency_check.py
new file mode 100644
index 0000000..42900ea
--- /dev/null
+++ b/tools/dependency_check.py
@@ -0,0 +1,121 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import hashlib
+import logging
+from pathlib import Path
+from typing import Dict, List
+
+import tomlkit
+
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.DEBUG)
+
+
+class DependencyCheck:
+
+ @staticmethod
+ def compute_sha256(file_path: Path):
+ hash_sha256 = hashlib.sha256()
+ with open(str(file_path), "rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ hash_sha256.update(chunk)
+ return hash_sha256.hexdigest()
+
+ @classmethod
+ def compare_hashes(
+ cls, pakckage_name: str, hash_list: List[Dict[str, str]], dist_directory: Path
+ ) -> List[str]:
+ dist_files = [item.name for item in dist_directory.iterdir() if item.is_file()]
+
+ matches = [hash_dict for hash_dict in hash_list if hash_dict["file"] in dist_files]
+
+ if not matches:
+ raise Exception(
+ f"No file found in {dist_directory} that matches the filename in {[hash_dict['file'] for hash_dict in hash_list ]}"
+ )
+
+ for match_dict in matches:
+ assert match_dict["hash"].startswith("sha256:"), f"Error: wrong hash type of {match_dict['hash']}"
+ expected_hash = match_dict["hash"].replace("sha256:", "")
+
+ file_hash = cls.compute_sha256(dist_directory / match_dict["file"])
+
+ if file_hash == expected_hash:
+ logger.info(
+ f"{pakckage_name}: {dist_directory / match_dict['file']} matches the hash from the lock file"
+ )
+ else:
+ raise Exception(
+ f"Hash of {dist_directory / match_dict['file']} == {file_hash} doesnt match {expected_hash} , len ({len(expected_hash)})"
+ )
+
+ @classmethod
+ def check_local_files_match_lockfile(
+ cls, source_package_path: Path = Path("../"), poetry_file=Path("poetry.lock")
+ ) -> List[str]:
+ directory_names = cls.list_directories(folder_path=source_package_path)
+ poetry_packages = cls.extract_packages(lock_file_path=poetry_file)
+
+ for pakckage_name, hash_list in poetry_packages.items():
+ if pakckage_name in directory_names:
+ if not hash_list:
+ continue
+ cls.compare_hashes(
+ pakckage_name=pakckage_name,
+ hash_list=hash_list,
+ dist_directory=source_package_path / pakckage_name / "dist",
+ )
+
+ @staticmethod
+ def list_directories(folder_path: Path) -> List[str]:
+ """
+ List all directories in a given folder using pathlib.
+
+ :param folder_path: Path, the path to the folder in which to list directories
+ :return: List[str], list of directory names found in the folder
+ """
+ # List comprehension to filter directories
+ directories = [item.name for item in folder_path.iterdir() if item.is_dir()]
+ return directories
+
+ @staticmethod
+ def extract_packages(lock_file_path: Path) -> Dict[str, List[Dict[str, str]]]:
+ with open(str(lock_file_path), "r", encoding="utf-8") as file:
+ data = tomlkit.parse(file.read())
+
+ packages = {}
+ for package in data["package"]:
+ if "name" in package and "version" in package:
+ packages[package["name"]] = package["files"]
+ return packages
+
+
+if __name__ == "__main__":
+ packages = DependencyCheck.check_local_files_match_lockfile()
+ print(packages)
diff --git a/tools/release.py b/tools/release.py
index 24cd7c5..64b0abe 100644
--- a/tools/release.py
+++ b/tools/release.py
@@ -239,11 +239,7 @@ def get_input_with_default(prompt: str, default: str = "") -> str:
str: The user input or the default value if the user inputs nothing.
"""
# Adjust the prompt based on whether a default value is provided
- if default is not None:
- user_input = input(f"{prompt} (default: {default}): ")
- else:
- user_input = input(f"{prompt}: ")
-
+ user_input = input(f"{prompt} (default: {default}): ")
# Return the user input or the default if the input is empty and a default is specified
return user_input if user_input else default
diff --git a/tools/translation_handler.py b/tools/translation_handler.py
new file mode 100644
index 0000000..e02b37d
--- /dev/null
+++ b/tools/translation_handler.py
@@ -0,0 +1,186 @@
+#
+# Bitcoin Safe
+# Copyright (C) 2024 Andreas Griffin
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of version 3 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+import csv
+import logging
+import operator
+import shlex
+import subprocess
+from pathlib import Path
+from subprocess import CompletedProcess
+from typing import List, Tuple, Union
+
+logger = logging.getLogger(__name__)
+
+
+from concurrent.futures.thread import ThreadPoolExecutor
+
+
+def threadtable(f, arglist, max_workers=20):
+ with ThreadPoolExecutor(max_workers=int(max_workers)) as executor:
+ logger.debug("Starting {} threads {}({})".format(max_workers, str(f), str(arglist)))
+ res = []
+ for arg in arglist:
+ res.append(executor.submit(f, arg))
+ return [r.result() for r in res]
+
+
+def run_local(cmd) -> CompletedProcess:
+ completed_process = subprocess.run(shlex.split(cmd), check=True)
+ return completed_process
+
+
+# https://www.fincher.org/Utilities/CountryLanguageList.shtml
+class TranslationHandler:
+ def __init__(
+ self,
+ module_name,
+ languages=["zh_CN", "es_ES", "ru_RU", "hi_IN", "pt_PT", "ja_JP", "ar_AE", "it_IT", "fr_FR"],
+ prefix="app",
+ ) -> None:
+ self.module_name = module_name
+ self.ts_folder = Path(module_name) / "gui" / "locales"
+ self.prefix = prefix
+ self.languages = languages
+
+ logger.info("=" * 20)
+ logger.info(
+ f"""
+Translate all following lines to the following languages
+ {languages}
+Formatting instructions:
+- no bullets points.
+- preserve the linebreaks of each line perfectly!
+- leave the brackets {{}} and their content unchanged
+- group by language (add 2 linebreaks after the language caption)
+- please keep the linebreaks as in the originals
+Content to translate:
+"""
+ )
+ logger.info("=" * 20)
+
+ def delete_po_files(self):
+ for file in self.ts_folder.glob("*.po"):
+ file.unlink()
+
+ def get_all_python_files(self, additional_dirs=[".venv/lib"]) -> List[str]:
+ all_dirs = [self.module_name] + additional_dirs
+
+ python_files: List[str] = []
+ for d in all_dirs:
+ python_files += [str(file) for file in Path(d).rglob("*.py")]
+ return python_files
+
+ def get_all_ts_files(self) -> List[str]:
+ python_files = [str(file) for file in self.ts_folder.rglob("*.ts")]
+ return python_files
+
+ def _ts_file(self, language: str) -> Path:
+ return self.ts_folder / f"{self.prefix}_{language}.ts"
+
+ @staticmethod
+ def sort_csv(input_file: Path, output_file: Path, sort_columns: Union[Tuple[str, ...], List[str]]):
+ """
+ Sorts a CSV file by specified columns and writes the sorted data to another CSV file.
+
+ Parameters:
+ input_file (Path): The input CSV file path.
+ output_file (Path): The output CSV file path.
+ sort_columns (Tuple[str, ...]): A tuple of column names to sort the CSV data by (in priority order).
+ """
+ # Read the CSV file into a list of dictionaries
+ with open(str(input_file), mode="r", newline="", encoding="utf-8") as infile:
+ reader = csv.DictReader(infile)
+ rows = list(reader)
+
+ # Validate that all sort columns are in the fieldnames
+ fieldnames = reader.fieldnames
+ assert fieldnames
+ for col in sort_columns:
+ if col not in fieldnames:
+ raise ValueError(f"Column '{col}' not found in CSV file")
+
+ # Sort the rows by the specified columns (in priority order)
+ sorted_rows = sorted(rows, key=operator.itemgetter(*sort_columns))
+
+ # Write the sorted data to the output CSV file
+ with open(str(output_file), mode="w", newline="", encoding="utf-8") as outfile:
+ writer = csv.DictWriter(outfile, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
+ writer.writeheader()
+ writer.writerows(sorted_rows)
+
+ def update_translations_from_py(self):
+ python_files = self.get_all_python_files()
+ print(f"Found {len(python_files)} python files for translation")
+
+ def process(language):
+ ts_file = self._ts_file(language)
+ run_local(f"pylupdate6 {' '.join(python_files)} -no-obsolete -ts {ts_file}") # -no-obsolete
+ run_local(f"ts2po {ts_file} -o {ts_file.with_suffix('.po')}")
+ run_local(f"po2csv {ts_file.with_suffix('.po')} -o {ts_file.with_suffix('.csv')}")
+ self.sort_csv(
+ ts_file.with_suffix(".csv"),
+ ts_file.with_suffix(".csv"),
+ sort_columns=["target", "location", "source"],
+ )
+
+ threadtable(process, self.languages)
+
+ self.delete_po_files()
+ self.compile()
+
+ @staticmethod
+ def quote_csv(input_file, output_file):
+ # Read the CSV content from the input file
+ with open(input_file, newline="") as infile:
+ reader = csv.reader(infile)
+ rows = list(reader)
+
+ # Write the CSV content with quotes around each item to the output file
+ with open(output_file, "w", newline="") as outfile:
+ writer = csv.writer(outfile, quoting=csv.QUOTE_ALL)
+ writer.writerows(rows)
+
+ def csv_to_ts(self):
+ for language in self.languages:
+ ts_file = self._ts_file(language)
+
+ # csv2po cannot handle partially quoted files
+ self.sort_csv(
+ ts_file.with_suffix(".csv"),
+ ts_file.with_suffix(".csv"),
+ sort_columns=["location", "source", "target"],
+ )
+ run_local(f"csv2po {ts_file.with_suffix('.csv')} -o {ts_file.with_suffix('.po')}")
+ run_local(f"po2ts {ts_file.with_suffix('.po')} -o {ts_file}")
+ self.delete_po_files()
+ self.compile()
+
+ def compile(self):
+ run_local(f"/usr/lib/qt6/bin/lrelease {' '.join(self.get_all_ts_files())}")