diff --git a/CHANGELOG.md b/CHANGELOG.md index e95ce43bb7..3558de5eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # Changelog -## 10.3.0 /2026-0-21 +## 10.3.1 /2026-05-06 + +## What's Changed +* Bumps cyscale and ASI by @thewhaleking in https://github.com/latent-to/bittensor/pull/3336 +* fix(networking): catch real exceptions in get_external_ip fallback chain by @ArtificialXai in https://github.com/latent-to/bittensor/pull/3318 +* Updates packages with CVEs by @thewhaleking in https://github.com/latent-to/bittensor/pull/3339 +* Should fix the change to pytest-asyncio 1.3 by @thewhaleking in https://github.com/latent-to/bittensor/pull/3340 +* Bumping deps versions by @basfroman in https://github.com/latent-to/bittensor/pull/3337 +* Updated error link to point to new docs page by @chideraao in https://github.com/latent-to/bittensor/pull/3341 + +## New Contributors +* @ArtificialXai made their first contribution in https://github.com/latent-to/bittensor/pull/3318 +* @chideraao made their first contribution in https://github.com/latent-to/bittensor/pull/3341 + +**Full Changelog**: https://github.com/latent-to/bittensor/compare/v10.3.0...v10.3.1 + +## 10.3.0 /2026-04-21 ## What's Changed * Fix logging.info and state transitions in LoggingMachine by @ionodeionode in https://github.com/latent-to/bittensor/pull/3270 diff --git a/bittensor/utils/__init__.py b/bittensor/utils/__init__.py index 374f1aa67b..e1ef7a0bd8 100644 --- a/bittensor/utils/__init__.py +++ b/bittensor/utils/__init__.py @@ -300,7 +300,7 @@ def format_error_message(error_message: Union[dict, Exception]) -> str: err_docs if isinstance(err_docs, str) else " ".join(err_docs) ) err_description += ( - f" | Please consult {BT_DOCS_LINK}/errors/subtensor#{err_name.lower()}" + f" | Please consult {BT_DOCS_LINK}/subtensor-api/errors#{err_name.lower()}" ) elif error_message.get("code") and error_message.get("message"): diff --git a/bittensor/utils/networking.py b/bittensor/utils/networking.py index 4558907a73..b89d9cd466 100644 --- a/bittensor/utils/networking.py +++ b/bittensor/utils/networking.py @@ -2,6 +2,7 @@ import os from typing import Optional +from urllib import error as urllib_error from urllib import request as urllib_request import netaddr @@ -13,6 +14,22 @@ class ExternalIPNotFound(Exception): """Raised if we cannot attain your external ip from CURL/URLLIB/IPIFY/AWS""" +# Exceptions each provider in `get_external_ip` can raise on failure. Caught per +# provider so that a single failing lookup falls through to the next one instead +# of crashing the whole function. Kept as a tuple so the list stays consistent +# across providers. +_IP_LOOKUP_EXCEPTIONS = ( + requests.exceptions.RequestException, + netaddr.core.AddrFormatError, + OSError, + urllib_error.URLError, + ValueError, + KeyError, + AssertionError, + ExternalIPNotFound, +) + + def int_to_ip(int_val: int) -> str: """Maps an integer to a unique ip-string @@ -68,7 +85,7 @@ def get_external_ip() -> str: external_ip = requests.get("https://checkip.amazonaws.com").text.strip() assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass # --- Try ipconfig. @@ -78,7 +95,7 @@ def get_external_ip() -> str: process.close() assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass # --- Try ipinfo. @@ -88,7 +105,7 @@ def get_external_ip() -> str: process.close() assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass # --- Try myip.dnsomatic @@ -98,7 +115,7 @@ def get_external_ip() -> str: process.close() assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass # --- Try urllib ipv6 @@ -106,7 +123,7 @@ def get_external_ip() -> str: external_ip = urllib_request.urlopen("https://ident.me").read().decode("utf8") assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass # --- Try Wikipedia @@ -114,7 +131,7 @@ def get_external_ip() -> str: external_ip = requests.get("https://www.wikipedia.org").headers["X-Client-IP"] assert isinstance(ip_to_int(external_ip), int) return str(external_ip) - except ExternalIPNotFound: + except _IP_LOOKUP_EXCEPTIONS: pass raise ExternalIPNotFound diff --git a/pyproject.toml b/pyproject.toml index 557681cbec..360932a3a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bittensor" -version = "10.3.0" +version = "10.3.1" description = "Bittensor SDK" readme = "README.md" authors = [ @@ -13,8 +13,8 @@ authors = [ license = { file = "LICENSE" } requires-python = ">=3.10,<3.15" dependencies = [ - "wheel", - "setuptools~=70.0", + "wheel>0.46.1", + "setuptools>=78.1.1", "aiohttp>=3.13.4,<4.0", "asyncstdlib~=3.13.0", "colorama~=0.4.6", @@ -29,40 +29,40 @@ dependencies = [ "retry==0.9.2", "requests>=2.33.0,<3.0", "pydantic>=2.3,<3", - "cyscale>=0.3.1,<1.0.0", + "cyscale>=0.3.3,<1.0.0", "uvicorn", "bittensor-drand>=1.3.0,<2.0.0", "bittensor-wallet==4.0.1", - "async-substrate-interface==2.0.2", + "async-substrate-interface>=2.0.3,<3.0.0", ] [project.optional-dependencies] dev = [ - "pytest==8.3.5", - "pytest-asyncio==0.26.0", - "pytest-mock==3.14.0", - "pytest-split==0.10.0", - "pytest-xdist==3.6.1", - "pytest-rerunfailures==10.2", - "coveralls==3.3.1", - "pytest-cov==4.0.0", + "pytest==9.0.3", + "pytest-asyncio==1.3.0", + "pytest-mock==3.15.1", + "pytest-split==0.11.0", + "pytest-xdist==3.8.0", + "pytest-rerunfailures==16.1", + "coveralls==4.1.0", + "pytest-cov==7.1.0", "ddt==1.6.0", "hypothesis==6.81.1", - "mypy==1.20.1", + "mypy==1.20.2", "types-retry==0.9.9.4", "typing_extensions>= 4.0.0; python_version<'3.11'", "freezegun==1.5.0", "httpx==0.27.0", # used by tests/unit_tests/test_axon: The starlette.testclient module requires the httpx package to be installed. "ruff==0.15.12", "aioresponses==0.7.6", - "factory-boy==3.3.0", + "factory-boy==3.3.3", "types-requests", ] torch = [ "torch>=1.13.1,<3.0" ] cli = [ - "bittensor-cli>=9.21.0" + "bittensor-cli>=9.21.1" ] diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 89b66c3368..fa2b10fc88 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -1,5 +1,3 @@ -import asyncio -import contextlib import os import re import shlex @@ -328,23 +326,3 @@ def log_test_start_and_end(request): logging.console.info(f"🏁[green]Testing[/green] [yellow]{test_name}[/yellow]") yield logging.console.success(f"✅ [green]Finished[/green] [yellow]{test_name}[/yellow]") - - -@pytest_asyncio.fixture(scope="session") -def event_loop(): - """Create an instance of the default event loop for each test case and close all alive tasks at the end.""" - loop = asyncio.get_event_loop() - yield loop - - # 1) cance all alive tasks - pending = [t for t in asyncio.all_tasks(loop) if not t.done()] - for t in pending: - t.cancel() - with contextlib.suppress(Exception): - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) - - # 2) cleanup async generators - with contextlib.suppress(Exception): - loop.run_until_complete(loop.shutdown_asyncgens()) - - loop.close() diff --git a/tests/pytest.ini b/tests/pytest.ini index 7da2f158de..102c6f42c4 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,4 +1,5 @@ [pytest] filterwarnings = ignore::DeprecationWarning:pkg_resources.*: asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session addopts = -s \ No newline at end of file diff --git a/tests/unit_tests/extrinsics/test__init__.py b/tests/unit_tests/extrinsics/test__init__.py index e0273d9a83..9bfffa28fa 100644 --- a/tests/unit_tests/extrinsics/test__init__.py +++ b/tests/unit_tests/extrinsics/test__init__.py @@ -24,7 +24,7 @@ def test_format_error_message_with_right_error_message(): assert ( result == "Subtensor returned `SomeErrorName(SomeType)` error. " "This means: `Some error description. I'm second part. Hah, I'm the last one." - f" | Please consult {BT_DOCS_LINK}/errors/subtensor#someerrorname`." + f" | Please consult {BT_DOCS_LINK}/subtensor-api/errors#someerrorname`." ) @@ -119,5 +119,5 @@ def test_format_error_with_string_docs(): assert ( result == "Subtensor returned `SomeErrorName(SomeType)` error. " "This means: `Some error description." - f" | Please consult {BT_DOCS_LINK}/errors/subtensor#someerrorname`." + f" | Please consult {BT_DOCS_LINK}/subtensor-api/errors#someerrorname`." ) diff --git a/tests/unit_tests/utils/test_networking.py b/tests/unit_tests/utils/test_networking.py index a3f2c54ac6..7c17b6cdb0 100644 --- a/tests/unit_tests/utils/test_networking.py +++ b/tests/unit_tests/utils/test_networking.py @@ -161,6 +161,79 @@ def urlopen(self): assert utils.networking.get_external_ip() +# Regression for https://github.com/latent-to/bittensor/issues/3309: +# providers that raise their real-world exceptions (not `ExternalIPNotFound`) +# must be caught and allow the fallback chain to continue. +def test_get_external_ip_aws_connection_error_falls_through_to_curl(mocker): + """AWS requests.ConnectionError should fall through to the curl provider.""" + mocked_requests_get = mock.Mock( + side_effect=requests.exceptions.ConnectionError("no network"), + ) + mocker.patch.object(requests, "get", mocked_requests_get) + + class FakeProcess: + def readline(self): + return "203.0.113.7" + + def close(self): + return None + + def read(self): + return '{"ip": "203.0.113.7"}' + + mocker.patch.object(os, "popen", mock.Mock(return_value=FakeProcess())) + + assert utils.networking.get_external_ip() == "203.0.113.7" + + +def test_get_external_ip_malformed_ip_falls_through(mocker): + """Malformed IP from AWS should raise AddrFormatError, not propagate.""" + mocked_requests_get = mock.Mock( + return_value=mock.Mock( + **{"text": "not-an-ip"}, + ), + ) + mocker.patch.object(requests, "get", mocked_requests_get) + + class FakeProcess: + def readline(self): + return "198.51.100.42" + + def close(self): + return None + + def read(self): + return '{"ip": "198.51.100.42"}' + + mocker.patch.object(os, "popen", mock.Mock(return_value=FakeProcess())) + + assert utils.networking.get_external_ip() == "198.51.100.42" + + +def test_get_external_ip_all_providers_exhausted_raises(mocker): + """If every provider fails, the function must raise ExternalIPNotFound + (not the last underlying exception).""" + mocker.patch.object( + requests, + "get", + mock.Mock(side_effect=requests.exceptions.ConnectionError("no network")), + ) + mocker.patch.object( + os, + "popen", + mock.Mock(side_effect=OSError("popen disabled")), + ) + # Patch through the module's own imported name to avoid interference from + # other tests in this file that rebind `urllib.request` at module scope. + mocker.patch( + "bittensor.utils.networking.urllib_request.urlopen", + mock.Mock(side_effect=urllib.error.URLError("urlopen disabled")), + ) + + with pytest.raises(utils.networking.ExternalIPNotFound): + utils.networking.get_external_ip() + + # Test formatting WebSocket endpoint URL @pytest.mark.parametrize( "url, expected",