speedtest-cli-ng 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- speedtest/__init__.py +15 -0
- speedtest/__main__.py +40 -0
- speedtest/cli/__init__.py +0 -0
- speedtest/cli/commands.py +120 -0
- speedtest/cli/main.py +108 -0
- speedtest/cli/output.py +46 -0
- speedtest/cli/parser.py +130 -0
- speedtest/client.py +144 -0
- speedtest/engine/__init__.py +0 -0
- speedtest/engine/config.py +120 -0
- speedtest/engine/results.py +195 -0
- speedtest/engine/servers.py +189 -0
- speedtest/engine/transfer.py +157 -0
- speedtest/exceptions.py +78 -0
- speedtest/http/__init__.py +0 -0
- speedtest/http/connections.py +36 -0
- speedtest/http/errors.py +21 -0
- speedtest/http/handlers.py +105 -0
- speedtest/http/request.py +80 -0
- speedtest/http/response.py +23 -0
- speedtest/http/workers.py +128 -0
- speedtest/utils/__init__.py +0 -0
- speedtest/utils/logger.py +82 -0
- speedtest/utils/status.py +17 -0
- speedtest_cli_ng-0.1.1.data/data/share/man/man1/speedtest-cli.1 +128 -0
- speedtest_cli_ng-0.1.1.dist-info/METADATA +169 -0
- speedtest_cli_ng-0.1.1.dist-info/RECORD +32 -0
- speedtest_cli_ng-0.1.1.dist-info/WHEEL +5 -0
- speedtest_cli_ng-0.1.1.dist-info/entry_points.txt +3 -0
- speedtest_cli_ng-0.1.1.dist-info/licenses/LICENSE +202 -0
- speedtest_cli_ng-0.1.1.dist-info/licenses/NOTICE +6 -0
- speedtest_cli_ng-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retrieves and parses the core configuration XML from speedtest.net.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from speedtest.exceptions import ConfigRetrievalError, SpeedtestConfigError
|
|
10
|
+
from speedtest.http.request import build_request, catch_request
|
|
11
|
+
from speedtest.http.response import get_response_stream
|
|
12
|
+
from speedtest.utils.logger import logger
|
|
13
|
+
|
|
14
|
+
__all__ = ["fetch_config"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch_config(opener: Any) -> dict[str, Any]:
|
|
18
|
+
"""
|
|
19
|
+
Download the speedtest.net configuration, parse the XML,
|
|
20
|
+
and return a standardized dictionary of settings.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
headers = {"Accept-Encoding": "gzip"}
|
|
24
|
+
request = build_request(
|
|
25
|
+
"https://www.speedtest.net/speedtest-config.php",
|
|
26
|
+
headers=headers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
uh, e = catch_request(request, opener=opener)
|
|
30
|
+
if e or not uh:
|
|
31
|
+
raise ConfigRetrievalError(e) from e
|
|
32
|
+
|
|
33
|
+
with uh:
|
|
34
|
+
if int(getattr(uh, "code", 200)) != 200:
|
|
35
|
+
raise ConfigRetrievalError(f"HTTP Error {uh.code} while fetching config") # type: ignore
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with get_response_stream(uh) as stream:
|
|
39
|
+
configxml = stream.read()
|
|
40
|
+
except (OSError, EOFError) as err:
|
|
41
|
+
raise ConfigRetrievalError(err) from err
|
|
42
|
+
|
|
43
|
+
logger.debug(f"Config XML:\n{configxml.decode(errors='ignore')}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
root = ET.fromstring(configxml)
|
|
47
|
+
except ET.ParseError as err:
|
|
48
|
+
raise SpeedtestConfigError(f"Malformed speedtest.net configuration: {err}")
|
|
49
|
+
|
|
50
|
+
server_config_node = root.find("server-config")
|
|
51
|
+
download_node = root.find("download")
|
|
52
|
+
upload_node = root.find("upload")
|
|
53
|
+
client_node = root.find("client")
|
|
54
|
+
|
|
55
|
+
if not all(
|
|
56
|
+
[
|
|
57
|
+
server_config_node is not None,
|
|
58
|
+
download_node is not None,
|
|
59
|
+
upload_node is not None,
|
|
60
|
+
client_node is not None,
|
|
61
|
+
]
|
|
62
|
+
):
|
|
63
|
+
raise SpeedtestConfigError("Missing expected XML tags in the config payload.")
|
|
64
|
+
|
|
65
|
+
server_config = server_config_node.attrib # type: ignore
|
|
66
|
+
download = download_node.attrib # type: ignore
|
|
67
|
+
upload = upload_node.attrib # type: ignore
|
|
68
|
+
client = client_node.attrib # type: ignore
|
|
69
|
+
|
|
70
|
+
ignore_servers = [int(i) for i in server_config.get("ignoreids", "").split(",") if i.strip()]
|
|
71
|
+
|
|
72
|
+
ratio = int(upload.get("ratio", 5))
|
|
73
|
+
upload_max = int(upload.get("maxchunkcount", 50))
|
|
74
|
+
up_sizes = [32768, 65536, 131072, 262144, 524288, 1048576, 7340032]
|
|
75
|
+
|
|
76
|
+
slice_idx = max(0, ratio - 1)
|
|
77
|
+
|
|
78
|
+
sizes = {
|
|
79
|
+
"upload": up_sizes[slice_idx:],
|
|
80
|
+
"download": [350, 500, 750, 1000, 1500, 2000, 2500, 3000, 3500, 4000],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
size_count = len(sizes["upload"])
|
|
84
|
+
if size_count == 0:
|
|
85
|
+
raise SpeedtestConfigError("Upload size list evaluated to empty based on configured ratio.")
|
|
86
|
+
|
|
87
|
+
upload_count = math.ceil(upload_max / size_count)
|
|
88
|
+
|
|
89
|
+
counts = {"upload": upload_count, "download": int(download.get("threadsperurl", 4))}
|
|
90
|
+
|
|
91
|
+
threads = {
|
|
92
|
+
"upload": int(upload.get("threads", 4)),
|
|
93
|
+
"download": int(server_config.get("threadcount", 4)) * 2,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
length = {
|
|
97
|
+
"upload": int(upload.get("testlength", 10)),
|
|
98
|
+
"download": int(download.get("testlength", 10)),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
lat_lon = (float(client["lat"]), float(client["lon"]))
|
|
103
|
+
except (ValueError, KeyError) as err:
|
|
104
|
+
raise SpeedtestConfigError(
|
|
105
|
+
f"Unknown location: lat={client.get('lat')} lon={client.get('lon')}"
|
|
106
|
+
) from err
|
|
107
|
+
|
|
108
|
+
parsed_config = {
|
|
109
|
+
"client": client,
|
|
110
|
+
"ignore_servers": ignore_servers,
|
|
111
|
+
"sizes": sizes,
|
|
112
|
+
"counts": counts,
|
|
113
|
+
"threads": threads,
|
|
114
|
+
"length": length,
|
|
115
|
+
"upload_max": upload_count * size_count,
|
|
116
|
+
"lat_lon": lat_lon,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
logger.debug(f"Config:\n{parsed_config}")
|
|
120
|
+
return parsed_config
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manages the aggregation, formatting, and submission of speedtest results.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from hashlib import md5
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, urlencode
|
|
12
|
+
from urllib.request import OpenerDirector
|
|
13
|
+
|
|
14
|
+
from speedtest.exceptions import ShareResultsConnectFailure, ShareResultsSubmitFailure
|
|
15
|
+
from speedtest.http.handlers import build_opener
|
|
16
|
+
from speedtest.http.request import build_request, catch_request
|
|
17
|
+
|
|
18
|
+
__all__ = ["SpeedtestResults"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SpeedtestResults:
|
|
22
|
+
"""
|
|
23
|
+
Class for holding the results of a speedtest, including:
|
|
24
|
+
|
|
25
|
+
* Download speed
|
|
26
|
+
* Upload speed
|
|
27
|
+
* Ping/Latency to test server
|
|
28
|
+
* Data about the server that the test was run against
|
|
29
|
+
|
|
30
|
+
Additionally, this class can return result data as a dictionary or CSV,
|
|
31
|
+
as well as submit a POST of the result data to the speedtest.net API
|
|
32
|
+
to get a share results image link.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
download: float = 0.0,
|
|
38
|
+
upload: float = 0.0,
|
|
39
|
+
ping: float = 0.0,
|
|
40
|
+
server: dict[str, Any] | None = None,
|
|
41
|
+
client: dict[str, str] | None = None,
|
|
42
|
+
opener: OpenerDirector | None = None,
|
|
43
|
+
):
|
|
44
|
+
self.download = download
|
|
45
|
+
self.upload = upload
|
|
46
|
+
self.ping = ping
|
|
47
|
+
self.server = server or {}
|
|
48
|
+
self.client = client or {}
|
|
49
|
+
|
|
50
|
+
self._share: str | None = None
|
|
51
|
+
|
|
52
|
+
# Generate a clean ISO 8601 UTC timestamp
|
|
53
|
+
self.timestamp = datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
54
|
+
|
|
55
|
+
self.bytes_received: int = 0
|
|
56
|
+
self.bytes_sent: int = 0
|
|
57
|
+
|
|
58
|
+
self._opener = opener or build_opener()
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return repr(self.to_dict())
|
|
62
|
+
|
|
63
|
+
def share(self) -> str:
|
|
64
|
+
"""POST data to the speedtest.net API to obtain a share results link."""
|
|
65
|
+
|
|
66
|
+
if self._share:
|
|
67
|
+
return self._share
|
|
68
|
+
|
|
69
|
+
download = round(self.download / 1000.0)
|
|
70
|
+
ping = round(self.ping)
|
|
71
|
+
upload = round(self.upload / 1000.0)
|
|
72
|
+
|
|
73
|
+
hash_str = f"{ping}-{upload}-{download}-297aae72"
|
|
74
|
+
hash_val = md5(hash_str.encode()).hexdigest()
|
|
75
|
+
|
|
76
|
+
# We use a list of tuples instead of a dict because the speedtest API
|
|
77
|
+
# expects parameters in a strict sequential order. urlencode preserves this.
|
|
78
|
+
api_parameters = [
|
|
79
|
+
("recommendedserverid", self.server.get("id", "")),
|
|
80
|
+
("ping", ping),
|
|
81
|
+
("screenresolution", ""),
|
|
82
|
+
("promo", ""),
|
|
83
|
+
("download", download),
|
|
84
|
+
("screendpi", ""),
|
|
85
|
+
("upload", upload),
|
|
86
|
+
("testmethod", "http"),
|
|
87
|
+
("hash", hash_val),
|
|
88
|
+
("touchscreen", "none"),
|
|
89
|
+
("startmode", "pingselect"),
|
|
90
|
+
("accuracy", 1),
|
|
91
|
+
("bytesreceived", self.bytes_received),
|
|
92
|
+
("bytessent", self.bytes_sent),
|
|
93
|
+
("serverid", self.server.get("id", "")),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
api_data = urlencode(api_parameters).encode()
|
|
97
|
+
headers = {"Referer": "https://c.speedtest.net/flash/speedtest.swf"}
|
|
98
|
+
|
|
99
|
+
request = build_request(
|
|
100
|
+
"https://www.speedtest.net/api/api.php",
|
|
101
|
+
data=api_data,
|
|
102
|
+
headers=headers,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
f, e = catch_request(request, opener=self._opener)
|
|
106
|
+
|
|
107
|
+
if e or not f:
|
|
108
|
+
raise ShareResultsConnectFailure(e or "Failed to connect to Share API")
|
|
109
|
+
|
|
110
|
+
# Context manager strictly handles network cleanup
|
|
111
|
+
with f:
|
|
112
|
+
code = int(getattr(f, "code", 500))
|
|
113
|
+
if code != 200:
|
|
114
|
+
raise ShareResultsSubmitFailure(f"Could not submit results. HTTP {code}")
|
|
115
|
+
|
|
116
|
+
response = f.read()
|
|
117
|
+
|
|
118
|
+
# Safely decode ignoring corrupted bytes
|
|
119
|
+
qsargs = parse_qs(response.decode(errors="ignore"))
|
|
120
|
+
resultid = qsargs.get("resultid")
|
|
121
|
+
|
|
122
|
+
if not resultid or len(resultid) != 1:
|
|
123
|
+
raise ShareResultsSubmitFailure("Could not parse result ID from API response")
|
|
124
|
+
|
|
125
|
+
self._share = f"https://www.speedtest.net/result/{resultid[0]}.png"
|
|
126
|
+
return self._share
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, Any]:
|
|
129
|
+
"""Return dictionary of result data cleanly formatted."""
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"download": self.download,
|
|
133
|
+
"upload": self.upload,
|
|
134
|
+
"ping": self.ping,
|
|
135
|
+
"server": self.server,
|
|
136
|
+
"timestamp": self.timestamp,
|
|
137
|
+
"bytes_sent": self.bytes_sent,
|
|
138
|
+
"bytes_received": self.bytes_received,
|
|
139
|
+
"share": self._share,
|
|
140
|
+
"client": self.client,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def csv_header(delimiter: str = ",") -> str:
|
|
145
|
+
"""Return CSV Headers."""
|
|
146
|
+
|
|
147
|
+
row = [
|
|
148
|
+
"Server ID",
|
|
149
|
+
"Sponsor",
|
|
150
|
+
"Server Name",
|
|
151
|
+
"Timestamp",
|
|
152
|
+
"Distance",
|
|
153
|
+
"Ping",
|
|
154
|
+
"Download",
|
|
155
|
+
"Upload",
|
|
156
|
+
"Share",
|
|
157
|
+
"IP Address",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
out = StringIO()
|
|
161
|
+
writer = csv.writer(out, delimiter=delimiter, lineterminator="")
|
|
162
|
+
writer.writerow(row)
|
|
163
|
+
|
|
164
|
+
return out.getvalue()
|
|
165
|
+
|
|
166
|
+
def csv(self, delimiter: str = ",") -> str:
|
|
167
|
+
"""Return data in CSV format."""
|
|
168
|
+
|
|
169
|
+
data = self.to_dict()
|
|
170
|
+
server = data.get("server", {})
|
|
171
|
+
|
|
172
|
+
row = [
|
|
173
|
+
server.get("id", ""),
|
|
174
|
+
server.get("sponsor", ""),
|
|
175
|
+
server.get("name", ""),
|
|
176
|
+
data.get("timestamp", ""),
|
|
177
|
+
server.get("d", ""),
|
|
178
|
+
data.get("ping", ""),
|
|
179
|
+
data.get("download", ""),
|
|
180
|
+
data.get("upload", ""),
|
|
181
|
+
self._share or "",
|
|
182
|
+
self.client.get("ip", ""),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
out = StringIO()
|
|
186
|
+
writer = csv.writer(out, delimiter=delimiter, lineterminator="")
|
|
187
|
+
writer.writerow(row)
|
|
188
|
+
|
|
189
|
+
return out.getvalue()
|
|
190
|
+
|
|
191
|
+
def json(self, pretty: bool = False) -> str:
|
|
192
|
+
"""Return data in JSON format."""
|
|
193
|
+
|
|
194
|
+
kwargs: dict[str, Any] = {"indent": 4, "sort_keys": True} if pretty else {}
|
|
195
|
+
return json.dumps(self.to_dict(), **kwargs)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handles fetching, distance calculation, and latency ranking of speedtest.net servers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.request import OpenerDirector
|
|
12
|
+
|
|
13
|
+
from speedtest.exceptions import ServersRetrievalError, SpeedtestBestServerFailure
|
|
14
|
+
from speedtest.http.request import build_request, build_user_agent, catch_request
|
|
15
|
+
from speedtest.http.response import get_response_stream
|
|
16
|
+
from speedtest.utils.logger import logger
|
|
17
|
+
|
|
18
|
+
__all__ = ["fetch_servers", "get_best_server"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _calculate_distance(origin: tuple[float, float], destination: tuple[float, float]) -> float:
|
|
22
|
+
"""Determine distance between 2 sets of [lat, lon] in km using the Haversine formula."""
|
|
23
|
+
|
|
24
|
+
lat1, lon1 = origin
|
|
25
|
+
lat2, lon2 = destination
|
|
26
|
+
radius = 6371.0 # km
|
|
27
|
+
|
|
28
|
+
dlat = math.radians(lat2 - lat1)
|
|
29
|
+
dlon = math.radians(lon2 - lon1)
|
|
30
|
+
|
|
31
|
+
a = (math.sin(dlat / 2) ** 2) + (
|
|
32
|
+
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * (math.sin(dlon / 2) ** 2)
|
|
33
|
+
)
|
|
34
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
35
|
+
|
|
36
|
+
return radius * c
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_servers(
|
|
40
|
+
opener: OpenerDirector,
|
|
41
|
+
lat_lon: tuple[float, float],
|
|
42
|
+
ignore_servers: list[int] | None = None,
|
|
43
|
+
) -> dict[float, list[dict[str, Any]]]:
|
|
44
|
+
"""
|
|
45
|
+
Fetch the server list from speedtest.net, parse the XML, calculate
|
|
46
|
+
distances from the client, and return a dictionary grouped and sorted by distance.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
ignore_servers = ignore_servers or []
|
|
50
|
+
urls = [
|
|
51
|
+
"https://c.speedtest.net/speedtest-servers-static.php",
|
|
52
|
+
"https://www.speedtest.net/speedtest-servers-static.php",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
servers_dict: defaultdict[float, list[dict[str, Any]]] = defaultdict(list)
|
|
56
|
+
|
|
57
|
+
for url in urls:
|
|
58
|
+
request = build_request(url)
|
|
59
|
+
uh, e = catch_request(request, opener=opener)
|
|
60
|
+
|
|
61
|
+
if e or not uh:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
with uh:
|
|
65
|
+
if int(getattr(uh, "code", 500)) != 200:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with get_response_stream(uh) as stream:
|
|
70
|
+
serversxml = stream.read()
|
|
71
|
+
except (OSError, EOFError):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
logger.debug(f"Servers XML:\n{serversxml.decode(errors='ignore')}")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
root = ET.fromstring(serversxml)
|
|
78
|
+
except ET.ParseError:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
for server in root.findall(".//server"):
|
|
82
|
+
attrib = server.attrib
|
|
83
|
+
server_id = int(attrib.get("id", 0))
|
|
84
|
+
|
|
85
|
+
if not server_id or server_id in ignore_servers:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
lat_str, lon_str = attrib.get("lat"), attrib.get("lon")
|
|
90
|
+
if not lat_str or not lon_str:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
server_lat_lon = (float(lat_str), float(lon_str))
|
|
94
|
+
distance = _calculate_distance(lat_lon, server_lat_lon)
|
|
95
|
+
except (ValueError, TypeError):
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
attrib["d"] = distance # type: ignore
|
|
99
|
+
servers_dict[distance].append(attrib)
|
|
100
|
+
|
|
101
|
+
if servers_dict:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if not servers_dict:
|
|
105
|
+
raise ServersRetrievalError("Failed to retrieve or parse speedtest server list.")
|
|
106
|
+
|
|
107
|
+
logger.debug(f"Discovered {sum(len(s) for s in servers_dict.values())} servers.")
|
|
108
|
+
|
|
109
|
+
return dict(servers_dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ping_server(
|
|
113
|
+
server: dict[str, Any],
|
|
114
|
+
opener: OpenerDirector,
|
|
115
|
+
headers: dict[str, Any] | None = None,
|
|
116
|
+
pings: int = 3,
|
|
117
|
+
) -> tuple[dict[str, Any], float]:
|
|
118
|
+
"""Ping a single server multiple times and return the average latency."""
|
|
119
|
+
|
|
120
|
+
headers = headers or {}
|
|
121
|
+
url: str = server.get("url", "").replace("upload.php", "latency.txt")
|
|
122
|
+
|
|
123
|
+
if not url:
|
|
124
|
+
return server, 3600.0
|
|
125
|
+
|
|
126
|
+
latencies = []
|
|
127
|
+
|
|
128
|
+
for i in range(pings):
|
|
129
|
+
request = build_request(url, headers=headers, bump=str(i))
|
|
130
|
+
start = time.monotonic()
|
|
131
|
+
|
|
132
|
+
uh, e = catch_request(request, opener=opener)
|
|
133
|
+
|
|
134
|
+
if e or not uh:
|
|
135
|
+
latencies.append(3600.0)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
with uh:
|
|
139
|
+
if int(getattr(uh, "code", 500)) != 200:
|
|
140
|
+
latencies.append(3600.0)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
latency = time.monotonic() - start
|
|
144
|
+
latencies.append(latency)
|
|
145
|
+
|
|
146
|
+
avg_latency = sum(latencies) / len(latencies)
|
|
147
|
+
server["latency"] = avg_latency
|
|
148
|
+
server["latency_ms"] = avg_latency * 1000.0
|
|
149
|
+
|
|
150
|
+
return server, avg_latency
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_best_server(
|
|
154
|
+
closest_servers: list[dict[str, Any]], opener: OpenerDirector
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Concurrently ping the closest servers to determine which has the lowest latency.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
best_server = None
|
|
161
|
+
lowest_latency = float("inf")
|
|
162
|
+
|
|
163
|
+
user_agent = build_user_agent()
|
|
164
|
+
headers = {"User-Agent": user_agent}
|
|
165
|
+
|
|
166
|
+
with ThreadPoolExecutor(max_workers=len(closest_servers)) as executor:
|
|
167
|
+
future_to_server = {
|
|
168
|
+
executor.submit(_ping_server, server, opener, headers): server
|
|
169
|
+
for server in closest_servers
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for future in as_completed(future_to_server):
|
|
173
|
+
try:
|
|
174
|
+
server, latency = future.result()
|
|
175
|
+
if latency < lowest_latency:
|
|
176
|
+
lowest_latency = latency
|
|
177
|
+
best_server = server
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.debug(f"Server ping generated an exception: {e}")
|
|
180
|
+
|
|
181
|
+
if best_server is None:
|
|
182
|
+
raise SpeedtestBestServerFailure("Unable to determine best server via latency pings.")
|
|
183
|
+
|
|
184
|
+
logger.debug(
|
|
185
|
+
f"Best server selected: {best_server.get('sponsor')} "
|
|
186
|
+
f"({best_server.get('name')}) with latency {best_server.get('latency_ms', 0.0):.4f} ms"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return best_server
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handles multi-threaded execution of download and upload tests.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.request import OpenerDirector, Request
|
|
11
|
+
|
|
12
|
+
from speedtest.http.request import build_request
|
|
13
|
+
from speedtest.http.workers import HTTPUploaderData, download_worker, upload_worker
|
|
14
|
+
from speedtest.utils.logger import logger
|
|
15
|
+
|
|
16
|
+
__all__ = ["run_download_test", "run_upload_test"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_download_test(
|
|
20
|
+
best_server_url: str,
|
|
21
|
+
config: dict[str, Any],
|
|
22
|
+
opener: OpenerDirector | None,
|
|
23
|
+
shutdown_event: threading.Event | None,
|
|
24
|
+
threads: int | None = None,
|
|
25
|
+
) -> tuple[int, float]:
|
|
26
|
+
"""
|
|
27
|
+
Execute a multi-threaded download speed test against the target server.
|
|
28
|
+
Returns a tuple of (bytes_received, download_speed_bps).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
urls: list[str] = []
|
|
32
|
+
|
|
33
|
+
base_url = os.path.dirname(best_server_url)
|
|
34
|
+
|
|
35
|
+
sizes = config.get("sizes", {}).get("download", [])
|
|
36
|
+
counts = config.get("counts", {}).get("download", 4)
|
|
37
|
+
|
|
38
|
+
for size in sizes:
|
|
39
|
+
for _ in range(counts):
|
|
40
|
+
urls.append(f"{base_url}/random{size}x{size}.jpg")
|
|
41
|
+
|
|
42
|
+
requests = [build_request(url, bump=str(i)) for i, url in enumerate(urls)]
|
|
43
|
+
max_threads = threads or config.get("threads", {}).get("download", 4)
|
|
44
|
+
|
|
45
|
+
bytes_received = 0
|
|
46
|
+
start = time.monotonic()
|
|
47
|
+
|
|
48
|
+
test_length = config.get("length", {}).get("download", 10)
|
|
49
|
+
|
|
50
|
+
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
|
51
|
+
futures = [
|
|
52
|
+
executor.submit(
|
|
53
|
+
download_worker,
|
|
54
|
+
req,
|
|
55
|
+
start,
|
|
56
|
+
test_length,
|
|
57
|
+
opener=opener,
|
|
58
|
+
shutdown_event=shutdown_event,
|
|
59
|
+
)
|
|
60
|
+
for req in requests
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for future in as_completed(futures):
|
|
64
|
+
try:
|
|
65
|
+
bytes_received += future.result()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.debug(f"Download thread failed: {e}")
|
|
68
|
+
|
|
69
|
+
stop = time.monotonic()
|
|
70
|
+
|
|
71
|
+
elapsed = max(stop - start, 0.001)
|
|
72
|
+
download_speed = (bytes_received / elapsed) * 8.0
|
|
73
|
+
|
|
74
|
+
# Legacy behavior: Adapt upload thread count dynamically based on download performance
|
|
75
|
+
if download_speed > 100000 and "threads" in config:
|
|
76
|
+
config["threads"]["upload"] = 8
|
|
77
|
+
|
|
78
|
+
return bytes_received, download_speed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_upload_test(
|
|
82
|
+
best_server_url: str,
|
|
83
|
+
config: dict[str, Any],
|
|
84
|
+
opener: OpenerDirector | None,
|
|
85
|
+
shutdown_event: threading.Event | None,
|
|
86
|
+
threads: int | None = None,
|
|
87
|
+
) -> tuple[int, float]:
|
|
88
|
+
"""
|
|
89
|
+
Execute a multi-threaded upload speed test against the target server.
|
|
90
|
+
Returns a tuple of (bytes_sent, upload_speed_bps).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
raw_sizes = [
|
|
94
|
+
size
|
|
95
|
+
for size in config.get("sizes", {}).get("upload", [])
|
|
96
|
+
for _ in range(config.get("counts", {}).get("upload", 1))
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# Truncate to the exact maximum needed BEFORE looping to save RAM overhead
|
|
100
|
+
request_count = config.get("upload_max", len(raw_sizes))
|
|
101
|
+
sizes = raw_sizes[:request_count]
|
|
102
|
+
|
|
103
|
+
requests: list[Request] = []
|
|
104
|
+
payloads: list[HTTPUploaderData] = []
|
|
105
|
+
|
|
106
|
+
test_length = config.get("length", {}).get("upload", 10)
|
|
107
|
+
|
|
108
|
+
# Prepare requests and allocate payloads before starting the clock
|
|
109
|
+
for size in sizes:
|
|
110
|
+
data = HTTPUploaderData(
|
|
111
|
+
length=size,
|
|
112
|
+
start_time=0.0, # Dummy value; will be updated right before execution
|
|
113
|
+
timeout=test_length,
|
|
114
|
+
shutdown_event=shutdown_event,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
headers = {"Content-length": str(size)}
|
|
118
|
+
req = build_request(best_server_url, data, headers=headers)
|
|
119
|
+
|
|
120
|
+
requests.append(req)
|
|
121
|
+
payloads.append(data)
|
|
122
|
+
|
|
123
|
+
max_threads = threads or config.get("threads", {}).get("upload", 4)
|
|
124
|
+
bytes_sent = 0
|
|
125
|
+
|
|
126
|
+
start = time.monotonic()
|
|
127
|
+
|
|
128
|
+
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
|
129
|
+
futures = []
|
|
130
|
+
|
|
131
|
+
for req, payload in zip(requests, payloads):
|
|
132
|
+
# Stamp the real start time immediately before submission
|
|
133
|
+
payload.start_time = start
|
|
134
|
+
|
|
135
|
+
futures.append(
|
|
136
|
+
executor.submit(
|
|
137
|
+
upload_worker,
|
|
138
|
+
req,
|
|
139
|
+
payload,
|
|
140
|
+
test_length,
|
|
141
|
+
opener=opener,
|
|
142
|
+
shutdown_event=shutdown_event,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for future in as_completed(futures):
|
|
147
|
+
try:
|
|
148
|
+
bytes_sent += future.result()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.debug(f"Upload thread failed: {e}")
|
|
151
|
+
|
|
152
|
+
stop = time.monotonic()
|
|
153
|
+
|
|
154
|
+
elapsed = max(stop - start, 0.001)
|
|
155
|
+
upload_speed = (bytes_sent / elapsed) * 8.0
|
|
156
|
+
|
|
157
|
+
return bytes_sent, upload_speed
|