File size: 8,389 Bytes
5f400f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""/speed: an Ookla Speedtest run from the bot's own network.

The official Speedtest CLI is used when it is installed.  If it is missing or fails, the ``speedtest-cli`` Python
package (also an Ookla client) is tried.  Both give the same figures; only the parsing differs.
"""
from __future__ import annotations

import asyncio
import html
import json
import logging
import os
import shutil
from dataclasses import dataclass
from typing import Any, Dict, List, Optional

from .utils import esc

log = logging.getLogger(__name__)

CLI_TIMEOUT = 120  # seconds for one run of either client
CANDIDATES = ("ookla-speedtest", "speedtest")  # the Dockerfile installs the official binary as ookla-speedtest


class SpeedError(RuntimeError):
    """The speed test could not be run; the message is safe to show to the user."""


@dataclass
class SpeedResult:
    download_mbps: float
    upload_mbps: float
    ping_ms: float
    jitter_ms: Optional[float] = None
    packet_loss: Optional[float] = None
    isp: str = ""
    server: str = ""
    location: str = ""
    url: str = ""  # the speedtest.net page of this result
    source: str = ""


def _num(value: Any) -> Optional[float]:
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def parse_cli(data: Dict[str, Any]) -> SpeedResult:
    """The JSON the official CLI prints (bandwidth is in bytes per second)."""
    download = _num((data.get("download") or {}).get("bandwidth"))
    upload = _num((data.get("upload") or {}).get("bandwidth"))
    if download is None or upload is None:
        raise SpeedError("the Speedtest CLI gave no download or upload figure")
    ping = data.get("ping") or {}
    server = data.get("server") or {}
    location = ", ".join(part for part in (server.get("location"), server.get("country")) if part)
    return SpeedResult(
        download_mbps=download * 8 / 1e6,
        upload_mbps=upload * 8 / 1e6,
        ping_ms=_num(ping.get("latency")) or 0.0,
        jitter_ms=_num(ping.get("jitter")),
        packet_loss=_num(data.get("packetLoss")),
        isp=str(data.get("isp") or ""),
        server=str(server.get("name") or server.get("host") or ""),
        location=location,
        url=str((data.get("result") or {}).get("url") or ""),
        source="Ookla Speedtest CLI",
    )


def parse_python(data: Dict[str, Any]) -> SpeedResult:
    """``Speedtest.results.dict()`` of the speedtest-cli package (speeds are in bits per second)."""
    download, upload = _num(data.get("download")), _num(data.get("upload"))
    if download is None or upload is None:
        raise SpeedError("speedtest-cli gave no download or upload figure")
    server = data.get("server") or {}
    client = data.get("client") or {}
    location = ", ".join(part for part in (server.get("name"), server.get("country")) if part)
    return SpeedResult(
        download_mbps=download / 1e6,
        upload_mbps=upload / 1e6,
        ping_ms=_num(data.get("ping")) or 0.0,
        isp=str(client.get("isp") or ""),
        server=str(server.get("sponsor") or ""),
        location=location,
        url=str(data.get("share") or ""),
        source="speedtest-cli (Ookla)",
    )


def _last_result(stdout: str) -> Optional[Dict[str, Any]]:
    """The ``{"type": "result", ...}`` line of the CLI's JSON output."""
    found = None
    for line in stdout.splitlines():
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            obj = json.loads(line)
        except ValueError:
            continue
        if isinstance(obj, dict) and obj.get("type") == "result":
            found = obj
    return found


def _log_message(stdout: str, stderr: str) -> str:
    """Why the CLI failed, as far as it says."""
    for text in (stdout, stderr):
        for line in reversed(text.splitlines()):
            line = line.strip()
            if line.startswith("{"):
                try:
                    obj = json.loads(line)
                except ValueError:
                    continue
                if isinstance(obj, dict) and obj.get("message"):
                    return str(obj["message"])
            elif line:
                return line
    return "no output"


async def _run(cmd: List[str], timeout: float) -> "tuple[int, str, str]":
    try:
        proc = await asyncio.create_subprocess_exec(
            *cmd, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
        )
    except (FileNotFoundError, PermissionError, OSError) as exc:
        raise SpeedError(f"{cmd[0]} cannot be started ({exc})") from exc
    try:
        out, err = await asyncio.wait_for(proc.communicate(), timeout)
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        raise SpeedError("the speed test took too long") from None
    return proc.returncode or 0, out.decode("utf-8", "replace"), err.decode("utf-8", "replace")


async def find_ookla_cli() -> Optional[str]:
    """The official CLI, if installed.  ``speedtest`` can also be the Python package's script, so ``--version`` decides."""
    names = [os.environ.get("SPEEDTEST_BIN", "")] + list(CANDIDATES)
    for name in dict.fromkeys(n for n in names if n):
        path = shutil.which(name)
        if not path:
            continue
        try:
            code, out, _err = await _run([path, "--version"], 15)
        except SpeedError:
            continue
        if code == 0 and "ookla" in out.lower():
            return path
    return None


async def run_cli(path: str, timeout: float = CLI_TIMEOUT) -> SpeedResult:
    code, out, err = await _run([path, "--accept-license", "--accept-gdpr", "--format=json"], timeout)
    result = _last_result(out)
    if result is None:
        raise SpeedError(f"the Speedtest CLI failed: {_log_message(out, err)}")
    return parse_cli(result)


def _run_python() -> Dict[str, Any]:
    """Blocking: one test with the speedtest-cli package."""
    try:
        import speedtest  # type: ignore
    except ImportError as exc:
        raise SpeedError("the speedtest-cli package is not installed") from exc
    try:
        test = speedtest.Speedtest(secure=True)
        test.get_best_server()
        test.download()
        test.upload()
        return dict(test.results.dict())
    except Exception as exc:  # noqa: BLE001 - the package raises its own family of errors
        raise SpeedError(f"speedtest-cli failed: {exc}") from exc


async def run_speedtest() -> SpeedResult:
    """Run a test with the official CLI, or with the Python client when the CLI is missing or fails."""
    problems: List[str] = []
    cli = await find_ookla_cli()
    if cli is not None:
        try:
            return await run_cli(cli)
        except SpeedError as exc:
            log.warning("Speedtest CLI failed: %s", exc)
            problems.append(str(exc))
    else:
        problems.append("the Speedtest CLI is not installed")
    try:
        data = await asyncio.wait_for(asyncio.to_thread(_run_python), CLI_TIMEOUT)
        return parse_python(data)
    except asyncio.TimeoutError:
        problems.append("speedtest-cli took too long")
    except SpeedError as exc:
        problems.append(str(exc))
    raise SpeedError("; ".join(problems))


def _mbps(value: float) -> str:
    return f"{value:,.2f} Mbps"


def format_result(r: SpeedResult) -> str:
    lines = [
        "<b>⚡ SPEEDTEST</b> · Ookla",
        "",
        f"◈ <b>Download</b> ▸ <code>{_mbps(r.download_mbps)}</code>",
        f"◈ <b>Upload</b> ▸ <code>{_mbps(r.upload_mbps)}</code>",
        f"◈ <b>Ping</b> ▸ <code>{r.ping_ms:.2f} ms</code>" + (f" · jitter <code>{r.jitter_ms:.2f} ms</code>" if r.jitter_ms is not None else ""),
    ]
    if r.packet_loss is not None:
        lines.append(f"◈ <b>Packet loss</b> ▸ <code>{r.packet_loss:g}%</code>")
    if r.isp:
        lines.append(f"◈ <b>ISP</b> ▸ {esc(r.isp)}")
    server = " · ".join(part for part in (esc(r.server), esc(r.location)) if part)
    if server:
        lines.append(f"◈ <b>Server</b> ▸ {server}")
    if r.url.startswith(("http://", "https://")):
        lines.append(f"◈ <b>Result</b> ▸ <a href=\"{html.escape(r.url, quote=True)}\">speedtest.net</a>")
    lines.append(f"\n<i>Measured from the machine the bot runs on · {esc(r.source)}</i>")
    return "\n".join(lines)