speedtest-cli-ng 0.3.0__tar.gz → 1.0.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.
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/.gitignore +3 -3
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/NOTICE +1 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/PKG-INFO +24 -12
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/README.md +19 -7
- speedtest_cli_ng-1.0.0/prek.toml +69 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/pyproject.toml +17 -18
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__main__.py +1 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/runner.py +1 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/client.py +50 -43
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/config.py +2 -3
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/network.py +11 -5
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/servers.py +1 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/transfer.py +14 -2
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/logger.py +3 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_display.py +17 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_parser.py +2 -3
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_runner.py +102 -1
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/conftest.py +59 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/engine/test_config.py +45 -26
- speedtest_cli_ng-1.0.0/tests/engine/test_network.py +309 -0
- speedtest_cli_ng-1.0.0/tests/engine/test_servers.py +120 -0
- speedtest_cli_ng-1.0.0/tests/engine/test_transfer.py +239 -0
- speedtest_cli_ng-1.0.0/tests/models/test_api_config.py +58 -0
- speedtest_cli_ng-1.0.0/tests/test_client.py +239 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/test_main.py +55 -3
- speedtest_cli_ng-1.0.0/tests/utils/test_logger.py +79 -0
- speedtest_cli_ng-1.0.0/uv.lock +361 -0
- speedtest_cli_ng-0.3.0/tests/models/test_api_config.py +0 -66
- speedtest_cli_ng-0.3.0/tests/utils/test_logger.py +0 -88
- speedtest_cli_ng-0.3.0/uv.lock +0 -303
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/LICENSE +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__version__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/display.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/parser.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/exceptions.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/api_config.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/context.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/location.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/result.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/server.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/constants.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/status.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/__init__.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_context.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_location.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_result.py +0 -0
- {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_server.py +0 -0
|
@@ -197,9 +197,9 @@ cython_debug/
|
|
|
197
197
|
.abstra/
|
|
198
198
|
|
|
199
199
|
# Visual Studio Code
|
|
200
|
-
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
200
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
201
201
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
202
|
-
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
202
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
203
203
|
# you could uncomment the following to ignore the entire vscode folder
|
|
204
204
|
# .vscode/
|
|
205
205
|
# Temporary file for partial code execution
|
|
@@ -217,4 +217,4 @@ marimo/_lsp/
|
|
|
217
217
|
__marimo__/
|
|
218
218
|
|
|
219
219
|
# Streamlit
|
|
220
|
-
.streamlit/secrets.toml
|
|
220
|
+
.streamlit/secrets.toml
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: speedtest-cli-ng
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Next generation CLI for testing internet bandwidth using speedtest.net
|
|
5
5
|
Project-URL: Homepage, https://github.com/Omikorin/speedtest-cli
|
|
6
6
|
Project-URL: Source, https://github.com/Omikorin/speedtest-cli
|
|
7
7
|
Project-URL: Issues, https://github.com/Omikorin/speedtest-cli/issues
|
|
8
|
-
Author
|
|
8
|
+
Author: Michał Korczak
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
License-File: NOTICE
|
|
12
12
|
Keywords: bandwidth,cli,internet,speedtest,speedtest.net
|
|
13
|
-
Classifier: Development Status ::
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
14
|
Classifier: Environment :: Console
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
16
16
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: System :: Networking
|
|
|
22
22
|
Classifier: Topic :: Terminals
|
|
23
23
|
Classifier: Topic :: Utilities
|
|
24
24
|
Requires-Python: >=3.14
|
|
25
|
-
Requires-Dist: httpx2<3,>=2.
|
|
25
|
+
Requires-Dist: httpx2<3,>=2.12.0
|
|
26
26
|
Requires-Dist: rich<16,>=15.0.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
@@ -34,12 +34,8 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
[](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml)
|
|
35
35
|
[](https://sonarcloud.io/summary/new_code?id=Omikorin_speedtest-cli)
|
|
36
36
|
|
|
37
|
-
---
|
|
38
|
-
|
|
39
37
|
Next generation CLI for testing internet bandwidth using speedtest.net
|
|
40
38
|
|
|
41
|
-
Rebuilt for modern Python 3.14+
|
|
42
|
-
|
|
43
39
|
## Getting started
|
|
44
40
|
|
|
45
41
|
### Installation
|
|
@@ -115,11 +111,21 @@ Miscellaneous Options:
|
|
|
115
111
|
### Requirements
|
|
116
112
|
|
|
117
113
|
- Python 3.14+
|
|
118
|
-
- uv 0.
|
|
114
|
+
- uv 0.13+
|
|
115
|
+
- prek 0.5+
|
|
119
116
|
|
|
120
|
-
### Setup
|
|
117
|
+
### Setup pre-commit hooks
|
|
121
118
|
|
|
122
|
-
|
|
119
|
+
You can install hooks to automatically run the validation checks:
|
|
120
|
+
|
|
121
|
+
```shell
|
|
122
|
+
uv tool install prek
|
|
123
|
+
prek install
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Install dependencies
|
|
127
|
+
|
|
128
|
+
This will install project's dependencies
|
|
123
129
|
|
|
124
130
|
```bash
|
|
125
131
|
uv sync
|
|
@@ -128,6 +134,12 @@ uv sync
|
|
|
128
134
|
### Basic workflow
|
|
129
135
|
|
|
130
136
|
```bash
|
|
137
|
+
# Run pre-commit checks for changed files
|
|
138
|
+
prek
|
|
139
|
+
|
|
140
|
+
# Run all pre-commit checks
|
|
141
|
+
prek --all-files
|
|
142
|
+
|
|
131
143
|
# Run format
|
|
132
144
|
uv run ruff format --check .
|
|
133
145
|
|
|
@@ -6,12 +6,8 @@
|
|
|
6
6
|
[](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml)
|
|
7
7
|
[](https://sonarcloud.io/summary/new_code?id=Omikorin_speedtest-cli)
|
|
8
8
|
|
|
9
|
-
---
|
|
10
|
-
|
|
11
9
|
Next generation CLI for testing internet bandwidth using speedtest.net
|
|
12
10
|
|
|
13
|
-
Rebuilt for modern Python 3.14+
|
|
14
|
-
|
|
15
11
|
## Getting started
|
|
16
12
|
|
|
17
13
|
### Installation
|
|
@@ -87,11 +83,21 @@ Miscellaneous Options:
|
|
|
87
83
|
### Requirements
|
|
88
84
|
|
|
89
85
|
- Python 3.14+
|
|
90
|
-
- uv 0.
|
|
86
|
+
- uv 0.13+
|
|
87
|
+
- prek 0.5+
|
|
91
88
|
|
|
92
|
-
### Setup
|
|
89
|
+
### Setup pre-commit hooks
|
|
93
90
|
|
|
94
|
-
|
|
91
|
+
You can install hooks to automatically run the validation checks:
|
|
92
|
+
|
|
93
|
+
```shell
|
|
94
|
+
uv tool install prek
|
|
95
|
+
prek install
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Install dependencies
|
|
99
|
+
|
|
100
|
+
This will install project's dependencies
|
|
95
101
|
|
|
96
102
|
```bash
|
|
97
103
|
uv sync
|
|
@@ -100,6 +106,12 @@ uv sync
|
|
|
100
106
|
### Basic workflow
|
|
101
107
|
|
|
102
108
|
```bash
|
|
109
|
+
# Run pre-commit checks for changed files
|
|
110
|
+
prek
|
|
111
|
+
|
|
112
|
+
# Run all pre-commit checks
|
|
113
|
+
prek --all-files
|
|
114
|
+
|
|
103
115
|
# Run format
|
|
104
116
|
uv run ruff format --check .
|
|
105
117
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
minimum_prek_version = "0.5.10"
|
|
2
|
+
|
|
3
|
+
[update]
|
|
4
|
+
cooldown_days = 7
|
|
5
|
+
freeze = true
|
|
6
|
+
include_tags = "v*"
|
|
7
|
+
exclude_tags = ["*-{alpha,beta,rc}*"]
|
|
8
|
+
repos = {
|
|
9
|
+
"https://github.com/python-jsonschema/check-jsonschema" = { include_tags = "*" },
|
|
10
|
+
"https://github.com/astral-sh/uv-pre-commit" = { include_tags = "*" }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
[[repos]]
|
|
14
|
+
repo = "builtin"
|
|
15
|
+
hooks = [
|
|
16
|
+
{ id = "check-merge-conflict" },
|
|
17
|
+
{ id = "trailing-whitespace" },
|
|
18
|
+
{ id = "end-of-file-fixer" },
|
|
19
|
+
{ id = "mixed-line-ending" },
|
|
20
|
+
{ id = "check-json" },
|
|
21
|
+
{ id = "check-json5" },
|
|
22
|
+
{ id = "check-toml" },
|
|
23
|
+
{ id = "check-yaml" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[repos]]
|
|
27
|
+
repo = "https://github.com/abravalheri/validate-pyproject"
|
|
28
|
+
rev = "82d21874c3242e4dc27bb47641c996902ef64eef" # frozen: v0.27
|
|
29
|
+
hooks = [
|
|
30
|
+
{ id = "validate-pyproject" },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[repos]]
|
|
34
|
+
repo = "https://github.com/python-jsonschema/check-jsonschema"
|
|
35
|
+
rev = "a76b92851fc9eede9f9bc4073eb55202f34eb0df" # frozen: 0.39.0
|
|
36
|
+
hooks = [
|
|
37
|
+
{ id = "check-github-workflows" },
|
|
38
|
+
{ id = "check-renovate", additional_dependencies = ['json5'] },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# zizmor detects security vulnerabilities in GitHub Actions workflows
|
|
42
|
+
[[repos]]
|
|
43
|
+
repo = "https://github.com/zizmorcore/zizmor-pre-commit"
|
|
44
|
+
rev = "c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f" # frozen: v1.31.1
|
|
45
|
+
hooks = [
|
|
46
|
+
{ id = "zizmor", args = ["--no-progress", "--fix"] },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[[repos]]
|
|
50
|
+
repo = "https://github.com/astral-sh/uv-pre-commit"
|
|
51
|
+
rev = "46787482b9fb2003c56e0f7592e312ef381829d9" # frozen: 0.13.6
|
|
52
|
+
hooks = [
|
|
53
|
+
{ id = "uv-lock" },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[[repos]]
|
|
57
|
+
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
|
58
|
+
rev = "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3
|
|
59
|
+
hooks = [
|
|
60
|
+
{ id = "ruff-check", args = ["--fix", "--exit-non-zero-on-fix"] },
|
|
61
|
+
{ id = "ruff-format", types_or = ["python", "pyi", "jupyter", "markdown"] },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[[repos]]
|
|
65
|
+
repo = "https://github.com/astral-sh/ty-pre-commit"
|
|
66
|
+
rev = "18b2b8bb1b9b90d2d21283756e89b07fc9c747bf" # frozen: v0.0.95
|
|
67
|
+
hooks = [
|
|
68
|
+
{ id = "ty" },
|
|
69
|
+
]
|
|
@@ -7,13 +7,13 @@ name = "speedtest-cli-ng"
|
|
|
7
7
|
description = "Next generation CLI for testing internet bandwidth using speedtest.net"
|
|
8
8
|
keywords = ["speedtest", "speedtest.net", "bandwidth", "internet", "cli"]
|
|
9
9
|
license = "Apache-2.0"
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "1.0.0"
|
|
11
11
|
requires-python = ">=3.14"
|
|
12
|
-
authors = [{ name = "Michał Korczak"
|
|
12
|
+
authors = [{ name = "Michał Korczak" }]
|
|
13
13
|
license-files = ["LICENSE", "NOTICE"]
|
|
14
14
|
readme = "README.md"
|
|
15
15
|
classifiers = [
|
|
16
|
-
"Development Status ::
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Intended Audience :: System Administrators",
|
|
@@ -26,20 +26,10 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Utilities",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"httpx2>=2.
|
|
29
|
+
"httpx2>=2.12.0,<3",
|
|
30
30
|
"rich>=15.0.0,<16",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
-
[dependency-groups]
|
|
34
|
-
dev = [
|
|
35
|
-
# Tests
|
|
36
|
-
"pytest>=9.0.0,<10",
|
|
37
|
-
"pytest-cov>=7.0.0,<8",
|
|
38
|
-
# Linting
|
|
39
|
-
"ruff>=0.15.0,<1",
|
|
40
|
-
"ty>=0.0.55,<1",
|
|
41
|
-
]
|
|
42
|
-
|
|
43
33
|
[project.urls]
|
|
44
34
|
Homepage = "https://github.com/Omikorin/speedtest-cli"
|
|
45
35
|
Source = "https://github.com/Omikorin/speedtest-cli"
|
|
@@ -50,6 +40,16 @@ speedtest = "speedtest.__main__:main"
|
|
|
50
40
|
speedtest-cli = "speedtest.__main__:main"
|
|
51
41
|
speedtest-cli-ng = "speedtest.__main__:main"
|
|
52
42
|
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
# Tests
|
|
46
|
+
"pytest>=9.1.1,<10",
|
|
47
|
+
"pytest-cov>=7.1.0,<8",
|
|
48
|
+
# Linting
|
|
49
|
+
"ruff>=0.16.4,<1",
|
|
50
|
+
"ty>=0.0.73,<1",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
53
|
[tool.hatch.build.targets.sdist]
|
|
54
54
|
exclude = [
|
|
55
55
|
".github",
|
|
@@ -61,7 +61,7 @@ exclude = [
|
|
|
61
61
|
packages = ["src/speedtest"]
|
|
62
62
|
|
|
63
63
|
[tool.uv]
|
|
64
|
-
required-version = ">=0.
|
|
64
|
+
required-version = ">=0.13.0"
|
|
65
65
|
exclude-newer = "7 days"
|
|
66
66
|
|
|
67
67
|
[[tool.uv.index]]
|
|
@@ -75,7 +75,7 @@ line-length = 120
|
|
|
75
75
|
src = ["speedtest", "tests"]
|
|
76
76
|
|
|
77
77
|
[tool.ruff.lint]
|
|
78
|
-
select = [
|
|
78
|
+
extend-select = [
|
|
79
79
|
"ANN", # flake8 annotations
|
|
80
80
|
"B", # flake8 bugbear
|
|
81
81
|
"PYI", # flake8 pyi
|
|
@@ -102,6 +102,5 @@ possibly-unresolved-reference = "warn"
|
|
|
102
102
|
[tool.pytest]
|
|
103
103
|
minversion = "9.0"
|
|
104
104
|
# Automatically show extra test info, fail if coverage drops, and output a terminal coverage report
|
|
105
|
-
addopts = ["-v", "--cov=speedtest", "--cov-report=term-missing"]
|
|
105
|
+
addopts = ["-v", "--cov=speedtest", "--cov-branch", "--cov-report=term-missing"]
|
|
106
106
|
testpaths = ["tests"]
|
|
107
|
-
|
|
@@ -91,7 +91,7 @@ def shell() -> int:
|
|
|
91
91
|
|
|
92
92
|
if ctx.share:
|
|
93
93
|
with console.status("[bold magenta]Generating share link..."):
|
|
94
|
-
results.share_url = client.generate_share_link(results)
|
|
94
|
+
results.share_url = client.generate_share_link(results, ctx.api_config)
|
|
95
95
|
|
|
96
96
|
logger.info(f"Share results: {results.share_url}")
|
|
97
97
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import urllib.parse
|
|
3
|
-
import urllib.request
|
|
1
|
+
import json
|
|
4
2
|
from hashlib import md5
|
|
5
3
|
from operator import attrgetter
|
|
6
4
|
|
|
5
|
+
import httpx2
|
|
6
|
+
|
|
7
7
|
from speedtest.engine import get_best_server, run_download_test, run_upload_test
|
|
8
|
+
from speedtest.engine.network import build_user_agent
|
|
8
9
|
from speedtest.exceptions import CLIError, NoMatchedServerError
|
|
9
10
|
from speedtest.models import ApiConfig, RunContext, Server, SpeedtestResult
|
|
10
11
|
|
|
@@ -55,59 +56,65 @@ class SpeedtestClient:
|
|
|
55
56
|
|
|
56
57
|
return run_upload_test(best_server_url=server.url, ctx=ctx)
|
|
57
58
|
|
|
58
|
-
def generate_share_link(self, results: SpeedtestResult) -> str:
|
|
59
|
+
def generate_share_link(self, results: SpeedtestResult, config: ApiConfig) -> str:
|
|
59
60
|
"""POST data to the speedtest.net API to obtain a share results link."""
|
|
60
61
|
|
|
61
|
-
if
|
|
62
|
+
if results.ping_ms is None or not results.server:
|
|
62
63
|
raise CLIError("Cannot generate share link: missing test results.")
|
|
63
64
|
|
|
64
|
-
# The
|
|
65
|
+
# The web client reports kbps and signs ping-upload-download with a fixed salt.
|
|
65
66
|
download_kbps = round((results.download_bps or 0) / 1000.0)
|
|
66
67
|
upload_kbps = round((results.upload_bps or 0) / 1000.0)
|
|
67
|
-
ping = round(results.ping_ms
|
|
68
|
+
ping = round(results.ping_ms)
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
hash_val = md5(f"{ping}-{upload_kbps}-{download_kbps}-817d699764d33f89c".encode()).hexdigest()
|
|
71
|
+
host, _, port = results.server.host.partition(":")
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
"
|
|
73
|
+
payload: dict[str, object] = {
|
|
74
|
+
"app": {"sdk": {"commit": "a36027299986af441deb58cd730f432fcad58871", "version": "3.1.1"}},
|
|
75
|
+
"serverid": results.server.id,
|
|
76
|
+
"testmethod": "http,http,http",
|
|
77
|
+
"source": "st4-js",
|
|
78
|
+
"configs": {"host": host, "port": int(port) if port else 8080},
|
|
74
79
|
"ping": ping,
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"download": download_kbps,
|
|
78
|
-
"screendpi": "",
|
|
79
|
-
"upload": upload_kbps,
|
|
80
|
-
"testmethod": "http",
|
|
80
|
+
"ispName": config.isp_name,
|
|
81
|
+
"guid": config.guid,
|
|
81
82
|
"hash": hash_val,
|
|
82
|
-
"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
"
|
|
83
|
+
"clientip": config.ip_address,
|
|
84
|
+
}
|
|
85
|
+
if download_kbps:
|
|
86
|
+
payload["download"] = download_kbps
|
|
87
|
+
if upload_kbps:
|
|
88
|
+
payload["upload"] = upload_kbps
|
|
89
|
+
|
|
90
|
+
headers = {
|
|
91
|
+
"User-Agent": build_user_agent(),
|
|
92
|
+
"Accept": "application/json",
|
|
93
|
+
"Origin": "https://www.speedtest.net",
|
|
94
|
+
"Referer": "https://www.speedtest.net/",
|
|
95
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
headers = {"Referer": "https://c.speedtest.net/flash/speedtest.swf"}
|
|
92
|
-
|
|
93
|
-
req = urllib.request.Request(
|
|
94
|
-
"https://www.speedtest.net/api/api.php", data=api_data, headers=headers, method="POST"
|
|
95
|
-
)
|
|
96
|
-
|
|
98
|
+
body = ""
|
|
97
99
|
try:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
response = httpx2.post(
|
|
101
|
+
"https://www.speedtest.net/api/results.php",
|
|
102
|
+
content=json.dumps(payload),
|
|
103
|
+
headers=headers,
|
|
104
|
+
timeout=10.0,
|
|
105
|
+
)
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
body = response.text
|
|
108
|
+
resultid = response.json().get("resultid")
|
|
109
|
+
|
|
110
|
+
except httpx2.HTTPStatusError as e:
|
|
111
|
+
raise CLIError(f"Could not submit results. HTTP {e.response.status_code}") from e
|
|
112
|
+
except httpx2.RequestError as e:
|
|
105
113
|
raise CLIError(f"Failed to connect to Share API: {e}") from e
|
|
114
|
+
except ValueError, AttributeError:
|
|
115
|
+
resultid = None
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if not resultid or len(resultid) != 1:
|
|
111
|
-
raise CLIError("Could not parse result ID from API response.")
|
|
117
|
+
if not resultid:
|
|
118
|
+
raise CLIError(f"Could not parse result ID from API response: {body[:200]!r}")
|
|
112
119
|
|
|
113
|
-
return f"https://www.speedtest.net/result/{resultid
|
|
120
|
+
return f"https://www.speedtest.net/result/{resultid}.png"
|
|
@@ -7,6 +7,7 @@ from json import JSONDecodeError
|
|
|
7
7
|
|
|
8
8
|
import httpx2
|
|
9
9
|
|
|
10
|
+
from speedtest.exceptions import CLIError
|
|
10
11
|
from speedtest.models import ApiConfig
|
|
11
12
|
from speedtest.utils import CONFIG_URL
|
|
12
13
|
|
|
@@ -15,11 +16,9 @@ from .network import build_user_agent
|
|
|
15
16
|
__all__ = ["ConfigFetchError", "fetch_raw_config", "get_config", "parse_config"]
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
class ConfigFetchError(
|
|
19
|
+
class ConfigFetchError(CLIError):
|
|
19
20
|
"""Raised when the configuration cannot be retrieved or parsed."""
|
|
20
21
|
|
|
21
|
-
pass
|
|
22
|
-
|
|
23
22
|
|
|
24
23
|
def fetch_raw_config(url: str = CONFIG_URL) -> str:
|
|
25
24
|
"""
|
|
@@ -15,11 +15,13 @@ import httpx2
|
|
|
15
15
|
|
|
16
16
|
from speedtest import __version__
|
|
17
17
|
from speedtest.exceptions import UploadTimeoutError
|
|
18
|
+
from speedtest.utils import logger
|
|
18
19
|
|
|
19
20
|
# --- Constants ---
|
|
20
|
-
CHUNK_SIZE_BYTES = 10240
|
|
21
21
|
UPLOAD_RESPONSE_TRUNCATION = 11
|
|
22
|
-
DUMMY_CHUNK = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" *
|
|
22
|
+
DUMMY_CHUNK = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 1820 # ~64 KiB
|
|
23
|
+
CHUNK_SIZE_BYTES = len(DUMMY_CHUNK)
|
|
24
|
+
UPLOAD_BODY_PREFIX = b"content1=" # upload.php expects a form-encoded body
|
|
23
25
|
|
|
24
26
|
__all__ = [
|
|
25
27
|
"HTTPUploaderData",
|
|
@@ -89,7 +91,7 @@ class HTTPUploaderData:
|
|
|
89
91
|
if remaining <= 0:
|
|
90
92
|
return b""
|
|
91
93
|
|
|
92
|
-
max_alloc =
|
|
94
|
+
max_alloc = CHUNK_SIZE_BYTES
|
|
93
95
|
|
|
94
96
|
if n < 0 or n > remaining:
|
|
95
97
|
read_size = min(remaining, max_alloc)
|
|
@@ -97,6 +99,8 @@ class HTTPUploaderData:
|
|
|
97
99
|
read_size = min(n, remaining, max_alloc)
|
|
98
100
|
|
|
99
101
|
chunk = DUMMY_CHUNK[:read_size]
|
|
102
|
+
if self.total_bytes_read == 0 and read_size > len(UPLOAD_BODY_PREFIX):
|
|
103
|
+
chunk = UPLOAD_BODY_PREFIX + chunk[len(UPLOAD_BODY_PREFIX) :]
|
|
100
104
|
|
|
101
105
|
self.total_bytes_read += read_size
|
|
102
106
|
return chunk
|
|
@@ -159,8 +163,10 @@ def upload_worker(
|
|
|
159
163
|
return 0
|
|
160
164
|
|
|
161
165
|
try:
|
|
162
|
-
client.send(request)
|
|
166
|
+
response = client.send(request)
|
|
167
|
+
logger.debug(f"Upload {payload_data.length} B -> HTTP {response.status_code}")
|
|
163
168
|
return payload_data.total_bytes_read
|
|
164
169
|
|
|
165
|
-
except httpx2.RequestError, UploadTimeoutError:
|
|
170
|
+
except (httpx2.RequestError, UploadTimeoutError) as e:
|
|
171
|
+
logger.debug(f"Upload ended early at {payload_data.total_bytes_read}/{payload_data.length} B: {e!r}")
|
|
166
172
|
return payload_data.total_bytes_read
|
|
@@ -65,7 +65,7 @@ def get_best_server(closest_servers: list[Server]) -> tuple[Server, float]:
|
|
|
65
65
|
for future in as_completed(future_to_server):
|
|
66
66
|
try:
|
|
67
67
|
yield future.result()
|
|
68
|
-
except
|
|
68
|
+
except OSError as e:
|
|
69
69
|
logger.debug(f"Server TCP ping generated an exception: {e}")
|
|
70
70
|
|
|
71
71
|
best_server, lowest_latency = find_fastest_server(_execute_pings())
|
|
@@ -68,6 +68,7 @@ def _generate_upload_payloads(
|
|
|
68
68
|
headers = {
|
|
69
69
|
"User-Agent": user_agent,
|
|
70
70
|
"Content-Length": str(size),
|
|
71
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
71
72
|
"Cache-Control": "no-cache",
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -75,6 +76,16 @@ def _generate_upload_payloads(
|
|
|
75
76
|
yield req, data
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
def _resolve_upload_url(url: str) -> str:
|
|
80
|
+
"""Follow redirects with an empty POST; streamed bodies can't be replayed after a 307."""
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with httpx2.Client(timeout=5.0, follow_redirects=True, verify=False) as client:
|
|
84
|
+
return str(client.post(url, content=b"").url)
|
|
85
|
+
except httpx2.HTTPError:
|
|
86
|
+
return url
|
|
87
|
+
|
|
88
|
+
|
|
78
89
|
def run_download_test(best_server_url: str, ctx: RunContext) -> tuple[int, float]:
|
|
79
90
|
"""Execute a multi-threaded download speed test leveraging connection pooling."""
|
|
80
91
|
|
|
@@ -104,7 +115,7 @@ def run_download_test(best_server_url: str, ctx: RunContext) -> tuple[int, float
|
|
|
104
115
|
for future in as_completed(futures):
|
|
105
116
|
try:
|
|
106
117
|
bytes_received += future.result()
|
|
107
|
-
except
|
|
118
|
+
except (OSError, httpx2.HTTPError) as e:
|
|
108
119
|
logger.debug(f"Download thread failed: {e}")
|
|
109
120
|
|
|
110
121
|
if ctx.shutdown_event and ctx.shutdown_event.is_set():
|
|
@@ -123,6 +134,7 @@ def run_upload_test(best_server_url: str, ctx: RunContext) -> tuple[int, float]:
|
|
|
123
134
|
test_length = 10.0
|
|
124
135
|
bytes_sent = 0
|
|
125
136
|
|
|
137
|
+
best_server_url = _resolve_upload_url(best_server_url)
|
|
126
138
|
tasks = list(_generate_upload_payloads(best_server_url, test_length, ctx.shutdown_event))
|
|
127
139
|
start_time = time.monotonic()
|
|
128
140
|
|
|
@@ -143,7 +155,7 @@ def run_upload_test(best_server_url: str, ctx: RunContext) -> tuple[int, float]:
|
|
|
143
155
|
for future in as_completed(futures):
|
|
144
156
|
try:
|
|
145
157
|
bytes_sent += future.result()
|
|
146
|
-
except
|
|
158
|
+
except (OSError, httpx2.HTTPError) as e:
|
|
147
159
|
logger.debug(f"Upload thread failed: {e}")
|
|
148
160
|
|
|
149
161
|
if ctx.shutdown_event and ctx.shutdown_event.is_set():
|
|
@@ -11,6 +11,7 @@ __all__ = ["console", "logger", "setup_logging"]
|
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger("speedtest")
|
|
13
13
|
console = Console()
|
|
14
|
+
error_console = Console(stderr=True)
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def setup_logging(debug: bool = False, quiet: bool = False) -> None:
|
|
@@ -29,8 +30,9 @@ def setup_logging(debug: bool = False, quiet: bool = False) -> None:
|
|
|
29
30
|
|
|
30
31
|
logger.setLevel(level)
|
|
31
32
|
|
|
33
|
+
# Quiet mode reserves stdout for JSON, so log records move to stderr instead of vanishing.
|
|
32
34
|
handler = RichHandler(
|
|
33
|
-
console=console,
|
|
35
|
+
console=error_console if quiet else console,
|
|
34
36
|
show_time=debug,
|
|
35
37
|
show_level=True,
|
|
36
38
|
show_path=debug,
|
|
@@ -24,7 +24,10 @@ def test_print_server_list_success(mock_server: Server) -> None:
|
|
|
24
24
|
|
|
25
25
|
table_arg = mock_print.call_args[0][0]
|
|
26
26
|
assert table_arg.title == "Available Target Servers"
|
|
27
|
-
assert
|
|
27
|
+
assert [c.header for c in table_arg.columns] == ["ID", "Distance", "Name", "Country", "Sponsor"]
|
|
28
|
+
|
|
29
|
+
row = [next(iter(c.cells)) for c in table_arg.columns]
|
|
30
|
+
assert row == ["1234", "17 km", "New York", "United States", "Acme Corp"]
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
def test_print_server_list_broken_pipe(mock_server: Server) -> None:
|
|
@@ -91,3 +94,16 @@ def test_print_json_tty_indenting(capsys: CaptureFixture[str]) -> None:
|
|
|
91
94
|
captured = capsys.readouterr()
|
|
92
95
|
|
|
93
96
|
assert '{\n "timestamp":' in captured.out
|
|
97
|
+
assert json.loads(captured.out)["ping"] == pytest.approx(10.0)
|
|
98
|
+
assert json.loads(captured.out)["server"] is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_print_json_compact_when_piped(capsys: CaptureFixture[str], mock_result: SpeedtestResult) -> None:
|
|
102
|
+
"""Piped output must be a single line so other tools can read it."""
|
|
103
|
+
|
|
104
|
+
with patch.object(sys.stdout, "isatty", return_value=False):
|
|
105
|
+
print_json(mock_result)
|
|
106
|
+
|
|
107
|
+
out = capsys.readouterr().out
|
|
108
|
+
assert out.count("\n") == 1
|
|
109
|
+
assert json.loads(out)["bytes_sent"] == 6_250_000
|
|
@@ -67,9 +67,8 @@ def test_parser_thread_options() -> None:
|
|
|
67
67
|
assert args.threads is None
|
|
68
68
|
|
|
69
69
|
# Test mutual exclusivity of threads and single
|
|
70
|
-
with patch.object(sys, "argv", ["speedtest-cli", "--threads", "4", "--single"]):
|
|
71
|
-
|
|
72
|
-
parse_args()
|
|
70
|
+
with patch.object(sys, "argv", ["speedtest-cli", "--threads", "4", "--single"]), pytest.raises(SystemExit):
|
|
71
|
+
parse_args()
|
|
73
72
|
|
|
74
73
|
|
|
75
74
|
def test_parser_output_options() -> None:
|