diff --git a/.travis.yml b/.travis.yml index 8863201..c40859c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python - -python: - - 2.7 +sudo: required +dist: xenial addons: apt: @@ -11,26 +10,52 @@ addons: - python2.4 - python2.5 - python2.6 - - pypy + - python3.2 + - python3.3 -env: - - TOXENV=py24 - - TOXENV=py25 - - TOXENV=py26 - - TOXENV=py27 - - TOXENV=py32 - - TOXENV=py33 - - TOXENV=py34 - - TOXENV=py35 - - TOXENV=pypy - - TOXENV=flake8 +matrix: + include: + - python: 2.7 + env: TOXENV=flake8 + - python: 2.7 + env: TOXENV=py24 + - python: 2.7 + env: TOXENV=py25 + - python: 2.7 + env: TOXENV=py26 + - python: 2.7 + env: TOXENV=py27 + - python: 2.7 + env: TOXENV=py32 + - python: 2.7 + env: TOXENV=py33 + - python: 3.4 + env: TOXENV=py34 + - python: 3.5 + env: TOXENV=py35 + - python: 3.6 + env: TOXENV=py36 + - python: 3.7 + env: TOXENV=py37 + - python: 3.8-dev + env: TOXENV=py38 + - python: pypy + env: TOXENV=pypy + dist: trusty + +before_install: + - if [[ $(echo "$TOXENV" | egrep -c "py35") != 0 ]]; then pyenv global system 3.5; fi; install: - - if [[ $(echo "$TOXENV" | egrep -c "(py2[45]|py3[12])") != 0 ]]; then pip install virtualenv==1.7.2 tox==1.3; fi; - - if [[ $(echo "$TOXENV" | egrep -c "(py2[45]|py3[12])") == 0 ]]; then pip install tox; fi; + - if [[ $(echo "$TOXENV" | egrep -c "py32") != 0 ]]; then pip install setuptools==17.1.1; fi; + - if [[ $(echo "$TOXENV" | egrep -c "(py2[45]|py3[12])") != 0 ]]; then pip install virtualenv==1.7.2 tox==1.3; fi; + - if [[ $(echo "$TOXENV" | egrep -c "(py26|py33)") != 0 ]]; then pip install virtualenv==15.2.0 tox==2.9.1; fi; + - if [[ $(echo "$TOXENV" | egrep -c "(py2[456]|py3[123])") == 0 ]]; then pip install tox; fi; + + script: - - tox + - tox notifications: email: diff --git a/README.rst b/README.rst index 0043b5c..7a98f31 100644 --- a/README.rst +++ b/README.rst @@ -7,9 +7,9 @@ speedtest.net .. image:: https://img.shields.io/pypi/v/speedtest-cli.svg :target: https://pypi.python.org/pypi/speedtest-cli/ :alt: Latest Version -.. image:: https://img.shields.io/pypi/dm/speedtest-cli.svg +.. image:: https://img.shields.io/travis/sivel/speedtest-cli.svg :target: https://pypi.python.org/pypi/speedtest-cli/ - :alt: Downloads + :alt: Travis .. image:: https://img.shields.io/pypi/l/speedtest-cli.svg :target: https://pypi.python.org/pypi/speedtest-cli/ :alt: License @@ -17,7 +17,7 @@ speedtest.net Versions -------- -speedtest-cli works with Python 2.4-3.5 +speedtest-cli works with Python 2.4-3.7 .. image:: https://img.shields.io/pypi/pyversions/speedtest-cli.svg :target: https://pypi.python.org/pypi/speedtest-cli/ @@ -51,7 +51,8 @@ or :: git clone https://github.com/sivel/speedtest-cli.git - python speedtest-cli/setup.py install + cd speedtest-cli + python setup.py install Just download (Like the way it used to be) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -74,9 +75,10 @@ Usage :: $ speedtest-cli -h - usage: speedtest-cli [-h] [--no-download] [--no-upload] [--bytes] [--share] - [--simple] [--csv] [--csv-delimiter CSV_DELIMITER] - [--csv-header] [--json] [--list] [--server SERVER] + usage: speedtest-cli [-h] [--no-download] [--no-upload] [--single] [--bytes] + [--share] [--simple] [--csv] + [--csv-delimiter CSV_DELIMITER] [--csv-header] [--json] + [--list] [--server SERVER] [--exclude EXCLUDE] [--mini MINI] [--source SOURCE] [--timeout TIMEOUT] [--secure] [--no-pre-allocate] [--version] @@ -88,6 +90,8 @@ Usage -h, --help show this help message and exit --no-download Do not perform download test --no-upload Do not perform upload test + --single Only use a single connection instead of multiple. This + simulates a typical file transfer. --bytes Display values in bytes instead of bits. Does not affect the image generated by --share, nor output from --json or --csv @@ -106,7 +110,10 @@ Usage affected by --bytes --list Display a list of speedtest.net servers sorted by distance - --server SERVER Specify a server ID to test against + --server SERVER Specify a server ID to test against. Can be supplied + multiple times + --exclude EXCLUDE Exclude a server from selection. Can be supplied + multiple times --mini MINI URL of the Speedtest Mini server --source SOURCE Source IP address to bind to --timeout TIMEOUT HTTP timeout in seconds. Default 10 diff --git a/setup.py b/setup.py index 00a8054..a617be4 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright 2012-2016 Matt Martz +# Copyright 2012 Matt Martz # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -66,7 +66,7 @@ setup( author_email='matt@sivel.net', url='https://github.com/sivel/speedtest-cli', license='Apache License, Version 2.0', - py_modules=['speedtest', 'speedtest_cli'], + py_modules=['speedtest'], entry_points={ 'console_scripts': [ 'speedtest=speedtest:main', @@ -90,5 +90,7 @@ setup( 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', ] ) diff --git a/speedtest-cli.1 b/speedtest-cli.1 index 9e1befe..367c977 100644 --- a/speedtest-cli.1 +++ b/speedtest-cli.1 @@ -1,4 +1,4 @@ -.TH "speedtest-cli" 1 "2014-04-23" "speedtest-cli" +.TH "speedtest-cli" 1 "2018-01-05" "speedtest-cli" .SH NAME speedtest\-cli \- Command line interface for testing internet bandwidth using speedtest.net .SH SYNOPSIS @@ -23,14 +23,29 @@ Displays usage for the tool. .B Options +\fB\-\-no\-download\fR +.RS +Do not perform download test +.RE + +\fB\-\-no\-upload\fR +.RS +Do not perform upload test +.RE + +\fB\-\-single\fR +.RS +Only use a single connection instead of multiple. This simulates a typical file transfer. +.RE + \fB\-\-bytes\fR .RS -Display values in bytes instead of bits. Does not affect the image generated by \-\-share +Display values in bytes instead of bits. Does not affect the image generated by \-\-share, nor output from \-\-json or \-\-csv .RE \fB\-\-share\fR .RS -Generate and provide a URL to the speedtest.net share results image +Generate and provide a URL to the speedtest.net share results image, not displayed with \-\-csv .RE \fB\-\-simple\fR @@ -43,12 +58,12 @@ Suppress verbose output, only show basic information Suppress verbose output, only show basic information in CSV format. Speeds listed in bit/s and not affected by \-\-bytes .RE -\fB\-\-csv-delimiter CSV_DELIMITER\fR +\fB\-\-csv\-delimiter CSV_DELIMITER\fR .RS Single character delimiter to use in CSV output. Default "," .RE -\fB\-\-csv-header\fR +\fB\-\-csv\-header\fR .RS Print CSV headers .RE @@ -65,7 +80,12 @@ Display a list of speedtest.net servers sorted by distance \fB\-\-server SERVER\fR .RS -Specify a server ID to test against +Specify a server ID to test against. Can be supplied multiple times +.RE + +\fB\-\-exclude EXCLUDE\fR +.RS +Exclude a server from selection. Can be supplied multiple times .RE \fB\-\-mini MINI\fR @@ -88,6 +108,11 @@ HTTP timeout in seconds. Default 10 Use HTTPS instead of HTTP when communicating with speedtest.net operated servers .RE +\fB\-\-no\-pre\-allocate\fR +.RS +Do not pre allocate upload data. Pre allocation is enabled by default to improve upload performance. To support systems with insufficient memory, use this option to avoid a MemoryError +.RE + \fB\-\-version\fR .RS Show the version number and exit diff --git a/speedtest.py b/speedtest.py index 92246d1..a004313 100755 --- a/speedtest.py +++ b/speedtest.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright 2012-2016 Matt Martz +# Copyright 2012 Matt Martz # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -36,7 +36,7 @@ except ImportError: gzip = None GZIP_BASE = object -__version__ = '1.0.7' +__version__ = '2.1.2a' class FakeShutdownEvent(object): @@ -51,14 +51,11 @@ class FakeShutdownEvent(object): # Some global variables we use -USER_AGENT = None -SOURCE = None -SHUTDOWN_EVENT = FakeShutdownEvent() -SCHEME = 'http' DEBUG = False - -# Used for bound_interface -SOCKET_SOCKET = socket.socket +_GLOBAL_DEFAULT_TIMEOUT = object() +PY25PLUS = sys.version_info[:2] >= (2, 5) +PY26PLUS = sys.version_info[:2] >= (2, 6) +PY32PLUS = sys.version_info[:2] >= (3, 2) # Begin import game to handle Python 2 and Python 3 try: @@ -70,23 +67,31 @@ except ImportError: json = None try: - import xml.etree.cElementTree as ET -except ImportError: + import xml.etree.ElementTree as ET try: - import xml.etree.ElementTree as ET + from xml.etree.ElementTree import _Element as ET_Element except ImportError: - from xml.dom import minidom as DOM - ET = None + pass +except ImportError: + from xml.dom import minidom as DOM + from xml.parsers.expat import ExpatError + ET = None try: - from urllib2 import urlopen, Request, HTTPError, URLError + from urllib2 import (urlopen, Request, HTTPError, URLError, + AbstractHTTPHandler, ProxyHandler, + HTTPDefaultErrorHandler, HTTPRedirectHandler, + HTTPErrorProcessor, OpenerDirector) except ImportError: - from urllib.request import urlopen, Request, HTTPError, URLError + from urllib.request import (urlopen, Request, HTTPError, URLError, + AbstractHTTPHandler, ProxyHandler, + HTTPDefaultErrorHandler, HTTPRedirectHandler, + HTTPErrorProcessor, OpenerDirector) try: - from httplib import HTTPConnection + from httplib import HTTPConnection, BadStatusLine except ImportError: - from http.client import HTTPConnection + from http.client import HTTPConnection, BadStatusLine try: from httplib import HTTPSConnection @@ -96,6 +101,11 @@ except ImportError: except ImportError: HTTPSConnection = None +try: + from httplib import FakeSocket +except ImportError: + FakeSocket = None + try: from Queue import Queue except ImportError: @@ -124,11 +134,13 @@ try: from argparse import SUPPRESS as ARG_SUPPRESS PARSER_TYPE_INT = int PARSER_TYPE_STR = str + PARSER_TYPE_FLOAT = float except ImportError: from optparse import OptionParser as ArgParser from optparse import SUPPRESS_HELP as ARG_SUPPRESS PARSER_TYPE_INT = 'int' PARSER_TYPE_STR = 'string' + PARSER_TYPE_FLOAT = 'float' try: from cStringIO import StringIO @@ -146,24 +158,31 @@ except ImportError: import builtins from io import TextIOWrapper, FileIO - class _Py3Utf8Stdout(TextIOWrapper): + class _Py3Utf8Output(TextIOWrapper): """UTF-8 encoded wrapper around stdout for py3, to override ASCII stdout """ - def __init__(self, **kwargs): - buf = FileIO(sys.stdout.fileno(), 'w') - super(_Py3Utf8Stdout, self).__init__( + def __init__(self, f, **kwargs): + buf = FileIO(f.fileno(), 'w') + super(_Py3Utf8Output, self).__init__( buf, encoding='utf8', errors='strict' ) def write(self, s): - super(_Py3Utf8Stdout, self).write(s) + super(_Py3Utf8Output, self).write(s) self.flush() _py3_print = getattr(builtins, 'print') - _py3_utf8_stdout = _Py3Utf8Stdout() + try: + _py3_utf8_stdout = _Py3Utf8Output(sys.stdout) + _py3_utf8_stderr = _Py3Utf8Output(sys.stderr) + except OSError: + # sys.stdout/sys.stderr is not a compatible stdout/stderr object + # just use it and hope things go ok + _py3_utf8_stdout = sys.stdout + _py3_utf8_stderr = sys.stderr def to_utf8(v): """No-op encode to utf-8 for py3""" @@ -171,7 +190,10 @@ except ImportError: def print_(*args, **kwargs): """Wrapper function for py3 to print, with a utf-8 encoded stdout""" - kwargs['file'] = _py3_utf8_stdout + if kwargs.get('file') == sys.stderr: + kwargs['file'] = _py3_utf8_stderr + else: + kwargs['file'] = kwargs.get('file', _py3_utf8_stdout) _py3_print(*args, **kwargs) else: del __builtin__ @@ -188,7 +210,7 @@ else: Taken from https://pypi.python.org/pypi/six/ - Modified to set encoding to UTF-8 always + Modified to set encoding to UTF-8 always, and to flush after write """ fp = kwargs.pop("file", sys.stdout) if fp is None: @@ -207,6 +229,7 @@ else: errors = "strict" data = data.encode(encoding, errors) fp.write(data) + fp.flush() want_unicode = False sep = kwargs.pop("sep", None) if sep is not None: @@ -243,6 +266,16 @@ else: write(arg) write(end) +if PY32PLUS: + etree_iter = ET.Element.iter +elif PY25PLUS: + etree_iter = ET_Element.getiterator + +if PY26PLUS: + thread_is_alive = threading.Thread.is_alive +else: + thread_is_alive = threading.Thread.isAlive + # Exception "constants" to support Python 2 through Python 3 try: @@ -252,10 +285,13 @@ try: except AttributeError: CERT_ERROR = tuple() - HTTP_ERRORS = ((HTTPError, URLError, socket.error, ssl.SSLError) + - CERT_ERROR) + HTTP_ERRORS = ( + (HTTPError, URLError, socket.error, ssl.SSLError, BadStatusLine) + + CERT_ERROR + ) except ImportError: - HTTP_ERRORS = (HTTPError, URLError, socket.error) + ssl = None + HTTP_ERRORS = (HTTPError, URLError, socket.error, BadStatusLine) class SpeedtestException(Exception): @@ -271,7 +307,11 @@ class SpeedtestHTTPError(SpeedtestException): class SpeedtestConfigError(SpeedtestException): - """Configuration provided is invalid""" + """Configuration XML is invalid""" + + +class SpeedtestServersError(SpeedtestException): + """Servers XML is invalid""" class ConfigRetrievalError(SpeedtestHTTPError): @@ -320,6 +360,229 @@ class SpeedtestBestServerFailure(SpeedtestException): """Unable to determine best server""" +class SpeedtestMissingBestServer(SpeedtestException): + """get_best_server not called or not able to determine best server""" + + +def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT, + source_address=None): + """Connect to *address* and return the socket object. + + Convenience function. Connect to *address* (a 2-tuple ``(host, + port)``) and return the socket object. Passing the optional + *timeout* parameter will set the timeout on the socket instance + before attempting to connect. If no *timeout* is supplied, the + global default timeout setting returned by :func:`getdefaulttimeout` + is used. If *source_address* is set it must be a tuple of (host, port) + for the socket to bind as a source address before making the connection. + An host of '' or port 0 tells the OS to use the default. + + Largely vendored from Python 2.7, modified to work with Python 2.4 + """ + + host, port = address + err = None + for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + sock = None + try: + sock = socket.socket(af, socktype, proto) + if timeout is not _GLOBAL_DEFAULT_TIMEOUT: + sock.settimeout(float(timeout)) + if source_address: + sock.bind(source_address) + sock.connect(sa) + return sock + + except socket.error: + err = get_exception() + if sock is not None: + sock.close() + + if err is not None: + raise err + else: + raise socket.error("getaddrinfo returns an empty list") + + +class SpeedtestHTTPConnection(HTTPConnection): + """Custom HTTPConnection to support source_address across + Python 2.4 - Python 3 + """ + def __init__(self, *args, **kwargs): + source_address = kwargs.pop('source_address', None) + timeout = kwargs.pop('timeout', 10) + + HTTPConnection.__init__(self, *args, **kwargs) + + self.source_address = source_address + self.timeout = timeout + + def connect(self): + """Connect to the host and port specified in __init__.""" + try: + self.sock = socket.create_connection( + (self.host, self.port), + self.timeout, + self.source_address + ) + except (AttributeError, TypeError): + self.sock = create_connection( + (self.host, self.port), + self.timeout, + self.source_address + ) + + +if HTTPSConnection: + class SpeedtestHTTPSConnection(HTTPSConnection, + SpeedtestHTTPConnection): + """Custom HTTPSConnection to support source_address across + Python 2.4 - Python 3 + """ + def __init__(self, *args, **kwargs): + source_address = kwargs.pop('source_address', None) + timeout = kwargs.pop('timeout', 10) + + HTTPSConnection.__init__(self, *args, **kwargs) + + self.timeout = timeout + self.source_address = source_address + + def connect(self): + "Connect to a host on a given (SSL) port." + + SpeedtestHTTPConnection.connect(self) + + if ssl: + try: + kwargs = {} + if hasattr(ssl, 'SSLContext'): + kwargs['server_hostname'] = self.host + self.sock = self._context.wrap_socket(self.sock, **kwargs) + except AttributeError: + self.sock = ssl.wrap_socket(self.sock) + try: + self.sock.server_hostname = self.host + except AttributeError: + pass + elif FakeSocket: + # Python 2.4/2.5 support + try: + self.sock = FakeSocket(self.sock, socket.ssl(self.sock)) + except AttributeError: + raise SpeedtestException( + 'This version of Python does not support HTTPS/SSL ' + 'functionality' + ) + else: + raise SpeedtestException( + 'This version of Python does not support HTTPS/SSL ' + 'functionality' + ) + + +def _build_connection(connection, source_address, timeout, context=None): + """Cross Python 2.4 - Python 3 callable to build an ``HTTPConnection`` or + ``HTTPSConnection`` with the args we need + + Called from ``http(s)_open`` methods of ``SpeedtestHTTPHandler`` or + ``SpeedtestHTTPSHandler`` + """ + def inner(host, **kwargs): + kwargs.update({ + 'source_address': source_address, + 'timeout': timeout + }) + if context: + kwargs['context'] = context + return connection(host, **kwargs) + return inner + + +class SpeedtestHTTPHandler(AbstractHTTPHandler): + """Custom ``HTTPHandler`` that can build a ``HTTPConnection`` with the + args we need for ``source_address`` and ``timeout`` + """ + def __init__(self, debuglevel=0, source_address=None, timeout=10): + AbstractHTTPHandler.__init__(self, debuglevel) + self.source_address = source_address + self.timeout = timeout + + def http_open(self, req): + return self.do_open( + _build_connection( + SpeedtestHTTPConnection, + self.source_address, + self.timeout + ), + req + ) + + http_request = AbstractHTTPHandler.do_request_ + + +class SpeedtestHTTPSHandler(AbstractHTTPHandler): + """Custom ``HTTPSHandler`` that can build a ``HTTPSConnection`` with the + args we need for ``source_address`` and ``timeout`` + """ + def __init__(self, debuglevel=0, context=None, source_address=None, + timeout=10): + AbstractHTTPHandler.__init__(self, debuglevel) + self._context = context + self.source_address = source_address + self.timeout = timeout + + def https_open(self, req): + return self.do_open( + _build_connection( + SpeedtestHTTPSConnection, + self.source_address, + self.timeout, + context=self._context, + ), + req + ) + + https_request = AbstractHTTPHandler.do_request_ + + +def build_opener(source_address=None, timeout=10): + """Function similar to ``urllib2.build_opener`` that will build + an ``OpenerDirector`` with the explicit handlers we want, + ``source_address`` for binding, ``timeout`` and our custom + `User-Agent` + """ + + printer('Timeout set to %d' % timeout, debug=True) + + if source_address: + source_address_tuple = (source_address, 0) + printer('Binding to source address: %r' % (source_address_tuple,), + debug=True) + else: + source_address_tuple = None + + handlers = [ + ProxyHandler(), + SpeedtestHTTPHandler(source_address=source_address_tuple, + timeout=timeout), + SpeedtestHTTPSHandler(source_address=source_address_tuple, + timeout=timeout), + HTTPDefaultErrorHandler(), + HTTPRedirectHandler(), + HTTPErrorProcessor() + ] + + opener = OpenerDirector() + opener.addheaders = [('User-agent', build_user_agent())] + + for handler in handlers: + opener.add_handler(handler) + + return opener + + class GzipDecodedResponse(GZIP_BASE): """A file-like object to decode a response encoded with the gzip method, as described in RFC 1952. @@ -357,14 +620,6 @@ def get_exception(): return sys.exc_info()[1] -def bound_socket(*args, **kwargs): - """Bind socket to a specified source IP address""" - - sock = SOCKET_SOCKET(*args, **kwargs) - sock.bind((SOURCE, 0)) - return sock - - def distance(origin, destination): """Determine distance between 2 sets of [lat,lon] in km""" @@ -387,37 +642,32 @@ def distance(origin, destination): def build_user_agent(): """Build a Mozilla/5.0 compatible User-Agent string""" - global USER_AGENT - if USER_AGENT: - return USER_AGENT - ua_tuple = ( 'Mozilla/5.0', - '(%s; U; %s; en-us)' % (platform.system(), platform.architecture()[0]), + '(%s; U; %s; en-us)' % (platform.platform(), + platform.architecture()[0]), 'Python/%s' % platform.python_version(), '(KHTML, like Gecko)', 'speedtest-cli/%s' % __version__ ) - USER_AGENT = ' '.join(ua_tuple) - printer(USER_AGENT, debug=True) - return USER_AGENT + user_agent = ' '.join(ua_tuple) + printer('User-Agent: %s' % user_agent, debug=True) + return user_agent -def build_request(url, data=None, headers=None, bump=''): +def build_request(url, data=None, headers=None, bump='0', secure=False): """Build a urllib2 request object This function automatically adds a User-Agent header to all requests """ - if not USER_AGENT: - build_user_agent() - if not headers: headers = {} if url[0] == ':': - schemed_url = '%s%s' % (SCHEME, url) + scheme = ('http', 'https')[bool(secure)] + schemed_url = '%s%s' % (scheme, url) else: schemed_url = url @@ -432,7 +682,6 @@ def build_request(url, data=None, headers=None, bump=''): bump) headers.update({ - 'User-Agent': USER_AGENT, 'Cache-Control': 'no-cache', }) @@ -442,14 +691,21 @@ def build_request(url, data=None, headers=None, bump=''): return Request(final_url, data=data, headers=headers) -def catch_request(request): +def catch_request(request, opener=None): """Helper function to catch common exceptions encountered when establishing a connection with a HTTP/HTTPS request """ + if opener: + _open = opener.open + else: + _open = urlopen + try: - uh = urlopen(request) + uh = _open(request) + if request.get_full_url() != uh.geturl(): + printer('Redirected to %s' % uh.geturl(), debug=True) return uh, False except HTTP_ERRORS: e = get_exception() @@ -484,18 +740,19 @@ def get_attributes_by_tag_name(dom, tag_name): return dict(list(elem.attributes.items())) -def print_dots(current, total, start=False, end=False): +def print_dots(shutdown_event): """Built in callback function used by Thread classes for printing status """ + def inner(current, total, start=False, end=False): + if shutdown_event.isSet(): + return - if SHUTDOWN_EVENT.isSet(): - return - - sys.stdout.write('.') - if current + 1 == total and end is True: - sys.stdout.write('\n') - sys.stdout.flush() + sys.stdout.write('.') + if current + 1 == total and end is True: + sys.stdout.write('\n') + sys.stdout.flush() + return inner def do_nothing(*args, **kwargs): @@ -505,19 +762,29 @@ def do_nothing(*args, **kwargs): class HTTPDownloader(threading.Thread): """Thread class for retrieving a URL""" - def __init__(self, i, request, start, timeout): + def __init__(self, i, request, start, timeout, opener=None, + shutdown_event=None): threading.Thread.__init__(self) self.request = request self.result = [0] self.starttime = start self.timeout = timeout self.i = i + if opener: + self._opener = opener.open + else: + self._opener = urlopen + + if shutdown_event: + self._shutdown_event = shutdown_event + else: + self._shutdown_event = FakeShutdownEvent() def run(self): try: if (timeit.default_timer() - self.starttime) <= self.timeout: - f = urlopen(self.request) - while (not SHUTDOWN_EVENT.isSet() and + f = self._opener(self.request) + while (not self._shutdown_event.isSet() and (timeit.default_timer() - self.starttime) <= self.timeout): self.result.append(len(f.read(10240))) @@ -533,11 +800,16 @@ class HTTPUploaderData(object): has been reached """ - def __init__(self, length, start, timeout): + def __init__(self, length, start, timeout, shutdown_event=None): self.length = length self.start = start self.timeout = timeout + if shutdown_event: + self._shutdown_event = shutdown_event + else: + self._shutdown_event = FakeShutdownEvent() + self._data = None self.total = [0] @@ -546,11 +818,17 @@ class HTTPUploaderData(object): chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' multiplier = int(round(int(self.length) / 36.0)) IO = BytesIO or StringIO - self._data = IO( - ('content1=%s' % - (chars * multiplier)[0:int(self.length) - 9] - ).encode() - ) + try: + self._data = IO( + ('content1=%s' % + (chars * multiplier)[0:int(self.length) - 9] + ).encode() + ) + except MemoryError: + raise SpeedtestCLIError( + 'Insufficient memory to pre-allocate upload data. Please ' + 'use --no-pre-allocate' + ) @property def data(self): @@ -560,7 +838,7 @@ class HTTPUploaderData(object): def read(self, n=10240): if ((timeit.default_timer() - self.start) <= self.timeout and - not SHUTDOWN_EVENT.isSet()): + not self._shutdown_event.isSet()): chunk = self.data.read(n) self.total.append(len(chunk)) return chunk @@ -574,7 +852,8 @@ class HTTPUploaderData(object): class HTTPUploader(threading.Thread): """Thread class for putting a URL""" - def __init__(self, i, request, start, size, timeout): + def __init__(self, i, request, start, size, timeout, opener=None, + shutdown_event=None): threading.Thread.__init__(self) self.request = request self.request.data.start = self.starttime = start @@ -583,20 +862,30 @@ class HTTPUploader(threading.Thread): self.timeout = timeout self.i = i + if opener: + self._opener = opener.open + else: + self._opener = urlopen + + if shutdown_event: + self._shutdown_event = shutdown_event + else: + self._shutdown_event = FakeShutdownEvent() + def run(self): request = self.request try: if ((timeit.default_timer() - self.starttime) <= self.timeout and - not SHUTDOWN_EVENT.isSet()): + not self._shutdown_event.isSet()): try: - f = urlopen(request) + f = self._opener(request) except TypeError: # PY24 expects a string or buffer # This also causes issues with Ctrl-C, but we will concede # for the moment that Ctrl-C on PY24 isn't immediate request = build_request(self.request.get_full_url(), data=request.data.read(self.size)) - f = urlopen(request) + f = self._opener(request) f.read(11) f.close() self.result = sum(self.request.data.total) @@ -619,7 +908,8 @@ class SpeedtestResults(object): to get a share results image link. """ - def __init__(self, download=0, upload=0, ping=0, server=None): + def __init__(self, download=0, upload=0, ping=0, server=None, client=None, + opener=None, secure=False): self.download = download self.upload = upload self.ping = ping @@ -627,11 +917,20 @@ class SpeedtestResults(object): self.server = {} else: self.server = server + self.client = client or {} + self._share = None self.timestamp = '%sZ' % datetime.datetime.utcnow().isoformat() self.bytes_received = 0 self.bytes_sent = 0 + if opener: + self._opener = opener + else: + self._opener = build_opener() + + self._secure = secure + def __repr__(self): return repr(self.dict()) @@ -673,8 +972,8 @@ class SpeedtestResults(object): headers = {'Referer': 'http://c.speedtest.net/flash/speedtest.swf'} request = build_request('://www.speedtest.net/api/api.php', data='&'.join(api_data).encode(), - headers=headers) - f, e = catch_request(request) + headers=headers, secure=self._secure) + f, e = catch_request(request, opener=self._opener) if e: raise ShareResultsConnectFailure(e) @@ -708,8 +1007,20 @@ class SpeedtestResults(object): 'bytes_sent': self.bytes_sent, 'bytes_received': self.bytes_received, 'share': self._share, + 'client': self.client, } + @staticmethod + def csv_header(delimiter=','): + """Return CSV Headers""" + + row = ['Server ID', 'Sponsor', 'Server Name', 'Timestamp', 'Distance', + 'Ping', 'Download', 'Upload', 'Share', 'IP Address'] + out = StringIO() + writer = csv.writer(out, delimiter=delimiter, lineterminator='') + writer.writerow([to_utf8(v) for v in row]) + return out.getvalue() + def csv(self, delimiter=','): """Return data in CSV format""" @@ -719,7 +1030,7 @@ class SpeedtestResults(object): row = [data['server']['id'], data['server']['sponsor'], data['server']['name'], data['timestamp'], data['server']['d'], data['ping'], data['download'], - data['upload']] + data['upload'], self._share or '', self.client['ip']] writer.writerow([to_utf8(v) for v in row]) return out.getvalue() @@ -738,17 +1049,40 @@ class SpeedtestResults(object): class Speedtest(object): """Class for performing standard speedtest.net testing operations""" - def __init__(self, config=None): + def __init__(self, config=None, source_address=None, timeout=10, + secure=False, shutdown_event=None): self.config = {} + + self._source_address = source_address + self._timeout = timeout + self._opener = build_opener(source_address, timeout) + + self._secure = secure + + if shutdown_event: + self._shutdown_event = shutdown_event + else: + self._shutdown_event = FakeShutdownEvent() + self.get_config() if config is not None: self.config.update(config) self.servers = {} self.closest = [] - self.best = {} + self._best = {} - self.results = SpeedtestResults() + self.results = SpeedtestResults( + client=self.config['client'], + opener=self._opener, + secure=secure, + ) + + @property + def best(self): + if not self._best: + self.get_best_server() + return self._best def get_config(self): """Download the speedtest.net configuration and return only the data @@ -759,17 +1093,20 @@ class Speedtest(object): if gzip: headers['Accept-Encoding'] = 'gzip' request = build_request('://www.speedtest.net/speedtest-config.php', - headers=headers) - uh, e = catch_request(request) + headers=headers, secure=self._secure) + uh, e = catch_request(request, opener=self._opener) if e: raise ConfigRetrievalError(e) - configxml = [] + configxml_list = [] stream = get_response_stream(uh) while 1: - configxml.append(stream.read(1024)) - if len(configxml[-1]) == 0: + try: + configxml_list.append(stream.read(1024)) + except (OSError, EOFError): + raise ConfigRetrievalError(get_exception()) + if len(configxml_list[-1]) == 0: break stream.close() uh.close() @@ -777,10 +1114,18 @@ class Speedtest(object): if int(uh.code) != 200: return None - printer(''.encode().join(configxml), debug=True) + configxml = ''.encode().join(configxml_list) + + printer('Config XML:\n%s' % configxml, debug=True) try: - root = ET.fromstring(''.encode().join(configxml)) + try: + root = ET.fromstring(configxml) + except ET.ParseError: + e = get_exception() + raise SpeedtestConfigError( + 'Malformed speedtest.net configuration: %s' % e + ) server_config = root.find('server-config').attrib download = root.find('download').attrib upload = root.find('upload').attrib @@ -788,7 +1133,13 @@ class Speedtest(object): client = root.find('client').attrib except AttributeError: - root = DOM.parseString(''.join(configxml)) + try: + root = DOM.parseString(configxml) + except ExpatError: + e = get_exception() + raise SpeedtestConfigError( + 'Malformed speedtest.net configuration: %s' % e + ) server_config = get_attributes_by_tag_name(root, 'server-config') download = get_attributes_by_tag_name(root, 'download') upload = get_attributes_by_tag_name(root, 'upload') @@ -837,27 +1188,38 @@ class Speedtest(object): 'upload_max': upload_count * size_count }) - self.lat_lon = (float(client['lat']), float(client['lon'])) + try: + self.lat_lon = (float(client['lat']), float(client['lon'])) + except ValueError: + raise SpeedtestConfigError( + 'Unknown location: lat=%r lon=%r' % + (client.get('lat'), client.get('lon')) + ) - printer(self.config, debug=True) + printer('Config:\n%r' % self.config, debug=True) return self.config - def get_servers(self, servers=None): + def get_servers(self, servers=None, exclude=None): """Retrieve a the list of speedtest.net servers, optionally filtered to servers matching those specified in the ``servers`` argument """ if servers is None: servers = [] + if exclude is None: + exclude = [] + self.servers.clear() - for i, s in enumerate(servers): - try: - servers[i] = int(s) - except ValueError: - raise InvalidServerIDType('%s is an invalid server type, must ' - 'be int' % s) + for server_list in (servers, exclude): + for i, s in enumerate(server_list): + try: + server_list[i] = int(s) + except ValueError: + raise InvalidServerIDType( + '%s is an invalid server type, must be int' % s + ) urls = [ '://www.speedtest.net/speedtest-servers-static.php', @@ -873,21 +1235,26 @@ class Speedtest(object): errors = [] for url in urls: try: - request = build_request('%s?threads=%s' % - (url, - self.config['threads']['download']), - headers=headers) - uh, e = catch_request(request) + request = build_request( + '%s?threads=%s' % (url, + self.config['threads']['download']), + headers=headers, + secure=self._secure + ) + uh, e = catch_request(request, opener=self._opener) if e: errors.append('%s' % e) raise ServersRetrievalError() stream = get_response_stream(uh) - serversxml = [] + serversxml_list = [] while 1: - serversxml.append(stream.read(1024)) - if len(serversxml[-1]) == 0: + try: + serversxml_list.append(stream.read(1024)) + except (OSError, EOFError): + raise ServersRetrievalError(get_exception()) + if len(serversxml_list[-1]) == 0: break stream.close() @@ -896,14 +1263,28 @@ class Speedtest(object): if int(uh.code) != 200: raise ServersRetrievalError() - printer(''.encode().join(serversxml), debug=True) + serversxml = ''.encode().join(serversxml_list) + + printer('Servers XML:\n%s' % serversxml, debug=True) try: try: - root = ET.fromstring(''.encode().join(serversxml)) - elements = root.getiterator('server') + try: + root = ET.fromstring(serversxml) + except ET.ParseError: + e = get_exception() + raise SpeedtestServersError( + 'Malformed speedtest.net server list: %s' % e + ) + elements = etree_iter(root, 'server') except AttributeError: - root = DOM.parseString(''.join(serversxml)) + try: + root = DOM.parseString(serversxml) + except ExpatError: + e = get_exception() + raise SpeedtestServersError( + 'Malformed speedtest.net server list: %s' % e + ) elements = root.getElementsByTagName('server') except (SyntaxError, xml.parsers.expat.ExpatError): raise ServersRetrievalError() @@ -917,14 +1298,15 @@ class Speedtest(object): if servers and int(attrib.get('id')) not in servers: continue - if int(attrib.get('id')) in self.config['ignore_servers']: + if (int(attrib.get('id')) in self.config['ignore_servers'] + or int(attrib.get('id')) in exclude): continue try: d = distance(self.lat_lon, (float(attrib.get('lat')), float(attrib.get('lon')))) - except: + except Exception: continue attrib['d'] = d @@ -934,14 +1316,12 @@ class Speedtest(object): except KeyError: self.servers[d] = [attrib] - printer(''.encode().join(serversxml), debug=True) - break except ServersRetrievalError: continue - if servers and not self.servers: + if (servers or exclude) and not self.servers: raise NoMatchedServers() return self.servers @@ -960,7 +1340,7 @@ class Speedtest(object): url = server request = build_request(url) - uh, e = catch_request(request) + uh, e = catch_request(request, opener=self._opener) if e: raise SpeedtestMiniConnectFailure('Failed to connect to %s' % server) @@ -973,8 +1353,10 @@ class Speedtest(object): if not extension: for ext in ['php', 'asp', 'aspx', 'jsp']: try: - f = urlopen('%s/upload.%s' % (url, ext)) - except: + f = self._opener.open( + '%s/upload.%s' % (url, ext) + ) + except Exception: pass else: data = f.read().strip().decode() @@ -1015,7 +1397,7 @@ class Speedtest(object): continue break - printer(self.closest, debug=True) + printer('Closest Servers:\n%r' % self.closest, debug=True) return self.closest def get_best_server(self, servers=None): @@ -1028,26 +1410,44 @@ class Speedtest(object): servers = self.get_closest_servers() servers = self.closest + if self._source_address: + source_address_tuple = (self._source_address, 0) + else: + source_address_tuple = None + + user_agent = build_user_agent() + results = {} for server in servers: cum = [] url = os.path.dirname(server['url']) - urlparts = urlparse('%s/latency.txt' % url) - printer('%s %s/latency.txt' % ('GET', url), debug=True) - for _ in range(0, 3): + stamp = int(timeit.time.time() * 1000) + latency_url = '%s/latency.txt?x=%s' % (url, stamp) + for i in range(0, 3): + this_latency_url = '%s.%s' % (latency_url, i) + printer('%s %s' % ('GET', this_latency_url), + debug=True) + urlparts = urlparse(latency_url) try: if urlparts[0] == 'https': - h = HTTPSConnection(urlparts[1]) + h = SpeedtestHTTPSConnection( + urlparts[1], + source_address=source_address_tuple + ) else: - h = HTTPConnection(urlparts[1]) - headers = {'User-Agent': USER_AGENT} + h = SpeedtestHTTPConnection( + urlparts[1], + source_address=source_address_tuple + ) + headers = {'User-Agent': user_agent} + path = '%s?%s' % (urlparts[2], urlparts[4]) start = timeit.default_timer() - h.request("GET", urlparts[2], headers=headers) + h.request("GET", path, headers=headers) r = h.getresponse() total = (timeit.default_timer() - start) except HTTP_ERRORS: e = get_exception() - printer('%r' % e, debug=True) + printer('ERROR: %r' % e, debug=True) cum.append(3600) continue @@ -1072,12 +1472,16 @@ class Speedtest(object): self.results.ping = fastest self.results.server = best - self.best.update(best) - printer(best, debug=True) + self._best.update(best) + printer('Best Server:\n%r' % best, debug=True) return best - def download(self, callback=do_nothing): - """Test download speed against speedtest.net""" + def download(self, callback=do_nothing, threads=None): + """Test download speed against speedtest.net + + A ``threads`` value of ``None`` will fall back to those dictated + by the speedtest.net configuration + """ urls = [] for size in self.config['sizes']['download']: @@ -1088,12 +1492,20 @@ class Speedtest(object): request_count = len(urls) requests = [] for i, url in enumerate(urls): - requests.append(build_request(url, bump=i)) + requests.append( + build_request(url, bump=i, secure=self._secure) + ) def producer(q, requests, request_count): for i, request in enumerate(requests): - thread = HTTPDownloader(i, request, start, - self.config['length']['download']) + thread = HTTPDownloader( + i, + request, + start, + self.config['length']['download'], + opener=self._opener, + shutdown_event=self._shutdown_event + ) thread.start() q.put(thread, True) callback(i, request_count, start=True) @@ -1101,14 +1513,15 @@ class Speedtest(object): finished = [] def consumer(q, request_count): + _is_alive = thread_is_alive while len(finished) < request_count: thread = q.get(True) - while thread.isAlive(): + while _is_alive(thread): thread.join(timeout=0.1) finished.append(sum(thread.result)) callback(thread.i, request_count, end=True) - q = Queue(self.config['threads']['download']) + q = Queue(threads or self.config['threads']['download']) prod_thread = threading.Thread(target=producer, args=(q, requests, request_count)) cons_thread = threading.Thread(target=consumer, @@ -1116,9 +1529,10 @@ class Speedtest(object): start = timeit.default_timer() prod_thread.start() cons_thread.start() - while prod_thread.isAlive(): + _is_alive = thread_is_alive + while _is_alive(prod_thread): prod_thread.join(timeout=0.1) - while cons_thread.isAlive(): + while _is_alive(cons_thread): cons_thread.join(timeout=0.1) stop = timeit.default_timer() @@ -1130,8 +1544,12 @@ class Speedtest(object): self.config['threads']['upload'] = 8 return self.results.download - def upload(self, callback=do_nothing, pre_allocate=True): - """Test upload speed against speedtest.net""" + def upload(self, callback=do_nothing, pre_allocate=True, threads=None): + """Test upload speed against speedtest.net + + A ``threads`` value of ``None`` will fall back to those dictated + by the speedtest.net configuration + """ sizes = [] @@ -1146,20 +1564,35 @@ class Speedtest(object): for i, size in enumerate(sizes): # We set ``0`` for ``start`` and handle setting the actual # ``start`` in ``HTTPUploader`` to get better measurements - data = HTTPUploaderData(size, 0, self.config['length']['upload']) + data = HTTPUploaderData( + size, + 0, + self.config['length']['upload'], + shutdown_event=self._shutdown_event + ) if pre_allocate: data.pre_allocate() + + headers = {'Content-length': size} requests.append( ( - build_request(self.best['url'], data), + build_request(self.best['url'], data, secure=self._secure, + headers=headers), size ) ) def producer(q, requests, request_count): for i, request in enumerate(requests[:request_count]): - thread = HTTPUploader(i, request[0], start, request[1], - self.config['length']['upload']) + thread = HTTPUploader( + i, + request[0], + start, + request[1], + self.config['length']['upload'], + opener=self._opener, + shutdown_event=self._shutdown_event + ) thread.start() q.put(thread, True) callback(i, request_count, start=True) @@ -1167,14 +1600,15 @@ class Speedtest(object): finished = [] def consumer(q, request_count): + _is_alive = thread_is_alive while len(finished) < request_count: thread = q.get(True) - while thread.isAlive(): + while _is_alive(thread): thread.join(timeout=0.1) finished.append(thread.result) callback(thread.i, request_count, end=True) - q = Queue(self.config['threads']['upload']) + q = Queue(threads or self.config['threads']['upload']) prod_thread = threading.Thread(target=producer, args=(q, requests, request_count)) cons_thread = threading.Thread(target=consumer, @@ -1182,9 +1616,10 @@ class Speedtest(object): start = timeit.default_timer() prod_thread.start() cons_thread.start() - while prod_thread.isAlive(): + _is_alive = thread_is_alive + while _is_alive(prod_thread): prod_thread.join(timeout=0.1) - while cons_thread.isAlive(): + while _is_alive(cons_thread): cons_thread.join(timeout=0.1) stop = timeit.default_timer() @@ -1195,32 +1630,29 @@ class Speedtest(object): return self.results.upload -def ctrl_c(signum, frame): +def ctrl_c(shutdown_event): """Catch Ctrl-C key sequence and set a SHUTDOWN_EVENT for our threaded operations """ - - SHUTDOWN_EVENT.set() - print_('\nCancelling...') - sys.exit(0) + def inner(signum, frame): + shutdown_event.set() + printer('\nCancelling...', error=True) + sys.exit(0) + return inner def version(): """Print the version""" - print_(__version__) + printer('speedtest-cli %s' % __version__) + printer('Python %s' % sys.version.replace('\n', '')) sys.exit(0) def csv_header(delimiter=','): """Print the CSV Headers""" - row = ['Server ID', 'Sponsor', 'Server Name', 'Timestamp', 'Distance', - 'Ping', 'Download', 'Upload'] - out = StringIO() - writer = csv.writer(out, delimiter=delimiter, lineterminator='') - writer.writerow([to_utf8(v) for v in row]) - print_(out.getvalue()) + printer(SpeedtestResults.csv_header(delimiter=delimiter)) sys.exit(0) @@ -1246,6 +1678,10 @@ def parse_args(): parser.add_argument('--no-upload', dest='upload', default=True, action='store_const', const=False, help='Do not perform upload test') + parser.add_argument('--single', default=False, action='store_true', + help='Only use a single connection instead of ' + 'multiple. This simulates a typical file ' + 'transfer.') parser.add_argument('--bytes', dest='units', action='store_const', const=('byte', 8), default=('bit', 1), help='Display values in bytes instead of bits. Does ' @@ -1273,11 +1709,15 @@ def parse_args(): parser.add_argument('--list', action='store_true', help='Display a list of speedtest.net servers ' 'sorted by distance') - parser.add_argument('--server', help='Specify a server ID to test against', - type=PARSER_TYPE_INT) + parser.add_argument('--server', type=PARSER_TYPE_INT, action='append', + help='Specify a server ID to test against. Can be ' + 'supplied multiple times') + parser.add_argument('--exclude', type=PARSER_TYPE_INT, action='append', + help='Exclude a server from selection. Can be ' + 'supplied multiple times') parser.add_argument('--mini', help='URL of the Speedtest Mini server') parser.add_argument('--source', help='Source IP address to bind to') - parser.add_argument('--timeout', default=10, type=PARSER_TYPE_INT, + parser.add_argument('--timeout', default=10, type=PARSER_TYPE_FLOAT, help='HTTP timeout in seconds. Default 10') parser.add_argument('--secure', action='store_true', help='Use HTTPS instead of HTTP when communicating ' @@ -1320,17 +1760,23 @@ def validate_optional_args(args): 'unavailable' % (info[0], arg)) -def printer(string, quiet=False, debug=False, **kwargs): - """Helper function to print a string only when not quiet""" +def printer(string, quiet=False, debug=False, error=False, **kwargs): + """Helper function print a string with various features""" if debug and not DEBUG: return if debug: - out = '\033[1;30mDEBUG: %s\033[0m' % string + if sys.stdout.isatty(): + out = '\033[1;30mDEBUG: %s\033[0m' % string + else: + out = 'DEBUG: %s' % string else: out = string + if error: + kwargs['file'] = sys.stderr + if not quiet: print_(out, **kwargs) @@ -1338,10 +1784,10 @@ def printer(string, quiet=False, debug=False, **kwargs): def shell(): """Run the full speedtest.net test""" - global SHUTDOWN_EVENT, SOURCE, SCHEME, DEBUG - SHUTDOWN_EVENT = threading.Event() + global DEBUG + shutdown_event = threading.Event() - signal.signal(signal.SIGINT, ctrl_c) + signal.signal(signal.SIGINT, ctrl_c(shutdown_event)) args = parse_args() @@ -1361,25 +1807,12 @@ def shell(): validate_optional_args(args) - socket.setdefaulttimeout(args.timeout) - - # If specified bind to a specific IP address - if args.source: - SOURCE = args.source - socket.socket = bound_socket - - if args.secure: - SCHEME = 'https' - debug = getattr(args, 'debug', False) if debug == 'SUPPRESSHELP': debug = False if debug: DEBUG = True - # Pre-cache the user agent string - build_user_agent() - if args.simple or args.csv or args.json: quiet = True else: @@ -1394,20 +1827,24 @@ def shell(): if quiet or debug: callback = do_nothing else: - callback = print_dots + callback = print_dots(shutdown_event) printer('Retrieving speedtest.net configuration...', quiet) try: - speedtest = Speedtest() - except (ConfigRetrievalError, HTTP_ERRORS): - printer('Cannot retrieve speedtest configuration') + speedtest = Speedtest( + source_address=args.source, + timeout=args.timeout, + secure=args.secure + ) + except (ConfigRetrievalError,) + HTTP_ERRORS: + printer('Cannot retrieve speedtest configuration', error=True) raise SpeedtestCLIError(get_exception()) if args.list: try: speedtest.get_servers() - except (ServersRetrievalError, HTTP_ERRORS): - print_('Cannot retrieve speedtest server list') + except (ServersRetrievalError,) + HTTP_ERRORS: + printer('Cannot retrieve speedtest server list', error=True) raise SpeedtestCLIError(get_exception()) for _, servers in sorted(speedtest.servers.items()): @@ -1415,35 +1852,38 @@ def shell(): line = ('%(id)5s) %(sponsor)s (%(name)s, %(country)s) ' '[%(d)0.2f km]' % server) try: - print_(line) + printer(line) except IOError: e = get_exception() if e.errno != errno.EPIPE: raise sys.exit(0) - # Set a filter of servers to retrieve - servers = [] - if args.server: - servers.append(args.server) - printer('Testing from %(isp)s (%(ip)s)...' % speedtest.config['client'], quiet) if not args.mini: printer('Retrieving speedtest.net server list...', quiet) try: - speedtest.get_servers(servers) + speedtest.get_servers(servers=args.server, exclude=args.exclude) except NoMatchedServers: - raise SpeedtestCLIError('No matched servers: %s' % args.server) - except (ServersRetrievalError, HTTP_ERRORS): - print_('Cannot retrieve speedtest server list') + raise SpeedtestCLIError( + 'No matched servers: %s' % + ', '.join('%s' % s for s in args.server) + ) + except (ServersRetrievalError,) + HTTP_ERRORS: + printer('Cannot retrieve speedtest server list', error=True) raise SpeedtestCLIError(get_exception()) except InvalidServerIDType: - raise SpeedtestCLIError('%s is an invalid server type, must ' - 'be an int' % args.server) + raise SpeedtestCLIError( + '%s is an invalid server type, must ' + 'be an int' % ', '.join('%s' % s for s in args.server) + ) - printer('Selecting best server based on ping...', quiet) + if args.server and len(args.server) == 1: + printer('Retrieving information for the selected server...', quiet) + else: + printer('Selecting best server based on ping...', quiet) speedtest.get_best_server() elif args.mini: speedtest.get_best_server(speedtest.set_mini_server(args.mini)) @@ -1456,38 +1896,48 @@ def shell(): if args.download: printer('Testing download speed', quiet, end=('', '\n')[bool(debug)]) - speedtest.download(callback=callback) + speedtest.download( + callback=callback, + threads=(None, 1)[args.single] + ) printer('Download: %0.2f M%s/s' % ((results.download / 1000.0 / 1000.0) / args.units[1], args.units[0]), quiet) else: - printer('Skipping download test') + printer('Skipping download test', quiet) if args.upload: printer('Testing upload speed', quiet, end=('', '\n')[bool(debug)]) - speedtest.upload(callback=callback, pre_allocate=args.pre_allocate) + speedtest.upload( + callback=callback, + pre_allocate=args.pre_allocate, + threads=(None, 1)[args.single] + ) printer('Upload: %0.2f M%s/s' % ((results.upload / 1000.0 / 1000.0) / args.units[1], args.units[0]), quiet) else: - printer('Skipping upload test') + printer('Skipping upload test', quiet) + + printer('Results:\n%r' % results.dict(), debug=True) + + if not args.simple and args.share: + results.share() if args.simple: - print_('Ping: %s ms\nDownload: %0.2f M%s/s\nUpload: %0.2f M%s/s' % - (results.ping, - (results.download / 1000.0 / 1000.0) / args.units[1], - args.units[0], - (results.upload / 1000.0 / 1000.0) / args.units[1], - args.units[0])) + printer('Ping: %s ms\nDownload: %0.2f M%s/s\nUpload: %0.2f M%s/s' % + (results.ping, + (results.download / 1000.0 / 1000.0) / args.units[1], + args.units[0], + (results.upload / 1000.0 / 1000.0) / args.units[1], + args.units[0])) elif args.csv: - print_(results.csv(delimiter=args.csv_delimiter)) + printer(results.csv(delimiter=args.csv_delimiter)) elif args.json: - if args.share: - results.share() - print_(results.json()) + printer(results.json()) if args.share and not machine_format: printer('Share results: %s' % results.share()) @@ -1497,11 +1947,15 @@ def main(): try: shell() except KeyboardInterrupt: - print_('\nCancelling...') + printer('\nCancelling...', error=True) except (SpeedtestException, SystemExit): e = get_exception() - if getattr(e, 'code', 1) != 0: - raise SystemExit('ERROR: %s' % e) + # Ignore a successful exit, or argparse exit + if getattr(e, 'code', 1) not in (0, 2): + msg = '%s' % e + if not msg: + msg = '%r' % e + raise SystemExit('ERROR: %s' % msg) if __name__ == '__main__': diff --git a/speedtest_cli.py b/tests/scripts/source.py similarity index 53% rename from speedtest_cli.py rename to tests/scripts/source.py index 1d0fb29..357f4c6 100644 --- a/speedtest_cli.py +++ b/tests/scripts/source.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright 2012-2016 Matt Martz +# Copyright 2018 Matt Martz # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,20 +15,23 @@ # License for the specific language governing permissions and limitations # under the License. -import warnings +import sys +import subprocess -DEPRECATED_MSG = ('The file speedtest_cli.py has been deprecated in favor of ' - 'speedtest.py\nand is available for download at:\n\n' - 'https://raw.githubusercontent.com/sivel/speedtest-cli/' - 'master/speedtest.py') +cmd = [sys.executable, 'speedtest.py', '--source', '127.0.0.1'] +p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE +) -if __name__ == '__main__': - raise SystemExit(DEPRECATED_MSG) -else: - try: - from speedtest import * - except ImportError: - raise SystemExit(DEPRECATED_MSG) - else: - warnings.warn(DEPRECATED_MSG, UserWarning) +stdout, stderr = p.communicate() + +if p.returncode != 1: + raise SystemExit('%s did not fail with exit code 1' % ' '.join(cmd)) + +if 'Invalid argument'.encode() not in stderr: + raise SystemExit( + '"Invalid argument" not found in stderr:\n%s' % stderr.decode() + ) diff --git a/tox.ini b/tox.ini index 477fa1c..8a63b5b 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,8 @@ commands = {envpython} -V {envpython} -m compileall speedtest.py {envpython} speedtest.py + {envpython} speedtest.py --source 172.17.0.1 + {envpython} tests/scripts/source.py [testenv:flake8] basepython=python @@ -19,3 +21,5 @@ commands = pypy -V pypy -m compileall speedtest.py pypy speedtest.py + pypy speedtest.py --source 172.17.0.1 + pypy tests/scripts/source.py