speedtest-cli-ng 0.1.1__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 (50) hide show
  1. {speedtest_cli_ng-0.1.1/speedtest_cli_ng.egg-info → speedtest_cli_ng-0.2.0}/PKG-INFO +9 -16
  2. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/README.md +8 -15
  3. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/extras/man/speedtest-cli.1 +2 -27
  4. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/pyproject.toml +1 -1
  5. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/__main__.py +1 -1
  6. speedtest_cli_ng-0.2.0/speedtest/cli/commands.py +26 -0
  7. speedtest_cli_ng-0.2.0/speedtest/cli/main.py +103 -0
  8. speedtest_cli_ng-0.2.0/speedtest/cli/output.py +62 -0
  9. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/cli/parser.py +6 -38
  10. speedtest_cli_ng-0.2.0/speedtest/client.py +117 -0
  11. speedtest_cli_ng-0.2.0/speedtest/engine/config.py +50 -0
  12. speedtest_cli_ng-0.2.0/speedtest/engine/network.py +170 -0
  13. speedtest_cli_ng-0.2.0/speedtest/engine/servers.py +77 -0
  14. speedtest_cli_ng-0.2.0/speedtest/engine/transfer.py +139 -0
  15. speedtest_cli_ng-0.2.0/speedtest/exceptions.py +35 -0
  16. speedtest_cli_ng-0.2.0/speedtest/models/__init__.py +12 -0
  17. speedtest_cli_ng-0.2.0/speedtest/models/config.py +27 -0
  18. speedtest_cli_ng-0.2.0/speedtest/models/context.py +54 -0
  19. speedtest_cli_ng-0.2.0/speedtest/models/result.py +58 -0
  20. speedtest_cli_ng-0.2.0/speedtest/models/server.py +62 -0
  21. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/utils/logger.py +17 -8
  22. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0/speedtest_cli_ng.egg-info}/PKG-INFO +9 -16
  23. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest_cli_ng.egg-info/SOURCES.txt +6 -8
  24. speedtest_cli_ng-0.1.1/speedtest/cli/commands.py +0 -120
  25. speedtest_cli_ng-0.1.1/speedtest/cli/main.py +0 -108
  26. speedtest_cli_ng-0.1.1/speedtest/cli/output.py +0 -46
  27. speedtest_cli_ng-0.1.1/speedtest/client.py +0 -144
  28. speedtest_cli_ng-0.1.1/speedtest/engine/config.py +0 -120
  29. speedtest_cli_ng-0.1.1/speedtest/engine/results.py +0 -195
  30. speedtest_cli_ng-0.1.1/speedtest/engine/servers.py +0 -189
  31. speedtest_cli_ng-0.1.1/speedtest/engine/transfer.py +0 -157
  32. speedtest_cli_ng-0.1.1/speedtest/exceptions.py +0 -78
  33. speedtest_cli_ng-0.1.1/speedtest/http/connections.py +0 -36
  34. speedtest_cli_ng-0.1.1/speedtest/http/errors.py +0 -21
  35. speedtest_cli_ng-0.1.1/speedtest/http/handlers.py +0 -105
  36. speedtest_cli_ng-0.1.1/speedtest/http/request.py +0 -80
  37. speedtest_cli_ng-0.1.1/speedtest/http/response.py +0 -23
  38. speedtest_cli_ng-0.1.1/speedtest/http/workers.py +0 -128
  39. speedtest_cli_ng-0.1.1/speedtest/utils/__init__.py +0 -0
  40. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/LICENSE +0 -0
  41. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/NOTICE +0 -0
  42. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/setup.cfg +0 -0
  43. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/__init__.py +0 -0
  44. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/cli/__init__.py +0 -0
  45. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/engine/__init__.py +0 -0
  46. {speedtest_cli_ng-0.1.1/speedtest/http → speedtest_cli_ng-0.2.0/speedtest/utils}/__init__.py +0 -0
  47. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest/utils/status.py +0 -0
  48. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest_cli_ng.egg-info/dependency_links.txt +0 -0
  49. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest_cli_ng.egg-info/entry_points.txt +0 -0
  50. {speedtest_cli_ng-0.1.1 → speedtest_cli_ng-0.2.0}/speedtest_cli_ng.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: speedtest-cli-ng
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Next generation CLI for testing internet bandwidth using speedtest.net
5
5
  Author-email: Michał Korczak <git@omikor.in>
6
6
  License-Expression: Apache-2.0
@@ -29,8 +29,8 @@ Dynamic: license-file
29
29
  [![PyPI version](https://img.shields.io/pypi/v/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
30
30
  [![PyPI license](https://img.shields.io/pypi/l/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
31
31
  [![PyPI pyversions](https://img.shields.io/pypi/pyversions/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
32
- <!-- [![GitHub License](https://img.shields.io/github/license/Omikorin/speedtest-cli)](https://github.com/Omikorin/speedtest-cli/blob/main/LICENSE) -->
33
- <!-- TODO: SonarQube, GitHub Actions? -->
32
+ [![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)
33
+ [![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)
34
34
 
35
35
  Next generation CLI for testing internet bandwidth using speedtest.net
36
36
 
@@ -75,15 +75,14 @@ speedtest-cli --help
75
75
  ```
76
76
 
77
77
  ```text
78
- usage: speedtest-cli [-h] [-l] [-s SERVER] [--no-download] [--no-upload] [-t THREADS | --single]
79
- [--share] [--bytes] [--csv | --json] [--csv-delimiter CSV_DELIMITER]
80
- [--csv-header] [--source SOURCE] [--timeout TIMEOUT] [--debug] [--version]
78
+ usage: speedtest-cli [-h] [-l] [-s SERVER] [--no-download] [--no-upload]
79
+ [-t THREADS | --single] [--share] [--bytes] [--json]
80
+ [--debug] [--version]
81
81
 
82
82
  Next generation CLI for testing internet bandwidth using speedtest.net.
83
83
 
84
84
  options:
85
85
  -h, --help show this help message and exit
86
- --version show program's version number and exit
87
86
 
88
87
  Core Options:
89
88
  -l, --list Show available speedtest.net servers sorted by distance. (default: False)
@@ -99,17 +98,12 @@ Transfer Modifiers:
99
98
 
100
99
  Output Options:
101
100
  --share Generate and provide a URL to the speedtest.net share results image. (default: False)
102
- --bytes Display values in bytes instead of bits. Does not affect image generation or JSON/CSV output. (default: ('bit', 1))
103
- --csv Suppress verbose output, only show basic information in CSV format. Speeds listed in bit/s. (default: False)
101
+ --bytes Display values in bytes instead of bits. Does not affect image generation or JSON output. (default: ('bit', 1))
104
102
  --json Suppress verbose output, only show basic information in JSON format. Speeds listed in bit/s. (default: False)
105
- --csv-delimiter CSV_DELIMITER
106
- Single character delimiter to use in CSV output. (default: ,)
107
- --csv-header Print CSV headers and exit. (default: False)
108
103
 
109
- Connection Options:
110
- --source SOURCE Bind a source IP address to use for connections. (default: None)
111
- --timeout TIMEOUT HTTP timeout in seconds. (default: 10.0)
104
+ Miscellaneous Options:
112
105
  --debug Show verbose debugging output. (default: False)
106
+ --version show program's version number and exit
113
107
  ```
114
108
 
115
109
  ## Development
@@ -153,7 +147,6 @@ uv run build
153
147
 
154
148
  The output will be in the `dist/` directory.
155
149
 
156
-
157
150
  ## Acknowledgments
158
151
 
159
152
  This project is a heavily refactored and modernized fork of the original [speedtest-cli](https://github.com/sivel/speedtest-cli) created by Matt Martz. While the codebase has been completely rewritten to support modern Python standards, concurrent thread pools, and memory-safe streaming, it was built upon the foundational concepts of the original tool.
@@ -3,8 +3,8 @@
3
3
  [![PyPI version](https://img.shields.io/pypi/v/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
4
4
  [![PyPI license](https://img.shields.io/pypi/l/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
5
5
  [![PyPI pyversions](https://img.shields.io/pypi/pyversions/speedtest-cli-ng.svg)](https://github.com/Omikorin/speedtest-cli)
6
- <!-- [![GitHub License](https://img.shields.io/github/license/Omikorin/speedtest-cli)](https://github.com/Omikorin/speedtest-cli/blob/main/LICENSE) -->
7
- <!-- TODO: SonarQube, GitHub Actions? -->
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
+ [![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
9
  Next generation CLI for testing internet bandwidth using speedtest.net
10
10
 
@@ -49,15 +49,14 @@ speedtest-cli --help
49
49
  ```
50
50
 
51
51
  ```text
52
- usage: speedtest-cli [-h] [-l] [-s SERVER] [--no-download] [--no-upload] [-t THREADS | --single]
53
- [--share] [--bytes] [--csv | --json] [--csv-delimiter CSV_DELIMITER]
54
- [--csv-header] [--source SOURCE] [--timeout TIMEOUT] [--debug] [--version]
52
+ usage: speedtest-cli [-h] [-l] [-s SERVER] [--no-download] [--no-upload]
53
+ [-t THREADS | --single] [--share] [--bytes] [--json]
54
+ [--debug] [--version]
55
55
 
56
56
  Next generation CLI for testing internet bandwidth using speedtest.net.
57
57
 
58
58
  options:
59
59
  -h, --help show this help message and exit
60
- --version show program's version number and exit
61
60
 
62
61
  Core Options:
63
62
  -l, --list Show available speedtest.net servers sorted by distance. (default: False)
@@ -73,17 +72,12 @@ Transfer Modifiers:
73
72
 
74
73
  Output Options:
75
74
  --share Generate and provide a URL to the speedtest.net share results image. (default: False)
76
- --bytes Display values in bytes instead of bits. Does not affect image generation or JSON/CSV output. (default: ('bit', 1))
77
- --csv Suppress verbose output, only show basic information in CSV format. Speeds listed in bit/s. (default: False)
75
+ --bytes Display values in bytes instead of bits. Does not affect image generation or JSON output. (default: ('bit', 1))
78
76
  --json Suppress verbose output, only show basic information in JSON format. Speeds listed in bit/s. (default: False)
79
- --csv-delimiter CSV_DELIMITER
80
- Single character delimiter to use in CSV output. (default: ,)
81
- --csv-header Print CSV headers and exit. (default: False)
82
77
 
83
- Connection Options:
84
- --source SOURCE Bind a source IP address to use for connections. (default: None)
85
- --timeout TIMEOUT HTTP timeout in seconds. (default: 10.0)
78
+ Miscellaneous Options:
86
79
  --debug Show verbose debugging output. (default: False)
80
+ --version show program's version number and exit
87
81
  ```
88
82
 
89
83
  ## Development
@@ -127,7 +121,6 @@ uv run build
127
121
 
128
122
  The output will be in the `dist/` directory.
129
123
 
130
-
131
124
  ## Acknowledgments
132
125
 
133
126
  This project is a heavily refactored and modernized fork of the original [speedtest-cli](https://github.com/sivel/speedtest-cli) created by Matt Martz. While the codebase has been completely rewritten to support modern Python standards, concurrent thread pools, and memory-safe streaming, it was built upon the foundational concepts of the original tool.
@@ -62,12 +62,7 @@ Generate and provide a URL to the speedtest.net share results image.
62
62
 
63
63
  \fB\-\-bytes\fR
64
64
  .RS
65
- Display values in bytes instead of bits. Does not affect image generation or JSON/CSV output.
66
- .RE
67
-
68
- \fB\-\-csv\fR
69
- .RS
70
- Suppress verbose output, only show basic information in CSV format. Speeds listed in bit/s.
65
+ Display values in bytes instead of bits. Does not affect image generation or JSON output.
71
66
  .RE
72
67
 
73
68
  \fB\-\-json\fR
@@ -75,27 +70,7 @@ Suppress verbose output, only show basic information in CSV format. Speeds liste
75
70
  Suppress verbose output, only show basic information in JSON format. Speeds listed in bit/s.
76
71
  .RE
77
72
 
78
- \fB\-\-csv\-delimiter CSV_DELIMITER\fR
79
- .RS
80
- Single character delimiter to use in CSV output. Default ","
81
- .RE
82
-
83
- \fB\-\-csv\-header\fR
84
- .RS
85
- Print CSV headers and exit.
86
- .RE
87
-
88
- .B Connection Options
89
-
90
- \fB\-\-source SOURCE\fR
91
- .RS
92
- Bind a source IP address to use for connections.
93
- .RE
94
-
95
- \fB\-\-timeout TIMEOUT\fR
96
- .RS
97
- HTTP timeout in seconds. Default 10.0
98
- .RE
73
+ .B Miscellaneous Options
99
74
 
100
75
  \fB\-\-debug\fR
101
76
  .RS
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "speedtest-cli-ng"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  authors = [
9
9
  {name = "Michał Korczak", email = "git@omikor.in"}
10
10
  # TODO: I'm not sure whether I should put the old author because of possible spam for him unknowingly etc
@@ -27,7 +27,7 @@ def main() -> int:
27
27
 
28
28
  if code not in (ExitStatus.SUCCESS.value, ExitStatus.ERROR_CTRL_C.value):
29
29
  msg = str(e) or repr(e)
30
- logger.error(f"ERROR: {msg}")
30
+ logger.error(msg)
31
31
 
32
32
  return int(code)
33
33
 
@@ -0,0 +1,26 @@
1
+ """
2
+ Wraps the core Speedtest logic into CLI actions.
3
+ """
4
+
5
+ from speedtest.models import Server
6
+ from speedtest.utils.status import ExitStatus
7
+
8
+ __all__ = ["handle_server_list"]
9
+
10
+
11
+ def handle_server_list(servers: list[Server]) -> int:
12
+ """Handle the --list argument by printing nearby servers and exiting."""
13
+
14
+ try:
15
+ for server in servers:
16
+ line = (
17
+ f"[{server.id:>5}]\t{server.distance} km\t"
18
+ f"{server.name} ({server.country}) "
19
+ f"by {server.sponsor}"
20
+ )
21
+ print(line)
22
+ except BrokenPipeError:
23
+ # Prevents messy tracebacks if the user pipes output to `head` or `less`
24
+ pass
25
+
26
+ return ExitStatus.SUCCESS.value
@@ -0,0 +1,103 @@
1
+ """
2
+ The main entry point of the CLI shell.
3
+ """
4
+
5
+ import dataclasses
6
+ import signal
7
+ import threading
8
+ from typing import Any
9
+
10
+ from speedtest.cli.commands import handle_server_list
11
+ from speedtest.cli.output import format_json, format_text
12
+ from speedtest.cli.parser import parse_args
13
+ from speedtest.client import SpeedtestClient
14
+ from speedtest.engine.config import get_config
15
+ from speedtest.exceptions import SpeedtestCLIError
16
+ from speedtest.models import RunContext, TestResult
17
+ from speedtest.utils.logger import logger, setup_logging
18
+ from speedtest.utils.status import ExitStatus
19
+
20
+ __all__ = ["shell"]
21
+
22
+
23
+ def _register_shutdown_handler() -> threading.Event:
24
+ """Register a SIGINT handler and return the associated shutdown event."""
25
+
26
+ shutdown_event = threading.Event()
27
+
28
+ def _handler(signum: int, frame: Any) -> None:
29
+ shutdown_event.set()
30
+ logger.warning("Stopping speedtest-cli...")
31
+ raise KeyboardInterrupt
32
+
33
+ signal.signal(signal.SIGINT, _handler)
34
+ return shutdown_event
35
+
36
+
37
+ def shell() -> int:
38
+ """Run the full speedtest.net test orchestrator."""
39
+
40
+ raw_args = parse_args()
41
+ ctx = RunContext.from_args(raw_args)
42
+ results = TestResult()
43
+
44
+ setup_logging(debug=ctx.debug_mode, quiet=ctx.is_quiet)
45
+
46
+ ctx.shutdown_event = _register_shutdown_handler()
47
+ client = SpeedtestClient()
48
+
49
+ try:
50
+ logger.info("[INFO] Retrieving speedtest.net configuration...")
51
+ ctx.api_config = get_config()
52
+
53
+ logger.info(
54
+ f"ISP: {ctx.api_config.ip_address} ({ctx.api_config.isp_name}) "
55
+ f"[{ctx.api_config.location.latitude:.4f}, {ctx.api_config.location.longitude:.4f}]"
56
+ )
57
+
58
+ target_servers = client.get_target_servers(
59
+ config=ctx.api_config, target_id=ctx.target_server_id
60
+ )
61
+
62
+ if ctx.list_servers_only:
63
+ return handle_server_list(target_servers)
64
+
65
+ # Latency test
66
+ results.server, results.ping_ms = client.select_best_server(target_servers)
67
+
68
+ logger.info(
69
+ f"Test server: [{results.server.id}] {results.server.distance} km "
70
+ f"{results.server.name} ({results.server.country}) "
71
+ f"by {results.server.sponsor}"
72
+ )
73
+
74
+ logger.info(f"Latency: {results.ping_ms:.5f} ms")
75
+
76
+ # Transfer tests
77
+ if not ctx.no_download:
78
+ results.download_bytes, results.download_bps = client.download(
79
+ server=results.server, ctx=ctx
80
+ )
81
+
82
+ if not ctx.no_upload:
83
+ results.upload_bytes, results.upload_bps = client.upload(server=results.server, ctx=ctx)
84
+
85
+ if ctx.share:
86
+ logger.info("[INFO] Generating share link...")
87
+ results.share_url = client.generate_share_link(results)
88
+
89
+ logger.debug(f"Results:\n{dataclasses.asdict(results)!r}")
90
+
91
+ if ctx.json_output:
92
+ final_output = format_json(results, ctx.api_config)
93
+ else:
94
+ final_output = format_text(results, ctx.units)
95
+
96
+ print(final_output)
97
+
98
+ return ExitStatus.SUCCESS.value
99
+
100
+ except SpeedtestCLIError as e:
101
+ logger.error(f"Test failed: {e}")
102
+
103
+ return ExitStatus.ERROR.value
@@ -0,0 +1,62 @@
1
+ """
2
+ Handles formatting of test results into strings.
3
+ """
4
+
5
+ import dataclasses
6
+ import json
7
+ import sys
8
+ from datetime import UTC, datetime
9
+
10
+ from speedtest.models import SpeedtestConfig, TestResult
11
+
12
+ __all__ = ["format_json", "format_text"]
13
+
14
+
15
+ def format_json(results: TestResult, client_config: SpeedtestConfig | None = None) -> str:
16
+ """Construct the machine-readable JSON representation as a string."""
17
+
18
+ json_data = {
19
+ "timestamp": datetime.now(UTC).isoformat(),
20
+ "ping": results.ping_ms,
21
+ "download": results.download_bps,
22
+ "upload": results.upload_bps,
23
+ "bytes_received": results.download_bytes,
24
+ "bytes_sent": results.upload_bytes,
25
+ "share": results.share_url,
26
+ "server": dataclasses.asdict(results.server) if results.server else None,
27
+ }
28
+
29
+ if client_config:
30
+ json_data["client"] = {
31
+ "ip": client_config.ip_address,
32
+ "isp": client_config.isp_name,
33
+ "isp_id": client_config.isp_id,
34
+ "location": dataclasses.asdict(client_config.location),
35
+ }
36
+
37
+ indent = 2 if sys.stdout.isatty() else None
38
+ return json.dumps(json_data, indent=indent)
39
+
40
+
41
+ def format_text(results: TestResult, units: tuple[str, int]) -> str:
42
+ """Format the human-readable text representation as a string."""
43
+
44
+ unit_name, unit_divisor = units
45
+ lines = []
46
+
47
+ dl_speed = results.get_download_speed(unit_divisor)
48
+ dl_bytes = results.get_downloaded_megabytes()
49
+
50
+ if dl_speed is not None and dl_bytes is not None:
51
+ lines.append(f"Download: {dl_speed:.2f} M{unit_name}/s ({dl_bytes:.2f} MB)")
52
+
53
+ ul_speed = results.get_upload_speed(unit_divisor)
54
+ ul_bytes = results.get_uploaded_megabytes()
55
+
56
+ if ul_speed is not None and ul_bytes is not None:
57
+ lines.append(f"Upload: {ul_speed:.2f} M{unit_name}/s ({ul_bytes:.2f} MB)")
58
+
59
+ if results.share_url is not None:
60
+ lines.append(f"Share results: {results.share_url}")
61
+
62
+ return "\n".join(lines)
@@ -9,16 +9,6 @@ from speedtest import __version__
9
9
  __all__ = ["parse_args"]
10
10
 
11
11
 
12
- def _single_char_delimiter(value: str) -> str:
13
- """Argparse type validator to ensure a delimiter is exactly one character."""
14
-
15
- if len(value) != 1:
16
- raise argparse.ArgumentTypeError(
17
- f"CSV delimiter must be a single character, got: {value!r}"
18
- )
19
- return value
20
-
21
-
22
12
  def parse_args() -> argparse.Namespace:
23
13
  """Function to handle building and parsing of command line arguments."""
24
14
 
@@ -83,23 +73,15 @@ def parse_args() -> argparse.Namespace:
83
73
  "--bytes",
84
74
  dest="units",
85
75
  action="store_const",
86
- const=("byte", 8),
87
- default=("bit", 1),
76
+ const=("B", 8),
77
+ default=("b", 1),
88
78
  help=(
89
79
  "Display values in bytes instead of bits. "
90
- "Does not affect image generation or JSON/CSV output."
80
+ "Does not affect image generation or JSON output."
91
81
  ),
92
82
  )
93
83
 
94
84
  format_group = output_group.add_mutually_exclusive_group()
95
- format_group.add_argument(
96
- "--csv",
97
- action="store_true",
98
- help=(
99
- "Suppress verbose output, only show basic information "
100
- "in CSV format. Speeds listed in bit/s."
101
- ),
102
- )
103
85
  format_group.add_argument(
104
86
  "--json",
105
87
  action="store_true",
@@ -109,22 +91,8 @@ def parse_args() -> argparse.Namespace:
109
91
  ),
110
92
  )
111
93
 
112
- output_group.add_argument(
113
- "--csv-delimiter",
114
- default=",",
115
- type=_single_char_delimiter,
116
- help="Single character delimiter to use in CSV output.",
117
- )
118
- output_group.add_argument(
119
- "--csv-header", action="store_true", help="Print CSV headers and exit."
120
- )
121
-
122
- conn_group = parser.add_argument_group("Connection Options")
123
- conn_group.add_argument(
124
- "--source", type=str, help="Bind a source IP address to use for connections."
125
- )
126
- conn_group.add_argument("--timeout", default=10.0, type=float, help="HTTP timeout in seconds.")
127
- conn_group.add_argument("--debug", action="store_true", help="Show verbose debugging output.")
128
- parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
94
+ misc_group = parser.add_argument_group("Miscellaneous Options")
95
+ misc_group.add_argument("--debug", action="store_true", help="Show verbose debugging output.")
96
+ misc_group.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
129
97
 
130
98
  return parser.parse_args()
@@ -0,0 +1,117 @@
1
+ import urllib.error
2
+ import urllib.parse
3
+ import urllib.request
4
+ from hashlib import md5
5
+ from operator import attrgetter
6
+
7
+ from speedtest.engine.servers import get_best_server
8
+ from speedtest.engine.transfer import run_download_test, run_upload_test
9
+ from speedtest.exceptions import NoMatchedServer, SpeedtestCLIError
10
+ from speedtest.models import Server, SpeedtestConfig, TestResult
11
+ from speedtest.models.context import RunContext
12
+
13
+ __all__ = ["SpeedtestClient"]
14
+
15
+
16
+ class SpeedtestClient:
17
+ """Orchestrates high-level speedtest actions."""
18
+
19
+ def get_target_servers(
20
+ self, config: SpeedtestConfig, target_id: int | None = None, limit: int = 5
21
+ ) -> list[Server]:
22
+ """Sorts available servers by distance and filters by ID if requested."""
23
+
24
+ servers = sorted(config.servers, key=attrgetter("distance"))
25
+
26
+ if target_id is not None:
27
+ servers = [srv for srv in servers if srv.id == target_id]
28
+ if not servers:
29
+ raise NoMatchedServer(f"No server matched the ID: {target_id}")
30
+
31
+ return servers[:limit]
32
+
33
+ def select_best_server(self, servers: list[Server]) -> tuple[Server, float]:
34
+ """Pings a list of servers and returns the fastest one alongside its latency."""
35
+
36
+ if not servers:
37
+ raise SpeedtestCLIError("No servers provided to test against.")
38
+
39
+ best_server, latency = get_best_server(servers)
40
+
41
+ if not best_server:
42
+ raise SpeedtestCLIError("Failed to identify a valid best server.")
43
+
44
+ return best_server, latency
45
+
46
+ def download(self, server: Server, ctx: RunContext) -> tuple[int, float]:
47
+ """Executes the download test. Returns (bytes_received, download_speed)."""
48
+
49
+ if not server.url:
50
+ raise SpeedtestCLIError("The target server is missing a valid URL.")
51
+
52
+ return run_download_test(best_server_url=server.url, ctx=ctx)
53
+
54
+ def upload(self, server: Server, ctx: RunContext) -> tuple[int, float]:
55
+ """Executes the upload test. Returns (bytes_sent, upload_speed)."""
56
+
57
+ if not server.url:
58
+ raise SpeedtestCLIError("The target server is missing a valid URL.")
59
+
60
+ return run_upload_test(best_server_url=server.url, ctx=ctx)
61
+
62
+ def generate_share_link(self, results: TestResult) -> str:
63
+ """POST data to the speedtest.net API to obtain a share results link."""
64
+
65
+ if not results.is_complete or not results.server:
66
+ raise SpeedtestCLIError("Cannot generate share link: missing test results.")
67
+
68
+ # The legacy Ookla API expects speeds in kilobits per second (kbps)
69
+ download_kbps = round((results.download_bps or 0) / 1000.0)
70
+ upload_kbps = round((results.upload_bps or 0) / 1000.0)
71
+ ping = round(results.ping_ms or 0)
72
+
73
+ hash_str = f"{ping}-{upload_kbps}-{download_kbps}-297aae72"
74
+ hash_val = md5(hash_str.encode()).hexdigest()
75
+
76
+ api_parameters = {
77
+ "recommendedserverid": results.server.id,
78
+ "ping": ping,
79
+ "screenresolution": "",
80
+ "promo": "",
81
+ "download": download_kbps,
82
+ "screendpi": "",
83
+ "upload": upload_kbps,
84
+ "testmethod": "http",
85
+ "hash": hash_val,
86
+ "touchscreen": "none",
87
+ "startmode": "pingselect",
88
+ "accuracy": 1,
89
+ "bytesreceived": results.download_bytes or 0,
90
+ "bytessent": results.upload_bytes or 0,
91
+ "serverid": results.server.id,
92
+ }
93
+
94
+ api_data = urllib.parse.urlencode(api_parameters).encode()
95
+ headers = {"Referer": "https://c.speedtest.net/flash/speedtest.swf"}
96
+
97
+ req = urllib.request.Request(
98
+ "https://www.speedtest.net/api/api.php", data=api_data, headers=headers, method="POST"
99
+ )
100
+
101
+ try:
102
+ with urllib.request.urlopen(req) as response:
103
+ if response.status != 200:
104
+ raise SpeedtestCLIError(f"Could not submit results. HTTP {response.status}")
105
+
106
+ body = response.read().decode(errors="ignore")
107
+
108
+ except urllib.error.URLError as e:
109
+ raise SpeedtestCLIError(f"Failed to connect to Share API: {e}") from e
110
+
111
+ qsargs = urllib.parse.parse_qs(body)
112
+ resultid = qsargs.get("resultid")
113
+
114
+ if not resultid or len(resultid) != 1:
115
+ raise SpeedtestCLIError("Could not parse result ID from API response.")
116
+
117
+ return f"https://www.speedtest.net/result/{resultid[0]}.png"
@@ -0,0 +1,50 @@
1
+ """
2
+ Retrieves and parses the core configuration JSON from speedtest.net.
3
+ """
4
+
5
+ import json
6
+ from json import JSONDecodeError
7
+ from typing import Any
8
+ from urllib.error import URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from speedtest.engine.network import build_user_agent
12
+ from speedtest.models import SpeedtestConfig
13
+
14
+ CONFIG_URL = "https://www.speedtest.net/api/js/config-sdk"
15
+
16
+
17
+ class ConfigFetchError(Exception):
18
+ """Raised when the configuration cannot be retrieved or parsed."""
19
+
20
+ pass
21
+
22
+
23
+ def fetch_raw_config(url: str = CONFIG_URL) -> dict[str, Any]:
24
+ """
25
+ Fetches the JSON configuration from the Speedtest API.
26
+ """
27
+
28
+ user_agent = build_user_agent()
29
+
30
+ req = Request(url, headers={"User-Agent": user_agent, "Accept": "application/json"})
31
+
32
+ try:
33
+ with urlopen(req, timeout=10.0) as response:
34
+ if response.status != 200:
35
+ raise ConfigFetchError(f"HTTP {response.status}: Failed to fetch config.")
36
+
37
+ body = response.read().decode("utf-8")
38
+ return json.loads(body)
39
+
40
+ except (URLError, JSONDecodeError) as e:
41
+ raise ConfigFetchError(f"Failed to retrieve or parse config: {e}") from e
42
+
43
+
44
+ def get_config() -> SpeedtestConfig:
45
+ """
46
+ Orchestrates fetching the raw data and building the strict dataclass models.
47
+ """
48
+
49
+ raw_data = fetch_raw_config()
50
+ return SpeedtestConfig.from_dict(raw_data)