diff --git a/cf_speedtest/locations.py b/cf_speedtest/locations.py index 055aeae..29fa5e5 100644 --- a/cf_speedtest/locations.py +++ b/cf_speedtest/locations.py @@ -17,6 +17,22 @@ 'region': 'Africa', 'city': 'Algiers', }, + { + 'iata': 'AAE', + 'lat': 36.85596, + 'lon': 7.79207, + 'cca2': 'DZ', + 'region': 'Africa', + 'city': 'Annaba', + }, + { + 'iata': 'ORN', + 'lat': 35.6911, + 'lon': -0.6416, + 'cca2': 'DZ', + 'region': 'Africa', + 'city': 'Oran', + }, { 'iata': 'LAD', 'lat': -8.8583698273, @@ -169,6 +185,14 @@ 'region': 'Asia Pacific', 'city': 'Jashore', }, + { + 'iata': 'BGI', + 'lat': 13.103562, + 'lon': -59.603226, + 'cca2': 'BB', + 'region': 'North America', + 'city': 'Bridgetown', + }, { 'iata': 'MSQ', 'lat': 53.9006, @@ -193,6 +217,14 @@ 'region': 'Asia Pacific', 'city': 'Thimphu', }, + { + 'iata': 'LPB', + 'lat': -16.4897, + 'lon': -68.1193, + 'cca2': 'BO', + 'region': 'South America', + 'city': 'La Paz', + }, { 'iata': 'GBE', 'lat': -24.6282, @@ -209,6 +241,14 @@ 'region': 'South America', 'city': 'Americana', }, + { + 'iata': 'ARU', + 'lat': -21.1413002014, + 'lon': -50.4247016907, + 'cca2': 'BR', + 'region': 'South America', + 'city': 'Aracatuba', + }, { 'iata': 'BEL', 'lat': -1.4563, @@ -257,6 +297,22 @@ 'region': 'South America', 'city': 'Campinas', }, + { + 'iata': 'CAW', + 'lat': -21.698299408, + 'lon': -41.301700592, + 'cca2': 'BR', + 'region': 'South America', + 'city': 'Campos dos Goytacazes', + }, + { + 'iata': 'XAP', + 'lat': -27.1341991425, + 'lon': -52.6566009521, + 'cca2': 'BR', + 'region': 'South America', + 'city': 'Chapeco', + }, { 'iata': 'CGB', 'lat': -15.59611, @@ -329,6 +385,14 @@ 'region': 'South America', 'city': 'Manaus', }, + { + 'iata': 'PMW', + 'lat': -10.2915000916, + 'lon': -48.3569984436, + 'cca2': 'BR', + 'region': 'South America', + 'city': 'Palmas', + }, { 'iata': 'POA', 'lat': -29.9944000244, @@ -361,14 +425,6 @@ 'region': 'South America', 'city': 'Rio de Janeiro', }, - { - 'iata': 'SSA', - 'lat': -12.9086112976, - 'lon': -38.3224983215, - 'cca2': 'BR', - 'region': 'South America', - 'city': 'Salvador', - }, { 'iata': 'SJP', 'lat': -20.807157, @@ -481,6 +537,14 @@ 'region': 'North America', 'city': 'Winnipeg', }, + { + 'iata': 'YHZ', + 'lat': 44.64601, + 'lon': -63.66844, + 'cca2': 'CA', + 'region': 'North America', + 'city': 'Halifax', + }, { 'iata': 'YOW', 'lat': 45.3224983215, @@ -521,14 +585,6 @@ 'region': 'South America', 'city': 'Arica', }, - { - 'iata': 'CCP', - 'lat': -36.8201, - 'lon': -73.0444, - 'cca2': 'CL', - 'region': 'South America', - 'city': 'Concepción', - }, { 'iata': 'SCL', 'lat': -33.3930015564, @@ -537,13 +593,21 @@ 'region': 'South America', 'city': 'Santiago', }, + { + 'iata': 'BAQ', + 'lat': 10.8896, + 'lon': -74.7808, + 'cca2': 'CO', + 'region': 'South America', + 'city': 'Barranquilla', + }, { 'iata': 'BOG', 'lat': 4.70159, 'lon': -74.1469, 'cca2': 'CO', 'region': 'South America', - 'city': 'Bogotá', + 'city': 'Bogota', }, { 'iata': 'MDE', @@ -569,6 +633,22 @@ 'region': 'South America', 'city': 'San José', }, + { + 'iata': 'ABJ', + 'lat': 5.292598, + 'lon': -3.999133, + 'cca2': 'CI', + 'region': 'Africa', + 'city': 'Abidjan', + }, + { + 'iata': 'ASK', + 'lat': 6.842178, + 'lon': -5.259932, + 'cca2': 'CI', + 'region': 'Africa', + 'city': 'Yamoussoukro', + }, { 'iata': 'ZAG', 'lat': 45.7429008484, @@ -577,14 +657,6 @@ 'region': 'Europe', 'city': 'Zagreb', }, - { - 'iata': 'CUR', - 'lat': 12.1888999939, - 'lon': -68.9598007202, - 'cca2': 'CW', - 'region': 'South America', - 'city': 'Willemstad', - }, { 'iata': 'LCA', 'lat': 34.8750991821, @@ -617,6 +689,14 @@ 'region': 'Africa', 'city': 'Djibouti', }, + { + 'iata': 'STI', + 'lat': 19.4060993195, + 'lon': -70.6046981812, + 'cca2': 'DO', + 'region': 'North America', + 'city': 'Santiago de los Caballeros', + }, { 'iata': 'SDQ', 'lat': 18.4297008514, @@ -641,6 +721,14 @@ 'region': 'South America', 'city': 'Quito', }, + { + 'iata': 'CAI', + 'lat': 30.1219005585, + 'lon': 31.4055995941, + 'cca2': 'EG', + 'region': 'Africa', + 'city': 'Cairo', + }, { 'iata': 'TLL', 'lat': 59.4132995605, @@ -649,6 +737,14 @@ 'region': 'Europe', 'city': 'Tallinn', }, + { + 'iata': 'SUV', + 'lat': -18.11319, + 'lon': 178.43859, + 'cca2': 'FJ', + 'region': 'Oceania', + 'city': 'Suva', + }, { 'iata': 'HEL', 'lat': 60.317199707, @@ -657,6 +753,22 @@ 'region': 'Europe', 'city': 'Helsinki', }, + { + 'iata': 'BOD', + 'lat': 44.82946, + 'lon': -0.58355, + 'cca2': 'FR', + 'region': 'Europe', + 'city': 'Bordeaux', + }, + { + 'iata': 'LYS', + 'lat': 45.7263, + 'lon': 5.0908, + 'cca2': 'FR', + 'region': 'Europe', + 'city': 'Lyon', + }, { 'iata': 'MRS', 'lat': 43.439271922, @@ -793,14 +905,6 @@ 'region': 'South America', 'city': 'Georgetown', }, - { - 'iata': 'PAP', - 'lat': 18.5799999237, - 'lon': -72.2925033569, - 'cca2': 'HT', - 'region': 'North America', - 'city': 'Port-au-Prince', - }, { 'iata': 'TGU', 'lat': 14.0608, @@ -881,6 +985,14 @@ 'region': 'Asia Pacific', 'city': 'Hyderabad', }, + { + 'iata': 'CNN', + 'lat': 11.915858, + 'lon': 75.55094, + 'cca2': 'IN', + 'region': 'Asia Pacific', + 'city': 'Kannur', + }, { 'iata': 'KNU', 'lat': 26.4499, @@ -937,6 +1049,14 @@ 'region': 'Asia Pacific', 'city': 'Patna', }, + { + 'iata': 'DPS', + 'lat': -8.748169899, + 'lon': 115.1669998169, + 'cca2': 'ID', + 'region': 'Asia Pacific', + 'city': 'Denpasar', + }, { 'iata': 'CGK', 'lat': -6.1275229, @@ -1233,6 +1353,14 @@ 'region': 'Africa', 'city': 'Port Louis', }, + { + 'iata': 'GDL', + 'lat': 20.5217990875, + 'lon': -103.3109970093, + 'cca2': 'MX', + 'region': 'North America', + 'city': 'Guadalajara', + }, { 'iata': 'MEX', 'lat': 19.4363002777, @@ -1297,6 +1425,14 @@ 'region': 'Asia Pacific', 'city': 'Yangon', }, + { + 'iata': 'WDH', + 'lat': -22.565587, + 'lon': 17.085334, + 'cca2': 'NA', + 'region': 'Africa', + 'city': 'Windhoek', + }, { 'iata': 'KTM', 'lat': 27.6965999603, @@ -1345,6 +1481,14 @@ 'region': 'Africa', 'city': 'Lagos', }, + { + 'iata': 'SKP', + 'lat': 41.9616012573, + 'lon': 21.6214008331, + 'cca2': 'MK', + 'region': 'Europe', + 'city': 'Skopje', + }, { 'iata': 'OSL', 'lat': 60.193901062, @@ -1441,6 +1585,14 @@ 'region': 'Asia Pacific', 'city': 'Manila', }, + { + 'iata': 'CRK', + 'lat': 15.1859, + 'lon': 120.5599, + 'cca2': 'PH', + 'region': 'Asia Pacific', + 'city': 'Tarlac City', + }, { 'iata': 'WAW', 'lat': 52.1656990051, @@ -1457,6 +1609,14 @@ 'region': 'Europe', 'city': 'Lisbon', }, + { + 'iata': 'SJU', + 'lat': 18.411391, + 'lon': -66.102793, + 'cca2': 'PR', + 'region': 'North America', + 'city': 'San Juan', + }, { 'iata': 'DOH', 'lat': 25.2605946, @@ -1481,14 +1641,6 @@ 'region': 'Europe', 'city': 'Bucharest', }, - { - 'iata': 'KHV', - 'lat': 48.5279998779, - 'lon': 135.18800354, - 'cca2': 'RU', - 'region': 'Asia Pacific', - 'city': 'Khabarovsk', - }, { 'iata': 'KJA', 'lat': 56.0153, @@ -1729,6 +1881,14 @@ 'region': 'Asia Pacific', 'city': 'Surat Thani', }, + { + 'iata': 'POS', + 'lat': 10.5953998566, + 'lon': -61.3372001648, + 'cca2': 'TT', + 'region': 'South America', + 'city': 'Port of Spain', + }, { 'iata': 'TUN', 'lat': 36.8510017395, @@ -1753,6 +1913,14 @@ 'region': 'Europe', 'city': 'Izmir', }, + { + 'iata': 'EBB', + 'lat': 0.3152, + 'lon': 32.5816, + 'cca2': 'UG', + 'region': 'Africa', + 'city': 'KAMPALA', + }, { 'iata': 'KBP', 'lat': 50.3450012207, @@ -1801,6 +1969,14 @@ 'region': 'North America', 'city': 'Montgomery', }, + { + 'iata': 'ANC', + 'lat': 61.158555, + 'lon': -149.890208, + 'cca2': 'US', + 'region': 'North America', + 'city': 'Anchorage', + }, { 'iata': 'PHX', 'lat': 33.434299469, @@ -1993,6 +2169,14 @@ 'region': 'North America', 'city': 'Newark', }, + { + 'iata': 'ABQ', + 'lat': 35.0844, + 'lon': -106.6504, + 'cca2': 'US', + 'region': 'North America', + 'city': 'Albuquerque', + }, { 'iata': 'BUF', 'lat': 42.94049835, @@ -2009,6 +2193,22 @@ 'region': 'North America', 'city': 'Charlotte', }, + { + 'iata': 'RDU', + 'lat': 35.93543, + 'lon': -78.88075, + 'cca2': 'US', + 'region': 'North America', + 'city': 'Durham', + }, + { + 'iata': 'CLE', + 'lat': 41.50069, + 'lon': -81.68412, + 'cca2': 'US', + 'region': 'North America', + 'city': 'Cleveland', + }, { 'iata': 'CMH', 'lat': 39.9980010986, @@ -2017,6 +2217,14 @@ 'region': 'North America', 'city': 'Columbus', }, + { + 'iata': 'OKC', + 'lat': 35.46655, + 'lon': -97.65373, + 'cca2': 'US', + 'region': 'North America', + 'city': 'Oklahoma City', + }, { 'iata': 'PDX', 'lat': 45.58869934, @@ -2097,6 +2305,14 @@ 'region': 'North America', 'city': 'McAllen', }, + { + 'iata': 'SAT', + 'lat': 29.429461, + 'lon': -98.487061, + 'cca2': 'US', + 'region': 'North America', + 'city': 'San Antonio', + }, { 'iata': 'SLC', 'lat': 40.7883987427, @@ -2145,6 +2361,14 @@ 'region': 'Asia Pacific', 'city': 'Tashkent', }, + { + 'iata': 'DAD', + 'lat': 16.02636, + 'lon': 108.20869, + 'cca2': 'VN', + 'region': 'Asia Pacific', + 'city': 'Da Nang', + }, { 'iata': 'HAN', 'lat': 21.221200943, diff --git a/cf_speedtest/options.py b/cf_speedtest/options.py index f16a535..77ecf19 100644 --- a/cf_speedtest/options.py +++ b/cf_speedtest/options.py @@ -69,10 +69,22 @@ def add_run_options(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: ) parser.add_argument( - '--testpatience', + '--testpatience', '-t', type=int, default=20, - help='The longest time to wait for an individual test to run', + help='The longest time to wait for an individual test to run. NOTICE: When used with --disableskipping, --testpatience will be ignored', + ) + + parser.add_argument( + '--disableskipping', '-s', + action='store_true', + help='Dont skip any speed test. This will ignore any --testpatience setting ', + ) + + parser.add_argument( + '--json', '-j', + action='store_true', + help='Output results of tests in JSON format. NOTICE: When using this option, output will be delayed until the execution is finished', ) return parser diff --git a/cf_speedtest/speedtest.py b/cf_speedtest/speedtest.py index df8f8dd..8e5ddf3 100644 --- a/cf_speedtest/speedtest.py +++ b/cf_speedtest/speedtest.py @@ -4,6 +4,7 @@ import argparse import math import statistics +import json import time from timeit import default_timer as timer @@ -29,6 +30,7 @@ PROXY_DICT = None VERIFY_SSL = True OUTPUT_FILE = None +JSON_STDOUT = {} # Could use python's statistics library, but quantiles are only available # in version 3.8 and above @@ -36,16 +38,24 @@ def percentile(data: list, percentile: int) -> float: size = len(data) + if percentile == 0: + return min(data) return sorted(data)[int(math.ceil((size * percentile) / 100)) - 1] # returns ms of how long cloudflare took to process the request, this is in the Server-Timing header def get_server_timing(server_timing: str) -> float: - split = server_timing.split(';') - for part in split: + for part in server_timing.split(';'): if 'dur=' in part: - return float(part.split('=')[1]) / 1000 + try: + return float(part.split('=')[1]) / 1000 + except (IndexError, ValueError): + try: + return float(part.split(',')[0].split('=')[1]) / 1000 + except (IndexError, ValueError): + pass + return 0.0 # given an amount of bytes, upload it and return the elapsed seconds taken @@ -135,7 +145,7 @@ def get_our_country() -> str: return cgi_dict.get('loc') or 'Unknown' -def preamble() -> str: +def preamble(json_output) -> str: r = REQ_SESSION.get( DOWNLOAD_ENDPOINT.format( 0, @@ -153,9 +163,12 @@ def preamble() -> str: ) or 'Unknown' for loc in locations.SERVER_LOCATIONS if loc['iata'] == colo.upper() ), 'Unknown', ) - preamble_str = f'Your IP:\t{our_ip} ({get_our_country()})\nServer loc:\t{server_city} ({colo}) - ({server_country})' - - return preamble_str + if json_output: + JSON_STDOUT['location'] = {'my_ip_addr': our_ip, "country": get_our_country(), 'server_city': server_city, + 'colocation': colo, 'server_country': server_country} + else: + preamble_str = f'Your IP:\t{our_ip} ({get_our_country()})\nServer loc:\t{server_city} ({colo}) - ({server_country})' + return preamble_str # runs x amount of y-byte tests, given a test_type ("down" or "up") # returns a list of measurements in bits per second @@ -183,6 +196,8 @@ def run_tests(test_type: str, bytes_to_xfer: int, iteration_count: int = 8) -> l def run_standard_test( measurement_sizes: list, + disable_tests_skip: bool, + json_output: bool, measurement_percentile: int = 90, verbose: bool = False, test_patience: int = 15, @@ -192,7 +207,10 @@ def run_standard_test( UPLOAD_MEASUREMENTS = [] if verbose: - print(preamble(), '\n') + if json_output: + preamble(json_output) + else: + print(preamble(json_output), '\n') latency_test() # ignore first request as it contains http connection setup for i in range(0, 20): @@ -202,9 +220,13 @@ def run_standard_test( latency = percentile(LATENCY_MEASUREMENTS, 50) jitter = statistics.stdev(LATENCY_MEASUREMENTS) if verbose: - print(f"{'Latency:':<16} {latency:.2f} ms") - print(f"{'Jitter:':<16} {jitter:.2f} ms") - print('Running speed tests...\n') + if json_output: + JSON_STDOUT['latency'] = f"{latency:.2f}" + JSON_STDOUT['jitter'] = f"{jitter:.2f}" + else: + print(f"{'Latency:':<16} {latency:.2f} ms") + print(f"{'Jitter:':<16} {jitter:.2f} ms") + print('Running speed tests...\n') first_dl_test, first_ul_test = True, True continue_dl_test, continue_ul_test = True, True @@ -218,9 +240,11 @@ def run_standard_test( upload_test_count = (-2 * i + 10) # this is how the website does it total_download_bytes = measurement * download_test_count total_upload_bytes = measurement * upload_test_count + if not 'tests' in JSON_STDOUT: + JSON_STDOUT['tests'] = {} if not first_dl_test: - if current_down_speed_mbps * test_patience < total_download_bytes / 125000: + if (current_down_speed_mbps * test_patience < total_download_bytes / 125000) and (not disable_tests_skip): continue_dl_test = False else: first_dl_test = False @@ -235,14 +259,19 @@ def run_standard_test( DOWNLOAD_MEASUREMENTS, measurement_percentile, ) / 1_000_000 if verbose: - # print(f"Current down: {current_down_speed_mbps:.2f} Mbit/sec") - print( - f"{'Current speeds:':<24} {'Down: '}{current_down_speed_mbps:.2f} Mbit/sec\t" - f"{'Up: '}{current_up_speed_mbps:.2f} Mbit/sec", - ) + if json_output: + if not str(measurement) in JSON_STDOUT['tests']: + JSON_STDOUT['tests'][str(measurement)] = {} + JSON_STDOUT['tests'][str(measurement)]['download'] = f"{current_down_speed_mbps:.2f}" + else: + # print(f"Current down: {current_down_speed_mbps:.2f} Mbit/sec") + print( + f"{'Current speeds:':<24} {'Down: '}{current_down_speed_mbps:.2f} Mbit/sec\t" + f"{'Up: '}{current_up_speed_mbps:.2f} Mbit/sec", + ) if not first_ul_test: - if current_up_speed_mbps * test_patience < total_upload_bytes / 125_000: + if (current_up_speed_mbps * test_patience < total_upload_bytes / 125_000) and (not disable_tests_skip): continue_ul_test = False else: first_ul_test = False @@ -257,11 +286,16 @@ def run_standard_test( UPLOAD_MEASUREMENTS, measurement_percentile, ) / 1_000_000 if verbose: - # print(f"Current up: {current_up_speed_mbps:.2f} Mbit/sec") - print( - f"{'Current speeds:':<24} {'Down: '}{current_down_speed_mbps:.2f} Mbit/sec\t" - f"{'Up: '}{current_up_speed_mbps:.2f} Mbit/sec", - ) + if json_output: + if not str(measurement) in JSON_STDOUT['tests']: + JSON_STDOUT['tests'][str(measurement)] = {} + JSON_STDOUT['tests'][str(measurement)]['upload'] = f"{current_up_speed_mbps:.2f}" + else: + # print(f"Current up: {current_up_speed_mbps:.2f} Mbit/sec") + print( + f"{'Current speeds:':<24} {'Down: '}{current_down_speed_mbps:.2f} Mbit/sec\t" + f"{'Up: '}{current_up_speed_mbps:.2f} Mbit/sec", + ) # all raw measurements are in bits per second pctile_download = percentile(DOWNLOAD_MEASUREMENTS, measurement_percentile) @@ -293,6 +327,8 @@ def main(argv=None) -> int: VERIFY_SSL = args.verifyssl OUTPUT_FILE = args.output patience = args.testpatience + disable_tests_skip = args.disableskipping + json_output = args.json proxy = args.proxy # clear the output file @@ -325,17 +361,22 @@ def main(argv=None) -> int: 250_000_000, ] - speeds = run_standard_test(measurement_sizes, percentile, True, patience) + speeds = run_standard_test(measurement_sizes, disable_tests_skip, json_output, percentile, True, patience) d = speeds['download_speed'] u = speeds['upload_speed'] d_s = speeds['download_stdev'] # noqa u_s = speeds['upload_stdev'] # noqa - print( - f"{args.percentile}{'th percentile results:':<24} Down: {d/1_000_000:.2f} Mbit/sec\t" - f'Up: {u/1_000_000:.2f} Mbit/sec', - ) + if json_output: + JSON_STDOUT[f"{args.percentile}_percentile"] = {'download': f"{d/1_000_000:.2f}", 'upload': f"{u/1_000_000:.2f}"} + json_string = json.dumps(JSON_STDOUT, indent=4) + print(json_string) + else: + print( + f"{args.percentile}{'th percentile results:':<24} Down: {d/1_000_000:.2f} Mbit/sec\t" + f'Up: {u/1_000_000:.2f} Mbit/sec', + ) return 0 diff --git a/requirements-dev.txt b/requirements-dev.txt index fabb588..5eb2cb9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,4 @@ pre-commit +pytest requests[socks] types-requests diff --git a/setup.cfg b/setup.cfg index c3d8029..71f208e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = cf_speedtest -version = 0.1.7 +version = 0.1.9 description = Command-line internet speed test long_description = README.md long_description_content_type = text/markdown @@ -17,6 +17,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 [options] python_requires = >=3.6.0 @@ -33,5 +34,5 @@ exclude = [options.entry_points] console_scripts = - cf-speedtest = cf_speedtest.cf_speedtest:main - cf_speedtest = cf_speedtest.cf_speedtest:main + cf-speedtest = cf_speedtest.speedtest:main + cf_speedtest = cf_speedtest.speedtest:main diff --git a/tests/all_test.py b/tests/all_test.py deleted file mode 100644 index 0585496..0000000 --- a/tests/all_test.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from cf_speedtest import speedtest - - -def test_country(): - speedtest.get_our_country() - - -def test_preamble(): - speedtest.preamble() - - -def test_main(): - assert speedtest.main() == 0 - - -'''python -def test_proxy(): - assert cf_speedtest.main(['--proxy', '100.24.216.83:80']) == 0 -''' - - -def test_nossl(): - assert speedtest.main(['--verifyssl', 'False']) == 0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..67e4a2b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_time(): + with patch('cf_speedtest.speedtest.time') as mock_time: + mock_time.time.return_value = 1234567890.0 + yield mock_time + + +@pytest.fixture +def mock_requests_session(): + with patch('cf_speedtest.speedtest.REQ_SESSION') as mock_session: + yield mock_session diff --git a/tests/integration_test.py b/tests/integration_test.py new file mode 100644 index 0000000..ed10db6 --- /dev/null +++ b/tests/integration_test.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import csv +import os + +import pytest + +from cf_speedtest import speedtest + + +@pytest.mark.integration +def test_country(): + country = speedtest.get_our_country() + assert isinstance(country, str) + assert len(country) == 2 # Assuming country codes are always 2 characters + + +@pytest.mark.integration +def test_preamble(): + preamble_text = speedtest.preamble(False) + assert isinstance(preamble_text, str) + assert 'Your IP:' in preamble_text + assert 'Server loc:' in preamble_text + + +@pytest.mark.integration +def test_main(): + assert speedtest.main() == 0 + + +@pytest.mark.integration +@pytest.mark.skip(reason='will fail without proxy') +def test_proxy(): + assert speedtest.main(['--proxy', '100.24.216.83:80']) == 0 + + +@pytest.mark.integration +def test_nossl(): + assert speedtest.main(['--verifyssl', 'False']) == 0 + + +@pytest.mark.integration +def test_csv_output(): + temp_file = 'test_output.csv' + + assert speedtest.main(['--output', temp_file]) == 0 + + assert os.path.exists(temp_file) + assert os.path.getsize(temp_file) > 0 + + with open(temp_file) as csvfile: + try: + csv.reader(csvfile) + next(csv.reader(csvfile)) + except csv.Error: + pytest.fail('The output file is not a valid CSV') + + os.remove(temp_file) diff --git a/tests/network_test.py b/tests/network_test.py new file mode 100644 index 0000000..d668c09 --- /dev/null +++ b/tests/network_test.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from cf_speedtest import speedtest + + +@pytest.fixture +def mock_requests_session(): + with patch('cf_speedtest.speedtest.REQ_SESSION') as mock_session: + yield mock_session + + +def test_get_our_country(mock_requests_session): + mock_response = MagicMock() + mock_response.text = 'loc=GB\nother=value' + mock_requests_session.get.return_value = mock_response + + assert speedtest.get_our_country() == 'GB' + + +def test_preamble_unit(mock_requests_session): + mock_response = MagicMock() + mock_response.headers = { + 'cf-meta-ip': '1.2.3.4', + 'cf-meta-colo': 'LAX', + } + mock_requests_session.get.return_value = mock_response + + with patch('cf_speedtest.speedtest.get_our_country', return_value='US'): + result = speedtest.preamble(False) + assert '1.2.3.4' in result + assert 'LAX' in result + assert 'US' in result diff --git a/tests/options_test.py b/tests/options_test.py new file mode 100644 index 0000000..dae1e9c --- /dev/null +++ b/tests/options_test.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest + +from cf_speedtest import options +from cf_speedtest import speedtest + + +@pytest.mark.parametrize( + 'input_str, expected', [ + ('yes', True), + ('no', False), + ('true', True), + ('false', False), + ('1', True), + ('0', False), + ('YES', True), + ('NO', False), + ('True', True), + ('False', False), + ('y', True), + ('n', False), + ], +) +def test_str_to_bool_valid(input_str, expected): + assert options.str_to_bool(input_str) == expected + + +@pytest.mark.parametrize('input_str', ['invalid', 'maybe', '2', '-1']) +def test_str_to_bool_invalid(input_str): + with pytest.raises(speedtest.argparse.ArgumentTypeError): + options.str_to_bool(input_str) + + +@pytest.mark.parametrize( + 'input_str, expected', [ + ('0', 0), + ('50', 50), + ('100', 100), + ], +) +def test_valid_percentile_valid(input_str, expected): + assert options.valid_percentile(input_str) == expected + + +@pytest.mark.parametrize('input_str', ['-1', '101', 'invalid', '50.5']) +def test_valid_percentile_invalid(input_str): + with pytest.raises(speedtest.argparse.ArgumentTypeError): + options.valid_percentile(input_str) diff --git a/tests/speedtest_test.py b/tests/speedtest_test.py new file mode 100644 index 0000000..cc8f584 --- /dev/null +++ b/tests/speedtest_test.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from cf_speedtest import speedtest + + +@pytest.mark.parametrize( + 'test_type, bytes_to_xfer, iteration_count, expected_len', [ + ('down', 1000, 3, 3), + ('up', 1000, 5, 5), + ('invalid', 1000, 2, 0), + ], +) +def test_run_tests(test_type, bytes_to_xfer, iteration_count, expected_len): + with patch('cf_speedtest.speedtest.download_test', return_value=(1000, 0.1)), \ + patch('cf_speedtest.speedtest.upload_test', return_value=(1000, 0.2)): + + results = speedtest.run_tests( + test_type, bytes_to_xfer, iteration_count, + ) + assert len(results) == expected_len + + if test_type == 'down': + assert results[0] == 80000 # (1000 / 0.1) * 8 + elif test_type == 'up': + assert results[0] == 40000 # (1000 / 0.2) * 8 + + +@patch('cf_speedtest.speedtest.run_tests') +@patch('cf_speedtest.speedtest.latency_test') +@patch('cf_speedtest.speedtest.preamble') +def test_run_standard_test(mock_preamble, mock_latency_test, mock_run_tests): + mock_latency_test.return_value = 0.05 + mock_run_tests.side_effect = [ + [100000000, 200000000], # download + [50000000, 100000000], # upload + ] + + results = speedtest.run_standard_test( + [1000000], disable_tests_skip=False, json_output=False, measurement_percentile=90, verbose=True, + ) + + assert results['download_speed'] == 200000000 + assert results['upload_speed'] == 100000000 + assert len(results['latency_measurements']) == 20 + + +@pytest.mark.parametrize( + 'args, expected_exit_code', [ + (['--percentile', '90', '--verifyssl', 'False', '--testpatience', '10'], 0), + (['--proxy', '100.24.216.83:80'], 0), + (['--output', 'test_output.csv'], 0), + ], +) +def test_main_unit(args, expected_exit_code, mock_requests_session): + with patch('cf_speedtest.speedtest.run_standard_test') as mock_run_test: + mock_run_test.return_value = { + 'download_speed': 100000000, + 'upload_speed': 50000000, + 'download_stdev': 1000000, + 'upload_stdev': 500000, + 'latency_measurements': [10, 20, 30], + 'download_measurements': [90000000, 100000000, 110000000], + 'upload_measurements': [45000000, 50000000, 55000000], + } + + assert speedtest.main(args) == expected_exit_code + + +@pytest.mark.parametrize( + 'proxy, expected_dict', [ + ( + '100.24.216.83:80', { + 'http': 'http://100.24.216.83:80', 'https': 'http://100.24.216.83:80', + }, + ), + ( + 'socks5://127.0.0.1:9150', + {'http': 'socks5://127.0.0.1:9150', 'https': 'socks5://127.0.0.1:9150'}, + ), + ( + 'http://user:pass@10.10.1.10:3128', + { + 'http': 'http://user:pass@10.10.1.10:3128', + 'https': 'http://user:pass@10.10.1.10:3128', + }, + ), + ], +) +def test_proxy_unit(proxy, expected_dict): + with patch('cf_speedtest.speedtest.run_standard_test') as mock_run_test: + mock_run_test.return_value = { + 'download_speed': 100000000, + 'upload_speed': 50000000, + 'download_stdev': 1000000, + 'upload_stdev': 500000, + 'latency_measurements': [10, 20, 30], + 'download_measurements': [90000000, 100000000, 110000000], + 'upload_measurements': [45000000, 50000000, 55000000], + } + + speedtest.main(['--proxy', proxy]) + assert speedtest.PROXY_DICT == expected_dict + + +def test_output_file(mock_time): + output_file = 'test_output.csv' + + with patch('cf_speedtest.speedtest.run_standard_test') as mock_run_test, \ + patch('builtins.open', create=True) as mock_open: + mock_run_test.return_value = { + 'download_speed': 100000000, + 'upload_speed': 50000000, + 'download_stdev': 1000000, + 'upload_stdev': 500000, + 'latency_measurements': [10, 20, 30], + 'download_measurements': [90000000, 100000000, 110000000], + 'upload_measurements': [45000000, 50000000, 55000000], + } + + speedtest.main(['--output', output_file]) + + mock_open.assert_called_with(output_file, 'w') + + if os.path.exists(output_file): + os.remove(output_file) diff --git a/tests/utils_test.py b/tests/utils_test.py new file mode 100644 index 0000000..18c2b9f --- /dev/null +++ b/tests/utils_test.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import pytest + +from cf_speedtest import speedtest + + +@pytest.mark.parametrize( + 'data, percentile, expected', [ + ([1, 2, 3, 4, 5], 50, 3), + ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 90, 9), + ([1, 1, 1, 1, 1], 100, 1), + ([1, 2, 3, 4, 5], 0, 1), + ], +) +def test_percentile(data, percentile, expected): + assert speedtest.percentile(data, percentile) == expected + + +@pytest.mark.parametrize( + 'server_timing, expected', [ + ('dur=1234.5', 1.2345), + ('key=value;dur=5678.9', 5.6789), + ('invalid', 0.0), + ('dur=1000', 1.0), + ('start=0;dur=500;desc="Backend"', 0.5), + ], +) +def test_get_server_timing(server_timing, expected): + assert speedtest.get_server_timing(server_timing) == expected diff --git a/tox.ini b/tox.ini index b687adb..179c087 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37,py38,py39,py310,py311 +envlist = py36,py37,py38,py39,py310,py311,py312 [testenv] deps = pytest