speedkit 0.1.0__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.
speedkit/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from speedkit.__meta__ import __version__
2
+ from speedkit.client import Speedkit
3
+ from speedkit.exceptions import (
4
+ BinaryDownloadError,
5
+ SpeedkitError,
6
+ SpeedtestError,
7
+ UnsupportedPlatformError,
8
+ )
9
+ from speedkit.types import Client, Provider, Server, SpeedtestResult
10
+
11
+ __all__ = [
12
+ "BinaryDownloadError",
13
+ "Client",
14
+ "Provider",
15
+ "Server",
16
+ "Speedkit",
17
+ "SpeedkitError",
18
+ "SpeedtestError",
19
+ "SpeedtestResult",
20
+ "UnsupportedPlatformError",
21
+ "__version__",
22
+ ]
speedkit/__meta__.py ADDED
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2026 Shon Ness
2
+ #
3
+ # This source code is licensed under the MIT License found in the
4
+ # LICENSE file in the root directory of this source tree.
5
+
6
+ __version__ = "0.1.0"
7
+ __author__ = "nessshon"
8
+ __url__ = "https://github.com/nessshon/speedkit"
speedkit/binaries.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import platform
6
+ import sys
7
+ import tarfile
8
+ import typing as t
9
+ from pathlib import Path
10
+
11
+ from speedkit.exceptions import BinaryDownloadError, UnsupportedPlatformError
12
+ from speedkit.utils import fetch
13
+
14
+ __all__ = [
15
+ "LIBRESPEED_VERSION",
16
+ "OOKLA_VERSION",
17
+ "librespeed_binary",
18
+ "ookla_binary",
19
+ ]
20
+
21
+ OOKLA_VERSION: t.Final[str] = "1.2.0"
22
+ """Pinned Ookla Speedtest CLI version."""
23
+
24
+ LIBRESPEED_VERSION: t.Final[str] = "1.0.13"
25
+ """Pinned librespeed-cli version."""
26
+
27
+ DOWNLOAD_TIMEOUT: t.Final[float] = 60.0
28
+ """Timeout in seconds for downloading a CLI binary."""
29
+
30
+ _OOKLA_URL: t.Final[str] = "https://install.speedtest.net/app/cli/ookla-speedtest-{version}-{platform}.{ext}"
31
+ _LIBRESPEED_URL: t.Final[str] = (
32
+ "https://github.com/librespeed/speedtest-cli/releases/download/v{version}/librespeed-cli_{version}_{platform}.{ext}"
33
+ )
34
+
35
+ _OOKLA_PLATFORMS: t.Final[dict[tuple[str, str], str]] = {
36
+ ("Linux", "x86_64"): "linux-x86_64",
37
+ ("Linux", "aarch64"): "linux-aarch64",
38
+ ("Darwin", "x86_64"): "macosx-universal",
39
+ ("Darwin", "arm64"): "macosx-universal",
40
+ }
41
+ _LIBRESPEED_PLATFORMS: t.Final[dict[tuple[str, str], str]] = {
42
+ ("Linux", "x86_64"): "linux_amd64",
43
+ ("Linux", "aarch64"): "linux_arm64",
44
+ ("Darwin", "x86_64"): "darwin_amd64",
45
+ ("Darwin", "arm64"): "darwin_arm64",
46
+ }
47
+
48
+
49
+ def ookla_binary(cache_dir: Path | None = None) -> Path:
50
+ """Return the Ookla Speedtest CLI binary path, downloading it on first use.
51
+
52
+ Honors the ``SPEEDKIT_OOKLA_BINARY`` environment variable override.
53
+
54
+ :param cache_dir: Directory for cached binaries; defaults to the user cache directory.
55
+ :return: Path to the executable binary.
56
+ :raises UnsupportedPlatformError: If no prebuilt binary exists for this platform.
57
+ :raises BinaryDownloadError: If downloading or extracting the binary fails.
58
+ """
59
+ override = os.environ.get("SPEEDKIT_OOKLA_BINARY")
60
+ if override:
61
+ return Path(override)
62
+ key = _platform_key(_OOKLA_PLATFORMS, brand="Ookla Speedtest CLI")
63
+ url = _OOKLA_URL.format(version=OOKLA_VERSION, platform=key, ext="tgz")
64
+ return _ensure(url, member="speedtest", subdir=f"ookla-{OOKLA_VERSION}", cache_dir=cache_dir)
65
+
66
+
67
+ def librespeed_binary(cache_dir: Path | None = None) -> Path:
68
+ """Return the librespeed-cli binary path, downloading it on first use.
69
+
70
+ Honors the ``SPEEDKIT_LIBRESPEED_BINARY`` environment variable override.
71
+
72
+ :param cache_dir: Directory for cached binaries; defaults to the user cache directory.
73
+ :return: Path to the executable binary.
74
+ :raises UnsupportedPlatformError: If no prebuilt binary exists for this platform.
75
+ :raises BinaryDownloadError: If downloading or extracting the binary fails.
76
+ """
77
+ override = os.environ.get("SPEEDKIT_LIBRESPEED_BINARY")
78
+ if override:
79
+ return Path(override)
80
+ key = _platform_key(_LIBRESPEED_PLATFORMS, brand="librespeed-cli")
81
+ url = _LIBRESPEED_URL.format(version=LIBRESPEED_VERSION, platform=key, ext="tar.gz")
82
+ return _ensure(url, member="librespeed-cli", subdir=f"librespeed-{LIBRESPEED_VERSION}", cache_dir=cache_dir)
83
+
84
+
85
+ def _platform_key(platforms: dict[tuple[str, str], str], *, brand: str) -> str:
86
+ """Map the current OS and architecture to a release asset key."""
87
+ system, machine = platform.system(), platform.machine()
88
+ key = platforms.get((system, machine))
89
+ if key is None:
90
+ raise UnsupportedPlatformError(
91
+ f"no prebuilt {brand} binary for {system}/{machine}",
92
+ hint="install it manually and set the SPEEDKIT_OOKLA_BINARY / SPEEDKIT_LIBRESPEED_BINARY variable",
93
+ )
94
+ return key
95
+
96
+
97
+ def _ensure(url: str, *, member: str, subdir: str, cache_dir: Path | None) -> Path:
98
+ """Return the cached binary path, downloading and extracting it if missing."""
99
+ target = (cache_dir or _user_cache_dir()) / subdir / member
100
+ if target.exists():
101
+ return target
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+ data = _extract(_download(url), url=url, member=member)
104
+ # PID-unique temp name: concurrent downloads must not write into one file.
105
+ temporary = target.with_name(f"{member}.{os.getpid()}.tmp")
106
+ temporary.write_bytes(data)
107
+ temporary.chmod(0o755)
108
+ temporary.replace(target)
109
+ return target
110
+
111
+
112
+ def _download(url: str) -> bytes:
113
+ """Download a release archive and return its bytes."""
114
+ try:
115
+ return fetch(url, timeout=DOWNLOAD_TIMEOUT)
116
+ except OSError as exc:
117
+ raise BinaryDownloadError(
118
+ f"failed to download {url}: {exc}",
119
+ hint="check network access (`pip install certifi` fixes SSL certificate errors) "
120
+ "or point SPEEDKIT_OOKLA_BINARY / SPEEDKIT_LIBRESPEED_BINARY at a local binary",
121
+ ) from exc
122
+
123
+
124
+ def _extract(data: bytes, *, url: str, member: str) -> bytes:
125
+ """Extract a single member from a ``.tgz``/``.tar.gz`` archive."""
126
+ try:
127
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as bundle:
128
+ extracted = bundle.extractfile(member)
129
+ if extracted is None:
130
+ raise KeyError(member)
131
+ return extracted.read()
132
+ except (KeyError, tarfile.TarError) as exc:
133
+ raise BinaryDownloadError(f"failed to extract {member!r} from {url}: {exc}") from exc
134
+
135
+
136
+ def _user_cache_dir() -> Path:
137
+ """Return the platform-specific user cache directory for speedkit."""
138
+ if sys.platform == "darwin":
139
+ base = Path("~/Library/Caches")
140
+ else:
141
+ base = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache"))
142
+ return base.expanduser() / "speedkit"
speedkit/cli.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from speedkit.__meta__ import __version__
8
+ from speedkit.client import Speedkit
9
+ from speedkit.exceptions import SpeedkitError
10
+ from speedkit.types import DEFAULT_ATTEMPTS, DEFAULT_TIMEOUT, PROVIDERS
11
+
12
+
13
+ def main() -> None:
14
+ """Run a speed test and print the result as JSON."""
15
+ parser = argparse.ArgumentParser(
16
+ prog="speedkit",
17
+ description="Measure network speed and print the result as JSON.",
18
+ )
19
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
20
+ parser.add_argument(
21
+ "-p",
22
+ "--provider",
23
+ choices=("auto", *PROVIDERS),
24
+ default="auto",
25
+ help="measurement source (default: %(default)s)",
26
+ )
27
+ parser.add_argument(
28
+ "-t",
29
+ "--timeout",
30
+ type=float,
31
+ default=DEFAULT_TIMEOUT,
32
+ help="time budget per provider in seconds (default: %(default)s)",
33
+ )
34
+ parser.add_argument(
35
+ "-a",
36
+ "--attempts",
37
+ type=int,
38
+ default=DEFAULT_ATTEMPTS,
39
+ help="maximum measurement attempts per provider (default: %(default)s)",
40
+ )
41
+ args = parser.parse_args()
42
+ try:
43
+ kit = Speedkit(args.provider, timeout=args.timeout, attempts=args.attempts)
44
+ except ValueError as exc:
45
+ parser.error(str(exc))
46
+ try:
47
+ result = kit.run()
48
+ except KeyboardInterrupt:
49
+ sys.exit(130)
50
+ except SpeedkitError as exc:
51
+ sys.exit(f"speedkit: {exc}")
52
+ print(json.dumps(result.to_dict(), indent=2))
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
speedkit/client.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+ from dataclasses import replace
5
+
6
+ from speedkit.exceptions import SpeedkitError, SpeedtestError
7
+ from speedkit.geoip import lookup
8
+ from speedkit.providers import BaseProvider, LibrespeedProvider, OoklaProvider
9
+ from speedkit.types import DEFAULT_ATTEMPTS, DEFAULT_TIMEOUT, PROVIDERS, Provider, SpeedtestResult
10
+
11
+ if t.TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ __all__ = ["Speedkit"]
15
+
16
+ _PROVIDERS: t.Final[dict[Provider, type[BaseProvider]]] = {
17
+ "ookla": OoklaProvider,
18
+ "librespeed": LibrespeedProvider,
19
+ }
20
+
21
+
22
+ class Speedkit:
23
+ """Network speed measurement client.
24
+
25
+ Measures with the Ookla Speedtest CLI and falls back to LibreSpeed when
26
+ Speedtest is blocked or unavailable, then resolves the client network
27
+ info from a geo-IP service so results look the same either way.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ provider: Provider | t.Literal["auto"] = "auto",
33
+ *,
34
+ timeout: float = DEFAULT_TIMEOUT,
35
+ attempts: int = DEFAULT_ATTEMPTS,
36
+ cache_dir: Path | None = None,
37
+ ) -> None:
38
+ """Initialize the client.
39
+
40
+ :param provider: Measurement source; ``"auto"`` tries Ookla first and falls
41
+ back to LibreSpeed, while a provider name pins that one.
42
+ :param timeout: Total time budget per provider run in seconds, covering
43
+ all measurement attempts.
44
+ :param attempts: Maximum measurement attempts per provider within the budget;
45
+ ``1`` disables retries.
46
+ :param cache_dir: Directory for cached CLI binaries; defaults to the user cache directory.
47
+ :raises ValueError: If ``provider`` is unknown or ``attempts`` is less than 1.
48
+ """
49
+ if provider != "auto" and provider not in PROVIDERS:
50
+ raise ValueError(f"unknown provider {provider!r}; choose from auto, {', '.join(PROVIDERS)}")
51
+ if attempts < 1:
52
+ raise ValueError("attempts must be at least 1")
53
+ self.provider = provider
54
+ self.timeout = timeout
55
+ self.attempts = attempts
56
+ self.cache_dir = cache_dir
57
+
58
+ def run(self) -> SpeedtestResult:
59
+ """Run the speed test.
60
+
61
+ :return: Measurement result, with client info from a geo-IP service when
62
+ one is reachable and from the measuring CLI otherwise.
63
+ :raises SpeedkitError: If the measurement fails — in ``"auto"`` mode,
64
+ after both providers have failed.
65
+ """
66
+ result = self._measure()
67
+ client = lookup()
68
+ return replace(result, client=client) if client else result
69
+
70
+ def _measure(self) -> SpeedtestResult:
71
+ """Measure with the pinned provider, or Ookla falling back to LibreSpeed."""
72
+ if self.provider != "auto":
73
+ return self._run(self.provider)
74
+ try:
75
+ return self._run("ookla")
76
+ except SpeedkitError as ookla_exc:
77
+ try:
78
+ return self._run("librespeed")
79
+ except SpeedkitError as librespeed_exc:
80
+ raise SpeedtestError(
81
+ f"ookla: {ookla_exc}; librespeed: {librespeed_exc}",
82
+ hint="check network access or set SPEEDKIT_OOKLA_BINARY / SPEEDKIT_LIBRESPEED_BINARY",
83
+ ) from librespeed_exc
84
+
85
+ def _run(self, provider: Provider) -> SpeedtestResult:
86
+ """Run one provider within the configured budget."""
87
+ return _PROVIDERS[provider](self.cache_dir).run(timeout=self.timeout, attempts=self.attempts)
speedkit/exceptions.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+
5
+ __all__ = [
6
+ "BinaryDownloadError",
7
+ "SpeedkitError",
8
+ "SpeedtestError",
9
+ "UnsupportedPlatformError",
10
+ ]
11
+
12
+
13
+ class SpeedkitError(Exception):
14
+ """Base exception for speedkit."""
15
+
16
+ def __init__(self, *args: t.Any, hint: str | None = None) -> None:
17
+ super().__init__(*args)
18
+ self.hint = hint
19
+
20
+ def __str__(self) -> str:
21
+ message = super().__str__()
22
+ if self.hint:
23
+ return f"{message}\n Hint: {self.hint}"
24
+ return message
25
+
26
+
27
+ class UnsupportedPlatformError(SpeedkitError):
28
+ """No prebuilt CLI binary exists for the current platform."""
29
+
30
+
31
+ class BinaryDownloadError(SpeedkitError):
32
+ """Downloading or extracting a CLI binary failed."""
33
+
34
+
35
+ class SpeedtestError(SpeedkitError):
36
+ """A speed test run failed."""
speedkit/geoip.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import typing as t
6
+
7
+ from speedkit.types import Client
8
+ from speedkit.utils import fetch
9
+
10
+ __all__ = ["lookup"]
11
+
12
+ GEOIP_TIMEOUT: t.Final[float] = 5.0
13
+ """Timeout in seconds for a single geo-IP service request."""
14
+
15
+ # ipinfo.io reports the ISP as "AS64496 Example ISP"; the other services omit the prefix.
16
+ _AS_PREFIX = re.compile(r"^AS\d+\s+")
17
+
18
+
19
+ def lookup(timeout: float = GEOIP_TIMEOUT) -> Client | None:
20
+ """Resolve client network info from the first responding geo-IP service.
21
+
22
+ Services are tried in order and the first usable answer wins, so a rate-limited
23
+ or unreachable service costs one timeout rather than the whole lookup.
24
+
25
+ :param timeout: Timeout in seconds for a single service request.
26
+ :return: Client info, or ``None`` if no service answered usefully.
27
+ """
28
+ for url, parse in _SERVICES:
29
+ try:
30
+ data = json.loads(fetch(url, timeout=timeout))
31
+ client = parse(data) if isinstance(data, dict) else None
32
+ except (AttributeError, OSError, TypeError, ValueError):
33
+ continue
34
+ # Services echo the IP back even in error payloads, so require the ISP too.
35
+ if client and client.ip and client.isp:
36
+ return client
37
+ return None
38
+
39
+
40
+ def _from_ipinfo(data: dict[str, t.Any]) -> Client:
41
+ """Map an ipinfo.io response."""
42
+ return Client(
43
+ ip=data.get("ip", ""),
44
+ isp=_AS_PREFIX.sub("", data.get("org", "")),
45
+ country=data.get("country", ""),
46
+ city=data.get("city", ""),
47
+ )
48
+
49
+
50
+ def _from_ipwho(data: dict[str, t.Any]) -> Client:
51
+ """Map an ipwho.is response."""
52
+ return Client(
53
+ ip=data.get("ip", ""),
54
+ isp=data.get("connection", {}).get("isp", ""),
55
+ country=data.get("country_code", ""),
56
+ city=data.get("city", ""),
57
+ )
58
+
59
+
60
+ def _from_ipapi(data: dict[str, t.Any]) -> Client:
61
+ """Map an ip-api.com response."""
62
+ return Client(
63
+ ip=data.get("query", ""),
64
+ isp=data.get("isp", ""),
65
+ country=data.get("countryCode", ""),
66
+ city=data.get("city", ""),
67
+ )
68
+
69
+
70
+ # Ordered by preference: HTTPS without a key first, plain-HTTP ip-api.com last.
71
+ _SERVICES: t.Final[tuple[tuple[str, t.Callable[[dict[str, t.Any]], Client]], ...]] = (
72
+ ("https://ipinfo.io/json", _from_ipinfo),
73
+ ("https://ipwho.is", _from_ipwho),
74
+ ("http://ip-api.com/json", _from_ipapi),
75
+ )
speedkit/providers.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import time
7
+ import typing as t
8
+ from abc import ABC, abstractmethod
9
+ from datetime import datetime, timezone
10
+
11
+ from speedkit.binaries import librespeed_binary, ookla_binary
12
+ from speedkit.exceptions import SpeedtestError
13
+ from speedkit.types import Client, Provider, Server, SpeedtestResult
14
+
15
+ if t.TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ __all__ = [
19
+ "BaseProvider",
20
+ "LibrespeedProvider",
21
+ "OoklaProvider",
22
+ ]
23
+
24
+
25
+ class BaseProvider(ABC):
26
+ """Base class for speed test providers backed by a CLI binary."""
27
+
28
+ provider: t.ClassVar[Provider]
29
+
30
+ def __init__(self, cache_dir: Path | None = None) -> None:
31
+ self._cache_dir = cache_dir
32
+
33
+ def run(self, timeout: float, attempts: int = 1) -> SpeedtestResult:
34
+ """Run the speed test and return the parsed result.
35
+
36
+ :param timeout: Total time budget in seconds, shared by all attempts.
37
+ :param attempts: Maximum number of measurement attempts.
38
+ :return: Measurement result.
39
+ :raises SpeedtestError: If every attempt fails or the budget runs out.
40
+ """
41
+ deadline = time.monotonic() + timeout
42
+ while True:
43
+ attempts -= 1
44
+ try:
45
+ return self._measure(self._command(), timeout=deadline - time.monotonic())
46
+ except SpeedtestError as exc:
47
+ remaining = deadline - time.monotonic()
48
+ if attempts <= 0 or remaining <= 0:
49
+ raise
50
+ self._note_failure(exc, timeout=remaining)
51
+
52
+ def _note_failure(self, error: SpeedtestError, *, timeout: float) -> None: # noqa: B027
53
+ """React to a failed attempt before the next one; does nothing by default."""
54
+
55
+ def _measure(self, command: list[str], *, timeout: float) -> SpeedtestResult:
56
+ """Execute a measurement command and parse its output."""
57
+ stdout = self._execute(command, timeout=timeout)
58
+ try:
59
+ return self._parse(stdout)
60
+ except (AttributeError, KeyError, IndexError, TypeError, ValueError) as exc:
61
+ raise SpeedtestError(f"unexpected output: {exc}") from exc
62
+
63
+ def _execute(self, command: list[str], *, timeout: float) -> str:
64
+ """Run a CLI command and return its stdout, raising ``SpeedtestError`` on failure."""
65
+ try:
66
+ process = subprocess.run(
67
+ command,
68
+ capture_output=True,
69
+ encoding="utf-8",
70
+ errors="replace",
71
+ timeout=timeout,
72
+ check=False,
73
+ )
74
+ except (OSError, subprocess.TimeoutExpired) as exc:
75
+ raise SpeedtestError(str(exc)) from exc
76
+ if process.returncode != 0:
77
+ detail = self._error(process.stderr)
78
+ message = f"exited with code {process.returncode}"
79
+ raise SpeedtestError(f"{message}: {detail}" if detail else message)
80
+ return process.stdout
81
+
82
+ @abstractmethod
83
+ def _command(self) -> list[str]:
84
+ """Build the CLI command to execute."""
85
+
86
+ @abstractmethod
87
+ def _parse(self, stdout: str) -> SpeedtestResult:
88
+ """Parse the CLI JSON output into a result."""
89
+
90
+ @staticmethod
91
+ def _error(stderr: str) -> str:
92
+ """Extract a human-readable error message from the CLI stderr."""
93
+ return stderr.strip()
94
+
95
+
96
+ class OoklaProvider(BaseProvider):
97
+ """Speed test via the official Ookla Speedtest CLI.
98
+
99
+ Running it implies acceptance of the Ookla EULA and GDPR terms:
100
+ the ``--accept-license`` and ``--accept-gdpr`` flags are passed automatically.
101
+ """
102
+
103
+ provider = "ookla"
104
+
105
+ def _command(self) -> list[str]:
106
+ """Build the Ookla Speedtest CLI command."""
107
+ return [str(ookla_binary(self._cache_dir)), "--format=json", "--accept-license", "--accept-gdpr"]
108
+
109
+ def _parse(self, stdout: str) -> SpeedtestResult:
110
+ """Parse ``speedtest --format=json`` output."""
111
+ data = json.loads(stdout)
112
+ server = data.get("server", {})
113
+ interface = data.get("interface", {})
114
+ latency = float(data.get("ping", {}).get("latency", 0.0))
115
+ host, port = server.get("host", ""), server.get("port", "")
116
+ return SpeedtestResult(
117
+ download=float(data["download"]["bandwidth"]) * 8.0,
118
+ upload=float(data["upload"]["bandwidth"]) * 8.0,
119
+ ping=latency,
120
+ server=Server(
121
+ name=server.get("location", ""),
122
+ country=server.get("country", ""),
123
+ sponsor=server.get("name", ""),
124
+ id=str(server.get("id", "")),
125
+ host=f"{host}:{port}" if host and port else host,
126
+ latency=latency,
127
+ ),
128
+ timestamp=_utc_now(),
129
+ bytes_sent=int(data["upload"].get("bytes", 0)),
130
+ bytes_received=int(data["download"].get("bytes", 0)),
131
+ client=Client(
132
+ ip=interface.get("externalIp", ""),
133
+ isp=data.get("isp", ""),
134
+ ),
135
+ provider=self.provider,
136
+ )
137
+
138
+ @staticmethod
139
+ def _error(stderr: str) -> str:
140
+ """Extract the error message from Ookla's JSON log lines on stderr."""
141
+ for line in reversed(stderr.splitlines()):
142
+ entry = _json_line(line)
143
+ if entry and entry.get("level") == "error":
144
+ return str(entry.get("message") or stderr.strip())
145
+ return stderr.strip()
146
+
147
+
148
+ class LibrespeedProvider(BaseProvider):
149
+ """Speed test via the LibreSpeed CLI.
150
+
151
+ The CLI probes the public LibreSpeed server list and picks the
152
+ lowest-latency server nearby.
153
+ """
154
+
155
+ provider = "librespeed"
156
+
157
+ def __init__(self, cache_dir: Path | None = None) -> None:
158
+ super().__init__(cache_dir)
159
+ self._excluded: list[int] = []
160
+
161
+ def _command(self) -> list[str]:
162
+ """Build the librespeed-cli command."""
163
+ command = [str(librespeed_binary(self._cache_dir)), "--json"]
164
+ for server in self._excluded:
165
+ command += ["--exclude", str(server)]
166
+ return command
167
+
168
+ def _parse(self, stdout: str) -> SpeedtestResult:
169
+ """Parse ``librespeed-cli --json`` output."""
170
+ results = json.loads(stdout)
171
+ if not results:
172
+ raise SpeedtestError(
173
+ "librespeed-cli returned no results",
174
+ hint="the LibreSpeed servers may be unreachable from this network",
175
+ )
176
+ data = results[0]
177
+ server = data.get("server", {})
178
+ client = data.get("client", {})
179
+ ping = float(data.get("ping", 0.0))
180
+ return SpeedtestResult(
181
+ download=float(data["download"]) * 1_000_000.0,
182
+ upload=float(data["upload"]) * 1_000_000.0,
183
+ ping=ping,
184
+ server=Server(
185
+ url=server.get("url", ""),
186
+ name=server.get("name", ""),
187
+ latency=ping,
188
+ ),
189
+ timestamp=_utc_now(),
190
+ bytes_sent=int(data.get("bytes_sent", 0)),
191
+ bytes_received=int(data.get("bytes_received", 0)),
192
+ client=Client(
193
+ ip=client.get("ip", ""),
194
+ isp=client.get("org", ""),
195
+ country=client.get("country", ""),
196
+ ),
197
+ provider=self.provider,
198
+ )
199
+
200
+ def _note_failure(self, error: SpeedtestError, *, timeout: float) -> None:
201
+ """Exclude the failed server from the next attempt, when identifiable.
202
+
203
+ Half-dead servers in the public list can win selection yet fail the
204
+ measurement; without the exclusion a retry would pick them again.
205
+ """
206
+ match = _ERROR_URL.search(str(error))
207
+ if match is None:
208
+ return
209
+ try:
210
+ listing = self._execute([str(librespeed_binary(self._cache_dir)), "--list"], timeout=timeout)
211
+ except SpeedtestError:
212
+ return
213
+ for entry in _LIST_LINE.finditer(listing):
214
+ if match["host"] in entry["url"]:
215
+ self._excluded.append(int(entry["id"]))
216
+ return
217
+
218
+
219
+ # Host of the failed server, taken from a CLI error like: Get "https://host/backend/...": timeout
220
+ _ERROR_URL = re.compile(r'https?://(?P<host>[^/\s"]+)')
221
+
222
+ # librespeed-cli --list line: "101: Springfield, Freedonia (Example Host) (https://example.net/) [Sponsor: ...]"
223
+ _LIST_LINE = re.compile(r"^(?P<id>\d+): .+ \((?P<url>https?://[^)]+)\)", re.MULTILINE)
224
+
225
+
226
+ def _json_line(line: str) -> dict[str, t.Any] | None:
227
+ """Parse one line as a JSON object, returning ``None`` on failure."""
228
+ try:
229
+ data = json.loads(line)
230
+ except ValueError:
231
+ return None
232
+ return data if isinstance(data, dict) else None
233
+
234
+
235
+ def _utc_now() -> str:
236
+ """Return the current time as an ISO 8601 UTC string with a ``Z`` suffix."""
237
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
speedkit/py.typed ADDED
File without changes
speedkit/types.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as t
4
+ from dataclasses import asdict, dataclass
5
+
6
+ __all__ = [
7
+ "DEFAULT_ATTEMPTS",
8
+ "DEFAULT_TIMEOUT",
9
+ "PROVIDERS",
10
+ "Client",
11
+ "Provider",
12
+ "Server",
13
+ "SpeedtestResult",
14
+ ]
15
+
16
+ DEFAULT_TIMEOUT: t.Final[float] = 120.0
17
+ """Default time budget of a single provider run in seconds."""
18
+
19
+ DEFAULT_ATTEMPTS: t.Final[int] = 2
20
+ """Default maximum number of measurement attempts per provider."""
21
+
22
+ Provider = t.Literal["ookla", "librespeed"]
23
+ """Measurement source that produced a result."""
24
+
25
+ PROVIDERS: t.Final[tuple[Provider, ...]] = t.get_args(Provider)
26
+ """All providers, in the order ``"auto"`` tries them."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Server:
31
+ """Test server info.
32
+
33
+ Fields a provider does not report are left at neutral defaults.
34
+ """
35
+
36
+ url: str = ""
37
+ name: str = ""
38
+ country: str = ""
39
+ sponsor: str = ""
40
+ id: str = ""
41
+ host: str = ""
42
+ latency: float = 0.0
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Client:
47
+ """Client connection info, resolved from a geo-IP service.
48
+
49
+ Fields the service does not report are left at neutral defaults.
50
+ """
51
+
52
+ ip: str = ""
53
+ isp: str = ""
54
+ country: str = ""
55
+ """Two-letter ISO country code, such as ``RU``."""
56
+ city: str = ""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SpeedtestResult:
61
+ """Speed test measurement result.
62
+
63
+ ``download`` and ``upload`` are in bits per second, ``ping`` in milliseconds.
64
+ """
65
+
66
+ client: Client
67
+ server: Server
68
+ provider: Provider
69
+
70
+ download: float
71
+ upload: float
72
+ ping: float
73
+ timestamp: str
74
+ bytes_sent: int
75
+ bytes_received: int
76
+
77
+ def to_dict(self) -> dict[str, t.Any]:
78
+ """Return the result as a plain JSON-serializable dict.
79
+
80
+ :return: Dict with a fixed key set and order, identical for every provider.
81
+ """
82
+ return asdict(self)
speedkit/utils.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import ssl
5
+ import urllib.request
6
+
7
+ __all__ = ["fetch"]
8
+
9
+
10
+ def fetch(url: str, timeout: float) -> bytes:
11
+ """Fetch a URL and return the raw response body.
12
+
13
+ :param url: URL to fetch.
14
+ :param timeout: Timeout in seconds.
15
+ :return: Raw response body.
16
+ :raises OSError: If the request fails, including broken HTTP framing.
17
+ """
18
+ try:
19
+ with urllib.request.urlopen(url, timeout=timeout, context=_ssl_context()) as response:
20
+ data: bytes = response.read()
21
+ return data
22
+ except http.client.HTTPException as exc:
23
+ # Truncated bodies and bad status lines are not OSError; normalize for callers.
24
+ raise OSError(str(exc)) from exc
25
+
26
+
27
+ def _ssl_context() -> ssl.SSLContext:
28
+ """Return an SSL context, preferring certifi's CA bundle when installed."""
29
+ try:
30
+ import certifi
31
+ except ImportError:
32
+ return ssl.create_default_context()
33
+ return ssl.create_default_context(cafile=certifi.where())
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: speedkit
3
+ Version: 0.1.0
4
+ Summary: Python SDK for network speed testing — bandwidth, latency, and connection details in a single call.
5
+ Author: nessshon
6
+ Maintainer: nessshon
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/nessshon/speedkit/
9
+ Project-URL: Examples, https://github.com/nessshon/speedkit/tree/main/examples/
10
+ Keywords: CLI,SDK,bandwidth,benchmark,internet speed,librespeed,network,ookla,speedtest
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Networking :: Monitoring
25
+ Requires-Python: <3.15,>=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.19.0; extra == "dev"
30
+ Requires-Dist: pytest>=8.0; extra == "dev"
31
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # 📦 Speedkit
35
+
36
+ ![Python Versions](https://img.shields.io/badge/Python-3.9%20--%203.14-black?color=FFE873&labelColor=3776AB)
37
+ [![PyPI](https://img.shields.io/pypi/v/speedkit.svg?color=FFE873&labelColor=3776AB)](https://pypi.org/project/speedkit/)
38
+ [![License](https://img.shields.io/github/license/nessshon/speedkit)](https://github.com/nessshon/speedkit/blob/main/LICENSE)
39
+
40
+ ### Python SDK for network speed testing
41
+
42
+ Measures download, upload, and latency with the official [Ookla Speedtest CLI](https://www.speedtest.net/apps/cli)
43
+ and falls back to [LibreSpeed](https://github.com/librespeed/speedtest-cli) when Speedtest is blocked or unavailable.
44
+
45
+ **Features**
46
+
47
+ - **Zero setup** — Ookla Speedtest first, LibreSpeed when it is blocked or fails.
48
+ - **Automatic binaries** — downloaded and cached for your OS on first run.
49
+ - **Nearby servers** — the lowest-latency server is picked automatically.
50
+ - **Self-recovery** — failed attempts are retried, broken servers excluded.
51
+ - **Uniform results** — same fields and units whichever provider ran; client info from geo-IP.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install speedkit
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```python
62
+ from speedkit import Speedkit
63
+
64
+ kit = Speedkit()
65
+ result = kit.run()
66
+
67
+ print(f"download: {result.download / 1_000_000:.1f} Mbit/s")
68
+ print(f"upload: {result.upload / 1_000_000:.1f} Mbit/s")
69
+ print(f"ping: {result.ping:.1f} ms")
70
+
71
+ data = result.to_dict() # plain JSON-serializable dict
72
+ ```
73
+
74
+ Or from the command line:
75
+
76
+ ```bash
77
+ speedkit # auto: Ookla, then LibreSpeed
78
+ speedkit -p librespeed # pin one provider
79
+ speedkit -t 180 # time budget per provider in seconds
80
+ speedkit -a 1 # disable retries
81
+ ```
82
+
83
+ `Speedkit()` needs no configuration; everything it accepts:
84
+
85
+ ```python
86
+ kit = Speedkit(
87
+ provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
88
+ timeout=120, # time budget per provider in seconds, covering all attempts
89
+ attempts=2, # measurement attempts within the budget; 1 disables retries
90
+ cache_dir=None, # where CLI binaries are cached; None = user cache directory
91
+ )
92
+ ```
93
+
94
+ ## Result format
95
+
96
+ `SpeedtestResult.to_dict()` returns `download`/`upload` in bits per second and `ping` in milliseconds:
97
+
98
+ ```json
99
+ {
100
+ "client": {
101
+ "ip": "203.0.113.7",
102
+ "isp": "Example ISP",
103
+ "country": "ZZ",
104
+ "city": "Springfield"
105
+ },
106
+ "server": {
107
+ "url": "",
108
+ "name": "Springfield",
109
+ "country": "Freedonia",
110
+ "sponsor": "Example Sponsor",
111
+ "id": "1234",
112
+ "host": "speedtest.example.net:8080",
113
+ "latency": 0.681
114
+ },
115
+ "provider": "ookla",
116
+ "download": 293111520.0,
117
+ "upload": 292265640.0,
118
+ "ping": 0.681,
119
+ "timestamp": "2026-07-19T15:22:48Z",
120
+ "bytes_sent": 418465064,
121
+ "bytes_received": 426981996
122
+ }
123
+ ```
124
+
125
+ - `client` — from a geo-IP service, identical whichever provider measured.
126
+ - `server` — the test server that provider picked; unreported fields stay at `""` / `0.0`.
127
+ - `provider` — which measurer produced the result.
128
+
129
+ ## Geo-IP lookup
130
+
131
+ Client details come from the first service that answers: `ipinfo.io` → `ipwho.is` → `ip-api.com`.
132
+
133
+ - The lookup takes a fraction of a second.
134
+ - If every service is unreachable, the measurement is still returned —
135
+ with whatever client info the CLI reported.
136
+
137
+ > This sends your IP address to the geo-IP service that answers.
138
+
139
+ ## Binaries
140
+
141
+ | Provider | Version | Platforms |
142
+ | ------------------- | ------- | ---------------------------- |
143
+ | Ookla Speedtest CLI | 1.2.0 | Linux x86_64/aarch64, macOS |
144
+ | librespeed-cli | 1.0.13 | Linux x86_64/aarch64, macOS |
145
+
146
+ Binaries are downloaded on first use and cached per platform:
147
+
148
+ - Linux — `~/.cache/speedkit`
149
+ - macOS — `~/Library/Caches/speedkit`
150
+
151
+ Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
152
+ binary to skip downloading entirely — useful for offline machines and locked-down networks.
153
+
154
+ To pre-download the binaries at image build time (Docker, CI):
155
+
156
+ ```bash
157
+ python -c "from speedkit.binaries import ookla_binary, librespeed_binary; ookla_binary(); librespeed_binary()"
158
+ ```
159
+
160
+ > Running the Ookla provider passes `--accept-license --accept-gdpr`, which implies acceptance
161
+ > of the [Ookla EULA](https://www.speedtest.net/about/eula) and privacy terms.
162
+
163
+ ## Errors
164
+
165
+ Every error derives from `SpeedkitError` and carries an actionable hint:
166
+
167
+ ```python
168
+ from speedkit import Speedkit, SpeedkitError
169
+
170
+ try:
171
+ result = Speedkit().run()
172
+ except SpeedkitError as error:
173
+ print(error) # cause, and a hint on how to fix it
174
+ ```
175
+
176
+ `UnsupportedPlatformError` — no prebuilt binary for this OS/arch; `BinaryDownloadError` —
177
+ the binary could not be fetched; `SpeedtestError` — the measurement itself failed.
178
+
179
+ ## License
180
+
181
+ This repository is distributed under the [MIT License](LICENSE).
@@ -0,0 +1,17 @@
1
+ speedkit/__init__.py,sha256=V_2XiPr97l9eCqPFv5CNRWPr8Wbvc8AIItPv6zXbl2o,497
2
+ speedkit/__meta__.py,sha256=pzYIq6M1N-karCmP525iivCKOZIJdhc2POP0lSCep_s,253
3
+ speedkit/binaries.py,sha256=xikSMDifxRlFyTMqHMYy6C7CeNX6gKCyi6oSB6qH-3E,5581
4
+ speedkit/cli.py,sha256=cDhoZhth7uwrwHe1TUuYgQntSdj5krvPpOaOYCqjkto,1606
5
+ speedkit/client.py,sha256=98NGhf13aL0_VxGO_l7cel6q4r1btOUql6fYnRbpF4c,3481
6
+ speedkit/exceptions.py,sha256=GX3dxSWuxswY4J5FmI-01a5tysFi54Lx96DoXE9sf8w,829
7
+ speedkit/geoip.py,sha256=2a0F8VBGrQnXYviw_W1sxQg7V4rQLZ-uxbVI6y9sxiM,2399
8
+ speedkit/providers.py,sha256=ImZbe_iinpF3o3iYnrhJ38K-gnI0p-nE9r0PjaH-_nk,8821
9
+ speedkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ speedkit/types.py,sha256=U2A9KhPz7NXF3RzkENtMg561DxOhrSBu7meJ9-xi_qI,1855
11
+ speedkit/utils.py,sha256=NihSDDuYoErc5iuf9ZjD2-84X8g70LZdwkZQCjkaA9I,1017
12
+ speedkit-0.1.0.dist-info/licenses/LICENSE,sha256=1YwwehUDcIl8EO2mm1P8V0ZVxAG6JkBkbJdRJU__GBo,1061
13
+ speedkit-0.1.0.dist-info/METADATA,sha256=wi_YI_-nJBcSf7VlS-Ff8gKjmOQbPUf9RxkJ_sZZbxg,6221
14
+ speedkit-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
15
+ speedkit-0.1.0.dist-info/entry_points.txt,sha256=cmOjvs5Hs1siTNV0VOtICzN_kVaAOepZDSwDuU2u9Iw,47
16
+ speedkit-0.1.0.dist-info/top_level.txt,sha256=GWxIZSNQULWKGXo-5HfC9AKXQD9klBoEiZpuDd5NUuQ,9
17
+ speedkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ speedkit = speedkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ness
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ speedkit