speedtest-cli-ng 0.1.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ speedtest-cli: Next generation CLI for testing internet bandwidth using speedtest.net
3
+
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("speedtest-cli-ng")
10
+ except PackageNotFoundError:
11
+ __version__ = "unknown"
12
+
13
+ __date__ = "2026-06-10"
14
+ __author__ = "Michał Korczak"
15
+ __licence__ = "Apache License, Version 2.0"
speedtest/__main__.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ The main entry point. Invoke as `speedtest-cli` or `python -m speedtest`.
3
+ """
4
+
5
+ import sys
6
+
7
+ from speedtest.cli.main import shell
8
+ from speedtest.exceptions import SpeedtestException
9
+ from speedtest.utils.logger import logger
10
+ from speedtest.utils.status import ExitStatus
11
+
12
+
13
+ def main() -> int:
14
+ """Execute the CLI and return an integer exit status."""
15
+
16
+ try:
17
+ exit_status = shell()
18
+
19
+ return int(exit_status)
20
+
21
+ except KeyboardInterrupt:
22
+ logger.error("Stopped by user")
23
+ return ExitStatus.ERROR_CTRL_C.value
24
+
25
+ except SpeedtestException as e:
26
+ code = getattr(e, "code", ExitStatus.ERROR.value)
27
+
28
+ if code not in (ExitStatus.SUCCESS.value, ExitStatus.ERROR_CTRL_C.value):
29
+ msg = str(e) or repr(e)
30
+ logger.error(f"ERROR: {msg}")
31
+
32
+ return int(code)
33
+
34
+ except Exception as e:
35
+ logger.exception("An unexpected error occurred.", e)
36
+ return ExitStatus.ERROR.value
37
+
38
+
39
+ if __name__ == "__main__":
40
+ sys.exit(main())
File without changes
@@ -0,0 +1,120 @@
1
+ """
2
+ Wraps the core Speedtest logic into CLI actions.
3
+ """
4
+
5
+ import threading
6
+
7
+ from speedtest.cli.output import convert_speed
8
+ from speedtest.client import Speedtest
9
+ from speedtest.exceptions import (
10
+ ConfigRetrievalError,
11
+ NoMatchedServer,
12
+ ServersRetrievalError,
13
+ SpeedtestCLIError,
14
+ )
15
+ from speedtest.http.errors import HTTP_ERRORS
16
+ from speedtest.utils.logger import logger
17
+ from speedtest.utils.status import ExitStatus
18
+
19
+ __all__ = [
20
+ "get_speedtest_instance",
21
+ "handle_server_list",
22
+ "run_transfer_tests",
23
+ "select_server",
24
+ ]
25
+
26
+ # Exception groupings for cleaner try/except blocks
27
+ CONFIG_EXCEPTIONS = (*tuple(HTTP_ERRORS), ConfigRetrievalError)
28
+ SERVER_EXCEPTIONS = (*tuple(HTTP_ERRORS), ServersRetrievalError)
29
+
30
+
31
+ def get_speedtest_instance(
32
+ source: str | None,
33
+ timeout: float,
34
+ threads: int | None,
35
+ shutdown_event: threading.Event,
36
+ ) -> Speedtest:
37
+ """Initialize the Speedtest core and fetch initial configurations."""
38
+
39
+ try:
40
+ return Speedtest(
41
+ source_address=source,
42
+ timeout=timeout,
43
+ threads=threads,
44
+ shutdown_event=shutdown_event,
45
+ )
46
+ except CONFIG_EXCEPTIONS as e:
47
+ logger.error("Cannot retrieve speedtest configuration")
48
+ raise SpeedtestCLIError(e) from e
49
+
50
+
51
+ def handle_server_list(st: Speedtest) -> int:
52
+ """Handle the --list argument by printing nearby servers and exiting."""
53
+
54
+ try:
55
+ st.get_servers()
56
+ except SERVER_EXCEPTIONS as e:
57
+ logger.error("Cannot retrieve speedtest server list")
58
+ raise SpeedtestCLIError(e) from e
59
+
60
+ try:
61
+ for _, servers in sorted(st.servers.items()):
62
+ for server in servers:
63
+ line = (
64
+ f"{server.get('id', 0):>5}) {server.get('sponsor', 'Unknown')} "
65
+ f"({server.get('name', 'Unknown')}, {server.get('country', 'Unknown')}) "
66
+ f"[{server.get('d', 0.0):.2f} km]"
67
+ )
68
+ print(line)
69
+ except BrokenPipeError:
70
+ pass
71
+
72
+ return ExitStatus.SUCCESS.value
73
+
74
+
75
+ def select_server(st: Speedtest, server: int | None = None) -> None:
76
+ """Fetch servers and filter down to the best candidate."""
77
+
78
+ logger.info("Retrieving speedtest.net server list...")
79
+ try:
80
+ st.get_servers(server=server)
81
+ except NoMatchedServer as e:
82
+ raise e
83
+ except SERVER_EXCEPTIONS as e:
84
+ logger.error("Cannot retrieve speedtest server list")
85
+ raise SpeedtestCLIError(e) from e
86
+
87
+ if server is not None:
88
+ logger.info("Retrieving information for the selected server...")
89
+ else:
90
+ logger.info("Selecting best server based on ping...")
91
+
92
+ st.get_best_server()
93
+
94
+
95
+ def run_transfer_tests(
96
+ st: Speedtest,
97
+ no_download: bool = False,
98
+ no_upload: bool = False,
99
+ units: tuple[str, int] = ("bits", 1),
100
+ ) -> None:
101
+ """Execute download and upload test sequences."""
102
+
103
+ results = st.results
104
+ unit_name, unit_divisor = units
105
+
106
+ if no_download:
107
+ logger.info("Skipping download test")
108
+ else:
109
+ logger.info("Testing download speed")
110
+ st.download()
111
+ download_speed = convert_speed(results.download, unit_divisor)
112
+ logger.info(f"Download: {download_speed:.2f} M{unit_name}/s")
113
+
114
+ if no_upload:
115
+ logger.info("Skipping upload test")
116
+ else:
117
+ logger.info("Testing upload speed")
118
+ st.upload()
119
+ upload_speed = convert_speed(results.upload, unit_divisor)
120
+ logger.info(f"Upload: {upload_speed:.2f} M{unit_name}/s")
speedtest/cli/main.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ The main entry point of the CLI shell.
3
+ """
4
+
5
+ import argparse
6
+ import signal
7
+ import threading
8
+ from typing import Any
9
+
10
+ from speedtest.cli.commands import (
11
+ get_speedtest_instance,
12
+ handle_server_list,
13
+ run_transfer_tests,
14
+ select_server,
15
+ )
16
+ from speedtest.cli.output import csv_header, display_results
17
+ from speedtest.cli.parser import parse_args
18
+ from speedtest.exceptions import SpeedtestCLIError
19
+ from speedtest.utils.logger import logger, setup_logging
20
+ from speedtest.utils.status import ExitStatus
21
+
22
+ __all__ = ["shell"]
23
+
24
+
25
+ def _register_shutdown_handler() -> threading.Event:
26
+ """Register a SIGINT handler and return the associated shutdown event."""
27
+
28
+ shutdown_event = threading.Event()
29
+
30
+ def _handler(signum: int, frame: Any) -> None:
31
+ shutdown_event.set()
32
+ logger.warning("\nStopping speedtest-cli...")
33
+ raise KeyboardInterrupt
34
+
35
+ signal.signal(signal.SIGINT, _handler)
36
+ return shutdown_event
37
+
38
+
39
+ def _validate_args(args: argparse.Namespace) -> None:
40
+ """Perform pre-flight validation on CLI arguments."""
41
+
42
+ if args.no_download and args.no_upload:
43
+ raise SpeedtestCLIError("Cannot supply both --no-download and --no-upload")
44
+
45
+
46
+ def shell() -> int:
47
+ """Run the full speedtest.net test orchestrator."""
48
+
49
+ args = parse_args()
50
+
51
+ is_quiet: bool = args.csv or args.json or args.csv_header
52
+ setup_logging(debug=args.debug, quiet=is_quiet)
53
+
54
+ _validate_args(args)
55
+
56
+ if args.csv_header:
57
+ return csv_header(args.csv_delimiter)
58
+
59
+ # Setup graceful shutdown for threads
60
+ shutdown_event = _register_shutdown_handler()
61
+
62
+ threads = 1 if args.single else args.threads
63
+
64
+ # Initialize Core Pipeline
65
+ logger.info("Retrieving speedtest.net configuration...")
66
+
67
+ st = get_speedtest_instance(
68
+ source=args.source,
69
+ timeout=args.timeout,
70
+ threads=threads,
71
+ shutdown_event=shutdown_event,
72
+ )
73
+
74
+ # Handle early-exit commands
75
+ if args.list:
76
+ return handle_server_list(st)
77
+
78
+ # Execute Standard Pipeline
79
+ client_cfg = st.config.get("client", {})
80
+ logger.info(
81
+ f"Testing from {client_cfg.get('isp', 'Unknown ISP')} "
82
+ f"({client_cfg.get('ip', 'Unknown IP')})..."
83
+ )
84
+
85
+ select_server(st, server=args.server)
86
+
87
+ server_cfg = st.results.server
88
+ logger.info(
89
+ f"Hosted by {server_cfg.get('sponsor', 'Unknown')} "
90
+ f"({server_cfg.get('name', 'Unknown')}) "
91
+ f"[{server_cfg.get('d', 0.0):.2f} km]: {st.results.ping:.4f} ms"
92
+ )
93
+
94
+ run_transfer_tests(
95
+ st,
96
+ no_download=args.no_download,
97
+ no_upload=args.no_upload,
98
+ units=args.units,
99
+ )
100
+ display_results(
101
+ results=st.results,
102
+ csv_format=args.csv,
103
+ json_format=args.json,
104
+ csv_delimiter=args.csv_delimiter,
105
+ share=args.share,
106
+ )
107
+
108
+ return ExitStatus.SUCCESS.value
@@ -0,0 +1,46 @@
1
+ """
2
+ Handles formatting, printing, and CSV/JSON output.
3
+ """
4
+
5
+ from speedtest.engine.results import SpeedtestResults
6
+ from speedtest.utils.logger import logger
7
+ from speedtest.utils.status import ExitStatus
8
+
9
+ __all__ = ["convert_speed", "csv_header", "display_results"]
10
+
11
+
12
+ def convert_speed(speed_bps: float, unit_divisor: int) -> float:
13
+ """Convert speed from bits per second to the requested unit (e.g., Mega/s)."""
14
+
15
+ return (speed_bps / 1_000_000) / unit_divisor
16
+
17
+
18
+ def csv_header(delimiter: str = ",") -> int:
19
+ """Print the CSV Headers and return a successful exit status."""
20
+
21
+ print(SpeedtestResults.csv_header(delimiter=delimiter))
22
+ return ExitStatus.SUCCESS.value
23
+
24
+
25
+ def display_results(
26
+ results: SpeedtestResults,
27
+ csv_format: bool = False,
28
+ json_format: bool = False,
29
+ csv_delimiter: str = ",",
30
+ share: bool = False,
31
+ ) -> None:
32
+ """Render the final output to the user based on requested format (JSON, CSV, Text)."""
33
+
34
+ logger.debug(f"Results:\n{results.to_dict()!r}")
35
+
36
+ share_link = None
37
+ if share:
38
+ share_link = results.share()
39
+
40
+ if csv_format:
41
+ print(results.csv(delimiter=csv_delimiter))
42
+ elif json_format:
43
+ print(results.json())
44
+
45
+ if share and not (csv_format or json_format):
46
+ print(f"Share results: {share_link}")
@@ -0,0 +1,130 @@
1
+ """
2
+ Handles CLI arguments and validation.
3
+ """
4
+
5
+ import argparse
6
+
7
+ from speedtest import __version__
8
+
9
+ __all__ = ["parse_args"]
10
+
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
+ def parse_args() -> argparse.Namespace:
23
+ """Function to handle building and parsing of command line arguments."""
24
+
25
+ description = (
26
+ "Next generation CLI for testing internet bandwidth using speedtest.net.\n"
27
+ "--------------------------------------------------------------------------\n"
28
+ "https://github.com/Omikorin/speedtest-cli"
29
+ )
30
+
31
+ parser = argparse.ArgumentParser(
32
+ prog="speedtest-cli",
33
+ description=description,
34
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
35
+ )
36
+
37
+ core_group = parser.add_argument_group("Core Options")
38
+ core_group.add_argument(
39
+ "-l",
40
+ "--list",
41
+ action="store_true",
42
+ help="Show available speedtest.net servers sorted by distance.",
43
+ )
44
+ core_group.add_argument(
45
+ "-s",
46
+ "--server",
47
+ type=int,
48
+ help="Specify a server id to test against.",
49
+ )
50
+
51
+ transfer_group = parser.add_argument_group("Transfer Modifiers")
52
+ transfer_group.add_argument(
53
+ "--no-download",
54
+ action="store_true",
55
+ help="Do not perform the download test.",
56
+ )
57
+ transfer_group.add_argument(
58
+ "--no-upload",
59
+ action="store_true",
60
+ help="Do not perform the upload test.",
61
+ )
62
+
63
+ threads_group = transfer_group.add_mutually_exclusive_group()
64
+ threads_group.add_argument(
65
+ "-t",
66
+ "--threads",
67
+ type=int,
68
+ help="Set the number of concurrent connections instead of using downloaded config.",
69
+ )
70
+ threads_group.add_argument(
71
+ "--single",
72
+ action="store_true",
73
+ help="Use one concurrent connection. Simulates a typical file transfer.",
74
+ )
75
+
76
+ output_group = parser.add_argument_group("Output Options")
77
+ output_group.add_argument(
78
+ "--share",
79
+ action="store_true",
80
+ help="Generate and provide a URL to the speedtest.net share results image.",
81
+ )
82
+ output_group.add_argument(
83
+ "--bytes",
84
+ dest="units",
85
+ action="store_const",
86
+ const=("byte", 8),
87
+ default=("bit", 1),
88
+ help=(
89
+ "Display values in bytes instead of bits. "
90
+ "Does not affect image generation or JSON/CSV output."
91
+ ),
92
+ )
93
+
94
+ 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
+ format_group.add_argument(
104
+ "--json",
105
+ action="store_true",
106
+ help=(
107
+ "Suppress verbose output, only show basic information "
108
+ "in JSON format. Speeds listed in bit/s."
109
+ ),
110
+ )
111
+
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__}")
129
+
130
+ return parser.parse_args()
speedtest/client.py ADDED
@@ -0,0 +1,144 @@
1
+ import threading
2
+ from typing import Any
3
+
4
+ from speedtest.engine.config import fetch_config
5
+ from speedtest.engine.results import SpeedtestResults
6
+ from speedtest.engine.servers import fetch_servers, get_best_server
7
+ from speedtest.engine.transfer import run_download_test, run_upload_test
8
+ from speedtest.exceptions import NoMatchedServer, SpeedtestCLIError
9
+ from speedtest.http.handlers import build_opener
10
+
11
+ __all__ = ["Speedtest"]
12
+
13
+
14
+ class Speedtest:
15
+ """Class for performing standard speedtest.net testing operations."""
16
+
17
+ def __init__(
18
+ self,
19
+ config: dict[str, Any] | None = None,
20
+ source_address: str | None = None,
21
+ timeout: float = 10.0,
22
+ shutdown_event: threading.Event | None = None,
23
+ threads: int | None = None,
24
+ ):
25
+ self._source_address = source_address
26
+ self._timeout = timeout
27
+ self._shutdown_event = shutdown_event or threading.Event()
28
+ self._threads = threads
29
+
30
+ self._opener = build_opener(source_address, timeout)
31
+
32
+ # Fetch default configuration and safely merge optional overrides
33
+ self.config = fetch_config(self._opener) or {}
34
+ if config:
35
+ self.config.update(config)
36
+
37
+ self.lat_lon = self.config.get("lat_lon") or (0.0, 0.0)
38
+
39
+ # Core state data structures
40
+ self.servers: dict[float, list[dict[str, Any]]] = {}
41
+ self.closest: list[dict[str, Any]] = []
42
+ self._best: dict[str, Any] = {}
43
+
44
+ self.results = SpeedtestResults(
45
+ client=self.config.get("client", {}),
46
+ opener=self._opener,
47
+ )
48
+
49
+ @property
50
+ def best(self) -> dict[str, Any]:
51
+ """Lazy-loaded property to retrieve the best available server."""
52
+
53
+ if not self._best:
54
+ self.get_best_server()
55
+ return self._best
56
+
57
+ def get_servers(self, server: int | None = None) -> dict[float, list[dict[str, Any]]]:
58
+ """
59
+ Fetch the server list from speedtest.net, sort them by distance,
60
+ and optionally filter down to a specific server ID.
61
+ """
62
+
63
+ ignore = self.config.get("ignore_servers", [])
64
+
65
+ self.servers = fetch_servers(
66
+ opener=self._opener,
67
+ lat_lon=self.lat_lon,
68
+ ignore_servers=ignore,
69
+ )
70
+
71
+ # Flatten the distance-grouped dict into a clean linear list sorted by proximity
72
+ sorted_distances = sorted(self.servers.keys())
73
+ self.closest = [srv for distance in sorted_distances for srv in self.servers[distance]]
74
+
75
+ # Filter by a specific server ID if requested
76
+ if server is not None:
77
+ self.closest = [s for s in self.closest if int(s.get("id", 0)) == server]
78
+
79
+ if not self.closest:
80
+ raise NoMatchedServer(f"No server matched the ID: {server}")
81
+
82
+ return self.servers
83
+
84
+ def get_best_server(self, limit: int = 5) -> dict[str, Any]:
85
+ """Determine the lowest-latency server by pinging the top `limit` closest options."""
86
+
87
+ if not self.closest:
88
+ self.get_servers()
89
+
90
+ if not self.closest:
91
+ raise SpeedtestCLIError("No servers available to test against.")
92
+
93
+ # Isolate the closest N servers to avoid wasting execution time pinging distant servers
94
+ candidates = self.closest[:limit]
95
+
96
+ self._best = get_best_server(candidates, self._opener)
97
+
98
+ if not self._best:
99
+ raise SpeedtestCLIError("Failed to identify a valid best server.")
100
+
101
+ self.results.server = self._best
102
+ self.results.ping = self._best.get("latency_ms", 0.0)
103
+
104
+ return self._best
105
+
106
+ def download(self) -> float:
107
+ """Test concurrent download speed against the chosen optimal server."""
108
+
109
+ best_url = self.best.get("url")
110
+ if not best_url:
111
+ raise SpeedtestCLIError("The best selected server is missing a valid URL.")
112
+
113
+ bytes_received, download_speed = run_download_test(
114
+ best_server_url=best_url,
115
+ config=self.config,
116
+ opener=self._opener,
117
+ shutdown_event=self._shutdown_event,
118
+ threads=self._threads,
119
+ )
120
+
121
+ self.results.bytes_received = bytes_received
122
+ self.results.download = download_speed
123
+
124
+ return self.results.download
125
+
126
+ def upload(self) -> float:
127
+ """Test concurrent upload speed against the chosen optimal server."""
128
+
129
+ best_url = self.best.get("url")
130
+ if not best_url:
131
+ raise SpeedtestCLIError("The best selected server is missing a valid URL.")
132
+
133
+ bytes_sent, upload_speed = run_upload_test(
134
+ best_server_url=best_url,
135
+ config=self.config,
136
+ opener=self._opener,
137
+ shutdown_event=self._shutdown_event,
138
+ threads=self._threads,
139
+ )
140
+
141
+ self.results.bytes_sent = bytes_sent
142
+ self.results.upload = upload_speed
143
+
144
+ return self.results.upload
File without changes