Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
a173f46
1
Parent(s): 6de4343
Automated sync from GitHub
Browse files- .gitattributes +1 -1
- Dockerfile +12 -5
- bot/speedtest.py +20 -7
- tests/test_speedtest.py +24 -11
.gitattributes
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
@@ -5,4 +6,3 @@
|
|
| 5 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 6 |
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.webp filter=lfs diff=lfs merge=lfs -text
|
|
|
Dockerfile
CHANGED
|
@@ -6,11 +6,18 @@ RUN apt-get update \
|
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Ookla speedtest CLI: /speed needs it. The version is pinned so a rebuild
|
| 9 |
-
# cannot silently change the tool.
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 16 |
RUN useradd -m -u 1000 user
|
|
|
|
| 6 |
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Ookla speedtest CLI: /speed needs it. The version is pinned so a rebuild
|
| 9 |
+
# cannot silently change the tool. The tarball unpacks to a binary called
|
| 10 |
+
# "speedtest"; it is installed as "ookla-speedtest" to match the bot.
|
| 11 |
+
RUN set -eux; \
|
| 12 |
+
case "$(uname -m)" in \
|
| 13 |
+
x86_64) arch=x86_64 ;; \
|
| 14 |
+
aarch64|arm64) arch=aarch64 ;; \
|
| 15 |
+
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
|
| 16 |
+
esac; \
|
| 17 |
+
curl -fsSL --retry 3 "https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-${arch}.tgz" -o /tmp/ookla.tgz; \
|
| 18 |
+
tar -xzf /tmp/ookla.tgz -C /tmp; \
|
| 19 |
+
install -m 0755 /tmp/speedtest /usr/local/bin/ookla-speedtest; \
|
| 20 |
+
rm -rf /tmp/ookla.tgz /tmp/speedtest
|
| 21 |
|
| 22 |
# Hugging Face Spaces run Docker containers as user 1000
|
| 23 |
RUN useradd -m -u 1000 user
|
bot/speedtest.py
CHANGED
|
@@ -13,8 +13,9 @@ from dataclasses import dataclass
|
|
| 13 |
from .utils import esc
|
| 14 |
|
| 15 |
OOKLA_BIN = "ookla-speedtest"
|
| 16 |
-
# --
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@dataclass
|
|
@@ -27,9 +28,17 @@ class SpeedResult:
|
|
| 27 |
|
| 28 |
|
| 29 |
def parse_ookla_json(raw: str) -> SpeedResult:
|
| 30 |
-
"""Read the JSON that ``ookla-speedtest --format=json`` prints.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
try:
|
| 32 |
-
data = json.loads(
|
| 33 |
except (TypeError, ValueError) as exc:
|
| 34 |
raise ValueError("the speedtest output was not valid JSON") from exc
|
| 35 |
if not isinstance(data, dict):
|
|
@@ -42,14 +51,18 @@ def parse_ookla_json(raw: str) -> SpeedResult:
|
|
| 42 |
|
| 43 |
ping = data.get("ping") or {}
|
| 44 |
try:
|
| 45 |
-
|
|
|
|
|
|
|
| 46 |
except (TypeError, ValueError):
|
| 47 |
ping_ms = 0.0
|
| 48 |
|
| 49 |
server = data.get("server") or {}
|
| 50 |
name = str(server.get("name") or "").strip()
|
|
|
|
| 51 |
country = str(server.get("country") or "").strip()
|
| 52 |
-
|
|
|
|
| 53 |
return SpeedResult(
|
| 54 |
download_mbps=download / 1_000_000,
|
| 55 |
upload_mbps=upload / 1_000_000,
|
|
@@ -75,7 +88,7 @@ def format_speed(result: SpeedResult) -> str:
|
|
| 75 |
return "\n".join(lines)
|
| 76 |
|
| 77 |
|
| 78 |
-
async def run_speedtest(binary: str = OOKLA_BIN, timeout: float =
|
| 79 |
"""Run the Ookla CLI and return the result.
|
| 80 |
|
| 81 |
Raises ``FileNotFoundError`` when the binary is missing and
|
|
|
|
| 13 |
from .utils import esc
|
| 14 |
|
| 15 |
OOKLA_BIN = "ookla-speedtest"
|
| 16 |
+
# --accept-license / --accept-gdpr stop the tool waiting for a prompt; the
|
| 17 |
+
# licence notice goes to stderr, so stdout stays pure JSON.
|
| 18 |
+
OOKLA_ARGS = ("--accept-license", "--accept-gdpr", "--format=json")
|
| 19 |
|
| 20 |
|
| 21 |
@dataclass
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def parse_ookla_json(raw: str) -> SpeedResult:
|
| 31 |
+
"""Read the JSON that ``ookla-speedtest --format=json`` prints.
|
| 32 |
+
|
| 33 |
+
The CLI puts its licence notice on stderr, but be forgiving about anything
|
| 34 |
+
that ends up in front of the JSON.
|
| 35 |
+
"""
|
| 36 |
+
text = (raw or "").strip()
|
| 37 |
+
start = text.find("{")
|
| 38 |
+
if start > 0:
|
| 39 |
+
text = text[start:]
|
| 40 |
try:
|
| 41 |
+
data = json.loads(text)
|
| 42 |
except (TypeError, ValueError) as exc:
|
| 43 |
raise ValueError("the speedtest output was not valid JSON") from exc
|
| 44 |
if not isinstance(data, dict):
|
|
|
|
| 51 |
|
| 52 |
ping = data.get("ping") or {}
|
| 53 |
try:
|
| 54 |
+
# the CLI reports the round-trip time as "latency"; "iqm" is the
|
| 55 |
+
# inter-quartile mean it uses for the per-phase numbers
|
| 56 |
+
ping_ms = float(ping.get("latency") or ping.get("iqm") or 0)
|
| 57 |
except (TypeError, ValueError):
|
| 58 |
ping_ms = 0.0
|
| 59 |
|
| 60 |
server = data.get("server") or {}
|
| 61 |
name = str(server.get("name") or "").strip()
|
| 62 |
+
location = str(server.get("location") or "").strip()
|
| 63 |
country = str(server.get("country") or "").strip()
|
| 64 |
+
where = ", ".join(part for part in (location, country) if part)
|
| 65 |
+
label = " — ".join(part for part in (name, where) if part)
|
| 66 |
return SpeedResult(
|
| 67 |
download_mbps=download / 1_000_000,
|
| 68 |
upload_mbps=upload / 1_000_000,
|
|
|
|
| 88 |
return "\n".join(lines)
|
| 89 |
|
| 90 |
|
| 91 |
+
async def run_speedtest(binary: str = OOKLA_BIN, timeout: float = 180.0) -> SpeedResult:
|
| 92 |
"""Run the Ookla CLI and return the result.
|
| 93 |
|
| 94 |
Raises ``FileNotFoundError`` when the binary is missing and
|
tests/test_speedtest.py
CHANGED
|
@@ -2,19 +2,32 @@ import pytest
|
|
| 2 |
|
| 3 |
from bot.speedtest import format_speed, parse_ookla_json
|
| 4 |
|
| 5 |
-
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
"
|
| 9 |
-
|
| 10 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def test_parse_ookla_json_reads_the_speeds():
|
| 14 |
r = parse_ookla_json(RAW)
|
| 15 |
-
assert r.download_mbps == pytest.approx(
|
| 16 |
-
assert r.upload_mbps == pytest.approx(
|
| 17 |
-
assert r.ping_ms ==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def test_parse_ookla_json_rejects_rubbish():
|
|
@@ -28,8 +41,8 @@ def test_parse_ookla_json_rejects_rubbish():
|
|
| 28 |
|
| 29 |
def test_format_speed_shows_the_numbers():
|
| 30 |
text = format_speed(parse_ookla_json(RAW))
|
| 31 |
-
assert "
|
| 32 |
-
assert "
|
| 33 |
|
| 34 |
|
| 35 |
def test_format_speed_copes_with_missing_details():
|
|
|
|
| 2 |
|
| 3 |
from bot.speedtest import format_speed, parse_ookla_json
|
| 4 |
|
| 5 |
+
# A real `ookla-speedtest --accept-license --accept-gdpr --format=json` run.
|
| 6 |
+
RAW = """{"type":"result","timestamp":"2026-10-20T10:45:15Z",
|
| 7 |
+
"ping":{"jitter":0.273,"latency":2.998,"low":2.833,"high":3.315},
|
| 8 |
+
"download":{"bandwidth":11034552,"bytes":39707592,"elapsed":3602,
|
| 9 |
+
"latency":{"iqm":16.372,"low":6.344,"high":48.044,"jitter":3.565}},
|
| 10 |
+
"upload":{"bandwidth":11886628,"bytes":162534754,"elapsed":14821,
|
| 11 |
+
"latency":{"iqm":128.627,"low":5.8,"high":523.355,"jitter":48.431}},
|
| 12 |
+
"packetLoss":0,"isp":"ACT Fibernet",
|
| 13 |
+
"interface":{"internalIp":"192.168.32.3","name":"wlan0","externalIp":"49.204.136.88"},
|
| 14 |
+
"server":{"id":60836,"host":"chn.infonetcomm.com","port":8080,"name":"ACT FIBERNET",
|
| 15 |
+
"location":"Chennai","country":"India","ip":"2406:7400:b0:1::2"},
|
| 16 |
+
"result":{"id":"18dc","url":"https://www.speedtest.net/result/c/18dc","persisted":true}}"""
|
| 17 |
|
| 18 |
|
| 19 |
def test_parse_ookla_json_reads_the_speeds():
|
| 20 |
r = parse_ookla_json(RAW)
|
| 21 |
+
assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
|
| 22 |
+
assert r.upload_mbps == pytest.approx(11.886628, rel=1e-4)
|
| 23 |
+
assert r.ping_ms == 2.998 # the CLI reports the round trip as "latency"
|
| 24 |
+
assert r.server == "ACT FIBERNET — Chennai, India"
|
| 25 |
+
assert r.isp == "ACT Fibernet"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_parse_ookla_json_survives_a_leading_licence_notice():
|
| 29 |
+
r = parse_ookla_json("You may only use this Speedtest software...\n" + RAW)
|
| 30 |
+
assert r.download_mbps == pytest.approx(11.034552, rel=1e-4)
|
| 31 |
|
| 32 |
|
| 33 |
def test_parse_ookla_json_rejects_rubbish():
|
|
|
|
| 41 |
|
| 42 |
def test_format_speed_shows_the_numbers():
|
| 43 |
text = format_speed(parse_ookla_json(RAW))
|
| 44 |
+
assert "11.03" in text and "11.89" in text and "3" in text
|
| 45 |
+
assert "ACT FIBERNET" in text and "Chennai" in text and "ACT Fibernet" in text
|
| 46 |
|
| 47 |
|
| 48 |
def test_format_speed_copes_with_missing_details():
|