tracerate 0.1.0__tar.gz

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,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracerate
3
+ Version: 0.1.0
4
+ Summary: A no-nonsense CLI internet speed tester
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx
8
+ Requires-Dist: rich
9
+ Requires-Dist: typer
10
+ Description-Content-Type: text/markdown
11
+
12
+ # tracerate
13
+
14
+ A no-nonsense CLI internet speed tester.
15
+
16
+ ## What it measures
17
+ - Download / upload speed (Mbps)
18
+ - Ping and packet loss
19
+ - Bufferbloat grade (A+ to F)
20
+ - DNS resolution time
21
+ - ISP and location detection
22
+ - Regional latency to 7 global servers
23
+
24
+ ## Install
25
+ pip install tracerate
26
+
27
+ ## Usage
28
+
29
+ | Command | Description |
30
+ |---|---|
31
+ | `tracerate` | Full test (download, upload, bufferbloat, regional latency) |
32
+ | `tracerate --quick` | Fast test (download only, skips upload and extras) |
33
+ | `tracerate --bytes 50` | Custom download size in MB (default: 25) |
34
+ | `tracerate --output json` | Machine readable output |
@@ -0,0 +1,23 @@
1
+ # tracerate
2
+
3
+ A no-nonsense CLI internet speed tester.
4
+
5
+ ## What it measures
6
+ - Download / upload speed (Mbps)
7
+ - Ping and packet loss
8
+ - Bufferbloat grade (A+ to F)
9
+ - DNS resolution time
10
+ - ISP and location detection
11
+ - Regional latency to 7 global servers
12
+
13
+ ## Install
14
+ pip install tracerate
15
+
16
+ ## Usage
17
+
18
+ | Command | Description |
19
+ |---|---|
20
+ | `tracerate` | Full test (download, upload, bufferbloat, regional latency) |
21
+ | `tracerate --quick` | Fast test (download only, skips upload and extras) |
22
+ | `tracerate --bytes 50` | Custom download size in MB (default: 25) |
23
+ | `tracerate --output json` | Machine readable output |
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tracerate"
7
+ version = "0.1.0"
8
+ description = "A no-nonsense CLI internet speed tester"
9
+ requires-python = ">=3.10"
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "httpx",
14
+ "typer",
15
+ "rich",
16
+ ]
17
+
18
+ [project.scripts]
19
+ tracerate = "tracerate.cli:app"
File without changes
@@ -0,0 +1,105 @@
1
+ import socket
2
+ import threading
3
+ import time
4
+
5
+ import httpx
6
+
7
+ from tracerate.tester import SERVER
8
+
9
+
10
+ def _saturate_download(stop_flag: threading.Event, url: str) -> None:
11
+ """Stream a large download until told to stop. Discards bytes."""
12
+ try:
13
+ with httpx.stream("GET", url, timeout=60, follow_redirects=True) as response:
14
+ for _ in response.iter_bytes():
15
+ if stop_flag.is_set():
16
+ break
17
+ except Exception:
18
+ pass
19
+
20
+
21
+ def _sample_ping(host: str, port: int) -> float | None:
22
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
23
+ sock.settimeout(2)
24
+ try:
25
+ start = time.perf_counter()
26
+ sock.connect((host, port))
27
+ return (time.perf_counter() - start) * 1000
28
+ except (socket.timeout, socket.error):
29
+ return None
30
+ finally:
31
+ try:
32
+ sock.close()
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ def measure_bufferbloat(duration: float = 5.0, baseline_attempts: int = 8) -> dict:
38
+ """
39
+ Saturate the link with a download in a background thread,
40
+ sample ping repeatedly during the saturation, compare to idle.
41
+
42
+ Why this matters: bufferbloat is when your modem queues
43
+ packets under load. Throughput stays high but latency
44
+ explodes — exactly what kills video calls and gaming.
45
+
46
+ Idle and loaded both use min-of-samples — the floor is
47
+ the true RTT, higher samples are jitter.
48
+
49
+ Grade scale follows the Waveform bufferbloat test convention.
50
+ """
51
+
52
+ idle_samples = []
53
+ for _ in range(baseline_attempts):
54
+ ms = _sample_ping(SERVER["host"], SERVER["port"])
55
+ if ms is not None:
56
+ idle_samples.append(ms)
57
+ time.sleep(0.05)
58
+
59
+ if not idle_samples:
60
+ return {"idle_ms": 0.0, "loaded_ms": 0.0, "delta_ms": 0.0, "grade": "?"}
61
+
62
+ idle = min(idle_samples)
63
+
64
+ url = SERVER["down"].format(bytes=200_000_000)
65
+ stop = threading.Event()
66
+ worker = threading.Thread(target=_saturate_download, args=(stop, url), daemon=True)
67
+ worker.start()
68
+
69
+ time.sleep(0.5)
70
+
71
+ samples = []
72
+ end_time = time.time() + duration
73
+ while time.time() < end_time:
74
+ ms = _sample_ping(SERVER["host"], SERVER["port"])
75
+ if ms is not None:
76
+ samples.append(ms)
77
+ time.sleep(0.2)
78
+
79
+ stop.set()
80
+ worker.join(timeout=2)
81
+
82
+ if not samples:
83
+ return {
84
+ "idle_ms": round(idle, 2),
85
+ "loaded_ms": 0.0,
86
+ "delta_ms": 0.0,
87
+ "grade": "?",
88
+ }
89
+
90
+ loaded = min(samples)
91
+ delta = max(0.0, loaded - idle)
92
+
93
+ if delta < 5: grade = "A+"
94
+ elif delta < 30: grade = "A"
95
+ elif delta < 60: grade = "B"
96
+ elif delta < 200: grade = "C"
97
+ elif delta < 400: grade = "D"
98
+ else: grade = "F"
99
+
100
+ return {
101
+ "idle_ms": round(idle, 2),
102
+ "loaded_ms": round(loaded, 2),
103
+ "delta_ms": round(delta, 2),
104
+ "grade": grade,
105
+ }
@@ -0,0 +1,248 @@
1
+ import json
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.progress import (
6
+ BarColumn,
7
+ DownloadColumn,
8
+ Progress,
9
+ TextColumn,
10
+ TransferSpeedColumn,
11
+ )
12
+
13
+ from tracerate.bufferbloat import measure_bufferbloat
14
+ from tracerate.info import get_ip_info, measure_dns
15
+ from tracerate.regional import measure_regions
16
+ from tracerate.tester import (
17
+ SERVER,
18
+ measure_download,
19
+ measure_ping,
20
+ measure_upload,
21
+ )
22
+ from tracerate.verdict import analyze
23
+
24
+
25
+ app = typer.Typer(help="tracerate — a no-nonsense CLI internet speed tester")
26
+ console = Console()
27
+
28
+
29
+ @app.command()
30
+ def run(
31
+ quick: bool = typer.Option(
32
+ default=False,
33
+ help="Skip upload + bufferbloat + regional probes; use 10MB download.",
34
+ ),
35
+ bytes_mb: int = typer.Option(
36
+ default=25,
37
+ help="Download size in MB.",
38
+ ),
39
+ output: str = typer.Option(
40
+ default="pretty",
41
+ help="Output format: pretty or json.",
42
+ ),
43
+ ):
44
+ if output not in ("pretty", "json"):
45
+ typer.echo("--output must be 'pretty' or 'json'", err=True)
46
+ raise typer.Exit(code=1)
47
+
48
+ download_bytes = (10 if quick else bytes_mb) * 1024 * 1024
49
+ test_upload = not quick
50
+ test_extras = not quick
51
+
52
+ if output == "pretty":
53
+ console.print()
54
+ console.print(" [bold]tracerate[/bold] [dim]· network diagnostics[/dim]")
55
+ console.print()
56
+
57
+ with console.status("[dim]Looking up your ISP...[/dim]", spinner="dots"):
58
+ info = get_ip_info()
59
+ dns_ms = measure_dns(SERVER["host"])
60
+
61
+ with console.status("[dim]Measuring latency...[/dim]", spinner="dots"):
62
+ ping_ms, loss_pct, jitter_ms = measure_ping(SERVER["host"], SERVER["port"])
63
+
64
+ download_mbps = _run_download(download_bytes, quiet=(output == "json"))
65
+
66
+ upload_mbps = None
67
+ if test_upload:
68
+ with console.status("[dim]Uploading 10 MB...[/dim]", spinner="dots"):
69
+ upload_mbps = measure_upload(SERVER["up"])
70
+
71
+ bufferbloat = None
72
+ if test_extras:
73
+ with console.status("[dim]Probing bufferbloat (saturating link)...[/dim]", spinner="dots"):
74
+ bufferbloat = measure_bufferbloat()
75
+
76
+ regions = []
77
+ if test_extras:
78
+ with console.status("[dim]Pinging regional servers...[/dim]", spinner="dots"):
79
+ regions = measure_regions()
80
+
81
+ result = {
82
+ "name": SERVER["name"],
83
+ "ping_ms": ping_ms,
84
+ "packet_loss_pct": loss_pct,
85
+ "jitter_ms": jitter_ms,
86
+ "download_mbps": download_mbps,
87
+ "upload_mbps": upload_mbps,
88
+ "error": None,
89
+ }
90
+ summary = analyze(result, bufferbloat=bufferbloat)
91
+
92
+ if output == "json":
93
+ print(json.dumps({
94
+ "info": info,
95
+ "dns_ms": dns_ms,
96
+ "result": result,
97
+ "bufferbloat": bufferbloat,
98
+ "regions": regions,
99
+ "summary": summary,
100
+ }, indent=2))
101
+ return
102
+
103
+ _render(info, dns_ms, result, bufferbloat, regions, summary)
104
+
105
+
106
+ def _run_download(download_bytes: int, quiet: bool) -> float:
107
+ """Run the main download with a live progress bar."""
108
+
109
+ if quiet:
110
+ return measure_download(SERVER["down"], download_bytes)
111
+
112
+ with Progress(
113
+ TextColumn(" [dim]Downloading[/dim]"),
114
+ BarColumn(bar_width=30, complete_style="cyan", finished_style="cyan"),
115
+ DownloadColumn(),
116
+ TransferSpeedColumn(),
117
+ console=console,
118
+ transient=True,
119
+ ) as progress:
120
+ task = progress.add_task("dl", total=download_bytes)
121
+
122
+ def on_progress(total: int) -> None:
123
+ progress.update(task, completed=total)
124
+
125
+ return measure_download(SERVER["down"], download_bytes, on_progress=on_progress)
126
+
127
+
128
+ # ────────────────────────── rendering ──────────────────────────
129
+
130
+ _DIVIDER = "[dim]" + "─" * 56 + "[/dim]"
131
+
132
+
133
+ def _bar(value: float, max_value: float, width: int = 20,
134
+ filled: str = "▰", empty: str = "▱") -> str:
135
+ if max_value <= 0:
136
+ return empty * width
137
+ ratio = min(value, max_value) / max_value
138
+ n = int(round(ratio * width))
139
+ return filled * n + empty * (width - n)
140
+
141
+
142
+ def _section(title: str) -> None:
143
+ console.print(f" [bold cyan]{title}[/bold cyan]")
144
+ console.print(f" {_DIVIDER}")
145
+
146
+
147
+ def _render(info, dns_ms, r, bb, regions, summary):
148
+ _render_connection(info, dns_ms)
149
+ _render_speed(r)
150
+
151
+ if bb is not None:
152
+ _render_bufferbloat(bb)
153
+
154
+ if regions:
155
+ _render_regions(regions)
156
+
157
+ _render_verdict(summary["verdict"])
158
+
159
+
160
+ def _render_connection(info: dict, dns_ms: float) -> None:
161
+ isp = info.get("isp") or "unknown"
162
+ asn = info.get("asn") or ""
163
+ city = info.get("city") or "?"
164
+ country = info.get("country") or "?"
165
+ colo = info.get("colo") or "?"
166
+ colo_city = info.get("colo_city")
167
+ ip = info.get("ip") or "?"
168
+
169
+ dns_color = "dim" if dns_ms < 50 else ("yellow" if dns_ms < 150 else "red")
170
+ edge = f"Cloudflare [bold]{colo}[/bold]" + (f" [dim]({colo_city})[/dim]" if colo_city else "")
171
+
172
+ console.print(f" [dim]ISP [/dim] [bold]{isp}[/bold] [dim]{asn}[/dim]")
173
+ console.print(f" [dim]Where [/dim] {city}, {country} [dim]→[/dim] {edge}")
174
+ console.print(f" [dim]IP [/dim] {ip} [dim]· DNS[/dim] [{dns_color}]{dns_ms} ms[/{dns_color}]")
175
+ console.print()
176
+
177
+
178
+ def _render_speed(r: dict) -> None:
179
+ _section("Speed")
180
+
181
+ dl = r.get("download_mbps") or 0.0
182
+ ul = r.get("upload_mbps") or 0.0
183
+ ping = r.get("ping_ms") or 0.0
184
+ jitter = r.get("jitter_ms") or 0.0
185
+ loss = r.get("packet_loss_pct") or 0.0
186
+
187
+ scale = max(dl, ul, 100.0)
188
+
189
+ console.print(f" [dim]Download[/dim] [cyan]{_bar(dl, scale)}[/cyan] [bold]{dl:>7.2f}[/bold] [dim]Mbps[/dim]")
190
+ if r.get("upload_mbps") is not None:
191
+ console.print(f" [dim]Upload [/dim] [cyan]{_bar(ul, scale)}[/cyan] [bold]{ul:>7.2f}[/bold] [dim]Mbps[/dim]")
192
+
193
+ loss_part = f"[red]· {loss}% loss[/red]" if loss > 0 else "[dim]· 0% loss[/dim]"
194
+ console.print(f" [dim]Ping [/dim] [bold]{ping}[/bold] [dim]ms[/dim] {loss_part}")
195
+ console.print(f" [dim]Jitter [/dim] [bold]{jitter}[/bold] [dim]ms[/dim]")
196
+ console.print()
197
+
198
+
199
+ _GRADE_COLOR = {
200
+ "A+": "green", "A": "green",
201
+ "B": "cyan",
202
+ "C": "yellow", "D": "yellow",
203
+ "F": "red", "?": "dim",
204
+ }
205
+
206
+
207
+ def _render_bufferbloat(bb: dict) -> None:
208
+ _section("Bufferbloat")
209
+ grade = bb["grade"]
210
+ color = _GRADE_COLOR.get(grade, "dim")
211
+ console.print(f" [dim]Idle [/dim] [bold]{bb['idle_ms']}[/bold] [dim]ms[/dim]")
212
+ console.print(
213
+ f" [dim]Loaded[/dim] [bold]{bb['loaded_ms']}[/bold] [dim]ms[/dim]"
214
+ f" [dim]Δ[/dim] [bold]+{bb['delta_ms']}[/bold] [dim]ms[/dim]"
215
+ f" [dim]Grade[/dim] [{color}][bold]{grade}[/bold][/{color}]"
216
+ )
217
+ console.print()
218
+
219
+
220
+ def _render_regions(regions: list[dict]) -> None:
221
+ _section("Regional latency")
222
+ reachable = [r for r in regions if r["ms"] > 0]
223
+ scale = max((r["ms"] for r in reachable), default=200.0)
224
+
225
+ ordered = sorted(regions, key=lambda r: r["ms"] if r["ms"] > 0 else 1e9)
226
+ for r in ordered:
227
+ ms = r["ms"]
228
+ if ms == 0:
229
+ bar = "[dim]" + "▱" * 12 + "[/dim]"
230
+ ms_str = "[dim]timeout[/dim]"
231
+ else:
232
+ color = "cyan" if ms < 80 else ("yellow" if ms < 180 else "red")
233
+ bar = f"[{color}]{_bar(ms, scale, width=12)}[/{color}]"
234
+ ms_str = f"[bold]{ms:>6.0f}[/bold] [dim]ms[/dim]"
235
+ console.print(f" [dim]{r['code']}[/dim] {r['city']:<11} {bar} {ms_str}")
236
+ console.print()
237
+
238
+
239
+ def _render_verdict(verdict: str) -> None:
240
+ if verdict == "Connection is healthy":
241
+ mark, color = "✔", "green"
242
+ elif verdict in ("ISP bandwidth is just low",):
243
+ mark, color = "⚠", "yellow"
244
+ else:
245
+ mark, color = "✘", "red"
246
+
247
+ console.print(f" [{color}]{mark}[/{color}] [bold]{verdict}[/bold]")
248
+ console.print()
@@ -0,0 +1,92 @@
1
+ import socket
2
+ import time
3
+ import httpx
4
+
5
+
6
+ def get_ip_info() -> dict:
7
+ """
8
+ Best-effort ISP / IP / location lookup.
9
+
10
+ Combines ipinfo.io (city precision, friendly ISP name) with
11
+ Cloudflare's /meta endpoint (ASN, edge PoP code) so we can
12
+ show "you hit Cloudflare BOM via Jio" in one line.
13
+ """
14
+
15
+ info = {
16
+ "ip": None,
17
+ "isp": None,
18
+ "city": None,
19
+ "country": None,
20
+ "asn": None,
21
+ "colo": None,
22
+ "colo_city": None,
23
+ }
24
+
25
+ try:
26
+ r = httpx.get("https://ipinfo.io/json", timeout=5)
27
+ if r.status_code == 200:
28
+ data = r.json()
29
+ info["ip"] = data.get("ip")
30
+ info["city"] = data.get("city")
31
+ info["country"] = data.get("country")
32
+ org = data.get("org") or ""
33
+ if org.startswith("AS") and " " in org:
34
+ asn, _, name = org.partition(" ")
35
+ info["asn"] = asn
36
+ info["isp"] = name
37
+ elif org:
38
+ info["isp"] = org
39
+ except Exception:
40
+ pass
41
+
42
+ try:
43
+ # Cloudflare blocks bare httpx requests; needs browser-ish headers.
44
+ r = httpx.get(
45
+ "https://speed.cloudflare.com/meta",
46
+ timeout=5,
47
+ headers={
48
+ "User-Agent": "Mozilla/5.0",
49
+ "Accept": "application/json",
50
+ "Referer": "https://speed.cloudflare.com/",
51
+ },
52
+ )
53
+ if r.status_code == 200:
54
+ data = r.json()
55
+ colo = data.get("colo")
56
+ if isinstance(colo, dict):
57
+ info["colo"] = colo.get("iata")
58
+ info["colo_city"] = colo.get("city")
59
+ elif isinstance(colo, str):
60
+ info["colo"] = colo
61
+ if not info["isp"]:
62
+ info["isp"] = data.get("asOrganization")
63
+ if not info["asn"] and data.get("asn"):
64
+ info["asn"] = f"AS{data['asn']}"
65
+ if not info["city"]:
66
+ info["city"] = data.get("city")
67
+ if not info["country"]:
68
+ info["country"] = data.get("country")
69
+ if not info["ip"]:
70
+ info["ip"] = data.get("clientIp")
71
+ except Exception:
72
+ pass
73
+
74
+ return info
75
+
76
+
77
+ def measure_dns(hostname: str = "speed.cloudflare.com") -> float:
78
+ """
79
+ DNS lookup time in ms via getaddrinfo.
80
+
81
+ Note: OS-level DNS cache (systemd-resolved, nscd) may
82
+ return a stale near-zero value on subsequent runs.
83
+ First call after a cache flush is the honest one.
84
+ """
85
+
86
+ try:
87
+ start = time.perf_counter()
88
+ socket.getaddrinfo(hostname, None)
89
+ elapsed = (time.perf_counter() - start) * 1000
90
+ return round(elapsed, 2)
91
+ except socket.gaierror:
92
+ return 0.0
@@ -0,0 +1,55 @@
1
+ import socket
2
+ import time
3
+
4
+
5
+ # Linode publishes public speedtest endpoints in each region.
6
+ # Stable hostnames, anycast-free, good proxies for "real" geo distance.
7
+ REGIONS = [
8
+ ("IN", "Mumbai", "speedtest.mumbai1.linode.com"),
9
+ ("SG", "Singapore", "speedtest.singapore.linode.com"),
10
+ ("JP", "Tokyo", "speedtest.tokyo2.linode.com"),
11
+ ("DE", "Frankfurt", "speedtest.frankfurt.linode.com"),
12
+ ("UK", "London", "speedtest.london.linode.com"),
13
+ ("US", "Newark", "speedtest.newark.linode.com"),
14
+ ("US", "Fremont", "speedtest.fremont.linode.com"),
15
+ ]
16
+
17
+
18
+ def _tcp_ping(host: str, port: int = 443, attempts: int = 3, timeout: float = 2.0) -> float:
19
+ """
20
+ TCP-connect latency in ms, returns the minimum of N attempts.
21
+ Min is more representative of true RTT than mean — high samples
22
+ are jitter, the floor is the actual round-trip.
23
+ """
24
+
25
+ samples = []
26
+ for _ in range(attempts):
27
+ try:
28
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
29
+ sock.settimeout(timeout)
30
+ start = time.perf_counter()
31
+ sock.connect((host, port))
32
+ samples.append((time.perf_counter() - start) * 1000)
33
+ except (socket.timeout, socket.error):
34
+ pass
35
+ finally:
36
+ try:
37
+ sock.close()
38
+ except Exception:
39
+ pass
40
+
41
+ if not samples:
42
+ return 0.0
43
+ return round(min(samples), 2)
44
+
45
+
46
+ def measure_regions() -> list[dict]:
47
+ out = []
48
+ for code, city, host in REGIONS:
49
+ out.append({
50
+ "code": code,
51
+ "city": city,
52
+ "host": host,
53
+ "ms": _tcp_ping(host),
54
+ })
55
+ return out
@@ -0,0 +1,118 @@
1
+ import os
2
+ import socket
3
+ import time
4
+ from typing import Callable
5
+
6
+ import httpx
7
+
8
+
9
+ SERVER = {
10
+ "name": "Cloudflare",
11
+ "down": "https://speed.cloudflare.com/__down?bytes={bytes}",
12
+ "up": "https://speed.cloudflare.com/__up",
13
+ "host": "speed.cloudflare.com",
14
+ "port": 443,
15
+ }
16
+
17
+
18
+ def measure_ping(host: str, port: int, attempts: int = 5) -> tuple[float, float]:
19
+ """
20
+ Measures latency and packet loss to a host using TCP connect time.
21
+
22
+ Why TCP and not ICMP?
23
+ ICMP (what 'ping' command uses) requires root on Linux.
24
+ TCP connect to port 443 works without any special permissions.
25
+
26
+ Returns: (average_latency_ms, packet_loss_percent)
27
+ """
28
+
29
+ results = []
30
+
31
+ for _ in range(attempts):
32
+ try:
33
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
34
+ sock.settimeout(3)
35
+ start = time.perf_counter()
36
+ sock.connect((host, port))
37
+ elapsed = (time.perf_counter() - start) * 1000
38
+ results.append(elapsed)
39
+ except (socket.timeout, socket.error):
40
+ pass
41
+ finally:
42
+ try:
43
+ sock.close()
44
+ except Exception:
45
+ pass
46
+
47
+ if not results:
48
+ return 0.0, 0.0
49
+
50
+ average_latency = sum(results) / len(results)
51
+ packet_loss = ((attempts - len(results)) / attempts) * 100
52
+ jitter = round(max(results) - min(results), 2)
53
+
54
+ return round(average_latency, 2), round(packet_loss, 1), round(jitter, 2)
55
+
56
+
57
+ def measure_download(
58
+ url: str,
59
+ bytes_to_download: int,
60
+ on_progress: Callable[[int], None] | None = None,
61
+ ) -> float:
62
+ """
63
+ Downloads a file in chunks and measures speed in Mbps.
64
+
65
+ We stream the response and discard each chunk —
66
+ bytes are never held in memory.
67
+
68
+ `on_progress(total_bytes)` is called per chunk if provided,
69
+ so the caller can drive a progress bar.
70
+
71
+ Returns: speed in Mbps, or 0.0 if it failed.
72
+ """
73
+
74
+ url = url.format(bytes=bytes_to_download)
75
+ try:
76
+ total_bytes = 0
77
+ start = time.perf_counter()
78
+
79
+ with httpx.stream("GET", url, timeout=30, follow_redirects=True) as response:
80
+ for chunk in response.iter_bytes():
81
+ total_bytes += len(chunk)
82
+ if on_progress is not None:
83
+ on_progress(total_bytes)
84
+
85
+ elapsed = time.perf_counter() - start
86
+
87
+ if elapsed == 0 or total_bytes == 0:
88
+ return 0.0
89
+
90
+ speed_mbps = (total_bytes * 8) / elapsed / 1_000_000
91
+ return round(speed_mbps, 2)
92
+
93
+ except Exception:
94
+ return 0.0
95
+
96
+
97
+ def measure_upload(url: str, bytes_to_upload: int = 10_000_000) -> float:
98
+ """
99
+ Uploads random bytes to a server and measures speed in Mbps.
100
+
101
+ Why random bytes?
102
+ Some servers or network equipment compress data in transit.
103
+ Random bytes can't be compressed — gives an honest measurement.
104
+
105
+ Returns: speed in Mbps, or 0.0 if it failed.
106
+ """
107
+
108
+ data = os.urandom(bytes_to_upload)
109
+ try:
110
+ start = time.perf_counter()
111
+ httpx.post(url, content=data, timeout=30)
112
+ elapsed = time.perf_counter() - start
113
+ if elapsed == 0:
114
+ return 0.0
115
+ speed_mbps = (bytes_to_upload * 8) / elapsed / 1_000_000
116
+ return round(speed_mbps, 2)
117
+ except Exception:
118
+ return 0.0
@@ -0,0 +1,49 @@
1
+ def analyze(result: dict, bufferbloat: dict | None = None) -> dict:
2
+ """
3
+ Takes a single test result (+ optional bufferbloat data)
4
+ and returns a summary with averages and a verdict string.
5
+
6
+ Verdict priority: worst-condition first, healthy last.
7
+ """
8
+
9
+ download = result.get("download_mbps") or 0.0
10
+ upload = result.get("upload_mbps")
11
+ ping = result.get("ping_ms") or 0.0
12
+ jitter = result.get("jitter_ms") or 0.0
13
+ loss = result.get("packet_loss_pct") or 0.0
14
+
15
+ verdict = _diagnose(
16
+ download=download,
17
+ ping=ping,
18
+ jitter=jitter,
19
+ loss=loss,
20
+ bufferbloat_delta=(bufferbloat or {}).get("delta_ms", 0.0),
21
+ )
22
+
23
+ return {
24
+ "download_mbps": download,
25
+ "upload_mbps": upload,
26
+ "ping_ms": ping,
27
+ "jitter_ms": jitter,
28
+ "packet_loss_pct": loss,
29
+ "verdict": verdict,
30
+ }
31
+
32
+
33
+ def _diagnose(download: float, ping: float, jitter: float, loss: float, bufferbloat_delta: float) -> str:
34
+ if loss > 5:
35
+ return "Packet loss detected"
36
+
37
+ if bufferbloat_delta > 200:
38
+ return "Severe bufferbloat — calls and gaming will lag"
39
+
40
+ if ping > 100 and download >= 10:
41
+ return "Congestion detected"
42
+
43
+ if jitter > 30:
44
+ return "High jitter — calls may drop out"
45
+
46
+ if download < 10:
47
+ return "ISP bandwidth is just low"
48
+
49
+ return "Connection is healthy"