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 +22 -0
- speedkit/__meta__.py +8 -0
- speedkit/binaries.py +142 -0
- speedkit/cli.py +56 -0
- speedkit/client.py +87 -0
- speedkit/exceptions.py +36 -0
- speedkit/geoip.py +75 -0
- speedkit/providers.py +237 -0
- speedkit/py.typed +0 -0
- speedkit/types.py +82 -0
- speedkit/utils.py +33 -0
- speedkit-0.1.0.dist-info/METADATA +181 -0
- speedkit-0.1.0.dist-info/RECORD +17 -0
- speedkit-0.1.0.dist-info/WHEEL +5 -0
- speedkit-0.1.0.dist-info/entry_points.txt +2 -0
- speedkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- speedkit-0.1.0.dist-info/top_level.txt +1 -0
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
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
|
+

|
|
37
|
+
[](https://pypi.org/project/speedkit/)
|
|
38
|
+
[](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,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
|