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 +15 -0
- speedtest/__main__.py +40 -0
- speedtest/cli/__init__.py +0 -0
- speedtest/cli/commands.py +120 -0
- speedtest/cli/main.py +108 -0
- speedtest/cli/output.py +46 -0
- speedtest/cli/parser.py +130 -0
- speedtest/client.py +144 -0
- speedtest/engine/__init__.py +0 -0
- speedtest/engine/config.py +120 -0
- speedtest/engine/results.py +195 -0
- speedtest/engine/servers.py +189 -0
- speedtest/engine/transfer.py +157 -0
- speedtest/exceptions.py +78 -0
- speedtest/http/__init__.py +0 -0
- speedtest/http/connections.py +36 -0
- speedtest/http/errors.py +21 -0
- speedtest/http/handlers.py +105 -0
- speedtest/http/request.py +80 -0
- speedtest/http/response.py +23 -0
- speedtest/http/workers.py +128 -0
- speedtest/utils/__init__.py +0 -0
- speedtest/utils/logger.py +82 -0
- speedtest/utils/status.py +17 -0
- speedtest_cli_ng-0.1.1.data/data/share/man/man1/speedtest-cli.1 +128 -0
- speedtest_cli_ng-0.1.1.dist-info/METADATA +169 -0
- speedtest_cli_ng-0.1.1.dist-info/RECORD +32 -0
- speedtest_cli_ng-0.1.1.dist-info/WHEEL +5 -0
- speedtest_cli_ng-0.1.1.dist-info/entry_points.txt +3 -0
- speedtest_cli_ng-0.1.1.dist-info/licenses/LICENSE +202 -0
- speedtest_cli_ng-0.1.1.dist-info/licenses/NOTICE +6 -0
- speedtest_cli_ng-0.1.1.dist-info/top_level.txt +1 -0
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
|
speedtest/cli/output.py
ADDED
|
@@ -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}")
|
speedtest/cli/parser.py
ADDED
|
@@ -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
|