Repository: martinbrose/cloudflarepycli (language: python)

Issue:
Upload speed is incorrect
Hi @tevslin,

unfortunately, the upload speed is incorrect when using cloudflarepycli.

This behaviour started on 21st of May:
![Screenshot 2024-05-23 at 17 20 33](https://github.com/tevslin/cloudflarepycli/assets/13284268/ad81c51f-74cd-46c8-8a4c-73aa4b608680)

I have run the Python package myself and could replicate the strange upload measurements:
![Screenshot 2024-05-23 at 17 22 16](https://github.com/tevslin/cloudflarepycli/assets/13284268/f9c7feb7-6703-4538-a208-ca4cbb227354)

But when I run the speedtest on the Cloudflare-Website, everything seems fine:
![Screenshot 2024-05-23 at 17 24 31](https://github.com/tevslin/cloudflarepycli/assets/13284268/520288d8-7352-456a-aa7a-60856ab18627)

There might have been a change to the servertimes we use in the calculation in lines 212-213 of cloudflareclass.py.
--bits option
Hello.
Can you add "show output in bits" option to collect data in monitoring systems (like zabbix)?
Look at https://github.com/tevslin/cloudflarepycli/pull/7
New option --bits
New option: --bits  write sysout in bps instead of Mbps


Discussion / hints:
On second thought... I didn't check the data returned from cloudflare. They probably just changed the structure. 



Fix patch:
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..3e87175
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,59 @@
+line-length = 80
+target-version = "py38"
+
+[lint]
+select = [
+    "A",    # flake8-builtins
+    "ANN", # flake8-annotations
+    "ARG",  # flake8-unused-arguments
+    "B",    # Bugbear
+    "C",
+    "COM", # flake8-commas
+#    "CPY",  # flake8-copyright
+    "C4",   # flake8-comprehensions
+    "DTZ",  # flake8-datetimez
+    "D",    # pydocstyle
+    "E",    # pycodestyle errors
+#    "ERA",  # eradicate commented out code
+    "F",    # pyflakes
+    "FA",   # flake8-future-annotations
+    "FBT",  # boolean traps
+    "FLY",  # flynt
+    "FURB", # refurb
+    "G",    # flake8-logging-format
+    "I",    # incomplete isort
+    "INP",  # flake8-no-pep420
+    "LOG",  # flake8-logging
+    "N",    # naming
+    "NPY",  # NumPy-specific rules
+    "PD",   # pandas-vet
+    "PERF", # Perflint
+    "PL",   # Pylint
+    "PTH",  # flake8-use-pathlib
+    "PYI",  # flake8-pyi
+    "RET",  # flake8-return
+    "RUF",  # Ruff-specific rules
+    "S",    # bandit, security
+    "SLF",  # flake8-self
+    "SIM",  # flake8-simplify
+    "TID",  # flake8-tidy-imports
+    "TCH",  # flake8-type-checking
+    "T10",  # flake8-print
+    "UP",   # pyupgrade
+    "W",    # pycodestyle warnings
+    "YTT",  # flake8-2020
+]
+ignore = [
+    "PLR2004",  # magic values
+    "COM812",  # trailing comma -> done by formatter
+    "D203", # no blank lines between classes and documentation
+    "D212", # consistent start level for multi-line edocumentation
+    "D415", # first line of docs should end with a period
+]
+preview = true
+
+[lint.mccabe]
+max-complexity = 11
+
+[lint.pyupgrade]
+keep-runtime-typing = true
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..dcd33c1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,63 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+# cloudflarepycli@2.0.0
+
+## Refactor
+
+- **BREAKING CHANGE:** separate library and logging functionality
+- **BREAKING CHANGE:** remove getcolo, getcolodetails, getisp, getfulldata in
+  favour of get_metadata
+- **BREAKING CHANGE:** change cloudflareclass module to cloudflare
+- **BREAKING CHANGE:** change cloudflare class to CloudflareSpeedtest
+- **BREAKING CHANGE:** remove download and upload in favour of run_test
+- **BREAKING CHANGE:** remove runalltests in favour of run_all
+- **BREAKING CHANGE:** change function signature of CloudflareSpeedtest
+  - remove thedict, debug, printit, downtests, uptests, latencyreps
+  - add results (previously thedict)
+  - add tests (previously downtests & uptests) including latency
+- **BREAKING CHANGE:** change result keys
+  - change all results to use bits instead of megabits by default
+    - this can be overriden by using the megabits argument of run_all, which
+      changes the suffix of test keys from "bps" to "mbps" accordingly
+  - metadata is now stored under "meta"
+  - test results are now stored under "tests"
+  - "your ip" -> "ip"
+  - "your ISP" -> "isp"
+  - "test location code" -> "location_code"
+  - "test location city" -> "location_city"
+  - "test location region" -> "location_region"
+  - "latency ms" -> "latency"
+  - "Jitter ms" -> "jitter"
+  - "{x} download Mbps" -> "{x}_down_bps"
+  - "{x} upload Mbps" -> "{x}_up_bps"
+  - "90th percentile download Mbps" -> "90th_percentile_down_bps"
+  - "90th percentile upload Mbps" -> "90th_percentile_up_bps"
+- **BREAKING CHANGE:** change return type of run_all to use new TestResult type
+
+## Features
+
+- add logger
+- add TestType enum
+- add TestSpec named tuple for easily defining new tests
+- add TestResult named tuple for working with test results
+- add TestTimers named tuple for timer collections
+- add TestMetadata named tuple for working with metadata
+
+# cloudflarepycli@1.8.1
+
+## Refactor
+
+- Remove dependency on numpy ([bc4b10f](https://github.com/tevslin/cloudflarepycli/commit/bc4b10fc7423408a21907636dbaef274bf975d22))
+
+# cloudflarepycli@1.8.0
+
+## Refactor
+
+- Remove dependency on ipdatabase.com
+- Deprecate getcolo, getcolodetails and getisp
+
+## Features
+
+- Add getfulldata to get all data about test locations and ISP from cloudflare
diff --git a/README.md b/README.md
index 3b19d3b..10f263d 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,15 @@
 # cloudflarepycli
 
-Python CLI and python class for retrieving user's realtime performance statistics.
+Python CLI and utilities for retrieving network performance statistics.
 
 ## Purpose
 
-Retrieve near-term performance data about the service provided to a user by an ISP. The data includes up and download speeds, latency, and jitter. The CLI makes it possible to pipe the data to other processes for possible uploading, analysis etc. and/or to build a GUI for displaying current and past data. The cloudflareclass.py module is useful for varying the types of test that are done. I will use both in future projects to expand the functionality.
+Retrieve near-term performance data about the service provided to a user by an
+ISP. The data includes up and download speeds, latency, and jitter. The CLI
+makes it possible to pipe the data to other processes for possible uploading,
+analysis etc. and/or to build a GUI for displaying current and past data. The
+cloudflare module is useful for varying the types of test that are done. I will
+use both in future projects to expand the functionality.
 
 ## Install
 
@@ -13,21 +18,21 @@ Windows users must also: pip install wres
 
 ## CLI usage
 
-Type 'cfspeedtest' in the environment where you installed the package. Note that this is a shell command, not a Python command.
+Type 'cfspeedtest' in the environment where you installed the package. Note that
+this is a shell command, not a Python command.
 
 ### Options
 
-  -h, --help  show help message and exit
-  
   --debug     log network io
-  
-  --json      write json to sysout instead of formatted results
-  
+  --json      write json to stdout instead of formatted results
   --version   show program's version number and exit
   
-## Cloudflareclass Documentation
+## Cloudflare Documentation
 
-The Cloudflare Connection Testing Class is a Python module that facilitates testing the speed and latency of your internet connection to Cloudflare's servers. It utilizes the `requests` library to perform various tests and provides insights into download and upload speeds, latency, and other connection metrics.
+The `CloudflareSpeedtest` Class is a Python module that facilitates testing the
+speed and latency of your internet connection to Cloudflare's servers. It
+utilizes the `requests` library to perform various tests and provides insights
+into download and upload speeds, latency, and other connection metrics.
 
 ### Features
 
@@ -36,101 +41,90 @@ The Cloudflare Connection Testing Class is a Python module that facilitates test
 - Provides information about your IP address, ISP, test location, and more.
 - Easy-to-use class interface to conduct various connection tests.
 
-### Function arguments
-
-  ```python
-  cloudflare(thedict=None,debug=False,print=True,downtests=None,uptests=None,latencyreps=20)
-  ```
-
-
-- `thedict`: dictionary to store results in
-    - if not passed in, created here
-    - if passed in, used and update - allows keeping partial results from previous runs
-    - each result has a key and the entry is a dict with "time" and "value" items
-- `debug`: if True, turns on io logging for debugging
-- `printit`: if True, results are printed as well as added to the dictionary
-- `downtests`: tuple of download tests to be performed
-    - if None, four download tests are performed consecutively: 100kB, 1MB, 10MB, and 25MB
-    - format is ((size, reps, label)......)
-        - size: size of block to download
-        - reps: number of times to repeat test
-        - label: text label for test - also becomes key in the dict
-- `uptests`: tuple of upload tests to be performed
-    - if None, three upload tests are performed consecutively: 100kB, 1MB, and 10MB
-    - format is ((size, reps, label)......)
-        - size: size of block to upload
-        - reps: number of times to repeat test
-        - label: text label for test - also becomes key in the dict
-- `latencyreps`: number of repetitions for latency test
-
 #### Usage
 
-The `cloudflare` class provides methods to perform various connection tests and retrieve relevant information. Here's a brief overview of its main methods:
+The `CloudflareSpeedtest` class provides methods to perform various connection
+tests and retrieve relevant information. Here's a brief overview of its main
+methods:
 
-- `runalltests()`: Runs a full suite of tests, including latency, jitter, download, and upload tests. Returns a dictionary with test results.
-- `getfulldata()`: Retrieves information about your IP address, ISP, test location, and more.
-- `download(numbytes, iterations)`: Runs download tests for the specified number of bytes and iterations. Returns timing results.
-- `upload(numbytes, iterations)`: Runs upload tests for the specified number of bytes and iterations. Returns timing results.
+- `run_all()`: Runs a full suite of tests, including latency, jitter, download,
+  and upload tests. Returns a dictionary with test results.
+- `get_metadata()`: Retrieves information about your IP address, ISP, test
+  location, and more.
+- `run_test(test)`: Runs a test according to the given `TestSpec`, and returns
+  timing results.
 
-Refer to the `cloudflareclass.py`` file for detailed documentation on each method and its parameters.
+Refer to the `cloudflare.py` file for detailed documentation on each method and
+its parameters.
 
 ### Stand-alone usage
 #### Getting Started
 
 1. Install the required dependencies:
     ```bash
-    pip install requests numpy
+    pip install requests
     ```
 2. Download the cloudflareclass.py file and include it in your project.
 3. Import the cloudflare class in your Python script:
     ```python
-    from cloudflareclass import cloudflare
+    from cfspeedtest import CloudflareSpeedtest
     ```
-4. Create an instance of the cloudflare class and run tests:
+4. Create an instance of the speedtest class and run tests:
     ```python
-    cf = cloudflare()
-    results = cf.runalltests()
+    suite = CloudflareSpeedtest()
+    results = suite.run_all()
     print(results)
     ```
 
-#### Example
-
-```python
-from cloudflareclass import cloudflare
-
-cf = cloudflare()
-results = cf.runalltests()
-print(results)
-```
-
-## Source repository
-
-https://github.com/tevslin/cloudflarepycli
-
 ## How it works
 
-Tests for latency are done by requesting one byte packets from Cloudflare, measuring the elapsed time to get a response, and subtracting the server processing time taken from the header in the returned message. Jitter is computed as the mean of the absolute difference between the arrival of consecutive requests.
-
-The cloudflareclass.py module makes Python requests to various subaddresses of [speed.cloudflare.com](https://speed.cloudflare.com). Their API is not documented, as far as I know, and so that is a vulnerability for this code.
-
-Mirroring the performance of the Cloudflare webpage, the CLI does multiple uploads and downloads with different block sizes and the 90th percentile of all these tests is used for calculating up and download times. Results are similar to those obtained from the webpage. Tests can be varied using the cloudflareclass module.
-
-Unlike Ookla's speedtest CLI, Cloudflare does not require downloading a licensed exe. Cloudflare uses test sites from its own network of caching and hosting centers. This is useful because much of the content users would be retrieving is actually coming from these centers. On the other hand, coverage may be thin in some parts of the world.
+Tests for latency are done by requesting one byte packets from Cloudflare,
+measuring the elapsed time to get a response, and subtracting the server
+processing time taken from the header in the returned message. Jitter is
+computed as the mean of the absolute difference between the arrival of
+consecutive requests.
+
+The cloudflare module makes Python requests to various endpoints of
+[speed.cloudflare.com](https://speed.cloudflare.com). Their API is not
+documented, as far as I know, and so that is a potential breaking point for this
+code.
+
+Mirroring the performance of the Cloudflare webpage, the CLI does multiple
+uploads and downloads with different block sizes and the 90th percentile of all
+these tests is used for calculating up and download times. Results are similar
+to those obtained from the webpage. Tests can be varied using the cloudflare
+module.
+
+Unlike Ookla's speedtest CLI, Cloudflare does not require downloading a licensed
+exe. Cloudflare uses test sites from its own network of caching and hosting
+centers. This is useful because much of the content users would be retrieving is
+actually coming from these centers. On the other hand, coverage may be thin in
+some parts of the world.
 
 ## Privacy
 
-No identifying information is sent to any website other than the IP address which servers can see in an HTTP request. Cloudflare can probably deduce something from the tests it runs. No results are sent anywhere. Because this an application and not running in a browser, there are no cookies.
+No identifying information is sent to any website other than the IP address that
+servers can see in an HTTP request. Cloudflare can probably deduce something,
+mostly about your location and network architecture, from the tests it runs. No
+results are sent anywhere. Because this an application and not running in a
+browser, there are no cookies.
 
 Full source is available in this package.
 
 ## Background
 
-Billions of federal dollars are being disbursed to improve broadband availability and quality, especially in rural areas. Tools are needed to assure that ISPs deliver the quality they promise. This software is a pro bono contribution to getting those tools written. 
+Billions of federal dollars are being disbursed to improve broadband
+availability and quality, especially in rural areas. Tools are needed to assure
+that ISPs deliver the quality they promise. This software is a pro bono
+contribution to getting those tools written. 
 
 ## Disclaimers
 
-No claims of any sort are made for this software. It has been tested on Windows 10 and 11, MacOS, and  Raspberry Pi OS and should work on other Linux versions but not tested. Use and/or redistribute solely at your own risk. No commitment is made to maintain this software. As noted above, changes made by Cloudflare might break the functionality.
-
-I have no affiliation with Cloudflare, any hosting service, or any ISP (except as a customer).
-
+No claims of any sort are made for this software. It has been tested on Windows
+10 and 11, MacOS, and  Raspberry Pi OS and should work on other Linux versions
+but not tested. Use and/or redistribute solely at your own risk. No commitment
+is made to maintain this software. As noted above, changes made by Cloudflare
+might break the functionality.
 
+I have no affiliation with Cloudflare, any hosting service, or any ISP (except
+as a customer).
diff --git a/pyproject.toml b/pyproject.toml
index dd81614..528a267 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,29 @@
+[project]
+name = "cloudflarepycli"
+version = "2.0.0"
+description = "Python CLI and utiltiies for retrieving network performance statistics."
+authors = [{ name = "Tom Evslin", email = "tevslin@gmail.com"}]
+readme = "README.md"
+license = { file = "LICENSE" }
+requires-python = ">=3.8"
+keywords = ["speedtest", "cloudflare", "latency", "jitter"]
+classifiers = [
+  "Programming Language :: Python :: 3",
+  "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+  "Operating System :: OS Independent",
+]
+dependencies = ["requests>=2.32.3"]
+
+[project.urls]
+Homepage = "https://github.com/tevslin/cloudflarepycli"
+Issues = "https://github.com/cloudflarepycli/issues"
+
+[project.scripts]
+cfspeedtest = "cfspeedtest.__main__:cfspeedtest"
+
 [build-system]
-requires=[
-	"pip>=21.0.1",
-	"wheel",
-	"setuptools>=42",
-	"twine>=3.4.1"
+requires = [
+  "setuptools>=61.0",
+  "twine>=5.1.0",
 ]
-build-backend = "setuptools.build_meta"
\ No newline at end of file
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index aad78ff..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,37 +0,0 @@
-[metadata]
-name = cloudflarepycli
-version = 1.8.2
-author = "Tom Evslin"
-author_email = tevslin@gmail.com
-description = "Python CLI and python class for retrieving user's realtime performance statistics"
-long_description = file: README.md
-long_description_content_type = text/markdown
-url = https://github.com/tevslin/cloudflarepycli
-project_urls =
-    Bug Tracker = https://github.com/tevslin/cloudflarepycli/issues
-license_files = file:License
-classifiers =
-    Programming Language :: Python :: 3
-    License :: OSI Approved :: GNU General Public License v3 (GPLv3)
-    Operating System :: OS Independent
-	
-keywords=
-	speedtest
-	cloudflare
-	latency
-	jitter	
-
-[options]
-package_dir =
-    = src
-packages = find:
-python_requires = >=3.7
-install_requires=
-	requests
-
-[options.packages.find]
-where = src
-
-[options.entry_points]
-console_scripts =
-    cfspeedtest = cloudflarepycli.__main__:main
diff --git a/src/cloudflarepycli/__init__.py b/src/cloudflarepycli/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/cloudflarepycli/__main__.py b/src/cloudflarepycli/__main__.py
deleted file mode 100644
index f5149fe..0000000
--- a/src/cloudflarepycli/__main__.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-
-Uses cloudflareclass.py to generate statistics for current ISP which approximate those generated by Cloudflares's
-browser interface.
-
-no arguments required:
-    
-    --debug write trace of io using logger (default does not)
-    --json  out put to sysout is json rather than formatted printing
-
-Created on Mon Nov  8 13:39:48 2021
-
-@author: tevsl
-"""
-def main():
-    try:
-        from . import cloudflareclass
-    except:
-        import cloudflareclass
-    import json
-    import argparse
-    
-    thetester=cloudflareclass.cloudflare() #open first with no args to get version
-    version=thetester.version
-    thetester=None
-    
-    parser=argparse.ArgumentParser(description='Runs speed test of net connection using ping and Cloudflare')
-    parser.add_argument('--debug',action='store_true',help='log network io')
-    parser.add_argument('--json',action='store_true',help='write json to sysout instead of formatted results')
-    parser.add_argument('--version',action='version',version='%(prog)s version '+str(version))
-    args=parser.parse_args()
-    
-    thetester=cloudflareclass.cloudflare(debug=args.debug,printit=(not args.json)) #reopen with correct params
-    thedict=thetester.runalltests()
-    if args.json:
-        print (json.dumps(thedict))
-    else:
-        #test deprecated rountines
-        print (thetester.getcolo())
-        print (thetester.getcolodetails(""))
-        print (thetester.getisp(""))
-
-if __name__=='__main__':
-    main()
-    
diff --git a/src/cloudflarepycli/cloudflareclass.py b/src/cloudflarepycli/cloudflareclass.py
deleted file mode 100644
index b232c48..0000000
--- a/src/cloudflarepycli/cloudflareclass.py
+++ /dev/null
@@ -1,222 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-Created on Fri Nov  5 15:10:57 2021
-class object for connection testing with requests to speed.cloudflare.com
-runs tests and stores results in dictionary
-cloudflare(thedict=None,debug=False,print=True,downtests=None,uptests=None,latencyreps=20)
-
-thedict: dictionary to store results in
-    if not passed in, created here
-    if passed in, used and update - allows keeping partial results from previous runs
-    each result has a key and the entry is a dict with "time" and "value" items
-debug: True turns on io logging for debugging
-printit: if true, results are printed as well as added to the dictionary
-downtests: tuple of download tests to be performed
-    if None, defaultdowntests (see below) is used
-    format is ((size, reps, label)......)
-        size: size of block to download
-        reps: number of times to repeat test
-        label: text label for test - also becomes key in the dict
-uptests: tuple of upload tests to be performed
-    if None, defaultuptests (see below) is used
-    format is ((size, reps, label)......)
-        size: size of block to upload
-        reps: number of times to repeat test
-        label: text label for test - also becomes key in the dict
-latencyreps: number of repetitions for latency test
-
-version 1.8.0
-removed dependency on ipdatabase.com
-added getfulldata to get all data about test locs and isp from cloudflare
-getcolo, getcolodetails, and getisp deprecated but left functional
-
-version 1.8.1 (courtesey of Martin Brose)
-removed numpy dependencies to reduce footprint in docker environments
-
-version 1.8.2
-changed cloudflare endpoint URLs to use https
-
-@author: /tevslin
-"""
-
-class cloudflare:
-    #tests changed 1/1/22 to mirror those done by web-based test
-    uploadtests=((101000,8,'100kB'),(1001000, 6,'1MB'),(10001000, 4,'10MB'))
-    downloadtests=((101000, 10,'100kB'),(1001000, 8,'1MB'),(10001000, 6,'10MB'),(25001000, 4,'25MB'))
-    version="1.8.0" #7/1/23
-    version="1.8.1" #8/17/23
-    version="1.8.2" #6/18/24
-    def __init__(self,thedict=None,debug=False,printit=True,downtests=None,uptests=None,latencyreps=20,timeout=(3.05,25)):
-
-        import requests
-        
-        if debug:
-            import logging
-            
-            # Enabling debugging at http.client level (requests->urllib3->http.client)
-            # you will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA.
-            # the only thing missing will be the response.body which is not logged.
-            try: # for Python 3
-                from http.client import HTTPConnection
-            except ImportError:
-                from httplib import HTTPConnection
-            HTTPConnection.debuglevel = 1
-            
-            logging.basicConfig() # you need to initialize logging, otherwise you will not see anything from requests
-            logging.getLogger().setLevel(logging.DEBUG)
-            requests_log = logging.getLogger("urllib3")
-            requests_log.setLevel(logging.DEBUG)
-            requests_log.propagate = True
-            requests.get('https://httpbin.org/headers')
-
-        self.debug=debug
-        self.printit=printit
-        self.latencyreps=latencyreps
-        
-        self.thedict={} if thedict is None else thedict
-        if not downtests is None:
-            self.downloadtests=downtests
-        if not uptests is None:
-            self.uploadtests=uptests
-        self.mequests=requests.Session()
-        self.timeout=timeout
-
-    def getcolo(self):
-    # retrieves cloudflare colo, user ip address 
-    # deprecated but left for compatability
-        colo,ip,org,region,city=self.getfulldata()
-        return colo,ip
-    
-    def getisp(self,ip):
-    # retrieves ISP
-    # deprecated but left for compatability
-        colo,ip,org,region,city=self.getfulldata()
-        return org
-    
-    def getcolodetails(self,colo):
-        #retrieves region and city for cf gateway
-        # deprecated but left for compatability
-        colo,ip,org,region,city=self.getfulldata()
-        return region,city
-
-    
-    def getfulldata(self):
-    # retrieves cloudflare colo, user ip address, ISP, city, and region
-        r=self.mequests.get('https://speed.cloudflare.com/meta')       
-        dicty=r.json()
-        return dicty['colo'],dicty['clientIp'],dicty['asOrganization'],dicty['region'],dicty['city']
-
-    def download(self,numbytes,iterations):
-        #runs download tests
-        import os
-        from contextlib import nullcontext
-        import time
-        if os.name == 'nt':
-            import wres
-        fulltimes=() #list for all successful times
-        servertimes=() #times reported by server
-        requesttimes=() #rough proxy for ttfb
-        if os.name == 'nt':
-            cm = wres.set_resolution()
-        else:
-            cm = nullcontext()
-        with cm:
-            for i in range(iterations):
-                start=time.time()
-                err=False
-                try: 
-                    r=self.mequests.get('https://speed.cloudflare.com/__down?bytes='+str(numbytes),timeout=self.timeout)
-                    end=time.time()
-                except:
-                    err=True
-                if not err:
-                    fulltimes=fulltimes+(end-start,)
-                    servertimes=servertimes+(float(r.headers['Server-Timing'].split('=')[1])/1e3,)
-                    requesttimes=requesttimes+(r.elapsed.seconds+r.elapsed.microseconds/1e6,)
-        return (fulltimes,servertimes,requesttimes)
-
-    def upload(self,numbytes,iterations):
-        #runs upload tests
-        servertimes=() #times reported by server
-        thedata=bytearray(numbytes)
-        for i in range(iterations):
-            err=False
-            try: 
-                r=self.mequests.post('https://speed.cloudflare.com/__up',data=thedata,timeout=self.timeout)
-            except:
-                err=True
-            if not err:
-                servertimes=servertimes+(float(r.headers['Server-Timing'].split('=')[1])/1e3,)
-        return (servertimes)
-
-    def sprint(self,label,value):
-        "time stamps entry and adds to dictionary replacing spaces with underscores in key and optionally prints"
-        import time
-        if self.printit:
-            print(label+":",value)
-        self.thedict[label.replace(' ','_')]={"time":time.time(),"value":value} #add to dictionary
-        
-
-    def calculate_percentile(self, data, percentile):
-        """
-        Find the percentile of a list of values.
-
-        Input:
-        data - is a list of values.
-        percent - a float value from 0.0 to 1.0.
-
-        Output: the percentile of the values
-        """
-        sorted_data = sorted(data)
-        index = (percentile / 100) * (len(sorted_data)-1)
-
-        if index.is_integer():
-            return sorted_data[int(index)]
-        else:
-            lower_index = int(index)
-            upper_index = lower_index + 1
-            d0 = sorted_data[int(lower_index)] * (upper_index-index)
-            d1 = sorted_data[int(upper_index)] * (index-lower_index)
-            return d0+d1
-
-    def runalltests(self):
-        #runs full suite of tests
-        import array
-        import statistics
-        
-        self.sprint('version',self.version)
-        colo,ip,isp,region,city=self.getfulldata() 
-        self.sprint('your ip',ip)
-        self.sprint('your ISP',isp)
-        self.sprint('test location code',colo)
-        self.sprint ('test location city',city)
-        self.sprint ('test location region',region)        
-        fulltimes,servertimes,requesttimes=self.download(1,self.latencyreps) #measure latency and jitter
-        latencies= [(requesttimes[i] - servertimes[i])*1e3 for i in range(len(requesttimes))]
-        jitter=statistics.median([abs(latencies[i]-latencies[i-1]) for i in range(1,len(latencies))])
-        self.sprint ('latency ms',round(statistics.median(latencies),2))
-        self.sprint ('Jitter ms',round(jitter,2))
-        
-            
-        alltests=()
-        
-        for tests in self.downloadtests:
-            fulltimes,servertimes,requesttimes=self.download(tests[0],tests[1])
-            downtimes = array.array('f',[fulltimes[i] - requesttimes[i] for i in range(len(fulltimes))])
-            downspeeds = [tests[0]*8 / downtimes[i] / 1e6 for i in range(len(downtimes))]
-            self.sprint(tests[2]+' download Mbps',round(statistics.mean(downspeeds),2))
-            for speed in downspeeds:
-                alltests=alltests+(speed,)
-    
-        self.sprint('90th percentile download speed',round(self.calculate_percentile(alltests,90),2))
-        
-        alltests=()
-        for tests in self.uploadtests:
-            servertimes=self.upload(tests[0],tests[1])
-            upspeeds = [tests[0]*8 / servertimes[i] / 1e6 for i in range(len(servertimes))]
-            self.sprint(tests[2]+' upload Mbps',round(statistics.mean(upspeeds),2))
-            for speed in upspeeds:
-                alltests=alltests+(speed,)
-        
-        self.sprint('90th percentile upload speed',round(self.calculate_percentile(alltests,90),2))
-        return(self.thedict)


Test patch:
diff --git a/cfspeedtest/__init__.py b/cfspeedtest/__init__.py
new file mode 100644
index 0000000..9b1e5db
--- /dev/null
+++ b/cfspeedtest/__init__.py
@@ -0,0 +1,5 @@
+"""cfspeedtest, a Cloudflare speedtest suite in Python."""
+
+from cfspeedtest.cloudflare import CloudflareSpeedtest, TestSpec, TestType
+
+__all__ = ("CloudflareSpeedtest", "TestSpec", "TestType")
diff --git a/cfspeedtest/__main__.py b/cfspeedtest/__main__.py
new file mode 100644
index 0000000..6de3f87
--- /dev/null
+++ b/cfspeedtest/__main__.py
@@ -0,0 +1,47 @@
+"""The cfspeedtest CLI implementation."""
+
+import json
+import sys
+from argparse import ArgumentParser
+
+from cfspeedtest.cloudflare import CloudflareSpeedtest
+from cfspeedtest.logger import log, set_verbosity, setup_log
+from cfspeedtest.version import __version__
+
+
+def cfspeedtest() -> None:
+    """Run a network speedtest suite via Cloudflare."""
+    parser = ArgumentParser(prog="cfspeedtest", description=cfspeedtest.__doc__)
+    parser.add_argument("--debug", action="store_true", help="Log network I/O.")
+    parser.add_argument(
+        "--json", action="store_true", help="Write JSON to stdout."
+    )
+    parser.add_argument(
+        "--bps",
+        action="store_true",
+        help="Show output in bits per second.",
+    )
+    parser.add_argument(
+        "--version",
+        action="store_true",
+        help="Show program's version and exit.",
+    )
+    args = parser.parse_args()
+
+    setup_log(silent=args.json)
+    set_verbosity(debug=args.debug)
+
+    if args.version:
+        log.info("cfspeedtest %s", __version__)
+        log.debug("Python %s", sys.version)
+        sys.exit(0)
+
+    results = CloudflareSpeedtest().run_all(megabits=not args.bps)
+
+    if args.json:
+        setup_log()
+        log.info(json.dumps(CloudflareSpeedtest.results_to_dict(results)))
+
+
+if __name__ == "__main__":
+    cfspeedtest()
diff --git a/cfspeedtest/cloudflare.py b/cfspeedtest/cloudflare.py
new file mode 100644
index 0000000..569b049
--- /dev/null
+++ b/cfspeedtest/cloudflare.py
@@ -0,0 +1,272 @@
+"""
+Library for the Cloudflare speedtest suite.
+
+This uses endpoints from speed.cloudflare.com.
+"""
+
+from __future__ import annotations
+
+import logging
+import statistics
+import time
+from enum import Enum
+from typing import Any, NamedTuple
+
+import requests
+
+log = logging.getLogger("cfspeedtest")
+
+
+class TestType(Enum):
+    """The type of an individual test."""
+
+    Down = "GET"
+    Up = "POST"
+
+
+class TestSpec(NamedTuple):
+    """The specifications of an individual test."""
+
+    size: int
+    """The size of the test in bytes."""
+    iterations: int
+    name: str
+    type: TestType
+
+    @property
+    def bits(self) -> int:
+        """The size of the test in bits."""
+        return self.size * 8
+
+
+TestSpecs = tuple[TestSpec, ...]
+
+DOWNLOAD_TESTS: TestSpecs = (
+    TestSpec(100_000, 10, "100kB", TestType.Down),
+    TestSpec(1_000_000, 8, "1MB", TestType.Down),
+    TestSpec(10_000_000, 6, "10MB", TestType.Down),
+    TestSpec(25_000_000, 4, "25MB", TestType.Down),
+)
+UPLOAD_TESTS: TestSpecs = (
+    TestSpec(100_000, 8, "100kB", TestType.Up),
+    TestSpec(1_000_000, 6, "1MB", TestType.Up),
+    TestSpec(10_000_000, 4, "10MB", TestType.Up),
+)
+DEFAULT_TESTS: TestSpecs = (
+    TestSpec(1, 20, "latency", TestType.Down),
+    *DOWNLOAD_TESTS,
+    *UPLOAD_TESTS,
+)
+
+
+class TestResult(NamedTuple):
+    """The result of an individual test."""
+
+    value: Any
+    time: float = time.time()
+
+
+class TestTimers(NamedTuple):
+    """A collection of test timer collections, measured in seconds."""
+
+    full: list[float]
+    """The times taken to prepare and perform the requests."""
+    server: list[float]
+    """The times taken to process the requests as reported by the worker."""
+    request: list[float]
+    """The internal client times elapsed to complete the requests."""
+
+    def to_speeds(self, test: TestSpec) -> list[int]:
+        """Compute the test speeds in bits per second from its type and size."""
+        if test.type == TestType.Up:
+            return [int(test.bits / server_time) for server_time in self.server]
+        return [
+            int(test.bits / (full_time - server_time))
+            for full_time, server_time in zip(self.full, self.server)
+        ]
+
+    def to_latencies(self) -> list[float]:
+        """Compute the test latencies in milliseconds."""
+        return [
+            (request_time - server_time) * 1e3
+            for request_time, server_time in zip(self.request, self.server)
+        ]
+
+    @staticmethod
+    def jitter_from(latencies: list[float]) -> float | None:
+        """Compute jitter as average deviation between consecutive latencies."""
+        if len(latencies) < 2:
+            return None
+        return statistics.mean(
+            [
+                abs(latencies[i] - latencies[i - 1])
+                for i in range(1, len(latencies))
+            ]
+        )
+
+
+class TestMetadata(NamedTuple):
+    """The metadata of a test suite."""
+
+    ip: str
+    isp: str
+    location_code: str
+    region: str
+    city: str
+
+
+def _calculate_percentile(data: list[float], percentile: float) -> float:
+    """Find the percentile of a list of values."""
+    data = sorted(data)
+    idx = (len(data) - 1) * percentile
+    rem = idx % 1
+
+    if rem == 0:
+        return data[int(idx)]
+
+    edges = (data[int(idx)], data[int(idx) + 1])
+    return edges[0] + (edges[1] - edges[0]) * rem
+
+
+SuiteResults = dict[str, dict[str, TestResult]]
+
+
+class CloudflareSpeedtest:
+    """Suite of speedtests."""
+
+    def __init__(  # noqa: D417
+        self,
+        results: SuiteResults | None = None,
+        tests: TestSpecs = DEFAULT_TESTS,
+        timeout: tuple[float, float] | float = (3.05, 25),
+    ) -> None:
+        """
+        Initialize the test suite.
+
+        Arguments:
+        ---------
+        - `results`: A dictionary of test results. This can be used to include
+        results from previous runs.
+        - `tests`: The specifications (see `TestSpec`) for all tests to run.
+        - `timeout`: The timeout settings for all requests. See the Timeouts
+        page of the `requests` documentation for more information.
+        - `logger`: The logger that `CloudflareSpeedtest` will use when it
+        runs tests, exclusively via `run_all`. When this is set to None,
+        no logging will occur.
+
+        """
+        self.results = results or {}
+        self.results.setdefault("tests", {})
+        self.results.setdefault("meta", {})
+
+        self.tests = tests
+        self.request_sess = requests.Session()
+        self.timeout = timeout
+
+    def metadata(self) -> TestMetadata:
+        """Retrieve test location code, IP address, ISP, city, and region."""
+        result_data: dict[str, str] = self.request_sess.get(
+            "https://speed.cloudflare.com/meta"
+        ).json()
+        return TestMetadata(
+            result_data["clientIp"],
+            result_data["asOrganization"],
+            result_data["colo"],
+            result_data["region"],
+            result_data["city"],
+        )
+
+    def run_test(self, test: TestSpec) -> TestTimers:
+        """Run a test specification iteratively and collect timers."""
+        coll = TestTimers([], [], [])
+        url = f"https://speed.cloudflare.com/__down?bytes={test.size}"
+        data = None
+        if test.type == TestType.Up:
+            url = "https://speed.cloudflare.com/__up"
+            data = b"".zfill(test.size)
+
+        for _ in range(test.iterations):
+            start = time.time()
+            r = self.request_sess.request(
+                test.type.value, url, data=data, timeout=self.timeout
+            )
+            coll.full.append(time.time() - start)
+            coll.server.append(
+                float(r.headers["Server-Timing"].split("=")[1]) / 1e3
+            )
+            coll.request.append(
+                r.elapsed.seconds + r.elapsed.microseconds / 1e6
+            )
+        return coll
+
+    def _sprint(
+        self, label: str, result: TestResult, *, meta: bool = False
+    ) -> None:
+        """Add an entry to the suite results and log it."""
+        log.info("%s: %s", label, result.value)
+        save_to = self.results["meta"] if meta else self.results["tests"]
+        save_to[label] = result
+
+    def run_all(self, *, megabits: bool = False) -> SuiteResults:
+        """Run the full test suite."""
+        meta = self.metadata()
+        self._sprint("ip", TestResult(meta.ip), meta=True)
+        self._sprint("isp", TestResult(meta.isp))
+        self._sprint("location_code", TestResult(meta.location_code), meta=True)
+        self._sprint("location_city", TestResult(meta.city), meta=True)
+        self._sprint("location_region", TestResult(meta.region), meta=True)
+
+        data = {"down": [], "up": []}
+        for test in self.tests:
+            timers = self.run_test(test)
+
+            if test.name == "latency":
+                latencies = timers.to_latencies()
+                jitter = timers.jitter_from(latencies)
+                if jitter:
+                    jitter = round(jitter, 2)
+                self._sprint(
+                    "latency",
+                    TestResult(round(statistics.mean(latencies), 2)),
+                )
+                self._sprint("jitter", TestResult(jitter))
+                continue
+
+            speeds = timers.to_speeds(test)
+            data[test.type.name.lower()].extend(speeds)
+            # TODO: reduce code duplication of megabits reporting
+            mean_speed = int(statistics.mean(speeds))
+            label_suffix = "bps"
+            if megabits:
+                mean_speed = round(mean_speed / 1e6, 2)
+                label_suffix = "mbps"
+            self._sprint(
+                f"{test.name}_{test.type.name.lower()}_{label_suffix}",
+                TestResult(mean_speed),
+            )
+
+        for k, v in data.items():
+            result = None
+            if len(v) > 0:
+                result = int(_calculate_percentile(v, 0.9))
+            label_suffix = "bps"
+            if megabits:
+                result = round(result / 1e6, 2) if result else result
+                label_suffix = "mbps"
+            self._sprint(
+                f"90th_percentile_{k}_{label_suffix}",
+                TestResult(result),
+            )
+
+        return self.results
+
+    @staticmethod
+    def results_to_dict(
+        results: SuiteResults,
+    ) -> dict[str, dict[str, dict[str, float]]]:
+        """Convert the test results to a full dictionary."""
+        return {
+            k: {sk: sv._asdict()}
+            for k, v in results.items()
+            for sk, sv in v.items()
+        }
diff --git a/cfspeedtest/logger.py b/cfspeedtest/logger.py
new file mode 100644
index 0000000..401693f
--- /dev/null
+++ b/cfspeedtest/logger.py
@@ -0,0 +1,26 @@
+"""Logger utility for the cfspeedtest CLI."""
+
+import logging
+from http.client import HTTPConnection
+
+log = logging.getLogger("cfspeedtest")
+
+
+def setup_log(*, silent: bool = False) -> None:
+    """Initialise the log."""
+    if not silent:
+        log.setLevel(logging.INFO)
+        logging.basicConfig(format="%(message)s")
+
+
+def set_verbosity(*, debug: bool = False) -> None:
+    """Set the logger to debug mode."""
+    if debug:
+        HTTPConnection.debuglevel = 1
+        log.setLevel(logging.DEBUG)
+        logging.basicConfig(
+            format="[%(levelname)s] (%(asctime)s) %(name)s: %(message)s"
+        )
+        requests_log = logging.getLogger("urllib3")
+        requests_log.setLevel(logging.DEBUG)
+        requests_log.propagate = True
diff --git a/cfspeedtest/version.py b/cfspeedtest/version.py
new file mode 100644
index 0000000..49d29c4
--- /dev/null
+++ b/cfspeedtest/version.py
@@ -0,0 +1,3 @@
+"""Current version of cfspeedtest."""
+
+__version__ = "2.0.0"