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.
- tracerate-0.1.0/PKG-INFO +34 -0
- tracerate-0.1.0/README.md +23 -0
- tracerate-0.1.0/pyproject.toml +19 -0
- tracerate-0.1.0/tracerate/__init__.py +0 -0
- tracerate-0.1.0/tracerate/bufferbloat.py +105 -0
- tracerate-0.1.0/tracerate/cli.py +248 -0
- tracerate-0.1.0/tracerate/info.py +92 -0
- tracerate-0.1.0/tracerate/regional.py +55 -0
- tracerate-0.1.0/tracerate/tester.py +118 -0
- tracerate-0.1.0/tracerate/verdict.py +49 -0
tracerate-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|