diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5ab60f7..aa83274 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 ccb1c4d..1141a92 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 ce6c041..b82cbce 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 f10eb3c..4100347 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 4b3e0e6..7c32ecc 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 0fe0bdd..c94817d 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 0000000..42f641c
--- /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 a2f8599..e7f161d 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 7b63db5..49481c2 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 239f5a6..ae32762 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 2cb47e0..0bb9ae3 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 9ff7cf2..6d2cd01 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 37376f3..64003a4 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]]