diff --git a/.circleci/config.yml b/.circleci/config.yml index f938f46ce1..6b8f4aecad 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,6 @@ common: &common command: | python -m pip install --upgrade pip python -m pip install tox - python web3/scripts/install_pre_releases.py - run: name: run tox command: python -m tox run -r @@ -221,7 +220,7 @@ workflows: - common: matrix: parameters: - python_minor_version: ["10", "11", "12", "13"] + python_minor_version: ["10", "11", "12", "13", "14"] tox_env: [ "lint", "core", @@ -234,7 +233,7 @@ workflows: - geth: matrix: parameters: - python_minor_version: ["10", "11", "12", "13"] + python_minor_version: ["10", "11", "12", "13", "14"] tox_env: [ "integration-goethereum-ipc", "integration-goethereum-ipc_async", @@ -252,7 +251,7 @@ workflows: - windows-wheel: matrix: parameters: - python_minor_version: ["10", "11", "12", "13"] + python_minor_version: ["10", "11", "12", "13", "14"] name: "py3<< matrix.python_minor_version >>-windows-wheel" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71439d587f..42d97ef717 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py38-plus] diff --git a/conftest.py b/conftest.py index 06cbbdcacc..c02cf9eeb5 100644 --- a/conftest.py +++ b/conftest.py @@ -1,7 +1,14 @@ import pytest +import inspect import time import warnings +# Monkey-patch cached_property to use inspect.iscoroutinefunction +# instead of the deprecated asyncio.iscoroutinefunction (deprecated in Python 3.14+) +# cached_property is a dependency of py-evm, so once we remove py-evm +# or once cached_property gets a release, we can remove this. +# https://github.com/pydanny/cached-property/issues/276 +import cached_property as _cached_property_module import pytest_asyncio from tests.utils import ( @@ -20,6 +27,8 @@ EthereumTesterProvider, ) +_cached_property_module.asyncio.iscoroutinefunction = inspect.iscoroutinefunction + @pytest.fixture() def sleep_interval(): diff --git a/newsfragments/3779.breaking.rst b/newsfragments/3779.breaking.rst new file mode 100644 index 0000000000..e982f1db8c --- /dev/null +++ b/newsfragments/3779.breaking.rst @@ -0,0 +1 @@ +Upgrade websockets requirement to >=14.0. diff --git a/newsfragments/3779.feature.rst b/newsfragments/3779.feature.rst new file mode 100644 index 0000000000..35b51feb8a --- /dev/null +++ b/newsfragments/3779.feature.rst @@ -0,0 +1 @@ +Add support for Python 3.14 diff --git a/setup.py b/setup.py index f57315b8b1..b888d23ab7 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ "towncrier>=24,<25", ], "test": [ - "pytest-asyncio>=0.18.1,<0.23", + "pytest-asyncio>=1.0.0", "pytest-mock>=1.10", "pytest-xdist>=2.4.0", "pytest>=7.0.0", @@ -36,6 +36,7 @@ "tox>=4.0.0", "mypy==1.10.0", "pre-commit>=3.4.0", + "cached-property>=2.0.1", ], } @@ -78,7 +79,7 @@ "requests>=2.23.0", "typing-extensions>=4.0.1", "types-requests>=2.0.0", - "websockets>=10.0.0,<16.0.0", + "websockets>=14.0.0", "pyunormalize>=15.0.0", ], python_requires=">=3.10, <4", @@ -99,5 +100,6 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], ) diff --git a/tests/core/contracts/test_contract_call_interface.py b/tests/core/contracts/test_contract_call_interface.py index bdc8fe1cc6..8d09b0535d 100644 --- a/tests/core/contracts/test_contract_call_interface.py +++ b/tests/core/contracts/test_contract_call_interface.py @@ -58,7 +58,10 @@ def bytes_contract(w3, request, address_conversion_func): bytes_contract_factory = w3.eth.contract(**BYTES_CONTRACT_DATA) return deploy( - w3, bytes_contract_factory, address_conversion_func, args=[request.param] + w3, + bytes_contract_factory, + address_conversion_func, + args=[request.param], ) @@ -81,7 +84,10 @@ def non_strict_bytes_contract( @pytest.fixture def call_transaction(): - return {"data": "0x61bc221a", "to": "0xc305c901078781C232A2a521C2aF7980f8385ee9"} + return { + "data": "0x61bc221a", + "to": "0xc305c901078781C232A2a521C2aF7980f8385ee9", + } @pytest.fixture @@ -97,7 +103,10 @@ def bytes32_contract_factory(w3): ) def bytes32_contract(w3, bytes32_contract_factory, request, address_conversion_func): return deploy( - w3, bytes32_contract_factory, address_conversion_func, args=[request.param] + w3, + bytes32_contract_factory, + address_conversion_func, + args=[request.param], ) @@ -134,7 +143,9 @@ def test_deploy_raises_due_to_strict_byte_checking_by_default( ) -def test_invalid_address_in_deploy_arg(contract_with_constructor_address_factory): +def test_invalid_address_in_deploy_arg( + contract_with_constructor_address_factory, +): with pytest.raises(InvalidAddress): contract_with_constructor_address_factory.constructor( "0xd3cda913deb6f67967b99d67acdfa1712c293601", @@ -294,7 +305,8 @@ def test_call_get_byte_const_array_strict_by_default(arrays_contract, call): def test_call_get_byte_const_array_non_strict(non_strict_arrays_contract, call): result = call( - contract=non_strict_arrays_contract, contract_function="getByteConstValue" + contract=non_strict_arrays_contract, + contract_function="getByteConstValue", ) expected_byte_arr = [b"\x00", b"\x01"] assert result == expected_byte_arr @@ -302,7 +314,8 @@ def test_call_get_byte_const_array_non_strict(non_strict_arrays_contract, call): def test_call_read_address_variable(contract_with_constructor_address, call): result = call( - contract=contract_with_constructor_address, contract_function="testAddr" + contract=contract_with_constructor_address, + contract_function="testAddr", ) assert result == "0xd3CdA913deB6f67967B99D67aCDFa1712C293601" @@ -437,7 +450,12 @@ def test_call_address_list_reflector_with_address( def test_call_address_reflector_single_name(address_reflector_contract, call): with contract_ens_addresses( address_reflector_contract, - [("dennisthepeasant.eth", "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413")], + [ + ( + "dennisthepeasant.eth", + "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413", + ) + ], ): result = call( contract=address_reflector_contract, @@ -817,7 +835,10 @@ def test_call_sending_ether_to_nonpayable_function(payable_tester_contract, call "reflect(ufixed256x80)", Decimal(2**256 - 1) / 10**80, ), # maximum allowed value - ("reflect(ufixed256x80)", Decimal(1) / 10**80), # smallest non-zero value + ( + "reflect(ufixed256x80)", + Decimal(1) / 10**80, + ), # smallest non-zero value # minimum value (for ufixed8x1) ("reflect_short_u", 0), # maximum value (for ufixed8x1) @@ -1334,7 +1355,7 @@ async def async_mismatched_math_contract( return _mismatched_math_contract -@pytest_asyncio.fixture +@pytest.mark.asyncio async def test_async_deploy_raises_due_to_strict_byte_checking_by_default( async_w3, async_bytes_contract_factory, address_conversion_func ): @@ -1454,7 +1475,8 @@ async def test_async_call_get_bytes32_array(async_arrays_contract, async_call): @pytest.mark.asyncio async def test_async_call_get_bytes32_const_array(async_arrays_contract, async_call): result = await async_call( - contract=async_arrays_contract, contract_function="getBytes32ConstValue" + contract=async_arrays_contract, + contract_function="getBytes32ConstValue", ) # expected_bytes32_array = [keccak('A'), keccak('B')] expected_bytes32_array = [ @@ -1478,7 +1500,8 @@ async def test_async_call_get_byte_array_non_strict( async_non_strict_arrays_contract, async_call ): result = await async_call( - contract=async_non_strict_arrays_contract, contract_function="getByteValue" + contract=async_non_strict_arrays_contract, + contract_function="getByteValue", ) expected_non_strict_byte_arr = [b"\xff", b"\xff", b"\xff", b"\xff"] assert result == expected_non_strict_byte_arr @@ -1487,7 +1510,11 @@ async def test_async_call_get_byte_array_non_strict( @pytest.mark.asyncio @pytest.mark.parametrize("args,expected", [([b""], [b"\x00"]), (["0x"], [b"\x00"])]) async def test_async_set_byte_array_non_strict( - async_non_strict_arrays_contract, async_call, async_transact, args, expected + async_non_strict_arrays_contract, + async_call, + async_transact, + args, + expected, ): await async_transact( contract=async_non_strict_arrays_contract, @@ -1495,7 +1522,8 @@ async def test_async_set_byte_array_non_strict( func_args=[args], ) result = await async_call( - contract=async_non_strict_arrays_contract, contract_function="getByteValue" + contract=async_non_strict_arrays_contract, + contract_function="getByteValue", ) assert result == expected @@ -1536,7 +1564,8 @@ async def test_async_call_get_byte_const_array_non_strict( async_non_strict_arrays_contract, async_call ): result = await async_call( - contract=async_non_strict_arrays_contract, contract_function="getByteConstValue" + contract=async_non_strict_arrays_contract, + contract_function="getByteConstValue", ) expected_byte_arr = [b"\x00", b"\x01"] assert result == expected_byte_arr @@ -1700,7 +1729,12 @@ async def test_async_call_address_reflector_single_name( ): with contract_ens_addresses( async_address_reflector_contract, - [("dennisthepeasant.eth", "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413")], + [ + ( + "dennisthepeasant.eth", + "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413", + ) + ], ): result = await async_call( contract=async_address_reflector_contract, @@ -1757,7 +1791,8 @@ async def test_async_call_missing_function(async_mismatched_math_contract, async expected_missing_function_error_message = "Could not decode contract function call" with pytest.raises(BadFunctionCallOutput) as exception_info: await async_call( - contract=async_mismatched_math_contract, contract_function="return13" + contract=async_mismatched_math_contract, + contract_function="return13", ) assert expected_missing_function_error_message in str(exception_info.value) @@ -1771,7 +1806,8 @@ async def test_async_call_undeployed_contract( ) with pytest.raises(BadFunctionCallOutput) as exception_info: await async_call( - contract=async_undeployed_math_contract, contract_function="return13" + contract=async_undeployed_math_contract, + contract_function="return13", ) assert expected_undeployed_call_error_message in str(exception_info.value) @@ -1970,7 +2006,8 @@ async def test_async_call_not_sending_ether_to_nonpayable_function( async_payable_tester_contract, async_call ): result = await async_call( - contract=async_payable_tester_contract, contract_function="doNoValueCall" + contract=async_payable_tester_contract, + contract_function="doNoValueCall", ) assert result == [] @@ -2004,7 +2041,10 @@ async def test_async_call_sending_ether_to_nonpayable_function( "reflect(ufixed256x80)", Decimal(2**256 - 1) / 10**80, ), # maximum allowed value - ("reflect(ufixed256x80)", Decimal(1) / 10**80), # smallest non-zero value + ( + "reflect(ufixed256x80)", + Decimal(1) / 10**80, + ), # smallest non-zero value # minimum value (for ufixed8x1) ("reflect_short_u", 0), # maximum value (for ufixed8x1) @@ -2056,7 +2096,11 @@ async def test_async_reflect_fixed_value( Decimal("25.4" + "9" * DEFAULT_DECIMALS), NO_MATCHING_ARGUMENTS, ), - ("reflect(ufixed256x80)", Decimal(1) / 10**81, MULTIPLE_MATCHING_ELEMENTS), + ( + "reflect(ufixed256x80)", + Decimal(1) / 10**81, + MULTIPLE_MATCHING_ELEMENTS, + ), # floats not accepted, for floating point error concerns ("reflect_short_u", 0.1, NO_MATCHING_ARGUMENTS), ), diff --git a/tests/core/providers/test_websocket_provider.py b/tests/core/providers/test_websocket_provider.py index 5b08bde61b..c5cf86c9b6 100644 --- a/tests/core/providers/test_websocket_provider.py +++ b/tests/core/providers/test_websocket_provider.py @@ -14,6 +14,9 @@ ConnectionClosed, ConnectionClosedOK, ) +from websockets.protocol import ( + State, +) from web3 import ( AsyncWeb3, @@ -42,12 +45,12 @@ def _mock_ws(provider): provider._ws = AsyncMock() - provider._ws.closed = False + provider._ws.state = State.OPEN async def _mocked_ws_conn(): _conn = AsyncMock() - _conn.closed = False + _conn.state = State.OPEN return _conn @@ -354,17 +357,21 @@ async def test_async_iterator_pattern_exception_handling_for_requests(): raise_exception=ConnectionClosed(None, None) ), ): - async for w3 in AsyncWeb3(WebSocketProvider("ws://mocked")): - try: - await w3.eth.block_number - except ConnectionClosed: - if iterations == 3: - break - else: - iterations += 1 - continue - - pytest.fail("Expected `ConnectionClosed` exception.") + agen = AsyncWeb3(WebSocketProvider("ws://mocked")).__aiter__() + try: + async for w3 in agen: + try: + await w3.eth.block_number + except ConnectionClosed: + if iterations == 3: + break + else: + iterations += 1 + continue + + pytest.fail("Expected `ConnectionClosed` exception.") + finally: + await agen.aclose() assert iterations == 3 @@ -379,19 +386,23 @@ async def test_async_iterator_pattern_exception_handling_for_subscriptions(): raise_exception=ConnectionClosed(None, None) ), ): - async for w3 in AsyncWeb3(WebSocketProvider("ws://mocked")): - try: - async for _ in w3.socket.process_subscriptions(): - # raises exception - pass - except ConnectionClosed: - if iterations == 3: - break - else: - iterations += 1 - continue - - pytest.fail("Expected `ConnectionClosed` exception.") + agen = AsyncWeb3(WebSocketProvider("ws://mocked")).__aiter__() + try: + async for w3 in agen: + try: + async for _ in w3.socket.process_subscriptions(): + # raises exception + pass + except ConnectionClosed: + if iterations == 3: + break + else: + iterations += 1 + continue + + pytest.fail("Expected `ConnectionClosed` exception.") + finally: + await agen.aclose() assert iterations == 3 diff --git a/tests/utils.py b/tests/utils.py index 0ec8d97717..0937c12d46 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,4 +1,5 @@ import asyncio +import inspect import socket from web3._utils.threads import ( @@ -65,7 +66,7 @@ async def _async_wait_for_transaction_fixture_logic(async_w3, txn_hash, timeout= def async_partial(f, *args, **kwargs): async def f2(*args2, **kwargs2): result = f(*args, *args2, **kwargs, **kwargs2) - if asyncio.iscoroutinefunction(f): + if inspect.iscoroutinefunction(f): result = await result return result diff --git a/tox.ini b/tox.ini index ff5d2b5e84..1b1e86bd7f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist= - py{310,311,312,313}-{ens,core,lint,wheel} - py{310,311,312,313}-integration-{goethereum,ethtester} + py{310,311,312,313,314}-{ens,core,lint,wheel} + py{310,311,312,313,314}-integration-{goethereum,ethtester} docs benchmark windows-wheel @@ -19,6 +19,9 @@ max_issue_threshold=1 allowlist_externals=make,pre-commit install_command=python -m pip install {opts} {packages} usedevelop=True +commands_pre= + python {toxinidir}/web3/scripts/install_pre_releases.py + python -m pip freeze --all commands= core: pytest {posargs:tests/core -m "not asyncio" -n auto --maxprocesses=15} core_async: pytest {posargs:tests/core -m asyncio -n auto --maxprocesses=15} @@ -48,8 +51,9 @@ basepython = py311: python3.11 py312: python3.12 py313: python3.13 + py314: python3.14 -[testenv:py{310,311,312,313}-lint] +[testenv:py{310,311,312,313,314}-lint] deps=pre-commit extras=dev commands= @@ -64,7 +68,7 @@ commands= python {toxinidir}/web3/tools/benchmark/main.py --num-calls 100 -[testenv:py{310,311,312,313}-wheel] +[testenv:py{310,311,312,313,314}-wheel] deps= wheel build[virtualenv] diff --git a/web3/_utils/caching/caching_utils.py b/web3/_utils/caching/caching_utils.py index 08783172a2..8692da3aee 100644 --- a/web3/_utils/caching/caching_utils.py +++ b/web3/_utils/caching/caching_utils.py @@ -1,8 +1,6 @@ -from asyncio import ( - iscoroutinefunction, -) import collections import hashlib +import inspect import threading from typing import ( TYPE_CHECKING, @@ -330,7 +328,7 @@ async def _async_should_cache_response( cache_validator = ASYNC_INTERNAL_VALIDATION_MAP[method] return ( await cache_validator(provider, params, result) - if iscoroutinefunction(cache_validator) + if inspect.iscoroutinefunction(cache_validator) else cache_validator(provider, params, result) ) return True diff --git a/web3/_utils/module_testing/module_testing_utils.py b/web3/_utils/module_testing/module_testing_utils.py index f17d84cdfc..7bbf278438 100644 --- a/web3/_utils/module_testing/module_testing_utils.py +++ b/web3/_utils/module_testing/module_testing_utils.py @@ -24,6 +24,9 @@ HexBytes, ) import requests +from websockets.protocol import ( + State, +) from web3._utils.http import ( DEFAULT_HTTP_TIMEOUT, @@ -159,10 +162,12 @@ async def _mock_specific_request( class WebSocketMessageStreamMock: - closed: bool = False + state: State = State.OPEN def __init__( - self, messages: Collection[bytes] = None, raise_exception: Exception = None + self, + messages: Collection[bytes] = None, + raise_exception: Exception = None, ) -> None: self.queue = asyncio.Queue() # type: ignore # py38 issue for msg in messages or []: diff --git a/web3/_utils/module_testing/utils.py b/web3/_utils/module_testing/utils.py index 37f298da92..ad70d402f5 100644 --- a/web3/_utils/module_testing/utils.py +++ b/web3/_utils/module_testing/utils.py @@ -1,7 +1,4 @@ -from asyncio import ( - iscoroutinefunction, -) -import copy +import inspect from typing import ( TYPE_CHECKING, Any, @@ -113,12 +110,11 @@ def __init__( "AsyncMakeRequestFn", "MakeRequestFn" ] = w3.provider.make_request + self._mock_request_counter = 1 + def _build_request_id(self) -> int: - request_id = ( - next(copy.deepcopy(self.w3.provider.request_counter)) - if hasattr(self.w3.provider, "request_counter") - else 1 - ) + request_id = self._mock_request_counter + self._mock_request_counter += 1 return request_id def __enter__(self) -> "Self": @@ -144,7 +140,11 @@ def _mock_request_handler( if all( method not in mock_dict - for mock_dict in (self.mock_errors, self.mock_results, self.mock_responses) + for mock_dict in ( + self.mock_errors, + self.mock_results, + self.mock_responses, + ) ): return self._make_request(method, params) @@ -224,7 +224,7 @@ async def _async_build_mock_result( if callable(mock_return): mock_return = mock_return(method, params) - elif iscoroutinefunction(mock_return): + elif inspect.iscoroutinefunction(mock_return): # this is the "correct" way to mock the async make_request mock_return = await mock_return(method, params) @@ -239,7 +239,7 @@ async def _async_build_mock_result( if callable(mock_return): # handle callable to make things easier since we're mocking mock_return = mock_return(method, params) - elif iscoroutinefunction(mock_return): + elif inspect.iscoroutinefunction(mock_return): # this is the "correct" way to mock the async make_request mock_return = await mock_return(method, params) @@ -249,7 +249,7 @@ async def _async_build_mock_result( error = self.mock_errors[method] if callable(error): error = error(method, params) - elif iscoroutinefunction(error): + elif inspect.iscoroutinefunction(error): error = await error(method, params) mocked_result = merge(response_dict, self._create_error_object(error)) @@ -265,7 +265,11 @@ async def _async_mock_request_handler( self._make_request = cast("AsyncMakeRequestFn", self._make_request) if all( method not in mock_dict - for mock_dict in (self.mock_errors, self.mock_results, self.mock_responses) + for mock_dict in ( + self.mock_errors, + self.mock_results, + self.mock_responses, + ) ): return await self._make_request(method, params) mocked_result = await self._async_build_mock_result(method, params) @@ -289,7 +293,11 @@ async def _async_mock_send_handler( ) -> "RPCRequest": if all( method not in mock_dict - for mock_dict in (self.mock_errors, self.mock_results, self.mock_responses) + for mock_dict in ( + self.mock_errors, + self.mock_results, + self.mock_responses, + ) ): return await self._send_request(method, params) else: @@ -304,7 +312,11 @@ async def _async_mock_recv_handler( request_id = rpc_request["id"] if all( method not in mock_dict - for mock_dict in (self.mock_errors, self.mock_results, self.mock_responses) + for mock_dict in ( + self.mock_errors, + self.mock_results, + self.mock_responses, + ) ): return await self._recv_for_request(request_id) mocked_result = await self._async_build_mock_result( diff --git a/web3/providers/async_base.py b/web3/providers/async_base.py index 50eaa6b6d1..4b1bfa0d92 100644 --- a/web3/providers/async_base.py +++ b/web3/providers/async_base.py @@ -49,8 +49,8 @@ ) if TYPE_CHECKING: - from websockets.legacy.client import ( - WebSocketClientProtocol, + from websockets.asyncio.client import ( + ClientConnection, ) from web3 import ( # noqa: F401 @@ -169,7 +169,7 @@ async def disconnect(self) -> None: ) # WebSocket typing - _ws: "WebSocketClientProtocol" + _ws: "ClientConnection" # IPC typing _reader: asyncio.StreamReader | None diff --git a/web3/providers/persistent/websocket.py b/web3/providers/persistent/websocket.py index 4c18c8b5f7..6d2efe4706 100644 --- a/web3/providers/persistent/websocket.py +++ b/web3/providers/persistent/websocket.py @@ -12,13 +12,16 @@ from toolz import ( merge, ) +from websockets.asyncio.client import ( + ClientConnection, + connect, +) from websockets.exceptions import ( ConnectionClosedOK, WebSocketException, ) -from websockets.legacy.client import ( - WebSocketClientProtocol, - connect, +from websockets.protocol import ( + State, ) from web3.exceptions import ( @@ -69,7 +72,7 @@ def __init__( ) super().__init__(**kwargs) self.use_text_frames = use_text_frames - self._ws: WebSocketClientProtocol | None = None + self._ws: ClientConnection | None = None if not any( self.endpoint_uri.startswith(prefix) @@ -131,9 +134,12 @@ async def socket_recv(self) -> RPCResponse: async def _provider_specific_connect(self) -> None: self._ws = await connect(self.endpoint_uri, **self.websocket_kwargs) + def is_open(self) -> bool: + return self._ws.state == State.OPEN + async def _provider_specific_disconnect(self) -> None: # this should remain idempotent - if self._ws is not None and not self._ws.closed: + if self._ws is not None and self.is_open(): await self._ws.close() self._ws = None