From de5c20a26fcde6dddc97ecf3d2049022b700356e Mon Sep 17 00:00:00 2001 From: Uxio Fuentefria Date: Mon, 9 Nov 2026 15:22:00 +0000 Subject: [PATCH] Run the test suite on anvil instead of ganache - Replace ganache with anvil in docker compose, CI and run_tests.sh. Same mnemonic and chain id 1337, so test accounts do not change. - CI starts anvil with the compose service, so CI and local tests use the same node setup. - Bump safe-eth-py to 7.29.1. - Fix drain: get_erc_20_list asked for logs past the requested block range, which anvil rejects. - Node and connection errors in the interactive prompt are printed and the session keeps running. Switching mode only happens after the Safe info is loaded. - Tests read the node URL from the configuration instead of a hardcoded localhost:8545. - run_tests.sh uses uv with the lockfile, returns the pytest exit code and always stops the containers. --- .github/workflows/ci.yml | 6 +-- docker-compose.yml | 15 +++++-- pyproject.toml | 2 +- run_tests.sh | 10 ++--- src/safe_cli/safe_cli.py | 35 ++++++++++++++- src/safe_cli/utils.py | 4 +- tests/conftest.py | 5 +++ tests/test_hw_wallet_manager.py | 9 ++-- tests/test_safe_cli.py | 68 ++++++++++++++++++++++++++++++ tests/test_safe_cli_entry_point.py | 34 +++++++-------- tests/test_safe_operator.py | 2 +- tests/test_utils.py | 19 ++++++++- uv.lock | 42 ++++++++++++------ 13 files changed, 196 insertions(+), 55 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab60f7b..aa832740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,11 +35,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - name: Create Ganache network - run: docker network create ganache - - name: Setup and run ganache - run: docker run --detach --publish 8545:8545 --network ganache -e DOCKER=true trufflesuite/ganache:latest --defaultBalanceEther 10000 --gasLimit 10000000 -a 30 --chain.chainId 1337 --chain.networkId 1337 -d - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run anvil + run: docker compose --profile develop up --detach --wait anvil - name: Install uv uses: astral-sh/setup-uv@18d369b23da25a81e9458561248e1aafff6a70fa # v10.3.1 with: diff --git a/docker-compose.yml b/docker-compose.yml index ccb1c4d3..1141a924 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,19 @@ services: context: . dockerfile: ./Dockerfile - ganache: - image: trufflesuite/ganache:latest + anvil: + image: ghcr.io/foundry-rs/foundry:v1.9.0 ports: - "8545:8545" - command: --defaultBalanceEther 10000 --gasLimit 10000000 -a 10 --chain.chainId 1337 --chain.networkId 1337 -d --host 0.0.0.0 + command: >- + "anvil --host 0.0.0.0 --chain-id 1337 --accounts 10 --balance 10000 + --gas-limit 200000000 + --mnemonic 'myth like bonus scare over problem client lizard pioneer submit female collect'" healthcheck: - test: bash -c "echo 'hello' > /dev/tcp/localhost/8545" + test: ["CMD", "cast", "block-number"] + interval: 1s + timeout: 5s + retries: 10 + start_period: 5s profiles: - develop diff --git a/pyproject.toml b/pyproject.toml index ce6c0417..b82cbcee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "prompt_toolkit>=3", "pygments>=2", "requests>=2", - "safe-eth-py>=7.20.0", + "safe-eth-py>=7.29.1", "tabulate>=0.8", "typer>=0.14.0", ] diff --git a/run_tests.sh b/run_tests.sh index f10eb3c5..41003479 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,5 +1,5 @@ -#/usr/bin/env sh -docker compose --profile develop up -d ganache -pip install -e . -pytest -docker compose down --profile develop +#!/usr/bin/env sh +trap 'docker compose --profile develop down' EXIT +docker compose --profile develop up --detach --wait anvil || exit 1 +uv sync --frozen --group dev --all-extras || exit 1 +uv run --frozen pytest diff --git a/src/safe_cli/safe_cli.py b/src/safe_cli/safe_cli.py index 4b3e0e63..7c32ecca 100644 --- a/src/safe_cli/safe_cli.py +++ b/src/safe_cli/safe_cli.py @@ -2,11 +2,19 @@ import os import sys +import requests from eth_typing import ChecksumAddress from prompt_toolkit import HTML, PromptSession, print_formatted_text from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.formatted_text import html from prompt_toolkit.history import FileHistory from prompt_toolkit.lexers import PygmentsLexer +from web3.exceptions import ( + ContractLogicError, + ProviderConnectionError, + TimeExhausted, + Web3RPCError, +) from .operators import ( SafeCliTerminationException, @@ -18,6 +26,23 @@ from .safe_completer import SafeCompleter from .safe_lexer import SafeLexer +NODE_ERRORS = ( + Web3RPCError, + ContractLogicError, + TimeExhausted, + ProviderConnectionError, + requests.exceptions.RequestException, +) + + +def print_node_error(node_error: Exception) -> None: + print_formatted_text( + HTML( + f"{type(node_error).__name__}: " + f"{html.html_escape(str(node_error))}" + ) + ) + class SafeCli: def __init__(self, safe_address: ChecksumAddress, node_url: str, history: bool): @@ -76,11 +101,16 @@ def parse_operator_mode(self, command: str) -> SafeOperator | None: split_command = command.split() try: if (split_command[0]) == "tx-service": + tx_service_operator = SafeTxServiceOperator( + self.safe_address, self.node_url + ) + tx_service_operator.refresh_safe_cli_info() print_formatted_text( HTML("Sending txs to tx service") ) - return SafeTxServiceOperator(self.safe_address, self.node_url) + return tx_service_operator elif split_command[0] == "blockchain": + self.safe_operator.refresh_safe_cli_info() print_formatted_text( HTML("Sending txs to blockchain") ) @@ -109,7 +139,6 @@ def loop(self): new_operator = self.parse_operator_mode(command) if new_operator: self.prompt_parser = PromptParser(new_operator) - new_operator.refresh_safe_cli_info() # ClI info needs to be initialized else: self.prompt_parser.process_command(command) except SafeCliTerminationException: @@ -120,3 +149,5 @@ def loop(self): continue except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit): pass + except NODE_ERRORS as node_error: + print_node_error(node_error) diff --git a/src/safe_cli/utils.py b/src/safe_cli/utils.py index 0fe0bdde..c94817d4 100644 --- a/src/safe_cli/utils.py +++ b/src/safe_cli/utils.py @@ -25,7 +25,9 @@ def get_erc_20_list( addresses = set() for i in range(from_block, to_block + 1, block_step): events = ethereum_client.erc20.get_total_transfer_history( - from_block=i, to_block=i + (block_step - 1), addresses=[safe_address] + from_block=i, + to_block=min(i + block_step - 1, to_block), + addresses=[safe_address], ) for event in events: if "value" in event["args"]: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..42f641c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import os + +# safe-eth-py leaves `ethereum_node_url` as None when ETHEREUM_NODE_URL is not +# set, and the CLI commands under test need the node URL as an argument +os.environ.setdefault("ETHEREUM_NODE_URL", "http://localhost:8545") diff --git a/tests/test_hw_wallet_manager.py b/tests/test_hw_wallet_manager.py index a2f85994..e7f161df 100644 --- a/tests/test_hw_wallet_manager.py +++ b/tests/test_hw_wallet_manager.py @@ -187,7 +187,7 @@ def test_execute( tx_parameters = { "from": owner.address, "gasPrice": safe_tx.w3.eth.gas_price, - "nonce": 0, + "nonce": self.ethereum_client.get_nonce_for_account(owner.address), "gas": safe_tx.recommended_gas(), } safe_tx.tx = safe_tx.w3_tx.build_transaction(tx_parameters) @@ -196,7 +196,7 @@ def test_execute( ) mocked_signed_transaction_response = SignedTransaction( - nonce=0, + nonce=safe_tx.tx["nonce"], gas_price=safe_tx.tx["gasPrice"], gas_limit=safe_tx.tx["gas"], destination=HexBytes(safe_tx.tx["to"]), @@ -210,6 +210,5 @@ def test_execute( mock_ledger_create_transaction.return_value = mocked_signed_transaction_response tx_hash, tx = hw_wallet_manager.execute_safe_tx(safe_tx) self.assertEqual(tx["data"], safe_tx.tx["data"]) - self.assertIsNotNone( - self.ethereum_client.w3.eth.get_transaction_receipt(tx_hash) - ) + self.assertEqual(tx["nonce"], tx_parameters["nonce"]) + self.assertEqual(self.wait_for_receipt(tx_hash)["status"], 1) diff --git a/tests/test_safe_cli.py b/tests/test_safe_cli.py index 7b63db53..49481c20 100644 --- a/tests/test_safe_cli.py +++ b/tests/test_safe_cli.py @@ -1,13 +1,17 @@ import unittest +from unittest import mock +import requests from eth_account import Account from safe_eth.safe import Safe from safe_eth.util.util import to_0x_hex_str from web3 import Web3 +from web3.exceptions import Web3RPCError from safe_cli.operators import SafeCliTerminationException from safe_cli.operators.safe_operator import SafeOperator from safe_cli.prompt_parser import PromptParser +from safe_cli.safe_cli import SafeCli from .safe_cli_test_case_mixin import SafeCliTestCaseMixin @@ -81,6 +85,70 @@ def test_safe_cli_happy_path(self): with self.assertRaises(SafeCliTerminationException): prompt_parser.process_command("exit") + def test_node_error_keeps_prompt_running(self): + """A node or connection error in a command is printed and the prompt keeps running.""" + safe_cli = SafeCli( + self.setup_operator().address, self.ethereum_node_url, history=False + ) + node_errors = [ + Web3RPCError("block is out of range"), + requests.exceptions.ConnectionError("node "), + ] + + for node_error in node_errors: + with ( + self.subTest(node_error=type(node_error).__name__), + mock.patch.object( + SafeCli, + "get_command", + side_effect=[f"drain {Account.create().address}", "exit"], + ), + mock.patch.object(SafeOperator, "drain", side_effect=node_error), + mock.patch("safe_cli.safe_cli.print_formatted_text") as print_mock, + ): + safe_cli.loop() + + printed_texts = [ + call.args[0].value for call in print_mock.call_args_list + ] + node_error_texts = [ + printed_text + for printed_text in printed_texts + if type(node_error).__name__ in printed_text + ] + self.assertEqual(len(node_error_texts), 1) + # The node message is escaped, so it does not break the HTML formatting + self.assertIn("<", node_error_texts[0]) + + def test_node_error_on_operator_switch_keeps_current_mode(self): + """A node error while switching operator mode is printed, no switch message is shown and the prompt keeps the current mode.""" + safe_cli = SafeCli( + self.setup_operator().address, self.ethereum_node_url, history=False + ) + previous_prompt_parser = safe_cli.prompt_parser + + with ( + mock.patch.object( + SafeCli, "get_command", side_effect=["blockchain", "exit"] + ), + mock.patch.object( + SafeOperator, + "refresh_safe_cli_info", + side_effect=requests.exceptions.ConnectionError("node down"), + ), + mock.patch("safe_cli.safe_cli.print_formatted_text") as print_mock, + ): + safe_cli.loop() + + printed_texts = [call.args[0].value for call in print_mock.call_args_list] + self.assertTrue( + any("ConnectionError" in printed_text for printed_text in printed_texts) + ) + self.assertFalse( + any("Sending txs to" in printed_text for printed_text in printed_texts) + ) + self.assertIs(safe_cli.prompt_parser, previous_prompt_parser) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_safe_cli_entry_point.py b/tests/test_safe_cli_entry_point.py index 239f5a63..ae327620 100644 --- a/tests/test_safe_cli_entry_point.py +++ b/tests/test_safe_cli_entry_point.py @@ -49,7 +49,7 @@ def test_send_ether(self): [ "send-ether", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "20", "--private-key", @@ -68,7 +68,7 @@ def test_send_ether(self): [ "send-ether", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "20", "--private-key", @@ -84,7 +84,7 @@ def test_send_ether(self): [ "send-ether", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "20", "--private-key", @@ -100,7 +100,7 @@ def test_send_ether(self): [ "send-ether", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "20", "--private-key", @@ -114,7 +114,7 @@ def test_send_ether(self): [ "send-ether", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "20", "--private-key", @@ -138,7 +138,7 @@ def test_send_erc20(self): [ "send-erc20", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, random_token_address, "20", @@ -158,7 +158,7 @@ def test_send_erc20(self): [ "send-erc20", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, random_token_address, "20", @@ -182,7 +182,7 @@ def test_send_erc721(self): [ "send-erc721", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, random_token_address, "1", @@ -202,7 +202,7 @@ def test_send_erc721(self): [ "send-erc721", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, random_token_address, "1", @@ -228,7 +228,7 @@ def test_send_custom(self): [ "send-custom", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "0", data, @@ -248,7 +248,7 @@ def test_send_custom(self): [ "send-custom", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, random_address, "0", data, @@ -270,7 +270,7 @@ def test_tx_builder(self): [ "tx-builder", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, "tests/mocks/tx_builder/empty_txs.json", "--private-key", to_0x_hex_str(safe_owner.key), @@ -284,7 +284,7 @@ def test_tx_builder(self): [ "tx-builder", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, "tests/mocks/tx_builder/single_tx.json", "--private-key", to_0x_hex_str(safe_owner.key), @@ -298,7 +298,7 @@ def test_tx_builder(self): [ "tx-builder", safe_operator.safe.address, - "http://localhost:8545", + self.ethereum_node_url, "tests/mocks/tx_builder/batch_txs.json", "--private-key", to_0x_hex_str(safe_owner.key), @@ -338,7 +338,7 @@ def test_build_safe_cli(self, retrieve_all_info_mock: MagicMock): [ "attended-mode", self.random_safe_address, - "http://localhost:8545", + self.ethereum_node_url, "--history", ], ) @@ -373,7 +373,7 @@ def test_build_safe_cli_for_owner( [ "attended-mode", safe_owner, - "http://localhost:8545", + self.ethereum_node_url, "--history", "--get-safes-from-owner", ], @@ -408,7 +408,7 @@ def test_parse_operator_mode( [ "attended-mode", self.random_safe_address, - "http://localhost:8545", + self.ethereum_node_url, "--history", ], ) diff --git a/tests/test_safe_operator.py b/tests/test_safe_operator.py index 2cb47e0e..0bb9ae39 100644 --- a/tests/test_safe_operator.py +++ b/tests/test_safe_operator.py @@ -498,7 +498,7 @@ def test_drain(self): generate_transfers_erc20( self.w3, safe_operator, account, num_contracts_erc20, num_transactions ) - # Deploying multisend contract on ganache + # Deploying multisend contract _, _, contract_address = MultiSend.deploy_contract( self.ethereum_client, account ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 9ff7cf2d..6d2cd014 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,11 @@ from eth_account import Account -from safe_cli.utils import choose_option_from_list, yes_or_no_question +from safe_cli.utils import ( + choose_option_from_list, + get_erc_20_list, + yes_or_no_question, +) class TestUtils(unittest.TestCase): @@ -54,6 +58,19 @@ def test_choose_option_from_list(self, input_mock: MagicMock): os.environ["PYTEST_CURRENT_TEST"] = pytest_current_test + def test_get_erc_20_list_does_not_query_past_to_block(self): + """Log ranges are split by block_step and the last range ends at to_block.""" + ethereum_client = MagicMock() + ethereum_client.erc20.get_total_transfer_history.return_value = [] + + get_erc_20_list(ethereum_client, Account.create().address, 1, 25, block_step=10) + + requested_block_ranges = [ + (call.kwargs["from_block"], call.kwargs["to_block"]) + for call in ethereum_client.erc20.get_total_transfer_history.call_args_list + ] + self.assertEqual(requested_block_ranges, [(1, 10), (11, 20), (21, 25)]) + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 37376f33..64003a4e 100644 --- a/uv.lock +++ b/uv.lock @@ -3264,7 +3264,7 @@ requires-dist = [ { name = "prompt-toolkit", specifier = ">=3" }, { name = "pygments", specifier = ">=2" }, { name = "requests", specifier = ">=2" }, - { name = "safe-eth-py", specifier = ">=7.20.0" }, + { name = "safe-eth-py", specifier = ">=7.29.1" }, { name = "tabulate", specifier = ">=0.8" }, { name = "trezor", marker = "extra == 'trezor'", specifier = "==0.13.10" }, { name = "typer", specifier = ">=0.14.0" }, @@ -3285,7 +3285,7 @@ dev = [ [[package]] name = "safe-eth-py" -version = "7.26.1" +version = "7.29.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, @@ -3294,23 +3294,37 @@ dependencies = [ { name = "safe-pysha3" }, { name = "web3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/91/bd8224dcad283bbd45387200bb9117690e3856945e11a35ffc00c60e6d85/safe_eth_py-7.26.1.tar.gz", hash = "sha256:aac60b4d041b930906a3a04deef9b6418b34578d4f44ce7c10e854c6c444154b", size = 892158, upload-time = "2026-09-23T12:36:04.345Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/dd/b02791a4210d02a119d925baa4927bd20c97b1a727e50821be7abd1dda24/safe_eth_py-7.29.1.tar.gz", hash = "sha256:bea81a3f1d89a28b4f9704421cfe06347a63fbc32d02a52651647baabd99db18", size = 898143, upload-time = "2026-11-05T16:21:54.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/18/f2c4f08cbd84eb14dd48d684d5d5c9b5230c7363f033738167328fe457ec/safe_eth_py-7.26.1-py3-none-any.whl", hash = "sha256:70380336f5c4005d5827c5f6e3d9656739e2287b35758fc417db93011906913f", size = 1015540, upload-time = "2026-09-23T12:36:02.461Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/fa810feb9f2a32af3966f457c28888c4b066ec77d95c07aa4efecd326466/safe_eth_py-7.29.1-py3-none-any.whl", hash = "sha256:ab713fb1789d80326f90785857449b2c2706ef31bd66becbfabff67d624c2153", size = 1021788, upload-time = "2026-11-05T16:21:53.149Z" }, ] [[package]] name = "safe-pysha3" -version = "1.0.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/27/a942b8a95b97c5fb8d27d3b86b8d269546cf21f87d9ec6b44c3702ce67cc/safe_pysha3-1.0.5.tar.gz", hash = "sha256:88ceaad6af4b6bdecd2f54b31ad0e5e5e210d4f5ecabb1bd1fd3539ad61b7bf1", size = 827796, upload-time = "2025-06-19T13:55:04.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/80/60f78a39693d5d38440d1e71f117f0131d668bfa42988ba23bcdd45f66ac/safe_pysha3-1.0.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4505f4b3ce327a8b02299e48b55c32094ed15c63f83e8d9477ebe91e8777fc8f", size = 190198, upload-time = "2025-06-20T15:19:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ad/30e8abf4535b0ed4f36eea451534f61adb1012226d7a36c1e1ed970717f0/safe_pysha3-1.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4048005b764861f36eed98a83fb04268c972b6100fe530303999ff6fce744e64", size = 184239, upload-time = "2025-06-20T15:19:19.786Z" }, - { url = "https://files.pythonhosted.org/packages/bd/59/8d7f64a3eb6ebbde248cc0347a67d7825e2ba6edfa2bd180ebdfb599b3c5/safe_pysha3-1.0.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d137a73029c6c5a1db5791ae9fa62373827eee5226d19b79b836a6cf48b6b197", size = 190427, upload-time = "2025-06-20T15:19:20.862Z" }, - { url = "https://files.pythonhosted.org/packages/da/6a/25e926ab9c8d714be2ee1d40f45eb425f438c968d97ede69a7cbff054094/safe_pysha3-1.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0cb37252a8767992f354d7d2af2ef04730032927eb6af2057e71744c741287", size = 184478, upload-time = "2025-06-20T15:19:22.289Z" }, - { url = "https://files.pythonhosted.org/packages/3a/da/7e1f252e6984f4b82393e4e083d3a48806e8a7225651559e50dec0000c68/safe_pysha3-1.0.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2019065f1b7d3db37cc52d091c9d5526d5d36a3e1b9efcf0b345c24e03755bff", size = 190543, upload-time = "2025-06-20T15:19:23.426Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a6/e134e353a4961090caf4f429044b845584fe11d2217960a2549943ef01ea/safe_pysha3-1.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa37d5d6138d5dd01d1035dba019b7525ad7c55669ded4524f589cddd13ea13b", size = 367223, upload-time = "2025-06-20T15:19:24.453Z" }, +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/42/567532dd77dc81f9462a92edf9b48146c81baf6085397a902b9dab1b1dd2/safe_pysha3-1.0.7.tar.gz", hash = "sha256:0ac3406fbe37c3c961efb446f117deaf3049411d298a6e573cc4bae9732e19a1", size = 829160, upload-time = "2026-10-14T12:15:54.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a0/445b7b6bc75a4a6e1beeedaf2a7c18c934726e2a5c2aaf2a497e9b4f3ed5/safe_pysha3-1.0.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:488d79ee7ccbef309cb76f424b33c7cfea17caa134fb1ff1ad9dc7d110a93b0d", size = 132806, upload-time = "2026-10-14T12:15:34.021Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ed/3579256a3941c8c27fdcabad649f180c6bd7b6e5be4a752aefb400fd8e65/safe_pysha3-1.0.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b5240408d8240c0cc238ec13b0bed54adfb264feaae1163d0b12f722e2e3ab4", size = 196992, upload-time = "2026-10-14T12:15:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/19/dbd8895ff1d6ae147fef036c200633defd3227f1fad580805d6e218d331e/safe_pysha3-1.0.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:64bf068593354a47a161c9342f88c2b90fb5270e602c0524e8831583532fc939", size = 129590, upload-time = "2026-10-14T12:15:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/05/68ea0e5b0fc8987e0bd5952c5fb207fc03c0dc464358a27dad856e4bef47/safe_pysha3-1.0.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7fcc86dcb3293a31e0333c88cbfc0f0cea63e4ec01924aaa332e69043c104a8d", size = 190623, upload-time = "2026-10-14T12:15:37.294Z" }, + { url = "https://files.pythonhosted.org/packages/46/12/93030939c50ea9c68a16103b65c1d2c015dfe74d06443003d510ea76b52e/safe_pysha3-1.0.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1574f6092f5e9f8196457d0831474b8d9a7773f0880044ab8a6f8cfb6621d0ae", size = 132630, upload-time = "2026-10-14T12:15:38.186Z" }, + { url = "https://files.pythonhosted.org/packages/df/76/9cfe8a083fa14f7f6367cadd141dcee9dd12f88d8f7ed6c20bbdae99aabe/safe_pysha3-1.0.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8de1c17b9df84117a4250bae982a1ca18724de3713ffa33a849c682390cfdba2", size = 197270, upload-time = "2026-10-14T12:15:39.418Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2c/dbdadc9b0e90a26f406f5d23db69a67b32d612a1fd641b0dc8e3529545b1/safe_pysha3-1.0.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c8ea872f17e633b994622dc2c24fce0b2f3c9a35ac8caec8031c63dae00c82", size = 129282, upload-time = "2026-10-14T12:15:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/12428516f15aa7fbb6406befff187d9fa067136d2adb76d99eda8e3d68f2/safe_pysha3-1.0.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6afd35e3789bfbde99adb44cec823690ce44235d9a9b65448d4d83da6c26bf7f", size = 190819, upload-time = "2026-10-14T12:15:41.387Z" }, + { url = "https://files.pythonhosted.org/packages/53/50/baa9822ea29641a4900b4df36ebde7346a8fcb66180468d14904ace4d0ae/safe_pysha3-1.0.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db29cf13f28999f05d3e0078cc6ed4dfb9a900df3c4d9499d3aa865e8141463", size = 132763, upload-time = "2026-10-14T12:15:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/3a98d45e064a2dcab9cd2c04f3fe5944ba4fdd4a74b4f8b084e2acd04872/safe_pysha3-1.0.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fc5869a7310a21f3e50f21a64c7f34d1601288a63c1d51010830963c3ea747", size = 197372, upload-time = "2026-10-14T12:15:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/9a82b34a32d31700019efb90ee3d4c86f3b6763d6b848e842bd19904ca74/safe_pysha3-1.0.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:173b178abeb466614b533e4b6a2d88b9859f8000261b8119706de5908aeb819b", size = 129400, upload-time = "2026-10-14T12:15:44.811Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ee/e0f24dd3b5da9c0db70342a6cb80a1fa391fe19f76b1f51da1ef956a8c1a/safe_pysha3-1.0.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0bfc11f61118227488ce7830c46406e324bef78932aa473bac03e564dac7d148", size = 190901, upload-time = "2026-10-14T12:15:45.747Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a4/c2c40999b6de06af5032678983266a8f0bcf351a9ba8b771551e367c5c48/safe_pysha3-1.0.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aea2115dc13c54836ef4e1e9c1a71e83fa74ec1ffd57f9d64b8493931834c18", size = 132771, upload-time = "2026-10-14T12:15:46.681Z" }, + { url = "https://files.pythonhosted.org/packages/16/2d/7174506bc69ea8e3391d7bbee5c9f339e444e86186790050594191b0a657/safe_pysha3-1.0.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d93cb8edbbb6b3a890d527b856bc8775f0afd719477d22afd96e5c376cc399ee", size = 197343, upload-time = "2026-10-14T12:15:47.888Z" }, + { url = "https://files.pythonhosted.org/packages/73/c8/b7d331fbbc5c33e24e3518ce89779c2209238f4e96a12072f2839b340112/safe_pysha3-1.0.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:78f946743fcb3cca48408d74d0f56d1035d091523d81799274858f9def897900", size = 129513, upload-time = "2026-10-14T12:15:48.816Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b0/5eab47b9e8f73425a4896c53033b448eb375111de794c346ed617e5a3ef2/safe_pysha3-1.0.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:faace7b6534052571b360aaa831e7ef17c0ac46ce7de427559995f27d9304290", size = 190883, upload-time = "2026-10-14T12:15:49.767Z" }, + { url = "https://files.pythonhosted.org/packages/b4/85/e820f3b680f45af41cee89df2c309a3acc5f3e22902b3461629f977e77f2/safe_pysha3-1.0.7-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c703e5ec3beb135b49fe88213c66f7d64b5b0f01cefe2f295087049a348ae7d", size = 133308, upload-time = "2026-10-14T12:15:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/665ce127d2082aab4473941756af76501a8e1a96fba37ff051029f3a396b/safe_pysha3-1.0.7-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dcb571dd50c3400446a650df416f9828278d1c089466189aba0736872f9648d", size = 202979, upload-time = "2026-10-14T12:15:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/63ddfc28e9a09f83401d186474b0593e1a4598fad4c2fb445ecf6346f899/safe_pysha3-1.0.7-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d4be23c5f995e6b691247bcf8410e6cb55ab1ca0c80b083b21566e200e9d9509", size = 130130, upload-time = "2026-10-14T12:15:52.721Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3a/96594d948ab91be607e1bb70bdab7190d5014613f8ca6c6cef44a1a9ea5a/safe_pysha3-1.0.7-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c3778bb47d1a445e7d34060a12e62eda05a0a81c129e793121d750d3cd20ccb2", size = 196444, upload-time = "2026-10-14T12:15:53.718Z" }, ] [[package]]