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.
@@ -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