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.
Files changed (52) hide show
  1. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/.gitignore +3 -3
  2. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/NOTICE +1 -1
  3. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/PKG-INFO +24 -12
  4. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/README.md +19 -7
  5. speedtest_cli_ng-1.0.0/prek.toml +69 -0
  6. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/pyproject.toml +17 -18
  7. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__main__.py +1 -1
  8. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/runner.py +1 -1
  9. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/client.py +50 -43
  10. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/config.py +2 -3
  11. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/network.py +11 -5
  12. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/servers.py +1 -1
  13. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/transfer.py +14 -2
  14. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/logger.py +3 -1
  15. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_display.py +17 -1
  16. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_parser.py +2 -3
  17. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/cli/test_runner.py +102 -1
  18. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/conftest.py +59 -0
  19. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/engine/test_config.py +45 -26
  20. speedtest_cli_ng-1.0.0/tests/engine/test_network.py +309 -0
  21. speedtest_cli_ng-1.0.0/tests/engine/test_servers.py +120 -0
  22. speedtest_cli_ng-1.0.0/tests/engine/test_transfer.py +239 -0
  23. speedtest_cli_ng-1.0.0/tests/models/test_api_config.py +58 -0
  24. speedtest_cli_ng-1.0.0/tests/test_client.py +239 -0
  25. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/test_main.py +55 -3
  26. speedtest_cli_ng-1.0.0/tests/utils/test_logger.py +79 -0
  27. speedtest_cli_ng-1.0.0/uv.lock +361 -0
  28. speedtest_cli_ng-0.3.0/tests/models/test_api_config.py +0 -66
  29. speedtest_cli_ng-0.3.0/tests/utils/test_logger.py +0 -88
  30. speedtest_cli_ng-0.3.0/uv.lock +0 -303
  31. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/LICENSE +0 -0
  32. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__init__.py +0 -0
  33. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/__version__.py +0 -0
  34. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/__init__.py +0 -0
  35. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/display.py +0 -0
  36. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/cli/parser.py +0 -0
  37. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/engine/__init__.py +0 -0
  38. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/exceptions.py +0 -0
  39. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/__init__.py +0 -0
  40. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/api_config.py +0 -0
  41. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/context.py +0 -0
  42. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/location.py +0 -0
  43. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/result.py +0 -0
  44. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/models/server.py +0 -0
  45. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/__init__.py +0 -0
  46. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/constants.py +0 -0
  47. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/src/speedtest/utils/status.py +0 -0
  48. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/__init__.py +0 -0
  49. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_context.py +0 -0
  50. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_location.py +0 -0
  51. {speedtest_cli_ng-0.3.0 → speedtest_cli_ng-1.0.0}/tests/models/test_result.py +0 -0
  52. {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
@@ -2,5 +2,5 @@ speedtest-cli
2
2
  Copyright (c) 2026 Michał Korczak
3
3
  Copyright (c) 2012-2026 Matt Martz
4
4
 
5
- This product includes software originally developed by Matt Martz
5
+ This product includes software originally developed by Matt Martz
6
6
  (https://github.com/sivel/speedtest-cli).
@@ -1,16 +1,16 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: speedtest-cli-ng
3
- Version: 0.3.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-email: Michał Korczak <git@omikor.in>
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 :: 4 - Beta
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.5.0
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
  [![CI](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml)
35
35
  [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Omikorin_speedtest-cli&metric=alert_status)](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.11+
114
+ - uv 0.13+
115
+ - prek 0.5+
119
116
 
120
- ### Setup project
117
+ ### Setup pre-commit hooks
121
118
 
122
- This will install project's dependencies. Keep in mind that there are no runtime dependencies in production.
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
  [![CI](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Omikorin/speedtest-cli/actions/workflows/ci.yml)
7
7
  [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Omikorin_speedtest-cli&metric=alert_status)](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.11+
86
+ - uv 0.13+
87
+ - prek 0.5+
91
88
 
92
- ### Setup project
89
+ ### Setup pre-commit hooks
93
90
 
94
- This will install project's dependencies. Keep in mind that there are no runtime dependencies in production.
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.3.0"
10
+ version = "1.0.0"
11
11
  requires-python = ">=3.14"
12
- authors = [{ name = "Michał Korczak", email = "git@omikor.in" }]
12
+ authors = [{ name = "Michał Korczak" }]
13
13
  license-files = ["LICENSE", "NOTICE"]
14
14
  readme = "README.md"
15
15
  classifiers = [
16
- "Development Status :: 4 - Beta",
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.5.0,<3",
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.11.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
-
@@ -28,7 +28,7 @@ def main() -> int:
28
28
 
29
29
  return int(code)
30
30
 
31
- except Exception:
31
+ except OSError, RuntimeError, ValueError:
32
32
  logger.exception("An unexpected error occurred.")
33
33
  return ExitStatus.ERROR.value
34
34
 
@@ -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 urllib.error
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 not results.is_complete or not results.server:
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 legacy Ookla API expects speeds in kilobits per second (kbps)
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 or 0)
68
+ ping = round(results.ping_ms)
68
69
 
69
- hash_str = f"{ping}-{upload_kbps}-{download_kbps}-297aae72"
70
- hash_val = md5(hash_str.encode()).hexdigest()
70
+ hash_val = md5(f"{ping}-{upload_kbps}-{download_kbps}-817d699764d33f89c".encode()).hexdigest()
71
+ host, _, port = results.server.host.partition(":")
71
72
 
72
- api_parameters = {
73
- "recommendedserverid": results.server.id,
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
- "screenresolution": "",
76
- "promo": "",
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
- "touchscreen": "none",
83
- "startmode": "pingselect",
84
- "accuracy": 1,
85
- "bytesreceived": results.download_bytes or 0,
86
- "bytessent": results.upload_bytes or 0,
87
- "serverid": results.server.id,
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
- api_data = urllib.parse.urlencode(api_parameters).encode()
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
- with urllib.request.urlopen(req) as response:
99
- if response.status != 200:
100
- raise CLIError(f"Could not submit results. HTTP {response.status}")
101
-
102
- body = response.read().decode(errors="ignore")
103
-
104
- except urllib.error.URLError as e:
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
- qsargs = urllib.parse.parse_qs(body)
108
- resultid = qsargs.get("resultid")
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[0]}.png"
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(Exception):
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" * 300
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 = len(DUMMY_CHUNK)
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 Exception as e:
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 Exception as e:
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 Exception as e:
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 len(table_arg.columns) == 5
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
- with pytest.raises(SystemExit):
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: