Skip to content
This repository was archived by the owner on Jul 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d361f28
fix(networking): fix ip-lookup fallback chain
ArtificialXai Apr 20, 2026
2256fbd
Merge branch 'staging' into fix/artificialxai/networking-ip-fallback-…
thewhaleking Apr 24, 2026
d9d3130
Merge branch 'staging' into fix/artificialxai/networking-ip-fallback-…
basfroman Apr 25, 2026
d70e197
Merge branch 'staging' into fix/artificialxai/networking-ip-fallback-…
thewhaleking Apr 28, 2026
45768cf
Bumps cyscale and ASI
thewhaleking Apr 29, 2026
69c5620
Merge pull request #3336 from latent-to/chore/thewhaleking/bump-cysca…
thewhaleking Apr 29, 2026
729b658
Merge branch 'staging' into fix/artificialxai/networking-ip-fallback-…
basfroman Apr 29, 2026
728048f
bumping dev deps versions and btcli one too
basfroman Apr 29, 2026
fc75601
Trigger GitHub Actions
basfroman Apr 30, 2026
5e832b1
Merge pull request #3318 from ArtificialXai/fix/artificialxai/network…
basfroman Apr 30, 2026
4e6e420
Merge branch 'staging' into feat/roman/deps-versions
ibraheem-abe Apr 30, 2026
f8ad240
trigger ci
thewhaleking Apr 30, 2026
104ab06
Updates packages with CVEs
thewhaleking Apr 30, 2026
b5a555e
Merge pull request #3339 from latent-to/feat/thewhaleking/update-for-…
basfroman Apr 30, 2026
a49ef55
Should fix the change to pytest-asyncio 1.3
thewhaleking Apr 30, 2026
a14537f
Merge pull request #3340 from latent-to/fix/thewhaleking/pytest-async…
basfroman Apr 30, 2026
988df79
Merge pull request #3337 from latent-to/feat/roman/deps-versions
basfroman Apr 30, 2026
95eb27f
Updated error link to point to new docs page (#3341)
chideraao May 6, 2026
3eedf6d
Version + changelog
thewhaleking May 6, 2026
2b6c0f3
Merge pull request #3342 from latent-to/changelog/10.3.1
basfroman May 6, 2026
554ef4f
Merge branch 'master' into release/10.3.1
basfroman May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion bittensor/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
29 changes: 23 additions & 6 deletions bittensor/utils/networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -98,23 +115,23 @@ 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
try:
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
try:
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
Expand Down
32 changes: 16 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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",
Expand All @@ -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"
]


Expand Down
22 changes: 0 additions & 22 deletions tests/e2e_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import asyncio
import contextlib
import os
import re
import shlex
Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions tests/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[pytest]
filterwarnings = ignore::DeprecationWarning:pkg_resources.*:
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session
addopts = -s
4 changes: 2 additions & 2 deletions tests/unit_tests/extrinsics/test__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
)


Expand Down Expand Up @@ -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`."
)
73 changes: 73 additions & 0 deletions tests/unit_tests/utils/test_networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading