speedkit 0.1.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/LICENSE ADDED
@@ -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,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: speedkit
3
+ Version: 0.1.0
4
+ Summary: Python SDK for network speed testing — bandwidth, latency, and connection details in a single call.
5
+ Author: nessshon
6
+ Maintainer: nessshon
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/nessshon/speedkit/
9
+ Project-URL: Examples, https://github.com/nessshon/speedkit/tree/main/examples/
10
+ Keywords: CLI,SDK,bandwidth,benchmark,internet speed,librespeed,network,ookla,speedtest
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Networking :: Monitoring
25
+ Requires-Python: <3.15,>=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.19.0; extra == "dev"
30
+ Requires-Dist: pytest>=8.0; extra == "dev"
31
+ Requires-Dist: ruff>=0.8.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # 📦 Speedkit
35
+
36
+ ![Python Versions](https://img.shields.io/badge/Python-3.9%20--%203.14-black?color=FFE873&labelColor=3776AB)
37
+ [![PyPI](https://img.shields.io/pypi/v/speedkit.svg?color=FFE873&labelColor=3776AB)](https://pypi.org/project/speedkit/)
38
+ [![License](https://img.shields.io/github/license/nessshon/speedkit)](https://github.com/nessshon/speedkit/blob/main/LICENSE)
39
+
40
+ ### Python SDK for network speed testing
41
+
42
+ Measures download, upload, and latency with the official [Ookla Speedtest CLI](https://www.speedtest.net/apps/cli)
43
+ and falls back to [LibreSpeed](https://github.com/librespeed/speedtest-cli) when Speedtest is blocked or unavailable.
44
+
45
+ **Features**
46
+
47
+ - **Zero setup** — Ookla Speedtest first, LibreSpeed when it is blocked or fails.
48
+ - **Automatic binaries** — downloaded and cached for your OS on first run.
49
+ - **Nearby servers** — the lowest-latency server is picked automatically.
50
+ - **Self-recovery** — failed attempts are retried, broken servers excluded.
51
+ - **Uniform results** — same fields and units whichever provider ran; client info from geo-IP.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install speedkit
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```python
62
+ from speedkit import Speedkit
63
+
64
+ kit = Speedkit()
65
+ result = kit.run()
66
+
67
+ print(f"download: {result.download / 1_000_000:.1f} Mbit/s")
68
+ print(f"upload: {result.upload / 1_000_000:.1f} Mbit/s")
69
+ print(f"ping: {result.ping:.1f} ms")
70
+
71
+ data = result.to_dict() # plain JSON-serializable dict
72
+ ```
73
+
74
+ Or from the command line:
75
+
76
+ ```bash
77
+ speedkit # auto: Ookla, then LibreSpeed
78
+ speedkit -p librespeed # pin one provider
79
+ speedkit -t 180 # time budget per provider in seconds
80
+ speedkit -a 1 # disable retries
81
+ ```
82
+
83
+ `Speedkit()` needs no configuration; everything it accepts:
84
+
85
+ ```python
86
+ kit = Speedkit(
87
+ provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
88
+ timeout=120, # time budget per provider in seconds, covering all attempts
89
+ attempts=2, # measurement attempts within the budget; 1 disables retries
90
+ cache_dir=None, # where CLI binaries are cached; None = user cache directory
91
+ )
92
+ ```
93
+
94
+ ## Result format
95
+
96
+ `SpeedtestResult.to_dict()` returns `download`/`upload` in bits per second and `ping` in milliseconds:
97
+
98
+ ```json
99
+ {
100
+ "client": {
101
+ "ip": "203.0.113.7",
102
+ "isp": "Example ISP",
103
+ "country": "ZZ",
104
+ "city": "Springfield"
105
+ },
106
+ "server": {
107
+ "url": "",
108
+ "name": "Springfield",
109
+ "country": "Freedonia",
110
+ "sponsor": "Example Sponsor",
111
+ "id": "1234",
112
+ "host": "speedtest.example.net:8080",
113
+ "latency": 0.681
114
+ },
115
+ "provider": "ookla",
116
+ "download": 293111520.0,
117
+ "upload": 292265640.0,
118
+ "ping": 0.681,
119
+ "timestamp": "2026-07-19T15:22:48Z",
120
+ "bytes_sent": 418465064,
121
+ "bytes_received": 426981996
122
+ }
123
+ ```
124
+
125
+ - `client` — from a geo-IP service, identical whichever provider measured.
126
+ - `server` — the test server that provider picked; unreported fields stay at `""` / `0.0`.
127
+ - `provider` — which measurer produced the result.
128
+
129
+ ## Geo-IP lookup
130
+
131
+ Client details come from the first service that answers: `ipinfo.io` → `ipwho.is` → `ip-api.com`.
132
+
133
+ - The lookup takes a fraction of a second.
134
+ - If every service is unreachable, the measurement is still returned —
135
+ with whatever client info the CLI reported.
136
+
137
+ > This sends your IP address to the geo-IP service that answers.
138
+
139
+ ## Binaries
140
+
141
+ | Provider | Version | Platforms |
142
+ | ------------------- | ------- | ---------------------------- |
143
+ | Ookla Speedtest CLI | 1.2.0 | Linux x86_64/aarch64, macOS |
144
+ | librespeed-cli | 1.0.13 | Linux x86_64/aarch64, macOS |
145
+
146
+ Binaries are downloaded on first use and cached per platform:
147
+
148
+ - Linux — `~/.cache/speedkit`
149
+ - macOS — `~/Library/Caches/speedkit`
150
+
151
+ Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
152
+ binary to skip downloading entirely — useful for offline machines and locked-down networks.
153
+
154
+ To pre-download the binaries at image build time (Docker, CI):
155
+
156
+ ```bash
157
+ python -c "from speedkit.binaries import ookla_binary, librespeed_binary; ookla_binary(); librespeed_binary()"
158
+ ```
159
+
160
+ > Running the Ookla provider passes `--accept-license --accept-gdpr`, which implies acceptance
161
+ > of the [Ookla EULA](https://www.speedtest.net/about/eula) and privacy terms.
162
+
163
+ ## Errors
164
+
165
+ Every error derives from `SpeedkitError` and carries an actionable hint:
166
+
167
+ ```python
168
+ from speedkit import Speedkit, SpeedkitError
169
+
170
+ try:
171
+ result = Speedkit().run()
172
+ except SpeedkitError as error:
173
+ print(error) # cause, and a hint on how to fix it
174
+ ```
175
+
176
+ `UnsupportedPlatformError` — no prebuilt binary for this OS/arch; `BinaryDownloadError` —
177
+ the binary could not be fetched; `SpeedtestError` — the measurement itself failed.
178
+
179
+ ## License
180
+
181
+ This repository is distributed under the [MIT License](LICENSE).
@@ -0,0 +1,148 @@
1
+ # 📦 Speedkit
2
+
3
+ ![Python Versions](https://img.shields.io/badge/Python-3.9%20--%203.14-black?color=FFE873&labelColor=3776AB)
4
+ [![PyPI](https://img.shields.io/pypi/v/speedkit.svg?color=FFE873&labelColor=3776AB)](https://pypi.org/project/speedkit/)
5
+ [![License](https://img.shields.io/github/license/nessshon/speedkit)](https://github.com/nessshon/speedkit/blob/main/LICENSE)
6
+
7
+ ### Python SDK for network speed testing
8
+
9
+ Measures download, upload, and latency with the official [Ookla Speedtest CLI](https://www.speedtest.net/apps/cli)
10
+ and falls back to [LibreSpeed](https://github.com/librespeed/speedtest-cli) when Speedtest is blocked or unavailable.
11
+
12
+ **Features**
13
+
14
+ - **Zero setup** — Ookla Speedtest first, LibreSpeed when it is blocked or fails.
15
+ - **Automatic binaries** — downloaded and cached for your OS on first run.
16
+ - **Nearby servers** — the lowest-latency server is picked automatically.
17
+ - **Self-recovery** — failed attempts are retried, broken servers excluded.
18
+ - **Uniform results** — same fields and units whichever provider ran; client info from geo-IP.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install speedkit
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from speedkit import Speedkit
30
+
31
+ kit = Speedkit()
32
+ result = kit.run()
33
+
34
+ print(f"download: {result.download / 1_000_000:.1f} Mbit/s")
35
+ print(f"upload: {result.upload / 1_000_000:.1f} Mbit/s")
36
+ print(f"ping: {result.ping:.1f} ms")
37
+
38
+ data = result.to_dict() # plain JSON-serializable dict
39
+ ```
40
+
41
+ Or from the command line:
42
+
43
+ ```bash
44
+ speedkit # auto: Ookla, then LibreSpeed
45
+ speedkit -p librespeed # pin one provider
46
+ speedkit -t 180 # time budget per provider in seconds
47
+ speedkit -a 1 # disable retries
48
+ ```
49
+
50
+ `Speedkit()` needs no configuration; everything it accepts:
51
+
52
+ ```python
53
+ kit = Speedkit(
54
+ provider="auto", # "auto" | "ookla" | "librespeed" — a pinned provider never falls back
55
+ timeout=120, # time budget per provider in seconds, covering all attempts
56
+ attempts=2, # measurement attempts within the budget; 1 disables retries
57
+ cache_dir=None, # where CLI binaries are cached; None = user cache directory
58
+ )
59
+ ```
60
+
61
+ ## Result format
62
+
63
+ `SpeedtestResult.to_dict()` returns `download`/`upload` in bits per second and `ping` in milliseconds:
64
+
65
+ ```json
66
+ {
67
+ "client": {
68
+ "ip": "203.0.113.7",
69
+ "isp": "Example ISP",
70
+ "country": "ZZ",
71
+ "city": "Springfield"
72
+ },
73
+ "server": {
74
+ "url": "",
75
+ "name": "Springfield",
76
+ "country": "Freedonia",
77
+ "sponsor": "Example Sponsor",
78
+ "id": "1234",
79
+ "host": "speedtest.example.net:8080",
80
+ "latency": 0.681
81
+ },
82
+ "provider": "ookla",
83
+ "download": 293111520.0,
84
+ "upload": 292265640.0,
85
+ "ping": 0.681,
86
+ "timestamp": "2026-07-19T15:22:48Z",
87
+ "bytes_sent": 418465064,
88
+ "bytes_received": 426981996
89
+ }
90
+ ```
91
+
92
+ - `client` — from a geo-IP service, identical whichever provider measured.
93
+ - `server` — the test server that provider picked; unreported fields stay at `""` / `0.0`.
94
+ - `provider` — which measurer produced the result.
95
+
96
+ ## Geo-IP lookup
97
+
98
+ Client details come from the first service that answers: `ipinfo.io` → `ipwho.is` → `ip-api.com`.
99
+
100
+ - The lookup takes a fraction of a second.
101
+ - If every service is unreachable, the measurement is still returned —
102
+ with whatever client info the CLI reported.
103
+
104
+ > This sends your IP address to the geo-IP service that answers.
105
+
106
+ ## Binaries
107
+
108
+ | Provider | Version | Platforms |
109
+ | ------------------- | ------- | ---------------------------- |
110
+ | Ookla Speedtest CLI | 1.2.0 | Linux x86_64/aarch64, macOS |
111
+ | librespeed-cli | 1.0.13 | Linux x86_64/aarch64, macOS |
112
+
113
+ Binaries are downloaded on first use and cached per platform:
114
+
115
+ - Linux — `~/.cache/speedkit`
116
+ - macOS — `~/Library/Caches/speedkit`
117
+
118
+ Set `SPEEDKIT_OOKLA_BINARY` / `SPEEDKIT_LIBRESPEED_BINARY` to a path of a preinstalled
119
+ binary to skip downloading entirely — useful for offline machines and locked-down networks.
120
+
121
+ To pre-download the binaries at image build time (Docker, CI):
122
+
123
+ ```bash
124
+ python -c "from speedkit.binaries import ookla_binary, librespeed_binary; ookla_binary(); librespeed_binary()"
125
+ ```
126
+
127
+ > Running the Ookla provider passes `--accept-license --accept-gdpr`, which implies acceptance
128
+ > of the [Ookla EULA](https://www.speedtest.net/about/eula) and privacy terms.
129
+
130
+ ## Errors
131
+
132
+ Every error derives from `SpeedkitError` and carries an actionable hint:
133
+
134
+ ```python
135
+ from speedkit import Speedkit, SpeedkitError
136
+
137
+ try:
138
+ result = Speedkit().run()
139
+ except SpeedkitError as error:
140
+ print(error) # cause, and a hint on how to fix it
141
+ ```
142
+
143
+ `UnsupportedPlatformError` — no prebuilt binary for this OS/arch; `BinaryDownloadError` —
144
+ the binary could not be fetched; `SpeedtestError` — the measurement itself failed.
145
+
146
+ ## License
147
+
148
+ This repository is distributed under the [MIT License](LICENSE).
@@ -0,0 +1,165 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "speedkit"
7
+ description = "Python SDK for network speed testing — bandwidth, latency, and connection details in a single call."
8
+ requires-python = ">=3.9,<3.15"
9
+ authors = [
10
+ { name = "nessshon" },
11
+ ]
12
+ maintainers = [
13
+ { name = "nessshon" },
14
+ ]
15
+ dependencies = []
16
+ keywords = [
17
+ "CLI",
18
+ "SDK",
19
+ "bandwidth",
20
+ "benchmark",
21
+ "internet speed",
22
+ "librespeed",
23
+ "network",
24
+ "ookla",
25
+ "speedtest",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Environment :: Console",
30
+ "Intended Audience :: Developers",
31
+ "Operating System :: MacOS",
32
+ "Operating System :: POSIX :: Linux",
33
+ "Programming Language :: Python :: 3.9",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "Programming Language :: Python :: 3.14",
39
+ "Topic :: Internet",
40
+ "Topic :: Software Development :: Libraries :: Python Modules",
41
+ "Topic :: System :: Networking :: Monitoring",
42
+ ]
43
+ dynamic = ["version"]
44
+ readme = "README.md"
45
+ license = "MIT"
46
+
47
+ [project.optional-dependencies]
48
+ dev = [
49
+ "mypy>=1.19.0",
50
+ "pytest>=8.0",
51
+ "ruff>=0.8.0",
52
+ ]
53
+
54
+ [project.scripts]
55
+ speedkit = "speedkit.cli:main"
56
+
57
+ [project.urls]
58
+ Homepage = "https://github.com/nessshon/speedkit/"
59
+ Examples = "https://github.com/nessshon/speedkit/tree/main/examples/"
60
+
61
+ [tool.setuptools.packages.find]
62
+ include = ["speedkit", "speedkit.*"]
63
+
64
+ [tool.setuptools.package-data]
65
+ speedkit = ["py.typed"]
66
+
67
+ [tool.setuptools.dynamic]
68
+ version = { attr = "speedkit.__meta__.__version__" }
69
+
70
+ [tool.ruff]
71
+ target-version = "py39"
72
+ line-length = 120
73
+
74
+ [tool.ruff.lint]
75
+ select = [
76
+ # pydocstyle
77
+ "D",
78
+ # pycodestyle
79
+ "E", "W",
80
+ # pyflakes
81
+ "F",
82
+ # isort
83
+ "I",
84
+ # flake8-bugbear
85
+ "B",
86
+ # flake8-comprehensions
87
+ "C4",
88
+ # pyupgrade
89
+ "UP",
90
+ # flake8-simplify
91
+ "SIM",
92
+ # type-checking imports
93
+ "TCH",
94
+ # perflint
95
+ "PERF",
96
+ # flake8-debugger
97
+ "T10",
98
+ # flake8-print
99
+ "T20",
100
+ # flake8-pie
101
+ "PIE",
102
+ # pylint errors
103
+ "PLE",
104
+ # flake8-return
105
+ "RET",
106
+ # ruff-specific
107
+ "RUF",
108
+ ]
109
+ ignore = [
110
+ # missing docstring in public module
111
+ "D100",
112
+ # missing docstring in public package
113
+ "D104",
114
+ # missing docstring in magic method
115
+ "D105",
116
+ # missing docstring in __init__
117
+ "D107",
118
+ # incorrect blank line before class
119
+ "D203",
120
+ # 1 blank line required between summary line and description
121
+ "D205",
122
+ # multi-line docstring summary should start at the second line
123
+ "D213",
124
+ # first word of the first line should be capitalized
125
+ "D403",
126
+ # first-party type-checking imports
127
+ "TC001",
128
+ # third-party type-checking imports
129
+ "TC002",
130
+ ]
131
+
132
+ [tool.ruff.lint.per-file-ignores]
133
+ "speedkit/cli.py" = ["T201"]
134
+ "examples/*" = ["D", "T20"]
135
+ # SIM117: nested with statements are deliberate — parenthesized ones need Python 3.10.
136
+ "tests/*" = ["D", "SIM117"]
137
+
138
+ [tool.ruff.lint.isort]
139
+ known-first-party = ["speedkit"]
140
+
141
+ [tool.ruff.format]
142
+ quote-style = "double"
143
+
144
+ [tool.mypy]
145
+ python_version = "3.10" # mypy dropped 3.9 analysis; real 3.9 support is verified by running the tests on 3.9
146
+ strict = true
147
+ pretty = true
148
+ show_error_codes = true
149
+ show_error_context = true
150
+ namespace_packages = true
151
+ follow_imports_for_stubs = true
152
+ warn_unreachable = true
153
+ enable_error_code = [
154
+ "ignore-without-code",
155
+ "redundant-cast",
156
+ "truthy-bool",
157
+ ]
158
+
159
+ [[tool.mypy.overrides]]
160
+ module = "tests.*"
161
+ disallow_untyped_defs = false
162
+ disallow_incomplete_defs = false
163
+
164
+ [tool.pytest.ini_options]
165
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2026 Shon Ness
2
+ #
3
+ # This source code is licensed under the MIT License found in the
4
+ # LICENSE file in the root directory of this source tree.
5
+
6
+ __version__ = "0.1.0"
7
+ __author__ = "nessshon"
8
+ __url__ = "https://github.com/nessshon/speedkit"
@@ -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"