Repository: NHSDigital/mesh-client (language: python)

Issue:
major uplift, drop support for python < 3.7 and migrate to using mesh-sandbox
Aligning it with other MESH repos, 
- use pyproject.toml
- use poetry, asdf, pytest, flake8, black, isort, coverage
- Major change: drop support for End of Life python versions ( pre python 3.7 )
- Major change: remove MockMeshServer from wheel distribution ( with an intent to remove it completely in the near future)
- Add type annotations, where possible


Discussion / hints:

Fix patch:
diff --git a/.gitallowed b/.gitallowed
new file mode 100644
index 0000000..e390d95
--- /dev/null
+++ b/.gitallowed
@@ -0,0 +1,36 @@
+
+token: \$\{\{ secrets.GITHUB_TOKEN \}\}
+token = self.generate_token()
+password: \$\{\{ secrets.PYPI_TOKEN \}\}
+password: \$\{\{ secrets.TEST_PYPI_TOKEN \}\}
+mesh_client/__init__.py:..*password: str
+mesh_client/nhs-dep-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-dep-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-dev-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-dev-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-digicert-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-digicert-ca-bundle.pem:28:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-digicert-ca-bundle.pem:50:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-digicert-root-ca.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-digicert-root-ca.pem:28:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-ig-int-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-ig-int-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-ig-live-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-ig-live-ca-bundle.pem:25:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-int-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-int-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-live-root-ca.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-opt-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-opt-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-train-ca-bundle.pem:1:-----BEGIN CERTIFICATE-----
+mesh_client/nhs-train-ca-bundle.pem:22:-----BEGIN CERTIFICATE-----
+tests/ca.cert.pem:1:-----BEGIN CERTIFICATE-----
+tests/ca.key.pem:1:-----BEGIN RSA PRIVATE KEY-----
+tests/ca.key.pem:51:-----END RSA PRIVATE KEY-----
+tests/client.cert.pem:1:-----BEGIN CERTIFICATE-----
+tests/client.key.pem:1:-----BEGIN RSA PRIVATE KEY-----
+tests/client.key.pem:51:-----END RSA PRIVATE KEY-----
+tests/server.cert.pem:1:-----BEGIN CERTIFICATE-----
+tests/server.key.pem:1:-----BEGIN RSA PRIVATE KEY-----
+tests/server.key.pem:51:-----END RSA PRIVATE KEY-----
+tests/mailboxes.jsonl:.*"password": "password"
\ No newline at end of file
diff --git a/.gitdisallowed b/.gitdisallowed
new file mode 100644
index 0000000..2683423
--- /dev/null
+++ b/.gitdisallowed
@@ -0,0 +1,21 @@
+https://hooks.slack.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8,12}/[a-zA-Z0-9_]{24}
+amzn\.mws\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
+arn:aws:iam::0*[0-9][0-9]*
+ghp_[0-9a-zA-Z]{36}
+gho_[0-9a-zA-Z]{36}
+(ghu|ghs)_[0-9a-zA-Z]{36}
+ghr_[0-9a-zA-Z]{76}
+xox[baprs]-([0-9a-zA-Z]{10,48})?
+-----BEGIN\ ((EC|PGP|DSA|RSA|OPENSSH)\ )?PRIVATE KEY( BLOCK)?-----
+AIza[0-9A-Za-z\\-_]{35}
+'"type": "service_account"'
+[a-z]{2}-[a-z-]*-[1,2,3]\.rds\.amazonaws\.com
+[a-z]{2}-[a-z-]*-[1,2,3]\.es\.amazonaws\.com
+[a-z]*[1-3]\.cache\.amazonaws\.com
+-----BEGIN[[:blank:]]CERTIFICATE-----
+[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}
+(CLIENT|client|Client)(_|\s)(SECRET|secret|Secret)\s*(:|=>|=)\s*("|')?(\{)?[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\})?("|')?
+("|'?)[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]("|'?)\s*(=|:)\s*.+
+("|'?)[Tt][Oo][Kk][Ee][Nn]("|'?)\s*(=|:)\s*.+
+
+###_NOTE_REMOVED_PREVIOUS_IP_RULE_:[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}###
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..b9badf8
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,1 @@
+* @matt-mercer @nhsdigital/mesh-to-cloud-admins
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..3b9065f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+  - package-ecosystem: "pip"
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "monthly"
\ No newline at end of file
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..7aa8ef7
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,11 @@
+changelog:
+  categories:
+    - title: 🏕 Features
+      labels:
+        - '*'
+      exclude:
+        labels:
+          - dependencies
+    - title: 👒 Dependencies
+      labels:
+        - dependencies
diff --git a/.github/workflows/merge-develop.yml b/.github/workflows/merge-develop.yml
new file mode 100644
index 0000000..fef7a9a
--- /dev/null
+++ b/.github/workflows/merge-develop.yml
@@ -0,0 +1,93 @@
+name: merge-develop
+on:
+  push:
+    branches:
+      - develop
+
+jobs:
+
+  coverage:
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client' && !contains(github.event.head_commit.message, 'tag release version:')
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: "3.8"
+
+      - name: setup poetry
+        uses: abatilo/actions-poetry@v2
+        with:
+          poetry-version: 1.5.1
+
+      - name: add poetry plugins
+        run: |
+          poetry self add "poetry-dynamic-versioning[plugin]"
+
+      - name: cache virtualenv
+        uses: actions/cache@v3
+        with:
+          path: |
+            .venv
+          key: ${{ runner.os }}-v3-poetry-py3.8-${{ hashFiles('./poetry.lock') }}
+
+      - name: git reset
+        run: git reset --hard
+
+      - name: install dependencies
+        run: make install-ci
+
+      - name: start docker containers
+        run: make up
+
+      - name: code coverage
+        run: make coverage-ci
+
+      - name: code coverage report
+        if: ${{ github.event_name == 'pull_request' }}
+        uses: orgoro/coverage@32a2af98e56f47d958cf4c64b1f5e02161f2853e
+        with:
+          coverageFile: reports/coverage.xml
+          token: ${{ secrets.GITHUB_TOKEN }}
+          thresholdAll: 0.70
+
+      - name: setup java
+        if: github.actor != 'dependabot[bot]' && (success() || failure())
+        uses: actions/setup-java@v3
+        with:
+          distribution: "corretto"
+          java-version: "17"
+
+      - name: provision sonar-scanner
+        if: github.actor != 'dependabot[bot]' && (success() || failure())
+        run: |
+          export SONAR_VERSION="4.7.0.2747"
+          wget -q "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}.zip" -O sonar-scanner.zip
+          unzip -q ./sonar-scanner.zip
+          mv ./sonar-scanner-${SONAR_VERSION} ./sonar-scanner
+          scripts/sonar_tests.py
+
+      - name: run sonar scan
+        if: github.actor != 'dependabot[bot]' && (success() || failure())
+        run: |
+          PATH="$PWD/sonar-scanner/bin:$PATH"
+          sonar-scanner
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+      - name: publish junit reports
+        if: success() || failure()
+        uses: mikepenz/action-junit-report@v2
+        with:
+          check_name: junit reports
+          report_paths: reports/junit/*.xml
+
+      - name: stop docker containers
+        if: success() || failure()
+        run: make down
diff --git a/.github/workflows/merge-release.yml b/.github/workflows/merge-release.yml
new file mode 100644
index 0000000..077afbf
--- /dev/null
+++ b/.github/workflows/merge-release.yml
@@ -0,0 +1,138 @@
+name: merge-release
+on:
+  push:
+    branches:
+      - release
+
+jobs:
+
+  test-publish:
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client' && github.actor != 'dependabot[bot]'
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: print branch info
+        run: |
+          git branch
+          echo "GITHUB_HEAD_REF=${GITHUB_HEAD_REF}"
+          echo "GITHUB_BASE_REF=${GITHUB_BASE_REF}"
+          git log --oneline -n 10
+
+      - name: clean
+        run: |
+          git clean -fdx
+          find . -type f | xargs chmod g+w
+
+      - name: merge into base_branch
+        if: ${{ github.event_name == 'pull_request' }}
+        run: |
+          echo base branch "${{ github.base_ref }}"
+          echo pr branch "${{ github.head_ref }}"
+          git checkout "${{ github.base_ref }}"
+          git checkout -b "merging-${{ github.event.number }}"
+          git merge --ff-only "${{ github.event.pull_request.head.sha }}"
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: "3.8"
+
+      - name: setup poetry
+        uses: abatilo/actions-poetry@v2
+        with:
+          poetry-version: 1.5.1
+
+      - name: add poetry plugins
+        run: |
+          poetry self add "poetry-dynamic-versioning[plugin]"
+
+      - name: build dist
+        run: |
+          RELEASE_VERSION="v$(poetry version | rev | cut -d' ' -f1 | rev)"
+          echo "${RELEASE_VERSION}"
+          echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV
+          if [[ "${RELEASE_VERSION}" == "0.0.0" ]]; then
+            echo "${RELEASE_VERSION} = 0.0.0"
+            exit 1
+          fi
+          git tag "${RELEASE_VERSION}"
+          RELEASE_VERSION="${RELEASE_VERSION}" make dist
+          ls -l dist
+
+      - name: test publish
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          password: ${{ secrets.TEST_PYPI_TOKEN }}
+          repository-url: https://test.pypi.org/legacy/
+
+      - name: install and test
+        run: |
+          python3 -m pip install --index-url https://test.pypi.org/simple/ "mesh-client==${RELEASE_VERSION}"
+          INSTALLED_VERSION="$(python3 -c 'import mesh_client; print(mesh_client.__version__)')"
+          if [[ "${INSTALLED_VERSION}" != "${RELEASE_VERSION}" ]]; then
+            echo "${INSTALLED_VERSION} != ${RELEASE_VERSION}"
+            exit 1
+          fi
+
+  release:
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client' && github.actor != 'dependabot[bot]'
+    needs:
+      - test-publish
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: clean
+        run: |
+          git clean -fdx
+          find . -type f | xargs chmod g+w
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: "3.8"
+
+      - name: setup poetry
+        uses: abatilo/actions-poetry@v2
+        with:
+          poetry-version: 1.5.1
+
+      - name: add poetry plugins
+        run: |
+          poetry self add "poetry-dynamic-versioning[plugin]"
+
+      - name: dist
+        run: |
+          RELEASE_VERSION="$(poetry version patch | rev | cut -d' ' -f1 | rev)"
+          echo "${RELEASE_VERSION}"
+          echo "RELEASE_VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV
+          if [[ "${RELEASE_VERSION}" == "0.0.0" ]]; then
+            echo "${RELEASE_VERSION} = 0.0.0"
+            exit 1
+          fi
+          git tag "${RELEASE_VERSION}"
+          RELEASE_VERSION="${RELEASE_VERSION}" make dist
+
+      - name: create release
+        id: create_release
+        uses: actions/create-release@v1
+        continue-on-error: false
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ env.RELEASE_VERSION }}
+          release_name: ${{ env.RELEASE_VERSION }}
+
+      - name: pypi publish
+        uses: pypa/gh-action-pypi-publish@release/v1
+        with:
+          password: ${{ secrets.PYPI_TOKEN }}
+
+
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
new file mode 100644
index 0000000..db79ecb
--- /dev/null
+++ b/.github/workflows/pull-request.yml
@@ -0,0 +1,264 @@
+name: pull-request
+on:
+  pull_request:
+    branches:
+      - develop
+
+jobs:
+
+  tox:
+    strategy:
+      matrix:
+        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client'
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: print branch info
+        run: |
+          git branch
+          echo "GITHUB_HEAD_REF=${GITHUB_HEAD_REF}"
+          echo "GITHUB_BASE_REF=${GITHUB_BASE_REF}"
+          git log --oneline -n 10
+
+      - name: clean
+        run: |
+          git clean -fdx
+          find . -type f | xargs chmod g+w
+
+      - name: merge into base_branch
+        if: ${{ github.event_name == 'pull_request' }}
+        run: |
+          echo base branch "${{ github.base_ref }}"
+          echo pr branch "${{ github.head_ref }}"
+          git checkout "${{ github.base_ref }}"
+          git checkout -b "merging-${{ github.event.number }}"
+          git merge --ff-only "${{ github.event.pull_request.head.sha }}"
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: start docker containers
+        run: make up
+
+      - name: install dependencies
+        run: pip install tox tox-gh-actions
+
+      - name: tox
+        run: tox
+
+      - name: stop docker containers
+        if: success() || failure()
+        run: make down
+
+
+  coverage:
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client'
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: print branch info
+        run: |
+          git branch
+          echo "GITHUB_HEAD_REF=${GITHUB_HEAD_REF}"
+          echo "GITHUB_BASE_REF=${GITHUB_BASE_REF}"
+          git log --oneline -n 10
+
+      - name: clean
+        run: |
+          git clean -fdx
+          find . -type f | xargs chmod g+w
+
+      - name: secrets-check
+        run: make check-secrets
+
+      - name: merge into base_branch
+        if: ${{ github.event_name == 'pull_request' }}
+        run: |
+          echo base branch "${{ github.base_ref }}"
+          echo pr branch "${{ github.head_ref }}"
+          git checkout "${{ github.base_ref }}"
+          git checkout -b "merging-${{ github.event.number }}"
+          git merge --ff-only "${{ github.event.pull_request.head.sha }}"
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: "3.8"
+
+      - name: setup poetry
+        uses: abatilo/actions-poetry@v2
+        with:
+          poetry-version: 1.5.1
+
+      - name: add poetry plugins
+        run: |
+          poetry self add "poetry-dynamic-versioning[plugin]"
+
+      - name: cache virtualenv
+        uses: actions/cache@v3
+        with:
+          path: |
+            .venv
+          key: ${{ runner.os }}-v3-poetry-py3.8-${{ hashFiles('./poetry.lock') }}
+
+      - name: git reset
+        run: git reset --hard
+
+      - name: install dependencies
+        run: make install-ci
+
+      - name: black
+        run: make black-check
+
+      - name: start docker containers
+        run: make up
+
+      - name: code coverage
+        run: make coverage-ci
+
+      - name: code coverage report
+        if: ${{ github.event_name == 'pull_request' }}
+        uses: orgoro/coverage@32a2af98e56f47d958cf4c64b1f5e02161f2853e
+        with:
+          coverageFile: reports/coverage.xml
+          token: ${{ secrets.GITHUB_TOKEN }}
+          thresholdAll: 0.70
+
+      - name: setup java
+        if: github.actor != 'dependabot[bot]' && (success() || failure())
+        uses: actions/setup-java@v3
+        with:
+          distribution: "corretto"
+          java-version: "17"
+
+#      - name: provision sonar-scanner
+#        if: github.actor != 'dependabot[bot]' && (success() || failure())
+#        run: |
+#          export SONAR_VERSION="4.7.0.2747"
+#          wget -q "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}.zip" -O sonar-scanner.zip
+#          unzip -q ./sonar-scanner.zip
+#          mv ./sonar-scanner-${SONAR_VERSION} ./sonar-scanner
+#          scripts/sonar_tests.py
+#
+#      - name: run sonar scan
+#        if: github.actor != 'dependabot[bot]' && (success() || failure())
+#        run: |
+#          PATH="$PWD/sonar-scanner/bin:$PATH"
+#          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+#            git checkout "${GITHUB_HEAD_REF}"
+#            sonar-scanner -Dsonar.pullrequest.branch="{{ github.event.pull_request.head.sha }}" -Dsonar.pullrequest.base="{{ github.event.pull_request.base.sha }}" -Dsonar.pullrequest.key="${{ github.event.number }}"
+#          else
+#            sonar-scanner
+#          fi
+#        env:
+#          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+#          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+
+      - name: archive reports
+        if: github.actor != 'dependabot[bot]' && (success() || failure())
+        uses: actions/upload-artifact@v3
+        with:
+          name: reports
+          path: reports/**/*
+
+      - name: publish junit reports
+        if: success() || failure()
+        uses: mikepenz/action-junit-report@v3
+        with:
+          check_name: junit reports
+          report_paths: reports/junit/*.xml
+
+      - name: stop docker containers
+        if: success() || failure()
+        run: make down
+
+  lint:
+    runs-on: ubuntu-latest
+    if: github.repository == 'NHSDigital/mesh-client'
+    steps:
+      - name: checkout
+        uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+
+      - name: clean
+        run: |
+          git clean -fdx
+
+      - name: secrets-check
+        run: make check-secrets
+
+      - name: merge into base_branch
+        if: ${{ github.event_name == 'pull_request' }}
+        run: |
+          echo base branch "${{ github.base_ref }}"
+          echo pr branch "${{ github.head_ref }}"
+          git checkout "${{ github.base_ref }}"
+          git checkout -b "merging-${{ github.event.number }}"
+          git merge --ff-only "${{ github.event.pull_request.head.sha }}"
+
+      - name: setup python
+        uses: actions/setup-python@v3
+        with:
+          python-version: "3.8"
+
+      - name: setup poetry
+        uses: abatilo/actions-poetry@v2
+        with:
+          poetry-version: 1.5.1
+
+      - name: add poetry plugins
+        run: |
+          poetry self add "poetry-dynamic-versioning[plugin]"
+
+      - name: cache virtualenv
+        uses: actions/cache@v3
+        with:
+          path: |
+            .venv
+          key: ${{ runner.os }}-v3-poetry-py3.8-${{ hashFiles('./poetry.lock') }}
+
+      - name: git reset
+        run: git reset --hard
+
+      - name: install dependencies
+        run: make install-ci
+
+      - name: black
+        run: make black-check
+
+      - name: isort
+        run: make isort-check
+
+      - name: flake8
+        run: make flake8
+
+      - name: mypy
+        run: make mypy
+
+      - name: shellcheck
+        uses: ludeeus/action-shellcheck@master
+        with:
+          ignore_paths: .venv build
+          ignore_names: git-secrets
+        env:
+          SHELLCHECK_OPTS: -f gcc -e SC1090,SC1091
+
+      - name: test next version
+        run: |
+          version="v$(poetry version patch | rev | cut -d' ' -f1 | rev)"
+          echo "RELEASE_VERSION=$version" >> $GITHUB_ENV
+          echo "${version}"
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
deleted file mode 100644
index 485a0be..0000000
--- a/.github/workflows/python-package.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-on:
-  push:
-    branches:
-    - develop
-  pull_request:
-    branches:
-    - develop
-
-name: Run Tox tests
-
-jobs:
-  tox_test:
-    name: Tox test
-    steps:
-    - uses: actions/checkout@v2
-    - name: Run Tox tests
-      id: test
-      uses: fedora-python/tox-github-action@master
-      with:
-        tox_env: ${{ matrix.tox_env }}
-    strategy:
-      matrix:
-        tox_env: [py27, py36, py37, py38, py39, pypy2, pypy3]
-
-    # Use GitHub's Linux Docker host
-    runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index d6ade3e..2b87b1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,60 @@
-__pycache__/
-*.pyc
-/MANIFEST
-/.tox/
-.idea/
-.venv/
+# IDE and various other developer tools
+.idea
+.ipynb_checkpoints
+*.swp
+
+# Misc
+!.gitkeep
+*.orig
+.server.env
+.certs.env
+.docker.env
+.github-artifacts
+scanner
+.scannerwork
+.vscode/*.log
+coverage.xml
+.coverage.*
+
+# Packaging
 build/
-help/
-*.whl
-*.egg-info
 dist/
-.eggs/
+reports/
+
+
+# Python
+.env
+.venv
+venv
+*.pyc
+__pycache__
+.mypy_cache
+**/.pytest_cache/
+.python-version
+.coverage
+.coverage-out/
+.tox
+.eggs
+*.egg-info/
+
+# certs/keys/pems
+*.crt
+*.csr
+*.key
+*.pem
+*.pfx
+*.srl*
+*.csr.cfg
+*id_rsa*
+
+# mailbox passwords etc.
+*secrets.json
+*.keystore
+
+# Big binaries
+*.bz2
+*.gz
+*.jar
+*.tar.gz
+*.tgz
+*.zip
diff --git a/.tool-versions b/.tool-versions
index 8255060..0192f76 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,4 +1,2 @@
-python 2.7.18
-python 3.7.12
-python 3.8.14
-python 3.9.13
\ No newline at end of file
+poetry 1.5.1
+python 3.10.12 3.9.13 3.7.12 3.8.14 3.11.4
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6ac3d2e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,93 @@
+# Contributing
+
+## dependencies
+tools used:
+- make
+- git
+- [asdf version manager](https://asdf-vm.com/guide/getting-started.html)
+
+
+## first run ...  
+
+### install project tools
+use asdf to ensure required tools are installed ... configured tools are in  [.tool-versions](.tool-versions)
+```bash
+cd ~/work/mesh-client
+asdf plugin add python
+asdf plugin add poetry
+asdf install
+```
+
+### install git hooks
+```shell
+make refresh-hooks
+```
+
+## normal development
+
+### create virtualenv and install python dependencies
+
+```shell
+make install
+source .venv/bin/activate
+```
+
+### running tests
+
+start the mesh-sandbox docker container
+```shell
+make up
+```
+
+```shell
+make test
+```
+
+### testing multiple python versions
+to test all python versions configured
+```shell
+make tox
+```
+
+
+### linting
+project uses:
+- [flake8](https://pypi.org/project/flake8/)
+- [mypy](https://pypi.org/project/mypy/)
+
+run both with 
+```shell
+make lint
+```
+or individually with
+```shell
+make mypy
+```
+or
+```shell
+make flake8 
+```
+
+
+### formatting code
+project uses:
+- [isort](https://pypi.org/project/isort/)
+- [black](https://pypi.org/project/black/)
+
+lint checks will fail if the code is not formaated correctly
+
+```shell
+# make black will run both isort and black
+make black
+```
+
+
+### secrets
+the git-secrets script will try and avoid accidental committing of secrets
+patterns are excluded using  [.gitdisallowed](.gitdisallowed) and allow listed using  [.gitallowed](.gitallowed)
+if the git hooks are registered `make refresh hooks`  then secrets will be scanned for in the [pre-commit hook](scripts/hooks/pre-commit.sh).
+You can check for secrets / test patterns at any time though with
+```shell
+make check-secrets-all
+```
+
diff --git a/LICENSE b/LICENSE
index 1f52229..50660c3 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,21 @@
-Copyright 2020 NHS Digital
+The MIT License (MIT)
 
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+Copyright (c) 2023 NHS england
 
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Makefile b/Makefile
index 965f995..835d04a 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,8 @@ SHELL:=/bin/bash -O globstar
 .SHELLFLAGS = -ec
 .PHONY: build dist
 .DEFAULT_GOAL := list
+# this is just to try and supress errors caused by poetry run
+export PYTHONWARNINGS=ignore:::setuptools.command.install
 
 list:
 	@grep '^[^#[:space:]].*:' Makefile
@@ -12,10 +14,121 @@ guard-%:
 		exit 1; \
 	fi
 
+########################################################################################################################
+##
+## Makefile for this project things
+##
+########################################################################################################################
+pwd := ${PWD}
+dirname := $(notdir $(patsubst %/,%,$(CURDIR)))
+DOCKER_BUILDKIT ?= 1
 
+ifneq (,$(wildcard ./.env))
+    include .env
+    export
+endif
 
 clean_dist:
-	rm -r dist/
+	rm -r dist/ || true
 
 dist: clean_dist
-	python -m build
\ No newline at end of file
+	python -m build
+
+
+delete-hooks:
+	rm .git/hooks/pre-commit 2>/dev/null || true
+	rm .git/hooks/commit-msg 2>/dev/null || true
+
+.git/hooks/pre-commit:
+	cp scripts/hooks/pre-commit.sh .git/hooks/pre-commit
+
+.git/hooks/commit-msg:
+	cp scripts/hooks/commit-msg.sh .git/hooks/commit-msg
+
+refresh-hooks: delete-hooks .git/hooks/pre-commit .git/hooks/commit-msg
+
+install:
+	poetry install --sync
+
+install-ci:
+	poetry install --without local --sync
+
+update:
+	poetry update
+
+mypy:
+	poetry run mypy . --exclude '(^|/)(build|dist|scripts)/.*\.py'
+
+shellcheck:
+	@# Only swallow checking errors (rc=1), not fatal problems (rc=2)
+	docker run --rm -i -v ${PWD}:/mnt:ro koalaman/shellcheck -f gcc -e SC1090,SC1091 `find . \( -path "*/.venv/*" -prune -o -path "*/build/*" -prune -o -path "*/.tox/*" -prune -o -path "*/java_client/*" -prune  \) -o -type f -name '*.sh' -print` || test $$? -eq 1
+
+
+flake8:
+	poetry run flake8
+
+lint: flake8 mypy shellcheck
+
+clean:
+	rm -rf ./dist || true
+	rm -rf ./reports || true
+	rm -f .docker.env || true
+	find . -type d -name '.mypy_cache' | xargs rm -rf || true
+	find . -type d -name '.pytest_cache' | xargs rm -rf || true
+	find . -type d -name '__pycache__' | xargs rm -rf || true
+	find . -type f -name '.coverage' | xargs rm -rf || true
+
+purge: clean
+	rm -rf .venv || true
+
+black-check:
+	poetry run black . --check
+
+isort-check:
+	poetry run isort . -c
+
+black:
+	poetry run isort .
+	poetry run black .
+
+
+coverage-cleanup:
+	rm -f .coverage* || true
+
+coverage-ci-test:
+	poetry run coverage run -m pytest --color=yes -v --junit-xml=./reports/junit/tests.xml
+
+coverage-report:
+	@poetry run coverage report; \
+	poetry run coverage xml;
+
+coverage: coverage-cleanup coverage-test coverage-report
+
+coverage-test:
+	poetry run coverage run -m pytest
+
+
+pytest:
+	poetry run python -m pytest
+
+test: pytest
+
+tox:
+	poetry run tox
+
+down:
+	docker compose down --remove-orphans || true
+
+up:
+	docker compose up -d --remove-orphans --build
+
+coverage-ci: coverage-cleanup coverage-ci-test coverage-report
+
+check-secrets:
+	scripts/check-secrets.sh
+
+check-secrets-all:
+	scripts/check-secrets.sh unstaged
+
+export-requirements:
+	poetry export --only main -f requirements.txt --output ./requirements.txt
diff --git a/README.md b/README.md
index a9a3fb4..389a0d9 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Installation
 ------------
 
 ```bash
-pip install mesh_client
+pip install mesh-client
 ```
 
 Example use
@@ -18,7 +18,7 @@ from mesh_client import MeshClient, NHS_DEP_ENDPOINT
 with MeshClient(
           NHS_DEP_ENDPOINT,
           'MYMAILBOX',
-          'Password123!',
+          'Password',
           cert=('/etc/certs/cert.pem', '/etc/certs/key.pem')  # Mesh uses SSL, so you'll need some certs
         ) as client:
 
@@ -37,44 +37,15 @@ with MeshClient(
     client.send_message('RECIPIENT_MAILBOX', b'Hello World!', subject='Important message')
 ```
 
-Guidance for contributors
--------------------------
+Testing your application
+------------------------
 
-You should be doing all your development in a virtualenv / venv. You can install
-everything you need for development with
+We recommend using the [mesh sandbox](https://github.com/NHSDigital/mesh-sandbox) 
+have a look at this [docker-compose.yml](docker-compose.yml) for an example of how to run the sandbox
 
-```bash
-virtualenv .venv
-source .venv/bin/activate
-pip install -r dev-requirements.txt
-pip install -e .
 
-# if using asdf
-pip install tox-asdf
-
-```
-
-We use unittest for tests, and you can run the test suite locally with:
-
-```bash
-python -m unittest discover . '*_test.py'
-```
-
-We use tox for testing on multiple versions. To run the tox tests, just run:
-
-```bash
-tox
-```
+Guidance for contributors
+-------------------------
 
-For releases, we use twine. The rough release process would be:
+see [CONTRIBUTING](CONTRIBUTING.md)
 
-```bash
-tox  # Re-run tests, just to be sure
-git tag $CURRENT_VERSION
-rm dist/*  # Get rid of previous distribution files
-python -m build
-twine upload -r testpypi dist/*
-# Check artifacts are uploaded correctly, and that entry on PyPI looks correct
-twine upload dist/*
-git push --tags
-```
diff --git a/dev-requirements.txt b/dev-requirements.txt
deleted file mode 100644
index 9f13362..0000000
--- a/dev-requirements.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-mock
-pip
-setuptools
-setuptools_scm[toml]
-tox
-twine
-wheel
-build
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f4c1333
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,26 @@
+version: '3.9'
+
+services:
+
+  mesh_sandbox:
+    build:
+      context: https://github.com/NHSDigital/mesh-sandbox.git#refs/tags/v1.0.4
+    ports:
+      - "8701:443"
+    deploy:
+      restart_policy:
+        condition: on-failure
+        max_attempts: 3
+    healthcheck:
+      test: curl -sf http://localhost:80/health || exit 1
+      interval: 3s
+      timeout: 10s
+    environment:
+      - SHARED_KEY=TestKey
+      - STORE_MODE=memory
+      - SSL=yes
+    volumes:
+      - ./tests/mailboxes.jsonl:/app/mesh_sandbox/store/data/mailboxes.jsonl:ro
+      - ./tests/workflows.jsonl:/app/mesh_sandbox/store/data/workflows.jsonl:ro
+      - ./tests/server.cert.pem:/tmp/server-cert.pem:ro
+      - ./tests/server.key.pem:/tmp/server-cert.key:ro
diff --git a/git-secrets b/git-secrets
new file mode 100755
index 0000000..f022b0e
--- /dev/null
+++ b/git-secrets
@@ -0,0 +1,357 @@
+#!/usr/bin/env bash
+# Copyright 2010-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License").
+# You may not use this file except in compliance with the License.
+# A copy of the License is located at
+#
+# http://aws.amazon.com/apache2.0
+#
+# or in the "license" file accompanying this file. This file is distributed
+# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+# express or implied. See the License for the specific language governing
+# permissions and limitations under the License.
+
+NONGIT_OK=1 OPTIONS_SPEC="\
+git secrets --scan [-r|--recursive] [--cached] [--no-index] [--untracked] [<files>...]
+git secrets --scan-history
+git secrets --install [-f|--force] [<target-directory>]
+git secrets --list [--global]
+git secrets --add [-a|--allowed] [-l|--literal] [--global] <pattern>
+git secrets --add-provider [--global] <command> [arguments...]
+git secrets --register-aws [--global]
+git secrets --aws-provider [<credentials-file>]
+--
+scan Scans <files> for prohibited patterns
+scan-history Scans repo for prohibited patterns
+install Installs git hooks for Git repository or Git template directory
+list Lists secret patterns
+add Adds a prohibited or allowed pattern, ensuring to de-dupe with existing patterns
+add-provider Adds a secret provider that when called outputs secret patterns on new lines
+aws-provider Secret provider that outputs credentials found in an ini file
+register-aws Adds common AWS patterns to the git config and scans for ~/.aws/credentials
+r,recursive --scan scans directories recursively
+cached --scan scans searches blobs registered in the index file
+no-index --scan searches files in the current directory that is not managed by Git
+untracked In addition to searching in the tracked files in the working tree, --scan also in untracked files
+f,force --install overwrites hooks if the hook already exists
+l,literal --add and --add-allowed patterns are escaped so that they are literal
+a,allowed --add adds an allowed pattern instead of a prohibited pattern
+global Uses the --global git config
+commit_msg_hook* commit-msg hook (internal only)
+pre_commit_hook* pre-commit hook (internal only)
+prepare_commit_msg_hook* prepare-commit-msg hook (internal only)"
+
+# Include the git setup script. This parses and normalized CLI arguments.
+. "$(git --exec-path)"/git-sh-setup
+
+load_patterns() {
+  git config --get-all secrets.patterns
+  # Execute each provider and use their output to build up patterns
+  git config --get-all secrets.providers | while read -r cmd; do
+    # Only split words on '\n\t ' and strip "\r" from the output to account
+    # for carriage returns being added on Windows systems. Note that this
+    # trimming is done before the test to ensure that the string is not empty.
+    local result="$(export IFS=$'\n\t '; $cmd | tr -d $'\r')"
+    # Do not add empty lines from providers as that would match everything.
+    if [ -n "${result}" ]; then
+      echo "${result}"
+    fi
+  done
+}
+
+load_allowed() {
+  git config --get-all secrets.allowed
+  local gitallowed="$(git rev-parse --show-toplevel)/.gitallowed"
+  if [ -e "$gitallowed" ]; then
+    cat $gitallowed | awk 'NF && $1!~/^#/'
+  fi
+}
+
+# load patterns and combine them with |
+load_combined_patterns() {
+  local patterns=$(load_patterns)
+  local combined_patterns=''
+  for pattern in $patterns; do
+    combined_patterns=${combined_patterns}${pattern}"|"
+  done
+  combined_patterns=${combined_patterns%?}
+  echo $combined_patterns
+}
+
+# Scans files or a repo using patterns.
+scan() {
+  local files=("${@}") options=""
+  [ "${SCAN_CACHED}" == 1 ] && options+="--cached"
+  [ "${SCAN_UNTRACKED}" == 1 ] && options+=" --untracked"
+  [ "${SCAN_NO_INDEX}" == 1 ] && options+=" --no-index"
+  # Scan using git-grep if there are no files or if git options are applied.
+  if [ ${#files[@]} -eq 0 ] || [ ! -z "${options}" ]; then
+    output=$(git_grep $options "${files[@]}")
+  else
+    output=$(regular_grep "${files[@]}")
+  fi
+  process_output $? "${output}"
+}
+
+# Scans through history using patterns
+scan_history() {
+  # git log does not support multiple patterns, so we need to combine them
+  local combined_patterns=$(load_combined_patterns)
+  [ -z "${combined_patterns}" ] && return 0
+  # Looks for differences matching the patterns, reduces the number of revisions to scan
+  local to_scan=$(git log --all -G"${combined_patterns}" --pretty=%H)
+  # Scan through revisions with findings to normalize output
+  output=$(GREP_OPTIONS= LC_ALL=C git grep -nwHEI "${combined_patterns}" $to_scan)
+  process_output $? "${output}"
+}
+
+# Performs a git-grep, taking into account patterns and options.
+# Note: this function returns 1 on success, 0 on error.
+git_grep() {
+  local options="$1"; shift
+  local files=("${@}") combined_patterns=$(load_combined_patterns)
+
+  [ -z "${combined_patterns}" ] && return 1
+  GREP_OPTIONS= LC_ALL=C git grep -nwHEI ${options} "${combined_patterns}" -- ':!.gitallowed' ':!.gitdisallowed' "${files[@]}"
+}
+
+# Performs a regular grep, taking into account patterns and recursion.
+# Note: this function returns 1 on success, 0 on error.
+regular_grep() {
+  local files=("${@}") patterns=$(load_patterns) action='skip'
+  [ -z "${patterns}" ] && return 1
+  [ ${RECURSIVE} -eq 1 ] && action="recurse"
+  GREP_OPTIONS= LC_ALL=C grep -d "${action}" -nwHEI "${patterns}" "${files[@]}"
+}
+
+# Process the given status ($1) and output variables ($2).
+# Takes into account allowed patterns, and if a bad match is found,
+# prints an error message and exits 1.
+process_output() {
+  local status="$1" output="$2"
+  local allowed=$(load_allowed)
+  case "$status" in
+    0)
+      [ -z "${allowed}" ] && echo "${output}" >&2 && return 1
+      # Determine with a negative grep if the found matches are allowed
+      echo "${output}" | GREP_OPTIONS= LC_ALL=C grep -Ev "${allowed}" >&2 \
+        && return 1 || return 0
+      ;;
+    1) return 0 ;;
+    *) exit $status
+  esac
+}
+
+# Calls the given scanning function at $1, shifts, and passes to it $@.
+# Exit 0 if success, otherwise exit 1 with error message.
+scan_with_fn_or_die() {
+  local fn="$1"; shift
+  $fn "$@" && exit 0
+  echo >&2
+  echo "[ERROR] Matched one or more prohibited patterns" >&2
+  echo >&2
+  echo "Possible mitigations:" >&2
+  echo "- Mark false positives as allowed using: git config --add secrets.allowed ..." >&2
+  echo "- Mark false positives as allowed by adding regular expressions to .gitallowed at repository's root directory" >&2
+  echo "- List your configured patterns: git config --get-all secrets.patterns" >&2
+  echo "- List your configured allowed patterns: git config --get-all secrets.allowed" >&2
+  echo "- List your configured allowed patterns in .gitallowed at repository's root directory" >&2
+  echo "- Use --no-verify if this is a one-time false positive" >&2
+  exit 1
+}
+
+# Scans a commit message, passed in the path to a file.
+commit_msg_hook() {
+  scan_with_fn_or_die "scan" "$1"
+}
+
+# Scans all files that are about to be committed.
+pre_commit_hook() {
+  SCAN_CACHED=1
+  local files=() file found_match=0 rev="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
+  # Diff against HEAD if this is not the first commit in the repo.
+  git rev-parse --verify HEAD >/dev/null 2>&1 && rev="HEAD"
+  # Filter out deleted files using --diff-filter
+  while IFS= read -r file; do
+    [ -n "$file" ] && files+=("$file")
+  done <<< "$(git diff-index --diff-filter 'ACMU' --name-only --cached $rev --)"
+  scan_with_fn_or_die "scan" "${files[@]}"
+}
+
+# Determines if merging in a commit will introduce tainted history.
+prepare_commit_msg_hook() {
+  case "$2,$3" in
+    merge,)
+      local git_head=$(env | grep GITHEAD)  # e.g. GITHEAD_<sha>=release/1.43
+      local sha="${git_head##*=}"           # Get just the SHA
+      local branch=$(git symbolic-ref HEAD) # e.g. refs/heads/master
+      local dest="${branch#refs/heads/}"    # cut out "refs/heads"
+      git log "${dest}".."${sha}" -p | scan_with_fn_or_die "scan" -
+      ;;
+  esac
+}
+
+install_hook() {
+  local path="$1" hook="$2" cmd="$3" dest
+  # Determines the approriate path for a hook to be installed
+  if [ -d "${path}/hooks/${hook}.d" ]; then
+    dest="${path}/hooks/${hook}.d/git-secrets"
+  else
+    dest="${path}/hooks/${hook}"
+  fi
+  [ -f "${dest}" ] && [ "${FORCE}" -ne 1 ] \
+    && die "${dest} already exists. Use -f to force"
+  echo "#!/usr/bin/env bash" > "${dest}"
+  echo "git secrets --${cmd} -- \"\$@\"" >> "${dest}"
+  chmod +x "${dest}"
+  say "$(tput setaf 2)✓$(tput sgr 0) Installed ${hook} hook to ${dest}"
+}
+
+install_all_hooks() {
+  install_hook "$1" "commit-msg" "commit_msg_hook"
+  install_hook "$1" "pre-commit" "pre_commit_hook"
+  install_hook "$1" "prepare-commit-msg" "prepare_commit_msg_hook"
+}
+
+# Adds a git config pattern, ensuring to de-dupe
+add_config() {
+  local key="$1"; shift
+  local value="$@"
+  if [ ${LITERAL} -eq 1 ]; then
+    value=$(sed 's/[\.|$(){}?+*^]/\\&/g' <<< "${value}")
+  fi
+  if [ ${GLOBAL} -eq 1 ]; then
+    git config --global --get-all $key | grep -Fq "${value}" && return 1
+    git config --global --add "${key}" "${value}"
+  else
+    git config --get-all $key | grep -Fq "${value}" && return 1
+    git config --add "${key}" "${value}"
+  fi
+}
+
+register_aws() {
+  # Reusable regex patterns
+  local aws="(AWS|aws|Aws)?_?" quote="(\"|')" connect="\s*(:|=>|=)\s*"
+  local opt_quote="${quote}?"
+  add_config 'secrets.providers' './git-secrets --aws-provider'
+  add_config 'secrets.patterns' '(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}'
+  add_config 'secrets.patterns' "${opt_quote}${aws}(SECRET|secret|Secret)?_?(ACCESS|access|Access)?_?(KEY|key|Key)${opt_quote}${connect}${opt_quote}[A-Za-z0-9/\+=]{40}${opt_quote}"
+  add_config 'secrets.patterns' "${opt_quote}${aws}(ACCOUNT|account|Account)_?(ID|id|Id)?${opt_quote}${connect}${opt_quote}[0-9]{4}\-?[0-9]{4}\-?[0-9]{4}${opt_quote}"
+  add_config 'secrets.allowed' 'AKIAIOSFODNN7EXAMPLE'
+  add_config 'secrets.allowed' "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+
+  if [[ $? == 0 ]]; then
+    echo 'OK'
+  fi
+
+  exit $?
+}
+
+aws_provider() {
+  local fi="$1"
+  [ -z "$fi" ] && fi=~/.aws/credentials
+  # Find keys and ensure that special characters are escaped.
+  if [ -f $fi ]; then
+    awk -F "=" '/aws_access_key_id|aws_secret_access_key/ {print $2}' $fi \
+      | tr -d ' "' \
+      | sed 's/[]\.|$(){}?+*^]/\\&/g'
+  fi
+}
+
+# Ensures that the command is what was expected for an option.
+assert_option_for_command() {
+  local expected_command="$1"
+  local option_name="$2"
+  if [ "${COMMAND}" != "${expected_command}" ]; then
+    die "${option_name} can only be supplied with the ${expected_command} subcommand"
+  fi
+}
+
+declare COMMAND="$1" FORCE=0 RECURSIVE=0 LITERAL=0 GLOBAL=0 ALLOWED=0
+declare SCAN_CACHED=0 SCAN_NO_INDEX=0 SCAN_UNTRACKED=0
+
+# Shift off the command name
+shift 1
+while [ "$#" -ne 0 ]; do
+  case "$1" in
+    -f)
+      assert_option_for_command "--install" "-f|--force"
+      FORCE=1
+      ;;
+    -r)
+      assert_option_for_command "--scan" "-r|--recursive"
+      RECURSIVE=1
+      ;;
+    -a)
+      assert_option_for_command "--add" "-a|--allowed"
+      ALLOWED=1
+      ;;
+    -l)
+      assert_option_for_command "--add" "-l|--literal"
+      LITERAL=1
+      ;;
+    --cached)
+      assert_option_for_command "--scan" "--cached"
+      SCAN_CACHED=1
+      ;;
+    --no-index)
+      assert_option_for_command "--scan" "--no-index"
+      SCAN_NO_INDEX=1
+      ;;
+    --untracked)
+      assert_option_for_command "--scan" "--untracked"
+      SCAN_UNTRACKED=1
+      ;;
+    --global) GLOBAL=1 ;;
+    --) shift; break ;;
+  esac
+  shift
+done
+
+# Ensure that recursive is not applied with mutually exclusive options.
+if [ ${RECURSIVE} -eq 1 ]; then
+  if [ ${SCAN_CACHED} -eq 1  ] \
+      || [ ${SCAN_NO_INDEX} -eq 1 ] \
+      || [ ${SCAN_UNTRACKED} -eq 1 ];
+  then
+    die "-r|--recursive cannot be supplied with --cached, --no-index, or --untracked"
+  fi
+fi
+
+case "${COMMAND}" in
+  -h|--help|--) "$0" -h; exit 0 ;;
+  --add-provider) add_config "secrets.providers" "$@" ;;
+  --register-aws) register_aws ;;
+  --aws-provider) aws_provider "$1" ;;
+  --commit_msg_hook|--pre_commit_hook|--prepare_commit_msg_hook)
+    ${COMMAND:2} "$@"
+    ;;
+  --add)
+    if [ ${ALLOWED} -eq 1 ]; then
+      add_config "secrets.allowed" "$1"
+    else
+      add_config "secrets.patterns" "$1"
+    fi
+    ;;
+  --scan) scan_with_fn_or_die "scan" "$@" ;;
+  --scan-history) scan_with_fn_or_die "scan_history" "$@" ;;
+  --list)
+    if [ ${GLOBAL} -eq 1 ]; then
+      git config --global --get-regex secrets.*
+    else
+      git config --get-regex secrets.*
+    fi
+    ;;
+  --install)
+    DIRECTORY="$1"
+    if [ -z "${DIRECTORY}" ]; then
+      DIRECTORY=$(git rev-parse --git-dir) || die "Not in a Git repository"
+    elif [ -d "${DIRECTORY}"/.git ]; then
+      DIRECTORY="${DIRECTORY}/.git"
+    fi
+    mkdir -p "${DIRECTORY}/hooks" || die "Could not create dir: ${DIRECTORY}"
+    install_all_hooks "${DIRECTORY}"
+    ;;
+  *) echo "Unknown option: ${COMMAND}" && "$0" -h ;;
+esac
diff --git a/java-mesh-client-helper-files/keystore.jks b/java-mesh-client-helper-files/keystore.jks
deleted file mode 100644
index cbcaaaf..0000000
Binary files a/java-mesh-client-helper-files/keystore.jks and /dev/null differ
diff --git a/java-mesh-client-helper-files/meshclient.cfg b/java-mesh-client-helper-files/meshclient.cfg
deleted file mode 100755
index 5f110cd..0000000
--- a/java-mesh-client-helper-files/meshclient.cfg
+++ /dev/null
@@ -1,24 +0,0 @@
-<?xml version="1.0"?>
-<MESHConfig>
-    <PrimaryURL>https://localhost:8000</PrimaryURL>
-    <LogPath>/var/mesh/log</LogPath>
-    <KeyStorePath>/opt/mesh-6.1.1/mockMesh.jks</KeyStorePath>
-    <KeyStorePassword>MockMesh</KeyStorePassword>
-    <WorkPath>/var/mesh/working</WorkPath>
-    <ProxyPath>/var/mesh/proxy</ProxyPath>
-    <SignalPath>/var/mesh/sig</SignalPath>
-    <PollPeriod>1</PollPeriod>
-    <ServerRetry>3</ServerRetry>
-    <MaxMessages>100</MaxMessages>
-    <MaxConnections>4</MaxConnections>
-    <AutoUpgrade>Y</AutoUpgrade>
-    <Client>
-        <ClientIdentity>X25HC001</ClientIdentity>
-        <ClientAuthentication>password</ClientAuthentication>
-        <InterfaceRoot>/var/mesh/X25HC001</InterfaceRoot>
-        <CollectReport>Y</CollectReport>
-        <TransferReport>Y</TransferReport>
-        <PollReport>N</PollReport>
-        <SaveSent>Y</SaveSent>
-    </Client>
-</MESHConfig>
diff --git a/mesh_client/__init__.py b/mesh_client/__init__.py
index f2337d1..244150e 100644
--- a/mesh_client/__init__.py
+++ b/mesh_client/__init__.py
@@ -1,50 +1,70 @@
-from __future__ import absolute_import
 import collections
-import warnings
-import functools
 import datetime
+import functools
 import hmac
-import pkg_resources
+import os.path
 import platform
-import requests
-import six
+import ssl
+import sys
 import time
 import uuid
+import warnings
+from dataclasses import dataclass
 from hashlib import sha256
 from io import BytesIO
 from itertools import chain
-from six.moves.urllib.parse import quote as q
-
+from typing import Any, Dict, List, Optional, Tuple, TypeVar, Union, cast
+from urllib.parse import quote as q
 
-from .io_helpers import \
-    CombineStreams, SplitStream, GzipCompressStream, GzipDecompressStream
+import requests
+from requests.adapters import HTTPAdapter
+
+from .io_helpers import (
+    CombineStreams,
+    GzipCompressStream,
+    GzipDecompressStream,
+    SplitStream,
+)
 from .key_helper import get_shared_key_from_environ
+from .types import (
+    EndpointLookupResponse_v1,
+    ListMessageResponse_v1,
+    SendMessageErrorResponse_v1,
+    SendMessageResponse_v1,
+    TrackingResponse_v1,
+)
+
+if sys.version_info[:2] >= (3, 8):
+    # TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
+    from importlib.metadata import PackageNotFoundError, version
+else:
+    from importlib_metadata import PackageNotFoundError, version
+
+
+def _get_version(*names: str) -> str:
+    """ """
+    for name in names:
+        try:
+            pkg_version = version(name)
+            return pkg_version
+        except PackageNotFoundError:
+            continue
+    return "unknown"
 
-MOCK_CA_CERT = pkg_resources.resource_filename('mesh_client', "ca.cert.pem")
-MOCK_CERT = pkg_resources.resource_filename('mesh_client', "client.cert.pem")
-MOCK_KEY = pkg_resources.resource_filename('mesh_client', "client.key.pem")
 
-MOCK_SSL_OPTS = {
-    "verify": MOCK_CA_CERT,
-    "cert": (MOCK_CERT, MOCK_KEY)
-}
-"""
-Usable default values for verify and cert, providing certificates and keys
-which should work with mock_server. Note that these certs will not work with
-any NHS Digital test environments - such certs must be obtained from
-NHS Digital.
-"""
-default_ssl_opts = MOCK_SSL_OPTS
-
-INT_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-int-ca-bundle.pem")
-DEV_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-dev-ca-bundle.pem")
-DEP_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-dep-ca-bundle.pem")
-TRAIN_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-train-ca-bundle.pem")
-LIVE_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-live-root-ca.pem")
-OPENTEST_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-opt-ca-bundle.pem")
-DIGICERT_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-digicert-ca-bundle.pem")
-IG_INT_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-ig-int-ca-bundle.pem")
-IG_LIVE_CA_CERT = pkg_resources.resource_filename('mesh_client', "nhs-ig-live-ca-bundle.pem")
+__version__ = _get_version("mesh-client")
+
+_PACKAGE_DIR = os.path.dirname(__file__)
+
+INT_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-int-ca-bundle.pem")
+DEV_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-dev-ca-bundle.pem")
+DEP_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-dep-ca-bundle.pem")
+TRAIN_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-train-ca-bundle.pem")
+LIVE_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-live-root-ca.pem")
+OPENTEST_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-opt-ca-bundle.pem")
+DIGICERT_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-digicert-ca-bundle.pem")
+IG_INT_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-ig-int-ca-bundle.pem")
+IG_LIVE_CA_CERT = os.path.join(_PACKAGE_DIR, "nhs-ig-live-ca-bundle.pem")
 
 
 _OPTIONAL_HEADERS = {
@@ -52,11 +72,11 @@
     "filename": "Mex-FileName",
     "local_id": "Mex-LocalID",
     "message_type": "Mex-MessageType",
-    "process_id": "Mex-ProcessID",
     "subject": "Mex-Subject",
     "encrypted": "Mex-Content-Encrypted",
     "compressed": "Mex-Content-Compressed",
-    "checksum": "Mex-Content-Checksum"
+    "checksum": "Mex-Content-Checksum",
+    "content_type": "Content-Type",
 }
 
 _BOOLEAN_HEADERS = {"compressed", "encrypted"}
@@ -67,26 +87,20 @@
     "message_id": "Mex-MessageID",
     "version": "Mex-Version",
     "partner_id": "Mex-PartnerID",
-    "recipient_smtp": "Mex-ToSMTP",
-    "sender_smtp": "Mex-FromSMTP",
 }
 _RECEIVE_HEADERS.update(_OPTIONAL_HEADERS)
 
 
-VERSION = pkg_resources.get_distribution('mesh_client').version
+Endpoint = collections.namedtuple("Endpoint", ["url", "verify", "cert", "check_hostname"])
 
-
-Endpoint = collections.namedtuple('Endpoint', ['url', 'verify', 'cert'])
-LOCAL_MOCK_ENDPOINT = Endpoint('https://localhost:8000', MOCK_CA_CERT, (MOCK_CERT, MOCK_KEY))
-LOCAL_FAKE_ENDPOINT = Endpoint('https://localhost:8829', MOCK_CA_CERT, (MOCK_CERT, MOCK_KEY))
-NHS_INT_ENDPOINT = Endpoint('https://msg.int.spine2.ncrs.nhs.uk', INT_CA_CERT, None)
-NHS_DEV_ENDPOINT = Endpoint('https://msg.dev.spine2.ncrs.nhs.uk', DEV_CA_CERT, None)
-NHS_DEP_ENDPOINT = Endpoint('https://msg.dep.spine2.ncrs.nhs.uk', DEP_CA_CERT, None)
-NHS_TRAIN_ENDPOINT = Endpoint('https://msg.train.spine2.ncrs.nhs.uk', TRAIN_CA_CERT, None)
-NHS_LIVE_ENDPOINT = Endpoint('https://mesh-sync.national.ncrs.nhs.uk', LIVE_CA_CERT, None)
-NHS_OPENTEST_ENDPOINT = Endpoint('https://192.168.128.11', OPENTEST_CA_CERT, None)
-NHS_INTERNET_GATEWAY_ENDPOINT = Endpoint('https://mesh-sync.spineservices.nhs.uk', IG_LIVE_CA_CERT, None)
-NHS_INTERNET_GATEWAY_INT_ENDPOINT = Endpoint('https://msg.intspineservices.nhs.uk', IG_INT_CA_CERT, None)
+NHS_INT_ENDPOINT = Endpoint("https://msg.int.spine2.ncrs.nhs.uk", INT_CA_CERT, None, False)
+NHS_DEV_ENDPOINT = Endpoint("https://msg.dev.spine2.ncrs.nhs.uk", DEV_CA_CERT, None, False)
+NHS_DEP_ENDPOINT = Endpoint("https://msg.dep.spine2.ncrs.nhs.uk", DEP_CA_CERT, None, False)
+NHS_TRAIN_ENDPOINT = Endpoint("https://msg.train.spine2.ncrs.nhs.uk", TRAIN_CA_CERT, None, False)
+NHS_LIVE_ENDPOINT = Endpoint("https://mesh-sync.national.ncrs.nhs.uk", LIVE_CA_CERT, None, False)
+NHS_OPENTEST_ENDPOINT = Endpoint("https://192.168.128.11", OPENTEST_CA_CERT, None, False)
+NHS_INTERNET_GATEWAY_ENDPOINT = Endpoint("https://mesh-sync.spineservices.nhs.uk", IG_LIVE_CA_CERT, None, True)
+NHS_INTERNET_GATEWAY_INT_ENDPOINT = Endpoint("https://msg.intspineservices.nhs.uk", IG_INT_CA_CERT, None, True)
 
 
 def deprecated(reason=None):
@@ -100,10 +114,12 @@ def new_func(*args, **kwargs):
             msg_extra = (reason or "").strip()
             if msg_extra:
                 msg_extra = " " + msg_extra
-            message = "Call to deprecated function {}.".format(func.__name__, msg_extra)
+            message = f"Call to deprecated function {func.__name__} {msg_extra}."
             warnings.warn(message, category=DeprecationWarning, stacklevel=2)
             return func(*args, **kwargs)
+
         return new_func
+
     return decorator
 
 
@@ -111,6 +127,54 @@ class MeshError(Exception):
     pass
 
 
+class SSLContextAdapter(HTTPAdapter):
+    def __init__(
+        self,
+        url: Union[str, Endpoint],
+        cert: Optional[Union[Tuple[str], Tuple[str, str], Tuple[str, str, str]]] = None,
+        verify: Optional[Union[str, bool]] = None,
+        check_hostname: Optional[bool] = None,
+    ):
+        self.url = url
+        self.cert = cert
+        self.verify = verify
+        self.check_hostname = check_hostname
+        if check_hostname is None and hasattr(url, "check_hostname"):
+            self.check_hostname = url.check_hostname
+
+        if cert is None and hasattr(url, "cert"):
+            self.cert = url.cert
+
+        if verify is None and hasattr(url, "verify"):
+            self.verify = url.verify
+
+        super().__init__()
+
+    def init_poolmanager(self, *args, **kwargs):
+        context = ssl.create_default_context()
+
+        if self.cert and isinstance(self.cert, (tuple, list)):
+            context.load_cert_chain(*self.cert)
+
+        if self.verify:
+            if isinstance(self.verify, (str, bytes)):
+                context.load_verify_locations(self.verify)
+
+            if context.check_hostname is not None:
+                context.check_hostname = cast(bool, self.check_hostname)
+
+            context.verify_mode = ssl.CERT_REQUIRED
+            if self.check_hostname is not False:
+                context.hostname_checks_common_name = True
+
+        if self.verify is False:
+            context.check_hostname = False
+            context.verify_mode = ssl.CERT_NONE
+
+        kwargs["ssl_context"] = context
+        return super(SSLContextAdapter, self).init_poolmanager(*args, **kwargs)
+
+
 class MeshClient(object):
     """
     A class representing a single MESH session, for a given user on a given
@@ -118,18 +182,21 @@ class MeshClient(object):
     transparently.
     """
 
-    def __init__(self,
-                 url,
-                 mailbox,
-                 password,
-                 shared_key=get_shared_key_from_environ(),
-                 cert=None,
-                 verify=None,
-                 max_chunk_size=75 * 1024 * 1024,
-                 proxies=None,
-                 transparent_compress=False,
-                 max_chunk_retries=0,
-                 timeout=10*60):
+    def __init__(
+        self,
+        url: Union[str, Endpoint],
+        mailbox: str,
+        password: str,
+        shared_key: Optional[bytes] = None,
+        cert: Optional[Union[Tuple[str], Tuple[str, str], Tuple[str, str, str]]] = None,
+        verify: Optional[Union[str, bool]] = None,
+        check_hostname: Optional[bool] = None,
+        max_chunk_size=75 * 1024 * 1024,
+        proxies: Optional[Dict[str, str]] = None,
+        transparent_compress: bool = False,
+        max_chunk_retries: int = 0,
+        timeout: Union[int, float] = 10 * 60,
+    ):
         """
         Create a new MeshClient.
 
@@ -137,8 +204,6 @@ def __init__(self,
         password. The endpoint URL can either be a string, or a preconfigured
         endpoint. Currently the following endpoints are preconfigured:
 
-        LOCAL_MOCK_ENDPOINT
-        LOCAL_FAKE_ENDPOINT
         NHS_INT_ENDPOINT
         NHS_LIVE_ENDPOINT
         NHS_OPENTEST_ENDPOINT
@@ -161,34 +226,29 @@ def __init__(self,
         sending.
         """
 
+        shared_key = shared_key or get_shared_key_from_environ()
+
         self._session = requests.Session()
+        adapter = SSLContextAdapter(url, cert, verify, check_hostname)
+        self._session.mount("https://", adapter)
+        self._session.mount("http://", adapter)
         self._session.headers = {
             "User-Agent": (
-                "mesh_client;{};N/A;{};{};{} {}".format(
-                    VERSION,
-                    platform.processor() or platform.machine(),
-                    platform.system(),
-                    platform.release(),
-                    platform.version()
-                )
+                f"mesh_client;{__version__};N/A;{platform.processor() or platform.machine()};"
+                f"{platform.system()};{platform.release()} {platform.version()}"
             ),
-            "Accept-Encoding": "gzip"
+            "Accept-Encoding": "gzip",
         }
         self._session.auth = AuthTokenGenerator(shared_key, mailbox, password)
-        if hasattr(url, 'url'):
+        if hasattr(url, "url"):
             self._url = url.url
         else:
             self._url = url
 
-        if hasattr(url, 'cert') and cert is None:
-            self._session.cert = url.cert
-        else:
-            self._session.cert = cert
-
-        if hasattr(url, 'verify') and verify is None:
-            self._session.verify = url.verify
-        else:
+        if verify is not None:
             self._session.verify = verify
+        elif hasattr(url, "verify"):
+            self._session.verify = url.verify
 
         self._session.proxies = proxies or {}
         self._mailbox = mailbox
@@ -198,115 +258,134 @@ def __init__(self,
         self._timeout = timeout
         self._close_called = False
 
+    @property
+    def mailbox_url(self) -> str:
+        return f"{self._url}/messageexchange/{q(self._mailbox)}"
+
+    def ping(self) -> dict:
+        """
+        just connect to the _ping endpoint
+        """
+        response = self._session.get(f"{self._url}/messageexchange/_ping", timeout=self._timeout)
+
+        response.raise_for_status()
+
+        return cast(dict, response.json())
+
     def handshake(self):
         """
         List all messages in user's inbox. Returns a list of message_ids
         """
         headers = {
-            "mex-ClientVersion": "mesh_client=={}".format(VERSION),
+            "mex-ClientVersion": f"mesh_client=={__version__}",
             "mex-OSArchitecture": platform.processor() or platform.machine(),
             "mex-OSName": platform.system(),
-            "mex-OSVersion": "{} {}".format(platform.release(), platform.version()),
-            "mex-JavaVersion": "N/A"
+            "mex-OSVersion": f"{platform.release()} {platform.version()}",
+            "mex-JavaVersion": "N/A",
         }
-        response = self._session.post(
-            "{}/messageexchange/{}".format(self._url, q(self._mailbox)), headers=headers,
-            timeout=self._timeout
-        )
+        response = self._session.post(self.mailbox_url, headers=headers, timeout=self._timeout)
 
         response.raise_for_status()
 
-        return b'hello'
+        return b"hello"
 
     @deprecated("this api endpoint is marked as deprecated")
-    def count_messages(self):
+    def count_messages(self) -> int:
         """
         Count all messages in user's inbox. Returns an integer
         """
-        response = self._session.get(
-            "{}/messageexchange/{}/count".format(self._url, q(self._mailbox)),
-            timeout=self._timeout)
+        response = self._session.get(f"{self.mailbox_url}/count", timeout=self._timeout)
         response.raise_for_status()
-        return response.json()["count"]
+        return cast(int, response.json()["count"])
 
-    def track_by_message_id(self, message_id=None):
+    def track_by_message_id(self, message_id: str) -> TrackingResponse_v1:
         """
         Gets tracking information from MESH about a message, by its  message id.
         Returns a dictionary, in much the same format that MESH provides it.
         """
-        url = "{}/messageexchange/{}/outbox/tracking?messageID={}".format(self._url, q(self._mailbox), q(message_id))
+        url = f"{self.mailbox_url}/outbox/tracking?messageID={q(message_id)}"
 
         response = self._session.get(url, timeout=self._timeout)
         response.raise_for_status()
-        return response.json()
+        return cast(TrackingResponse_v1, response.json())
+
+    def _get_tracking_url(self, local_id: Optional[str] = None, message_id: Optional[str] = None) -> str:
+        if message_id:
+            return f"{self.mailbox_url}/outbox/tracking?messageID={q(message_id)}"
+
+        if local_id:
+            return f"{self.mailbox_url}/outbox/tracking/{q(local_id)}"
+
+        raise ValueError(
+            "Exactly one of local message id (called tracking_id, for historical reasons) "
+            "and message_id must be provided"
+        )
 
     @deprecated(reason="tracking by local_id is deprecated, please use 'track_by_message_id'")
-    def get_tracking_info(self, tracking_id=None, message_id=None):
+    def get_tracking_info(
+        self, local_id: Optional[str] = None, message_id: Optional[str] = None
+    ) -> TrackingResponse_v1:
         """
         Gets tracking information from MESH about a message, by its local message id.
         Returns a dictionary, in much the same format that MESH provides it.
         """
-        if (tracking_id is not None) + (message_id is not None) != 1:
-            raise ValueError("Exactly one of local message id (called tracking_id, for historical reasons) and message_id must be provided")
 
-        if tracking_id:
-            url = "{}/messageexchange/{}/outbox/tracking/{}".format(self._url, q(self._mailbox), q(tracking_id))
-        else:
-            url = "{}/messageexchange/{}/outbox/tracking?messageID={}".format(self._url, q(self._mailbox), q(message_id))
+        url = self._get_tracking_url(local_id, message_id)
 
         response = self._session.get(url, timeout=self._timeout)
         response.raise_for_status()
-        return response.json()
+        return cast(TrackingResponse_v1, response.json())
 
-    def lookup_endpoint(self, organisation_code, workflow_id):
+    def lookup_endpoint(self, ods_code: str, workflow_id: str) -> EndpointLookupResponse_v1:
         """
         Lookup a mailbox by organisation code and workflow id.
         Returns a dictionary, in much the same format that MESH provides it.
         """
         response = self._session.get(
-            "{}/endpointlookup/mesh/{}/{}".format(self._url, q(organisation_code), q(workflow_id)),
-            timeout=self._timeout)
+            f"{self._url}/messageexchange/endpointlookup/{q(ods_code)}/{q(workflow_id)}",
+            timeout=self._timeout,
+        )
         response.raise_for_status()
-        return response.json()
+        return cast(EndpointLookupResponse_v1, response.json())
 
-    def list_messages(self):
+    def list_messages(self) -> List[str]:
         """
         List all messages in user's inbox. Returns a list of message_ids
         """
-        response = self._session.get(
-            "{}/messageexchange/{}/inbox".format(self._url, q(self._mailbox)),
-            timeout=self._timeout)
+        response = self._session.get(f"{self.mailbox_url}/inbox", timeout=self._timeout)
         response.raise_for_status()
-        return response.json()["messages"]
+        return cast(ListMessageResponse_v1, response.json())["messages"]
 
-    def retrieve_message(self, message_id):
+    def retrieve_message(self, message_id: str):
         """
         Retrieve a message based on its message_id. This will return a Message
         object
         """
         message_id = getattr(message_id, "_msg_id", message_id)
         response = self._session.get(
-            "{}/messageexchange/{}/inbox/{}".format(self._url, q(self._mailbox), q(message_id)),
+            f"{self.mailbox_url}/inbox/{q(message_id)}",
             stream=True,
-            timeout=self._timeout)
+            timeout=self._timeout,
+        )
         response.raise_for_status()
         return Message(message_id, response, self)
 
-    def retrieve_message_chunk(self, message_id, chunk_num):
+    def retrieve_message_chunk(self, message_id: str, chunk_num: Union[int, str]):
         response = self._session.get(
-            "{}/messageexchange/{}/inbox/{}/{}".format(self._url, q(self._mailbox), q(message_id), chunk_num),
+            f"{self.mailbox_url}/inbox/{q(message_id)}/{chunk_num}",
             stream=True,
-            timeout=self._timeout)
+            timeout=self._timeout,
+        )
         response.raise_for_status()
         return response
 
     def send_message(
-         self,
-         recipient,
-         data,
-         max_chunk_size = None,
-         **kwargs
-    ):
+        self,
+        recipient: str,
+        data,
+        max_chunk_size: Optional[int] = None,
+        **kwargs,
+    ) -> str:
         """
         Send a message to recipient containing data.
 
@@ -319,7 +398,6 @@ def send_message(
         filename
         local_id
         message_type
-        process_id
         subject
         encrypted
         compressed
@@ -329,38 +407,34 @@ def send_message(
         message_id
         version
         partner_id
-        recipient_smtp
-        sender_smtp
 
         Note that compressed refers to *non-transparent* compression - the
         client will not attempt to compress or decompress data. Transparent
         compression for sending is enabled as a constructor option.
         """
         transparent_compress = self._transparent_compress
-        maybe_compressed = (
-            lambda stream: GzipCompressStream(
-                stream) if transparent_compress else stream
-        )
+
+        def maybe_compressed(maybe_compress: bytes):
+            if not transparent_compress:
+                return maybe_compress
+            return GzipCompressStream(maybe_compress)
+
         headers = {
             "Mex-From": self._mailbox,
             "Mex-To": recipient,
-            "Mex-MessageType": 'DATA',
-            "Mex-Version": '1.0',
+            "Mex-MessageType": "DATA",
+            "Mex-Version": "1.0",
             "Content-Type": "application/octet-stream",
         }
 
         for key, value in kwargs.items():
             if key in _OPTIONAL_HEADERS:
                 if key in _BOOLEAN_HEADERS:
-                    value = 'Y' if value else 'N'
+                    value = "Y" if value else "N"
                 headers[_OPTIONAL_HEADERS[key]] = str(value)
             else:
-                raise TypeError("Unrecognised keyword argument {key}."
-                                " optional arguments are: {args}".format(
-                                    key=key,
-                                    args=", ".join([
-                                        "recipient", "data"
-                                    ] + list(_OPTIONAL_HEADERS.keys()))))
+                optional_args = ", ".join(["recipient", "data"] + list(_OPTIONAL_HEADERS.keys()))
+                raise TypeError(f"Unrecognised keyword argument '{key}'.  optional arguments are: {optional_args}")
 
         if transparent_compress:
             headers["Mex-Content-Compress"] = "Y"
@@ -368,28 +442,37 @@ def send_message(
 
         max_chunk_size = max_chunk_size or self._max_chunk_size
         chunks = SplitStream(data, max_chunk_size)
-        headers["Mex-Chunk-Range"] = "1:{}".format(len(chunks))
+        headers["Mex-Chunk-Range"] = f"1:{len(chunks)}"
         chunk_iterator = iter(chunks)
 
-        chunk1 = maybe_compressed(six.next(chunk_iterator))
+        chunk1 = maybe_compressed(next(chunk_iterator))
         response1 = self._session.post(
-            "{}/messageexchange/{}/outbox".format(self._url, q(self._mailbox)),
+            f"{self.mailbox_url}/outbox",
             data=chunk1,
             headers=headers,
-            timeout=self._timeout)
+            timeout=self._timeout,
+        )
         # MESH server dumps XML SOAP output on internal server error
         if response1.status_code >= 500:
             response1.raise_for_status()
-        json_resp = response1.json()
-        if response1.status_code == 417 or "errorDescription" in json_resp:
-            raise MeshError(json_resp["errorDescription"], json_resp)
-        message_id = json_resp["messageID"]
+
+        response_dict = response1.json()
+        if response1.status_code == 417 or "errorDescription" in response_dict:
+            error_response = cast(SendMessageErrorResponse_v1, response_dict)
+            raise MeshError(error_response["errorDescription"], error_response)
+
+        if response1.status_code not in (200, 202):
+            raise MeshError(response_dict)
+
+        success_response = cast(SendMessageResponse_v1, response_dict)
+
+        message_id = success_response["messageID"]
 
         for i, chunk in enumerate(chunk_iterator):
             data = maybe_compressed(chunk)
 
             if self._max_chunk_retries > 0:
-                if hasattr(data, 'read'):
+                if hasattr(data, "read"):
                     data = data.read()
                 buf = BytesIO(data)
             else:
@@ -398,14 +481,13 @@ def send_message(
             chunk_num = i + 2
             headers = {
                 "Content-Type": "application/octet-stream",
-                "Mex-Chunk-Range": "{}:{}".format(chunk_num, len(chunks)),
+                "Mex-Chunk-Range": f"{chunk_num}:{len(chunks)}",
                 "Mex-From": self._mailbox,
             }
             if transparent_compress:
                 headers["Mex-Content-Compress"] = "Y"
                 headers["Content-Encoding"] = "gzip"
 
-            response = None
             for i in range(self._max_chunk_retries + 1):
                 if self._max_chunk_retries > 0:
                     buf.seek(0)
@@ -414,29 +496,32 @@ def send_message(
                 time.sleep(i**2)
 
                 response = self._session.post(
-                    "{}/messageexchange/{}/outbox/{}/{}".format(
-                        self._url, q(self._mailbox), q(message_id), chunk_num),
+                    f"{self.mailbox_url}/outbox/{q(message_id)}/{chunk_num}",
                     data=buf,
                     headers=headers,
-                    timeout=self._timeout)
+                    timeout=self._timeout,
+                )
 
                 # check other successful response codes
-                if response.status_code == 200 or response.status_code == 202:
+                if response.status_code in (200, 202):
                     break
-            else:
+
+                if i < self._max_chunk_retries:
+                    continue
+
                 response.raise_for_status()
 
         return message_id
 
-    def acknowledge_message(self, message_id):
+    def acknowledge_message(self, message_id: str):
         """
         Acknowledge a message_id, deleting it from MESH.
         """
         message_id = getattr(message_id, "_msg_id", message_id)
         response = self._session.put(
-            "{}/messageexchange/{}/inbox/{}/status/acknowledged".format(
-                self._url, q(self._mailbox), q(message_id)),
-            timeout=self._timeout)
+            f"{self.mailbox_url}/inbox/{q(message_id)}/status/acknowledged",
+            timeout=self._timeout,
+        )
         response.raise_for_status()
 
     def iterate_all_messages(self):
@@ -472,7 +557,33 @@ def __exit__(self, type_, value, tb):
         self.close()
 
 
-class Message(object):
+@dataclass
+class _MessageAttrs:
+    """
+    this is for type hinting on the commonly used message attrs below ( which are dynamically generated )
+    """
+
+    message_id: str
+    message_type: str
+    recipient: str
+    content_type: str
+    sender: Optional[str] = None
+
+    workflow_id: Optional[str] = None
+    filename: Optional[str] = None
+    local_id: Optional[str] = None
+    partner_id: Optional[str] = None
+    chunk_range: Optional[str] = None
+
+    subject: Optional[str] = None
+    encrypted: Optional[Union[str, bool]] = None
+    compressed: Optional[Union[str, bool]] = None
+
+
+TDefault = TypeVar("TDefault")
+
+
+class _BaseMessage:
     """
     An object representing a message received from MESH. This is a file-like
     object, and can be passed to anything that expects an object with a `read`
@@ -486,14 +597,17 @@ class Message(object):
     filename
     local_id
     message_type
-    process_id
     subject
     encrypted
     compressed
+    checksum
+    content_type
 
     Note that compressed refers to *non-transparent* compression - the
-    client will not attempt to compress or decompress data. Transparent
+    client will not attempt to compress or decompress data. Transparent (Content-Encoding)
     compression is handled automatically, with no intervention needed.
+    This is merely a header that is passed through to let the recipient know the decoded content is further compressed.
+
 
     Messages have a read method, and will handle chunking and transparent
     compression automatically. Once the data has been read, you must close the
@@ -505,35 +619,35 @@ class Message(object):
     no exceptions are thrown whilst handling the message.
     """
 
-    def __init__(self, msg_id, response, client):
+    def __init__(self, msg_id: str, response, client):
         self._msg_id = msg_id
         self._client = client
-        self._mex_headers = {}
+        self._mex_headers: Dict[str, Any] = {}
 
         headers = response.headers
-        for key, value in six.iteritems(headers):
-            lkey = key.lower()
-            if lkey.startswith('mex-'):
-                self._mex_headers[lkey[4:]] = value
-
-        for key, value in _RECEIVE_HEADERS.items():
-            header_value = headers.get(value, None)
-            if key in _BOOLEAN_HEADERS:
+        for header, header_value in headers.items():
+            lkey = header.lower()
+            if lkey.startswith("mex-"):
+                self._mex_headers[lkey[4:]] = header_value
+
+        for attribute, header in _RECEIVE_HEADERS.items():
+            header_value = headers.get(header, None)
+            if attribute in _BOOLEAN_HEADERS:
                 header_value = header_value or "N"
                 header_value = header_value.upper() in ["Y", "TRUE"]
-            setattr(self, key, header_value)
+            setattr(self, attribute, header_value)
         chunk, chunk_count = map(int, headers.get("Mex-Chunk-Range", "1:1").split(":"))
         maybe_decompress = (
-            lambda resp:
-            GzipDecompressStream(resp.raw)
-            if resp.headers.get("Content-Encoding") == "gzip" else resp.raw
+            lambda resp: GzipDecompressStream(resp.raw) if resp.headers.get("Content-Encoding") == "gzip" else resp.raw
         )
         self._response = CombineStreams(
-            chain([maybe_decompress(response)], (maybe_decompress(
-                client.retrieve_message_chunk(msg_id, str(
-                    i + 2))) for i in range(chunk_count - 1))))
+            chain(
+                [maybe_decompress(response)],
+                (maybe_decompress(client.retrieve_message_chunk(msg_id, str(i + 2))) for i in range(chunk_count - 1)),
+            )
+        )
 
-    def id(self):
+    def id(self) -> str:
         """return the message id
 
         Returns:
@@ -541,20 +655,20 @@ def id(self):
         """
         return self._msg_id
 
-    def read(self, n=None):
+    def read(self, n=None) -> bytes:
         """
         Read up to n bytes from the message, or read the remainder of the
         message, if n is not provided.
         """
         return self._response.read(n)
 
-    def readline(self):
+    def readline(self) -> bytes:
         """
         Read a single line from the message
         """
         return self._response.readline()
 
-    def readlines(self):
+    def readlines(self) -> List[bytes]:
         """
         Read all lines from the message
         """
@@ -566,7 +680,7 @@ def close(self):
             try:
                 self._response.close()
             finally:
-                self._response = None
+                self._response = None  # type: ignore[assignment]
 
     def acknowledge(self):
         """
@@ -574,8 +688,8 @@ def acknowledge(self):
         """
         self._client.acknowledge_message(self._msg_id)
 
-    def mex_header(self, key, default=None):
-        """ get a mex header if present
+    def mex_header(self, key: str, default: Optional[TDefault] = None) -> Union[str, TDefault]:
+        """get a mex header if present
 
         Args:
             key (str): key
@@ -587,7 +701,7 @@ def mex_header(self, key, default=None):
 
     def mex_headers(self):
         """returns a generator iteritems for all the headers"""
-        return six.iteritems(self._mex_headers)
+        return self._mex_headers.items()
 
     def __enter__(self):
         return self
@@ -606,9 +720,13 @@ def __iter__(self):
         return iter(self._response)
 
 
-class AuthTokenGenerator(object):
+class Message(_BaseMessage, _MessageAttrs):
+    def __init__(self, msg_id: str, response, client):
+        super().__init__(msg_id, response, client)
+
 
-    def __init__(self, key, mailbox, password):
+class AuthTokenGenerator(object):
+    def __init__(self, key: bytes, mailbox: str, password: str):
         self._key = key
         self._mailbox = mailbox
         self._password = password
@@ -619,27 +737,20 @@ def __call__(self, r=None):
         token = self.generate_token()
         if r is not None:
             # This is being used as a Requests auth handler
-            r.headers['Authorization'] = token
+            r.headers["Authorization"] = token
             return r
         else:
             # This is being used in its legacy capacity
             return token
 
-    def generate_token(self):
+    def generate_token(self) -> str:
         now = datetime.datetime.utcnow().strftime("%Y%m%d%H%M")
-        public_auth_data = _combine(self._mailbox, self._nonce,
-                                    self._nonce_count, now)
-        private_auth_data = _combine(self._mailbox, self._nonce,
-                                     self._nonce_count, self._password, now)
-        myhash = hmac.HMAC(self._key, private_auth_data.encode("ASCII"),
-                           sha256).hexdigest()
+        public_auth_data = f"{self._mailbox}:{self._nonce}:{self._nonce_count}:{now}"
+        private_auth_data = f"{self._mailbox}:{self._nonce}:{self._nonce_count}:{self._password}:{now}"
+        myhash = hmac.HMAC(self._key, private_auth_data.encode("ASCII"), sha256).hexdigest()
         self._nonce_count += 1
-        return "NHSMESH {public_auth_data}:{myhash}".format(**locals())
+        return f"NHSMESH {public_auth_data}:{myhash}"
 
 
 # Preserve old name, even though it's part of the API now
 _AuthTokenGenerator = AuthTokenGenerator
-
-
-def _combine(*elements):
-    return ":".join(str(x) for x in elements)
diff --git a/mesh_client/io_helpers.py b/mesh_client/io_helpers.py
index ef367be..161fa60 100644
--- a/mesh_client/io_helpers.py
+++ b/mesh_client/io_helpers.py
@@ -1,15 +1,15 @@
-from __future__ import division
 import io
 import os
-import six
 import warnings
 import zlib
+from typing import List, cast
 
 
 class IteratorMixin(object):
     """
     Produce a series of lines, from a readable that only exposes a `read` method
     """
+
     __block_size = 65536
     __line_iterator = None
 
@@ -17,7 +17,7 @@ def __iter__(self):
         # We use a BytesIO as our buffer, so we can piggyback on its readlines method
         buff = io.BytesIO()
         while True:
-            block = self.read(self.__block_size)
+            block = self.read(self.__block_size)  # type: ignore[attr-defined]
             buff.write(block)
             last_block = len(block) == 0
             buff.seek(0)
@@ -32,44 +32,43 @@ def __iter__(self):
                 yield lines[-1]
                 break
 
-    def readline(self):
+    def readline(self) -> bytes:
         if not self.__line_iterator:
             self.__line_iterator = iter(self)
         try:
-            return six.next(self.__line_iterator)
+            return cast(bytes, next(self.__line_iterator))
         except StopIteration:
-            return b''
+            return b""
 
-    def readlines(self):
+    def readlines(self) -> List[bytes]:
         return list(iter(self))
 
 
 class CloseUnderlyingMixin(object):
     def close(self):
         try:
-            if hasattr(self._underlying, "close"):
-                self._underlying.close()
+            if hasattr(self._underlying, "close"):  # type: ignore[has-type]
+                self._underlying.close()  # type: ignore[has-type]
         finally:
             self._underlying = None
             if hasattr(super(CloseUnderlyingMixin, self), "close"):
-                super(CloseUnderlyingMixin, self).close()
+                super(CloseUnderlyingMixin, self).close()  # type: ignore[misc]
 
     def __del__(self):
         self.close()
         if hasattr(super(CloseUnderlyingMixin, self), "__del__"):
-            super(CloseUnderlyingMixin, self).__del__()
+            super(CloseUnderlyingMixin, self).__del__()  # type: ignore[misc]
 
     def __enter__(self):
         if hasattr(super(CloseUnderlyingMixin, self), "__enter__"):
-            return super(CloseUnderlyingMixin, self).__enter__()
+            return super(CloseUnderlyingMixin, self).__enter__()  # type: ignore[misc]
         else:
             return self
 
     def __exit__(self, typ, value, traceback):
         self.close()
         if hasattr(super(CloseUnderlyingMixin, self), "__exit__"):
-            return super(CloseUnderlyingMixin, self).__exit__(typ, value,
-                                                              traceback)
+            return super(CloseUnderlyingMixin, self).__exit__(typ, value, traceback)  # type: ignore[misc]
 
 
 class AbstractGzipStream(IteratorMixin, CloseUnderlyingMixin):
@@ -130,9 +129,7 @@ class GzipCompressStream(AbstractGzipStream):
     def __init__(self, underlying, block_size=65536):
         AbstractGzipStream.__init__(self, underlying, block_size)
         self._compress_obj = zlib.compressobj(
-            9,  # level
-            zlib.DEFLATED,  # method
-            31  # wbits - gzip header, maximum window
+            9, zlib.DEFLATED, 31  # level  # method  # wbits - gzip header, maximum window
         )
 
     def _process_block(self, block):
@@ -150,9 +147,7 @@ class GzipDecompressStream(AbstractGzipStream):
 
     def __init__(self, underlying, block_size=65536):
         AbstractGzipStream.__init__(self, underlying, block_size)
-        self._decompress_obj = zlib.decompressobj(
-            47  # wbits - detect header, maximum window
-        )
+        self._decompress_obj = zlib.decompressobj(47)  # wbits - detect header, maximum window
 
     def _process_block(self, block):
         return self._decompress_obj.decompress(block)
@@ -184,18 +179,17 @@ def __init__(self, data, chunk_size=75 * 1024 * 1024):
         self._remaining = 0
 
     def __len__(self):
-        return max(1,
-                   (self._length + self._chunk_size - 1) // self._chunk_size)
+        return max(1, (self._length + self._chunk_size - 1) // self._chunk_size)
 
     def __iter__(self):
         for i in range(len(self)):
             if self._remaining > 0:
                 warnings.warn(
-                    "moving to next chunk, despite unread content in buffer "
-                    "- existing chunk will become invalid")
+                    "moving to next chunk, despite unread content in buffer " "- existing chunk will become invalid"
+                )
+                assert self._underlying
                 self._underlying.read(self._remaining)
-            self._remaining = min(self._chunk_size,
-                                  self._length - i * self._chunk_size)
+            self._remaining = min(self._chunk_size, self._length - i * self._chunk_size)
             yield _SplitChunk(self)
 
 
@@ -220,9 +214,9 @@ def __len__(self):
 class CombineStreams(IteratorMixin):
     def __init__(self, streams):
         self._streams = iter(streams)
-        self._current_stream = six.next(self._streams)
+        self._current_stream = next(self._streams)
 
-    def read(self, n=-1):
+    def read(self, n=-1) -> bytes:
         if n == -1:
             n = None
         result = io.BytesIO()
@@ -232,7 +226,7 @@ def read(self, n=-1):
                 result.write(data_read)
                 if n is None or len(data_read) < n:
                     self._close_current_stream()
-                    self._current_stream = six.next(self._streams)
+                    self._current_stream = next(self._streams)
                     if n is not None:
                         n -= len(data_read)
                 else:
@@ -247,7 +241,7 @@ def close(self):
     def _close_current_stream(self):
         try:
             self._current_stream.close()
-        except:
+        except:  # noqa: E722
             pass
 
 
@@ -261,6 +255,7 @@ def read(self, n=-1):
             n = self._remaining
         n = min(n, self._remaining)
         try:
+            assert self._underlying
             return self._underlying.read(n)
         finally:
             self._remaining -= n
@@ -302,14 +297,3 @@ def read(self, n=-1):
                     self._buffer.seek(0)
                     self._underlying.close()
                     self._underlying = None
-
-
-def stream_from_wsgi_environ(environ):
-    if environ.get("CONTENT_LENGTH"):
-        return FiniteLengthStream(environ["wsgi.input"],
-                                  int(environ["CONTENT_LENGTH"]))
-    elif environ.get("HTTP_TRANSFER_ENCODING") == "chunked":
-        return ChunkedStream(environ["wsgi.input"])
-    else:
-        # terminated by close
-        return environ["wsgi.input"]
diff --git a/mesh_client/key_helper.py b/mesh_client/key_helper.py
index 545594f..8b50c35 100644
--- a/mesh_client/key_helper.py
+++ b/mesh_client/key_helper.py
@@ -1,12 +1,9 @@
 import os
 
 
-def get_shared_key_from_environ():
-    key = os.environ.get(
-        'MESH_CLIENT_SHARED_KEY',
-        '\x42\x61\x63\x6b\x42\x6f\x6e\x65'
-    )
-    if not isinstance(key, bytes):
-        # Handle Python 3's non-Posix handling of binary environment variables
-        key = key.encode('utf-8', 'surrogateescape')
-    return key
+def get_shared_key_from_environ() -> bytes:
+    key_str = os.environ.get("MESH_CLIENT_SHARED_KEY", "\x42\x61\x63\x6b\x42\x6f\x6e\x65")
+    if isinstance(key_str, bytes):
+        return key_str
+
+    return key_str.encode("utf-8", "surrogateescape")
diff --git a/mesh_client/mesh_auth.py b/mesh_client/mesh_auth.py
index d6a5ec8..2b7fa44 100755
--- a/mesh_client/mesh_auth.py
+++ b/mesh_client/mesh_auth.py
@@ -1,26 +1,29 @@
 #!/usr/bin/env python
-from __future__ import print_function
+
+from argparse import ArgumentParser
+
 from mesh_client import AuthTokenGenerator
 from mesh_client.key_helper import get_shared_key_from_environ
-from argparse import ArgumentParser
 
 
 def encode_bytes(key):
-    return key.encode('utf-8')
+    return key.encode("utf-8", "surrogateescape")
 
 
 def main():
     parser = ArgumentParser(description="Create MESH auth token for user")
-    parser.add_argument('user', help="The username")
-    parser.add_argument('password', help="The password for the user")
-    parser.add_argument('--shared-key',
-                        help="The shared key to use for MESH authentication",
-                        default=get_shared_key_from_environ(),
-                        type=encode_bytes)
+    parser.add_argument("user", help="The username")
+    parser.add_argument("password", help="The password for the user")
+    parser.add_argument(
+        "--shared-key",
+        help="The shared key to use for MESH authentication",
+        default=get_shared_key_from_environ(),
+        type=encode_bytes,
+    )
     args = parser.parse_args()
     generator = AuthTokenGenerator(args.shared_key, args.user, args.password)
     print(generator.generate_token())
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     main()
diff --git a/mesh_client/nhs-int-ca-bundle.pem b/mesh_client/nhs-int-ca-bundle.pem
index fd24e2c..719864b 100644
--- a/mesh_client/nhs-int-ca-bundle.pem
+++ b/mesh_client/nhs-int-ca-bundle.pem
@@ -1,45 +1,42 @@
 -----BEGIN CERTIFICATE-----
-MIID0TCCArmgAwIBAgIEQHf1YTANBgkqhkiG9w0BAQUFADAsMQwwCgYDVQQKEwNu
-aHMxCzAJBgNVBAsTAkNBMQ8wDQYDVQQDEwZSb290Q0EwHhcNMDQwNDEwMTI1MzQ5
-WhcNMjQwNDEwMTMyMzQ5WjAsMQwwCgYDVQQKEwNuaHMxCzAJBgNVBAsTAkNBMQ8w
-DQYDVQQDEwZSb290Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCz
-8qjO+JfqKKTYvNo9k6mucBFB2xWVfJoamQD9kY94CxnOP/uzwoj8B7Icqy9hGBkL
-HlzFd63vjKNODLPhJpSHSqxeIJ3GfrSA4OeX6e3X92qdhsAWnGRBEOjRt65am0Y2
-UKZuBA9dg+zd4o9YH3pqRJUdxOUfyycQOV4c7vdvJwssFcXG+TgqmdvbiHaatBaZ
-qGgqOIav+ht22g9K0kfYCho4nkD1CdPCHHfoc4RJ94S+VQ1/ham+xNouf+p0Fd6O
-iJz9coCCH/+w8LKv09QntdhZpW4HABtaIOfNbIs4IkhvKxh16xrX3Lez0X/H70EZ
-DqrxZAQZMIje4xLH8ah7AgMBAAGjgfowgfcwTgYDVR0fBEcwRTBDoEGgP6Q9MDsx
-DDAKBgNVBAoTA25oczELMAkGA1UECxMCQ0ExDzANBgNVBAMTBlJvb3RDQTENMAsG
-A1UEAxMEQ1JMMTArBgNVHRAEJDAigA8yMDA0MDQxMDEyNTM0OVqBDzIwMjQwNDEw
-MTMyMzQ5WjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUvGuETRMkDF1p+ALjXNWe
-e3FTv8cwHQYDVR0OBBYEFLxrhE0TJAxdafgC41zVnntxU7/HMAwGA1UdEwQFMAMB
-Af8wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMDo0LjADAgSQMA0GCSqGSIb3DQEBBQUA
-A4IBAQByVCEFsrPxsFVHWzIW0AqFHZQ8eBjLbemvg2XgzeD3FMh0MqK2dTu4/3Fn
-MbBfEGrR1gyYTdfaHTIqxyzxKr3KKy0eXxaymIYjm5jiGZ2bslZoPdVXnYtIJa1K
-GSlfaC3QFllEi6lB15LOouros3lwVbm2ANtrvtj/aJx7h4T2p5F1cc2EmT9aTfSw
-3rv05uuqvKTH+UtjANnf1RSCgTlMN5MCxj6gbSLDYv9V+uMbXXIMJkUYNp+gEp7r
-QxMkJ7nUtC6F3ni4V18nbyvcMvcvX69rh539pUWtOmbcHCQmrqT8ApRnQ90kLFo3
-etwc6yPrtDcN1bxchGleXzHXuAxG
+MIIDiDCCAnCgAwIBAgIEXa2L4zANBgkqhkiG9w0BAQsFADA8MQwwCgYDVQQKEwNu
+aHMxCzAJBgNVBAsTAkNBMR8wHQYDVQQDExZOSFMgUFRMIFJvb3QgQXV0aG9yaXR5
+MB4XDTE5MTExMTEzMzAyMFoXDTI5MTExMTE0MDAyMFowNjEMMAoGA1UEChMDbmhz
+MQswCQYDVQQLEwJDQTEZMBcGA1UEAxMQTkhTIElOVCBMZXZlbCAxQzCCASIwDQYJ
+KoZIhvcNAQEBBQADggEPADCCAQoCggEBAJZYX0s2PA9y/iXNijgemCsN4wnhKnCM
+3biC3A9PKMJ683QvBdofruGA03mvRQsB3qFBcuy2heR+8sRIUYVooeoUnNoqwcQO
+7KaklXOtdn4q0e80qKISKkSKCAUgGGxe466hdgtLcP5/pZKWtPXx6Ci0vwnsOH/k
+Mv6Bz62pryndTaQl7a30JMlHQkXpzy4ynreiIgTZ1LtTZZ79vaVZ1gFXneqGkHEF
+jAk+xnFInWzU00VdVt+RWTydLrphl5y9OW52Agxu9rfjNhSwmBkKX2zlKLIBD/0z
+y9L6LIGTU2SpeSXAjlvsp1NwMPQIgqcPyasobmRMyNv88e6YqaLZs4ECAwEAAaOB
+lzCBlDA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLm5ocy51ay9wdGwvcm9v
+dGNhL2FybGExLmNybDALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUz4gDrKnt3tEA
+CoCAAEC4A4jIkFIwHQYDVR0OBBYEFAAGgm2rphJD7bYN9OY8xCDBYZugMAwGA1Ud
+EwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABbqnyMmVyOmIUiBAW5prGeIxJek
+10hY92fztnTur5Qd6s1TLh+8YNpZ1Cyvd5eUPWYkPcK28S+0mXGWB3mxOGyorVm/
+V9TeIccYckANSE64GzZpEZDJQa8tHI4mrszZaXQnbEpQ5XQCzh3dXRzcVngyxBtG
+oVWhuFO5ORH2pTenc15srX3U5COh4aEMiH10X5Ua3M8loA98yf9Ot8VGXKnzQSbn
+0s8IFPaZolw6MFoXPV7p/9KoqJWWjOCDAPlmFBQRnJ7nypIT57d2WhoqojlP773M
+qBxGPncwDs2VeZBGJIdE/paDBWeTfQFVSCFri8msWUfwW8c1s3llFord5FY=
 -----END CERTIFICATE-----
 -----BEGIN CERTIFICATE-----
-MIIDoTCCAomgAwIBAgIEQHgs8DANBgkqhkiG9w0BAQUFADAsMQwwCgYDVQQKEwNu
-aHMxCzAJBgNVBAsTAkNBMQ8wDQYDVQQDEwZSb290Q0EwHhcNMTIwNTEzMTk1OTE0
-WhcNMjIwNTEzMjAyOTE0WjAtMQwwCgYDVQQKEwNuaHMxCzAJBgNVBAsTAkNBMRAw
-DgYDVQQDEwdTdWJDQTAyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
-pDCaoo0vEeSRL4+la+7y4p2b5tY3byDVVvROTbP9mrcnhbZXjjciTMgUu9s2OVj7
-K37JIy/1QxzAasGSalxGm+t93vBYL3clwMtRRJZCVx9TSjC7x91BCxm91vBU4WBZ
-lR1Ete0ppMFCgJ1H1GsZKzFFuLbBIq4/FoO5fzgXPnD26EVRecy6t4KyoW9cQvLq
-wt5hN4bYDUViRQkmGoHrkdKhrsaMWRqDMhG9euEec4erWbHty+0And2UMQibqk2+
-vaphZbcrlH05x4Gs7VJuSAAhNBRlSwkEw4zPayYvroowoF8YuX5b86eCc+8TLDxN
-18mcjfjcWNPN+tB85zzDywIDAQABo4HJMIHGME4GA1UdHwRHMEUwQ6BBoD+kPTA7
-MQwwCgYDVQQKEwNuaHMxCzAJBgNVBAsTAkNBMQ8wDQYDVQQDEwZSb290Q0ExDTAL
-BgNVBAMTBENSTDEwCwYDVR0PBAQDAgEGMB8GA1UdIwQYMBaAFLxrhE0TJAxdafgC
-41zVnntxU7/HMB0GA1UdDgQWBBQSSoi3gsCYCcPcpsZpewnlLvD2BTAMBgNVHRME
-BTADAQH/MBkGCSqGSIb2fQdBAAQMMAobBFY3LjEDAgCBMA0GCSqGSIb3DQEBBQUA
-A4IBAQAMRud0q8/pCKeFDaOUepjDJyx1dr+QFyVT2QXxN2vNg6Umei9v5HI/06We
-kow4L7mGCx5DM2dyuCeAsSmAcnc8XeS1otfjheMy3VvKyQJHfYYBXqhSOc7Bd2Ro
-dHDofdb14Iu0NTGCn1+AFhY/FAkILGre8mV/HPK/6+vANlP8M3r/8RoAbhQwv4hp
-ac1BZvRYyEHeqqE79h1Rh1YMPTJlScVpLJrOyFj6sQZkQDq8DK9u+/f/lNIzhaNE
-yJJrBm5CUrjDDMoy1rZs9lpqdlFnygSiV2mEjjm/XT6+FhwusapUW48xvD9HRtQZ
-E0iE3Q3lIM2BW9Gg4RHeQS6Pgana
+MIIDgjCCAmqgAwIBAgIEXR9LkDANBgkqhkiG9w0BAQsFADA8MQwwCgYDVQQKEwNu
+aHMxCzAJBgNVBAsTAkNBMR8wHQYDVQQDExZOSFMgUFRMIFJvb3QgQXV0aG9yaXR5
+MB4XDTE5MDcwNTEyMzcyOFoXDTM5MDcwNTEzMDcyOFowPDEMMAoGA1UEChMDbmhz
+MQswCQYDVQQLEwJDQTEfMB0GA1UEAxMWTkhTIFBUTCBSb290IEF1dGhvcml0eTCC
+ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJkUOEvituvw0SSmU4adXDnP
+SXiaLsQb8CPZwLylSYqumfIzSjsxkktJsekegF6ybtGRMPWW+zBXMI15C3cT+tn8
+gpCNYyn1P8/+oNgxtqTLYjxackfU6S8AL+6d39grDd6PlI5ILvCZko82m23cUx2y
+2aRnpAIBgDk518tWLTZbMuM14bza/QvYVeX5DqW9gsz947opb6FYRe3MjeHiQmxq
+GvWfPY/yb/cggo5y8m2fTQa6dencHeFwnmwbX6nwbrFx8UXzflD0Yke4i5Z2NN4C
+xmgAqtxSu5Bz9f7ZQLPPBT5FL+pvxkAu4cEHma4JDD3KayhxzxigKbQcnQpXWEcC
+AwEAAaOBizCBiDArBgNVHRAEJDAigA8yMDE5MDcwNTEyMzcyOFqBDzIwMzkwNzA1
+MTMwNzI4WjALBgNVHQ8EBAMCAQYwHwYDVR0jBBgwFoAUz4gDrKnt3tEACoCAAEC4
+A4jIkFIwHQYDVR0OBBYEFM+IA6yp7d7RAAqAgABAuAOIyJBSMAwGA1UdEwQFMAMB
+Af8wDQYJKoZIhvcNAQELBQADggEBAJU06JOPpBMgj/Owzd2aDDIlzEjHDyBx0UOa
+xcIeeAT/ByYkTuPees9b5vsPmUwVuNzKscChQ9tvuugPPoTu9gXQfLldKTLlPqOf
++VCghQhM4H+i9+JyK7iKYN7xdVJLjH0AIgpNLwNN/2pnZ+69dOU4G7W0+NYT4e/w
+wDcJSq0fi8dwixDTYU8DkFCfEyahEqeuNPp9X3ddWZryq3XjbR3n0YA1sUmnuwiT
+ruMNXR0m34pt0nFLQFz57+dLWhAsB5xQdLPc1AuWwK/R8jnHh3+Q/RXOnXG4NNmn
+RKYUDjLpFTnWmiZfGyX6edIrjN72M3Guu5cAs0pD/2d6QFsp1Dg=
 -----END CERTIFICATE-----
diff --git a/mesh_client/py.typed b/mesh_client/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/mesh_client/types.py b/mesh_client/types.py
new file mode 100644
index 0000000..917def9
--- /dev/null
+++ b/mesh_client/types.py
@@ -0,0 +1,22 @@
+from typing import Dict, List, Union
+
+# https://digital.nhs.uk/developer/api-catalogue/message-exchange-for-social-care-and-health-api#get-/messageexchange/endpointlookup/-ods_code-/-workflow_id-
+EndpointLookupItem_v1 = Dict[str, str]
+EndpointLookupResponse_v1 = Dict[str, Union[str, List[EndpointLookupItem_v1]]]
+EndpointLookupItem_v2 = Dict[str, str]
+EndpointLookupResponse_v2 = Dict[str, List[Dict[str, str]]]
+# https://digital.nhs.uk/developer/api-catalogue/message-exchange-for-social-care-and-health-api#get-/messageexchange/-mailbox_id-/inbox
+ListMessageResponse_v1 = Dict[str, List[str]]
+ListMessageResponse_v2 = Dict[str, Union[List[str], Dict[str, str], int]]
+CountMessagesResponse_v1 = Dict[str, Union[str, int, bool]]
+CountMessagesResponse_v2 = Dict[str, int]
+AcknowledgeMessageResponse_v1 = Dict[str, str]
+# https://digital.nhs.uk/developer/api-catalogue/message-exchange-for-social-care-and-health-api#get-/messageexchange/-mailbox_id-/outbox/tracking/-local_id-
+TrackingResponse_v1 = Dict[str, Union[str, int, bool]]
+# https://digital.nhs.uk/developer/api-catalogue/message-exchange-for-social-care-and-health-api#get-/messageexchange/-mailbox_id-/outbox/tracking
+TrackingResponse_v2 = Dict[str, Union[str, int, bool]]
+
+# https://digital.nhs.uk/developer/api-catalogue/message-exchange-for-social-care-and-health-api#post-/messageexchange/-mailbox_id-/outbox
+SendMessageResponse_v1 = Dict[str, str]
+SendMessageResponse_v2 = Dict[str, str]
+SendMessageErrorResponse_v1 = Dict[str, str]
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..27da598
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1635 @@
+# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+
+[[package]]
+name = "appnope"
+version = "0.1.3"
+description = "Disable App Nap on macOS >= 10.9"
+optional = false
+python-versions = "*"
+files = [
+    {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"},
+    {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"},
+]
+
+[[package]]
+name = "asttokens"
+version = "2.2.1"
+description = "Annotate AST trees with source code positions"
+optional = false
+python-versions = "*"
+files = [
+    {file = "asttokens-2.2.1-py2.py3-none-any.whl", hash = "sha256:6b0ac9e93fb0335014d382b8fa9b3afa7df546984258005da0b9e7095b3deb1c"},
+    {file = "asttokens-2.2.1.tar.gz", hash = "sha256:4622110b2a6f30b77e1473affaa97e711bc2f07d3f10848420ff1898edbe94f3"},
+]
+
+[package.dependencies]
+six = "*"
+
+[package.extras]
+test = ["astroid", "pytest"]
+
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+optional = false
+python-versions = "*"
+files = [
+    {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+    {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
+]
+
+[[package]]
+name = "black"
+version = "23.3.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"},
+    {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"},
+    {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"},
+    {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"},
+    {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"},
+    {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"},
+    {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"},
+    {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"},
+    {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"},
+    {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"},
+    {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"},
+    {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"},
+    {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"},
+    {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"},
+    {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"},
+    {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"},
+    {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"},
+    {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
+typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "bleach"
+version = "6.0.0"
+description = "An easy safelist-based HTML-sanitizing tool."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4"},
+    {file = "bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414"},
+]
+
+[package.dependencies]
+six = ">=1.9.0"
+webencodings = "*"
+
+[package.extras]
+css = ["tinycss2 (>=1.1.0,<1.2)"]
+
+[[package]]
+name = "build"
+version = "0.10.0"
+description = "A simple, correct Python build frontend"
+optional = false
+python-versions = ">= 3.7"
+files = [
+    {file = "build-0.10.0-py3-none-any.whl", hash = "sha256:af266720050a66c893a6096a2f410989eeac74ff9a68ba194b3f6473e8e26171"},
+    {file = "build-0.10.0.tar.gz", hash = "sha256:d5b71264afdb5951d6704482aac78de887c80691c52b88a9ad195983ca2c9269"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "os_name == \"nt\""}
+importlib-metadata = {version = ">=0.22", markers = "python_version < \"3.8\""}
+packaging = ">=19.0"
+pyproject_hooks = "*"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2021.08.31)", "sphinx (>=4.0,<5.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)"]
+test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "toml (>=0.10.0)", "wheel (>=0.36.0)"]
+typing = ["importlib-metadata (>=5.1)", "mypy (==0.991)", "tomli", "typing-extensions (>=3.7.4.3)"]
+virtualenv = ["virtualenv (>=20.0.35)"]
+
+[[package]]
+name = "cachetools"
+version = "5.3.1"
+description = "Extensible memoizing collections and decorators"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"},
+    {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"},
+]
+
+[[package]]
+name = "certifi"
+version = "2023.5.7"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
+    {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
+]
+
+[[package]]
+name = "cffi"
+version = "1.15.1"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = "*"
+files = [
+    {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
+    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"},
+    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"},
+    {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"},
+    {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"},
+    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"},
+    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"},
+    {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"},
+    {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"},
+    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"},
+    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"},
+    {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"},
+    {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"},
+    {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"},
+    {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"},
+    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"},
+    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"},
+    {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"},
+    {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"},
+    {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"},
+    {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"},
+    {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"},
+    {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"},
+    {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"},
+    {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"},
+    {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"},
+    {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"},
+    {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"},
+    {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"},
+    {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"},
+    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"},
+    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"},
+    {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"},
+    {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"},
+    {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "chardet"
+version = "5.1.0"
+description = "Universal encoding detector for Python 3"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"},
+    {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.1.0"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"},
+    {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"},
+    {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"},
+    {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"},
+    {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"},
+    {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"},
+    {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.3"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.2.7"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"},
+    {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"},
+    {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"},
+    {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"},
+    {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"},
+    {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"},
+    {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"},
+    {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"},
+    {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"},
+    {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"},
+    {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"},
+    {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"},
+    {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"},
+    {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"},
+    {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"},
+    {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"},
+    {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"},
+    {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "cryptography"
+version = "41.0.1"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "cryptography-41.0.1-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:f73bff05db2a3e5974a6fd248af2566134d8981fd7ab012e5dd4ddb1d9a70699"},
+    {file = "cryptography-41.0.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1a5472d40c8f8e91ff7a3d8ac6dfa363d8e3138b961529c996f3e2df0c7a411a"},
+    {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fa01527046ca5facdf973eef2535a27fec4cb651e4daec4d043ef63f6ecd4ca"},
+    {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b46e37db3cc267b4dea1f56da7346c9727e1209aa98487179ee8ebed09d21e43"},
+    {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d198820aba55660b4d74f7b5fd1f17db3aa5eb3e6893b0a41b75e84e4f9e0e4b"},
+    {file = "cryptography-41.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:948224d76c4b6457349d47c0c98657557f429b4e93057cf5a2f71d603e2fc3a3"},
+    {file = "cryptography-41.0.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:059e348f9a3c1950937e1b5d7ba1f8e968508ab181e75fc32b879452f08356db"},
+    {file = "cryptography-41.0.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b4ceb5324b998ce2003bc17d519080b4ec8d5b7b70794cbd2836101406a9be31"},
+    {file = "cryptography-41.0.1-cp37-abi3-win32.whl", hash = "sha256:8f4ab7021127a9b4323537300a2acfb450124b2def3756f64dc3a3d2160ee4b5"},
+    {file = "cryptography-41.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:1fee5aacc7367487b4e22484d3c7e547992ed726d14864ee33c0176ae43b0d7c"},
+    {file = "cryptography-41.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9a6c7a3c87d595608a39980ebaa04d5a37f94024c9f24eb7d10262b92f739ddb"},
+    {file = "cryptography-41.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5d092fdfedaec4cbbffbf98cddc915ba145313a6fdaab83c6e67f4e6c218e6f3"},
+    {file = "cryptography-41.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a8e6c2de6fbbcc5e14fd27fb24414507cb3333198ea9ab1258d916f00bc3039"},
+    {file = "cryptography-41.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb33ccf15e89f7ed89b235cff9d49e2e62c6c981a6061c9c8bb47ed7951190bc"},
+    {file = "cryptography-41.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f0ff6e18d13a3de56f609dd1fd11470918f770c6bd5d00d632076c727d35485"},
+    {file = "cryptography-41.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7bfc55a5eae8b86a287747053140ba221afc65eb06207bedf6e019b8934b477c"},
+    {file = "cryptography-41.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eb8163f5e549a22888c18b0d53d6bb62a20510060a22fd5a995ec8a05268df8a"},
+    {file = "cryptography-41.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8dde71c4169ec5ccc1087bb7521d54251c016f126f922ab2dfe6649170a3b8c5"},
+    {file = "cryptography-41.0.1.tar.gz", hash = "sha256:d34579085401d3f49762d2f7d6634d6b6c2ae1242202e860f4d26b046e3a1006"},
+]
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
+nox = ["nox"]
+pep8test = ["black", "check-sdist", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "decorator"
+version = "5.1.1"
+description = "Decorators for Humans"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
+    {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
+]
+
+[[package]]
+name = "distlib"
+version = "0.3.6"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+files = [
+    {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
+    {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
+]
+
+[[package]]
+name = "docutils"
+version = "0.20.1"
+description = "Docutils -- Python Documentation Utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"},
+    {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.1.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"},
+    {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "executing"
+version = "1.2.0"
+description = "Get the currently executing AST node of a frame, and other information"
+optional = false
+python-versions = "*"
+files = [
+    {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"},
+    {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"},
+]
+
+[package.extras]
+tests = ["asttokens", "littleutils", "pytest", "rich"]
+
+[[package]]
+name = "filelock"
+version = "3.12.2"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"},
+    {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"]
+
+[[package]]
+name = "flake8"
+version = "6.0.0"
+description = "the modular source code checker: pep8 pyflakes and co"
+optional = false
+python-versions = ">=3.8.1"
+files = [
+    {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"},
+    {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"},
+]
+
+[package.dependencies]
+mccabe = ">=0.7.0,<0.8.0"
+pycodestyle = ">=2.10.0,<2.11.0"
+pyflakes = ">=3.0.0,<3.1.0"
+
+[[package]]
+name = "flake8-black"
+version = "0.3.6"
+description = "flake8 plugin to call black as a code style validator"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "flake8-black-0.3.6.tar.gz", hash = "sha256:0dfbca3274777792a5bcb2af887a4cad72c72d0e86c94e08e3a3de151bb41c34"},
+    {file = "flake8_black-0.3.6-py3-none-any.whl", hash = "sha256:fe8ea2eca98d8a504f22040d9117347f6b367458366952862ac3586e7d4eeaca"},
+]
+
+[package.dependencies]
+black = ">=22.1.0"
+flake8 = ">=3"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+
+[package.extras]
+develop = ["build", "twine"]
+
+[[package]]
+name = "flake8-pyproject"
+version = "1.2.3"
+description = "Flake8 plug-in loading the configuration from pyproject.toml"
+optional = false
+python-versions = ">= 3.6"
+files = [
+    {file = "flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a"},
+]
+
+[package.dependencies]
+Flake8 = ">=5"
+TOMLi = {version = "*", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["pyTest", "pyTest-cov"]
+
+[[package]]
+name = "idna"
+version = "3.4"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
+    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "6.7.0"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"},
+    {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
+
+[[package]]
+name = "importlib-resources"
+version = "5.12.0"
+description = "Read resources from Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"},
+    {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"},
+]
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "ipython"
+version = "8.14.0"
+description = "IPython: Productive Interactive Computing"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "ipython-8.14.0-py3-none-any.whl", hash = "sha256:248aca623f5c99a6635bc3857677b7320b9b8039f99f070ee0d20a5ca5a8e6bf"},
+    {file = "ipython-8.14.0.tar.gz", hash = "sha256:1d197b907b6ba441b692c48cf2a3a2de280dc0ac91a3405b39349a50272ca0a1"},
+]
+
+[package.dependencies]
+appnope = {version = "*", markers = "sys_platform == \"darwin\""}
+backcall = "*"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+decorator = "*"
+jedi = ">=0.16"
+matplotlib-inline = "*"
+pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""}
+pickleshare = "*"
+prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0"
+pygments = ">=2.4.0"
+stack-data = "*"
+traitlets = ">=5"
+typing-extensions = {version = "*", markers = "python_version < \"3.10\""}
+
+[package.extras]
+all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"]
+black = ["black"]
+doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["ipywidgets", "notebook"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["pytest (<7.1)", "pytest-asyncio", "testpath"]
+test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"]
+
+[[package]]
+name = "isort"
+version = "5.12.0"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+    {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
+    {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
+]
+
+[package.extras]
+colors = ["colorama (>=0.4.3)"]
+pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
+plugins = ["setuptools"]
+requirements-deprecated-finder = ["pip-api", "pipreqs"]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.2.3"
+description = "Utility functions for Python class constructs"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
+    {file = "jaraco.classes-3.2.3.tar.gz", hash = "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a"},
+]
+
+[package.dependencies]
+more-itertools = "*"
+
+[package.extras]
+docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[[package]]
+name = "jedi"
+version = "0.18.2"
+description = "An autocompletion tool for Python that can be used for text editors."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "jedi-0.18.2-py2.py3-none-any.whl", hash = "sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e"},
+    {file = "jedi-0.18.2.tar.gz", hash = "sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"},
+]
+
+[package.dependencies]
+parso = ">=0.8.0,<0.9.0"
+
+[package.extras]
+docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"]
+qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
+testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
+
+[[package]]
+name = "jeepney"
+version = "0.8.0"
+description = "Low-level, pure Python DBus protocol wrapper."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"},
+    {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"},
+]
+
+[package.extras]
+test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+trio = ["async_generator", "trio"]
+
+[[package]]
+name = "keyring"
+version = "24.1.1"
+description = "Store and access your passwords safely."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "keyring-24.1.1-py3-none-any.whl", hash = "sha256:bc402c5e501053098bcbd149c4ddbf8e36c6809e572c2d098d4961e88d4c270d"},
+    {file = "keyring-24.1.1.tar.gz", hash = "sha256:3d44a48fa9a254f6c72879d7c88604831ebdaac6ecb0b214308b02953502c510"},
+]
+
+[package.dependencies]
+importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
+importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
+"jaraco.classes" = "*"
+jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
+pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
+SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
+
+[package.extras]
+completion = ["shtab"]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+
+[[package]]
+name = "markdown-it-py"
+version = "2.2.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"},
+    {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.1.6"
+description = "Inline Matplotlib backend for Jupyter"
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"},
+    {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"},
+]
+
+[package.dependencies]
+traitlets = "*"
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+    {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+    {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "mock"
+version = "5.0.2"
+description = "Rolling backport of unittest.mock for all Pythons"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "mock-5.0.2-py3-none-any.whl", hash = "sha256:0e0bc5ba78b8db3667ad636d964eb963dc97a59f04c6f6214c5f0e4a8f726c56"},
+    {file = "mock-5.0.2.tar.gz", hash = "sha256:06f18d7d65b44428202b145a9a36e99c2ee00d1eb992df0caf881d4664377891"},
+]
+
+[package.extras]
+build = ["blurb", "twine", "wheel"]
+docs = ["sphinx"]
+test = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "more-itertools"
+version = "9.1.0"
+description = "More routines for operating on iterables, beyond itertools"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"},
+    {file = "more_itertools-9.1.0-py3-none-any.whl", hash = "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.4.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"},
+    {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"},
+    {file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"},
+    {file = "mypy-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc"},
+    {file = "mypy-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1"},
+    {file = "mypy-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462"},
+    {file = "mypy-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258"},
+    {file = "mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2"},
+    {file = "mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7"},
+    {file = "mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01"},
+    {file = "mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b"},
+    {file = "mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b"},
+    {file = "mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7"},
+    {file = "mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9"},
+    {file = "mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042"},
+    {file = "mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3"},
+    {file = "mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6"},
+    {file = "mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f"},
+    {file = "mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc"},
+    {file = "mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828"},
+    {file = "mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3"},
+    {file = "mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816"},
+    {file = "mypy-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c"},
+    {file = "mypy-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f"},
+    {file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"},
+    {file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""}
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+python2 = ["typed-ast (>=1.4.0,<2)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+    {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+    {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "packaging"
+version = "23.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"},
+    {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"},
+]
+
+[[package]]
+name = "parso"
+version = "0.8.3"
+description = "A Python Parser"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"},
+    {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"},
+]
+
+[package.extras]
+qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
+testing = ["docopt", "pytest (<6.0.0)"]
+
+[[package]]
+name = "pathspec"
+version = "0.11.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"},
+    {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"},
+]
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+optional = false
+python-versions = "*"
+files = [
+    {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+    {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+    {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
+
+[[package]]
+name = "pkginfo"
+version = "1.9.6"
+description = "Query metadata from sdists / bdists / installed packages."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pkginfo-1.9.6-py3-none-any.whl", hash = "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546"},
+    {file = "pkginfo-1.9.6.tar.gz", hash = "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046"},
+]
+
+[package.extras]
+testing = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "platformdirs"
+version = "3.8.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "platformdirs-3.8.0-py3-none-any.whl", hash = "sha256:ca9ed98ce73076ba72e092b23d3c93ea6c4e186b3f1c3dad6edd98ff6ffcca2e"},
+    {file = "platformdirs-3.8.0.tar.gz", hash = "sha256:b0cabcb11063d21a0b261d557acb0a9d2126350e63b70cdf7db6347baea456dc"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.6.3", markers = "python_version < \"3.8\""}
+
+[package.extras]
+docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"]
+
+[[package]]
+name = "pluggy"
+version = "1.2.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"},
+    {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"},
+]
+
+[package.dependencies]
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.38"
+description = "Library for building powerful interactive command lines in Python"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "prompt_toolkit-3.0.38-py3-none-any.whl", hash = "sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f"},
+    {file = "prompt_toolkit-3.0.38.tar.gz", hash = "sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b"},
+]
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+optional = false
+python-versions = "*"
+files = [
+    {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+    {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.2"
+description = "Safely evaluate AST nodes without side effects"
+optional = false
+python-versions = "*"
+files = [
+    {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"},
+    {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"},
+]
+
+[package.extras]
+tests = ["pytest"]
+
+[[package]]
+name = "pycodestyle"
+version = "2.10.0"
+description = "Python style guide checker"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"},
+    {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"},
+]
+
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+
+[[package]]
+name = "pyflakes"
+version = "3.0.1"
+description = "passive checker of Python programs"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"},
+    {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"},
+]
+
+[[package]]
+name = "pygments"
+version = "2.15.1"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"},
+    {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+
+[[package]]
+name = "pyproject-api"
+version = "1.5.2"
+description = "API to interact with the python pyproject.toml based projects"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pyproject_api-1.5.2-py3-none-any.whl", hash = "sha256:9cffcbfb64190f207444d7579d315f3278f2c04ba46d685fad93197b5326d348"},
+    {file = "pyproject_api-1.5.2.tar.gz", hash = "sha256:999f58fa3c92b23ebd31a6bad5d1f87d456744d75e05391be7f5c729015d3d91"},
+]
+
+[package.dependencies]
+packaging = ">=23.1"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
+testing = ["covdefaults (>=2.3)", "importlib-metadata (>=6.6)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "setuptools (>=67.8)", "wheel (>=0.40)"]
+
+[[package]]
+name = "pyproject-hooks"
+version = "1.0.0"
+description = "Wrappers to call pyproject.toml-based build backend hooks."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pyproject_hooks-1.0.0-py3-none-any.whl", hash = "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8"},
+    {file = "pyproject_hooks-1.0.0.tar.gz", hash = "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5"},
+]
+
+[package.dependencies]
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "pytest"
+version = "7.4.0"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"},
+    {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.2"
+description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
+    {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
+]
+
+[[package]]
+name = "readme-renderer"
+version = "37.3"
+description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "readme_renderer-37.3-py3-none-any.whl", hash = "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343"},
+    {file = "readme_renderer-37.3.tar.gz", hash = "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273"},
+]
+
+[package.dependencies]
+bleach = ">=2.1.0"
+docutils = ">=0.13.1"
+Pygments = ">=2.5.1"
+
+[package.extras]
+md = ["cmarkgfm (>=0.8.0)"]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+description = "A utility belt for advanced users of python-requests"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"},
+    {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"},
+]
+
+[package.dependencies]
+requests = ">=2.0.1,<3.0.0"
+
+[[package]]
+name = "rfc3986"
+version = "2.0.0"
+description = "Validating URI References per RFC 3986"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"},
+    {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"},
+]
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "rich"
+version = "13.4.2"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+    {file = "rich-13.4.2-py3-none-any.whl", hash = "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec"},
+    {file = "rich-13.4.2.tar.gz", hash = "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "secretstorage"
+version = "3.3.3"
+description = "Python bindings to FreeDesktop.org Secret Service API"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
+    {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"},
+]
+
+[package.dependencies]
+cryptography = ">=2.0"
+jeepney = ">=0.6"
+
+[[package]]
+name = "setuptools"
+version = "68.0.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"},
+    {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "setuptools-scm"
+version = "7.1.0"
+description = "the blessed package to manage your versions by scm tags"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "setuptools_scm-7.1.0-py3-none-any.whl", hash = "sha256:73988b6d848709e2af142aa48c986ea29592bbcfca5375678064708205253d8e"},
+    {file = "setuptools_scm-7.1.0.tar.gz", hash = "sha256:6c508345a771aad7d56ebff0e70628bf2b0ec7573762be9960214730de278f27"},
+]
+
+[package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+packaging = ">=20.0"
+setuptools = "*"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+typing-extensions = "*"
+
+[package.extras]
+test = ["pytest (>=6.2)", "virtualenv (>20)"]
+toml = ["setuptools (>=42)"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.2"
+description = "Extract data from python stack frames and tracebacks for informative displays"
+optional = false
+python-versions = "*"
+files = [
+    {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"},
+    {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"},
+]
+
+[package.dependencies]
+asttokens = ">=2.1.0"
+executing = ">=1.2.0"
+pure-eval = "*"
+
+[package.extras]
+tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "tox"
+version = "4.6.3"
+description = "tox is a generic virtualenv management and test command line tool"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "tox-4.6.3-py3-none-any.whl", hash = "sha256:2946a0bb38924c3a9f9575c7fb4ca1f4c11a7c69c61592f176778892155cb50c"},
+    {file = "tox-4.6.3.tar.gz", hash = "sha256:9e2c5091a117d03b583c57c4c40aecd068099c17d40520e7b165e85c19334534"},
+]
+
+[package.dependencies]
+cachetools = ">=5.3.1"
+chardet = ">=5.1"
+colorama = ">=0.4.6"
+filelock = ">=3.12.2"
+importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""}
+packaging = ">=23.1"
+platformdirs = ">=3.5.3"
+pluggy = ">=1"
+pyproject-api = ">=1.5.2"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=4.6.3", markers = "python_version < \"3.8\""}
+virtualenv = ">=20.23.1"
+
+[package.extras]
+docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.23.2,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.6)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.17.1)", "psutil (>=5.9.5)", "pytest (>=7.3.2)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.10)", "wheel (>=0.40)"]
+
+[[package]]
+name = "traitlets"
+version = "5.9.0"
+description = "Traitlets Python configuration system"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "traitlets-5.9.0-py3-none-any.whl", hash = "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8"},
+    {file = "traitlets-5.9.0.tar.gz", hash = "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9"},
+]
+
+[package.extras]
+docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
+test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"]
+
+[[package]]
+name = "twine"
+version = "4.0.2"
+description = "Collection of utilities for publishing packages on PyPI"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "twine-4.0.2-py3-none-any.whl", hash = "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8"},
+    {file = "twine-4.0.2.tar.gz", hash = "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8"},
+]
+
+[package.dependencies]
+importlib-metadata = ">=3.6"
+keyring = ">=15.1"
+pkginfo = ">=1.8.1"
+readme-renderer = ">=35.0"
+requests = ">=2.20"
+requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
+rfc3986 = ">=1.4.0"
+rich = ">=12.0.0"
+urllib3 = ">=1.26.0"
+
+[[package]]
+name = "typed-ast"
+version = "1.5.4"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
+    {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
+    {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
+    {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
+    {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
+    {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
+    {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
+    {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
+    {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
+    {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
+    {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
+    {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
+    {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
+    {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
+    {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
+    {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
+]
+
+[[package]]
+name = "types-mock"
+version = "5.0.0.7"
+description = "Typing stubs for mock"
+optional = false
+python-versions = "*"
+files = [
+    {file = "types-mock-5.0.0.7.tar.gz", hash = "sha256:35f7e2ec85ecf4ba740ad897a3870e860170f1f7b52376842f8fb95874c98749"},
+    {file = "types_mock-5.0.0.7-py3-none-any.whl", hash = "sha256:0628586fb507264a4a6b4c1d49364993be70125745fb994e279be055444eac8e"},
+]
+
+[[package]]
+name = "types-pkg-resources"
+version = "0.1.3"
+description = "Typing stubs for pkg_resources"
+optional = false
+python-versions = "*"
+files = [
+    {file = "types-pkg_resources-0.1.3.tar.gz", hash = "sha256:834a9b8d3dbea343562fd99d5d3359a726f6bf9d3733bccd2b4f3096fbab9dae"},
+    {file = "types_pkg_resources-0.1.3-py2.py3-none-any.whl", hash = "sha256:0cb9972cee992249f93fff1a491bf2dc3ce674e5a1926e27d4f0866f7d9b6d9c"},
+]
+
+[[package]]
+name = "types-requests"
+version = "2.31.0.1"
+description = "Typing stubs for requests"
+optional = false
+python-versions = "*"
+files = [
+    {file = "types-requests-2.31.0.1.tar.gz", hash = "sha256:3de667cffa123ce698591de0ad7db034a5317457a596eb0b4944e5a9d9e8d1ac"},
+    {file = "types_requests-2.31.0.1-py3-none-any.whl", hash = "sha256:afb06ef8f25ba83d59a1d424bd7a5a939082f94b94e90ab5e6116bd2559deaa3"},
+]
+
+[package.dependencies]
+types-urllib3 = "*"
+
+[[package]]
+name = "types-six"
+version = "1.16.21.8"
+description = "Typing stubs for six"
+optional = false
+python-versions = "*"
+files = [
+    {file = "types-six-1.16.21.8.tar.gz", hash = "sha256:02a892ff8f423c4c5d15de7c6f4d433e643c863bcbefabd19251b478cbb284ab"},
+    {file = "types_six-1.16.21.8-py3-none-any.whl", hash = "sha256:e118ebebb4944af96b4c022b15c0769c065af09126eb148b7797023e905e0652"},
+]
+
+[[package]]
+name = "types-urllib3"
+version = "1.26.25.13"
+description = "Typing stubs for urllib3"
+optional = false
+python-versions = "*"
+files = [
+    {file = "types-urllib3-1.26.25.13.tar.gz", hash = "sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5"},
+    {file = "types_urllib3-1.26.25.13-py3-none-any.whl", hash = "sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.7.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "typing_extensions-4.7.0-py3-none-any.whl", hash = "sha256:5d8c9dac95c27d20df12fb1d97b9793ab8b2af8a3a525e68c80e21060c161771"},
+    {file = "typing_extensions-4.7.0.tar.gz", hash = "sha256:935ccf31549830cda708b42289d44b6f74084d616a00be651601a4f968e77c82"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.0.3"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"},
+    {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "virtualenv"
+version = "20.23.1"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"},
+    {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.6,<1"
+filelock = ">=3.12,<4"
+importlib-metadata = {version = ">=6.6", markers = "python_version < \"3.8\""}
+platformdirs = ">=3.5.1,<4"
+
+[package.extras]
+docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"]
+
+[[package]]
+name = "wcwidth"
+version = "0.2.6"
+description = "Measures the displayed width of unicode strings in a terminal"
+optional = false
+python-versions = "*"
+files = [
+    {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"},
+    {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"},
+]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+description = "Character encoding aliases for legacy web content"
+optional = false
+python-versions = "*"
+files = [
+    {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
+    {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
+]
+
+[[package]]
+name = "zipp"
+version = "3.15.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
+    {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.7"
+content-hash = "d7e825986463a9d73dc264129fadb8959ba469a4015f1108b33dd93d87e17357"
diff --git a/poetry.toml b/poetry.toml
new file mode 100644
index 0000000..7ebcab0
--- /dev/null
+++ b/poetry.toml
@@ -0,0 +1,4 @@
+[virtualenvs]
+in-project = true
+prefer-active-python = true
+
diff --git a/pyproject.toml b/pyproject.toml
index 1cd983f..c9607a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,193 @@
+[tool.poetry]
+name = "Mesh-Client"
+version = "0.0.0"
+description = "Client for NHS England's MESH messaging system"
+repository = "https://github.com/NHSDigital/mesh-client"
+authors = ["spinecore"]
+license = "MIT"
+packages = [
+    { include = "mesh_client" },
+]
+readme = "README.md"
+classifiers = [
+    "Programming Language :: Python :: 3.7",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+]
+
+# including this would turn on pyproject.toml for setuptools and fail validation
+#[tool.setuptools.package-data]
+#"mesh_client" = [
+#    "py.typed",
+#    "*.pem"
+#]
+
+[tool.poetry.dependencies]
+# core dependencies
+python = ">=3.7"
+requests = ">=2.9.0"
+
+
+[tool.poetry.group.dev.dependencies]
+black = "^23.3.0"
+isort = {version = "^5.12.0", python = ">=3.8"}
+mypy = "^1.4.1"
+flake8 = {version = "^6.0.0", python = ">=3.8.1"}
+flake8-pyproject = "^1.2.3"
+flake8-black = "^0.3.6"
+types-six = "^1.16.21.8"
+types-requests = "^2.31.0.1"
+types-mock = "^5.0.0.7"
+mock = "^5.0.2"
+build = "^0.10.0"
+twine = "^4.0.2"
+toml = "^0.10.2"
+setuptools-scm = "^7.1.0"
+tox = "^4.6.3"
+types-pkg-resources = "^0.1.3"
+coverage = "^7.2.7"
+pytest = "^7.4.0"
+
+[tool.poetry.group.local.dependencies]
+ipython = {version = "^8.14.0", python = ">=3.9,<4.0"}
+
+
+[tool.pytest.ini_options]
+#asyncio_mode = "auto"
+python_classes = "NoTestClassesPlease"
+python_files = "*_testz.py test_*.py *_tests.py"
+norecursedirs = ".git .venv .eggs build dist terraform features"
+
+[tool.black]
+line-length = 120
+target-version = ['py37']
+include = '\.pyi?$'
+#extend-exclude = '''
+#/(
+#  # The following are specific to Black, you probably don't want those.
+#  | blib2to3
+#  | tests/data
+#  | profiling
+#)/
+#'''
+
+[tool.flake8]
+ignore = ["E203", "W503"]
+exclude = [
+    ".git",
+    "__pycache__",
+    ".venv",
+    "build",
+    "dist",
+    ".tox",
+    ".eggs"
+]
+#extend-ignore = ["E203"]
+#per-file-ignores = [
+#    '__init__.py:F401',
+#]
+max-line-length = 120
+count = true
+
+
+[tool.tox]
+legacy_tox_ini = """
+[tox]
+envlist = py37,py38,py39,py310,py311
+
+[gh-actions]
+python =
+    3.7: py37
+    3.8: py38
+    3.9: py39
+    3.10: py310
+    3.11: py311
+
+[testenv:.pkg]
+set_env =
+    RELEASE_VERSION=1.2.3
+
+[testenv]
+wheel_build_env = .pkg
+use_develop = true
+package = wheel
+deps =
+    requests>=2.9.0
+    six>=1.10.0
+    mock
+    pytest
+commands =
+    python -m pytest
+
+"""
+
+[tool.coverage.run]
+branch = true
+omit = [
+    "features/*",
+    ".venv/lib/*",
+    "build/*",
+    "**/build/*",
+    "dist/*",
+    "**/dist/*",
+    "src/shared/aws.py",
+    "scripts/*",
+]
+source = [
+    "."
+]
+
+
+[tool.coverage.report]
+# Regexes for lines to exclude from consideration
+exclude_lines = [
+    "pragma: no cover",
+    "from .*",
+    "import .*"
+]
+omit = [
+    "*/conftest.py",
+    "*_tests.py",
+    "*/test_helpers*",
+    "*/test_mailbox_provider.py"
+]
+sort = "-cover"
+ignore_errors = true
+precision = 2
+fail_under = 85
+show_missing = true
+
+[tool.coverage.xml]
+output = "reports/coverage.xml"
+
+[tool.mypy]
+python_version = "3.7"
+warn_return_any = true
+warn_unused_configs = true
+show_error_codes = true
+check_untyped_defs = true
+
+[tool.isort]
+profile = "black"
+
+
+[tool.poetry-dynamic-versioning]
+enable = true
+metadata = false
+vcs = "git"
+style = "pep440"
+format-jinja = """
+    {%- if distance == 0 -%}
+        {{ serialize_pep440(base, stage, revision) }}
+    {%- else -%}
+        {{ serialize_pep440(bump_version(base), stage, revision, dev=timestamp) }}
+    {%- endif -%}
+"""
+pattern = "(?x)^((?P<epoch>\\d+)!)?(?P<base>\\d+(\\.\\d+)*)([-._]?((?P<stage>[a-zA-Z]+)[-._]?(?P<revision>\\d+)?))?(\\+(?P<tagged_metadata>.+))?$"
+
+
 [build-system]
-requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"]
+requires = ["setuptools>=42", "toml", "wheel", "setuptools_scm[toml]>=3.4"]
 build-backend = "setuptools.build_meta"
-
-[tool.setuptools_scm]
diff --git a/scripts/check-secrets.sh b/scripts/check-secrets.sh
new file mode 100755
index 0000000..10aae45
--- /dev/null
+++ b/scripts/check-secrets.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+./git-secrets --register-aws
+./git-secrets --add-provider -- cat .gitdisallowed 2>/dev/null || true
+
+scan_type=${1-_}
+if { [ "${scan_type}" == "unstaged" ]; } ; then
+  echo "Scanning staged and unstaged files for secrets"
+  ./git-secrets --scan --recursive
+  echo "Scanning untracked files for secrets"
+  ./git-secrets --scan --untracked
+else
+  echo "Scanning for secrets"
+  # if staged files exist, this will scan staged files only, otherwise normal scan
+  ./git-secrets --pre_commit_hook
+fi
diff --git a/scripts/hooks/commit-msg.sh b/scripts/hooks/commit-msg.sh
new file mode 100755
index 0000000..d91bfb8
--- /dev/null
+++ b/scripts/hooks/commit-msg.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+PWD="$(pwd)"
+
+message_file="${1}"
+
+commit_message="$(tr '[:upper:]' '[:lower:]' < "${message_file}")"
+
+if [[ "${commit_message}" =~ ^(((mesh|spinecore)?\-[0-9]+\:?\ )|(merge\ branch)).* ]]; then
+  exit 0
+else
+  echo ""
+  echo "commit message should start with <jira-ref>:"
+  echo "e.g.  git commit -m \"mesh-123: fix some problem with an issue\""
+  echo ""
+  exit 1
+fi
diff --git a/scripts/hooks/pre-commit.sh b/scripts/hooks/pre-commit.sh
new file mode 100755
index 0000000..ff88ac7
--- /dev/null
+++ b/scripts/hooks/pre-commit.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+PWD="$(pwd)"
+
+ps -ocommand= -p "${PPID}"
+
+if ! make check-secrets-all; then
+  echo "make check-secrets-all failed"
+  exit 1
+fi
+
+echo ""
+echo "check formatting ..."
+echo ""
+
+if ! make isort-check; then
+  echo ""
+  echo "isort-check failed"
+  echo ""
+  exit 1
+fi
+
+if ! make black-check; then
+  echo ""
+  echo "black-check failed"
+  echo ""
+  exit 1
+fi
+
+echo ""
diff --git a/scripts/wait-for-container.sh b/scripts/wait-for-container.sh
new file mode 100755
index 0000000..b04c8e9
--- /dev/null
+++ b/scripts/wait-for-container.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+
+set -e
+timeout="${2-60}"
+wait_till=$(($(date -d "+${timeout} seconds" +%s) +0))
+until [ "$(docker inspect -f "{{.State.Health.Status}}" "${1}")" == "healthy" ]; do
+    if [ $(($(date +%s) +0)) -gt $wait_till ]; then
+        echo "timeout"
+        exit 1
+    fi
+    echo -n "."
+    sleep 2;
+done;
+echo ""
diff --git a/setup.py b/setup.py
old mode 100644
new mode 100755
index 3cba2ff..19c1866
--- a/setup.py
+++ b/setup.py
@@ -1,43 +1,39 @@
 #!/usr/bin/env python
-from setuptools import setup
+"""
+ this shim is required ... because the PyPI package name contains upper case chars . and pyproject.toml
+ will lowercase under PEP-518 build systems.
+"""
+import os
 from os.path import dirname, join
 
-with open(join(dirname(__file__), 'README.md')) as f:
+import toml  # type: ignore[import]
+from setuptools import setup  # type: ignore[import]
+
+with open(join(dirname(__file__), "pyproject.toml"), "r") as f:
+    pyproject = toml.loads(f.read())
+
+poetry_cfg = pyproject["tool"]["poetry"]
+
+
+with open(join(dirname(__file__), poetry_cfg["readme"]), "r") as f:
     long_description = f.read()
 
+
 setup(
-    name='Mesh Client',
-    description='Client for NHS Digital\'s MESH messaging system',
-    url='https://github.com/NHSDigital/mesh-client',
+    name=poetry_cfg["name"],
+    version=os.environ.get("RELEASE_VERSION", poetry_cfg["version"]),
+    description=poetry_cfg["description"],
+    url=poetry_cfg["repository"],
     long_description=long_description,
-    long_description_content_type='text/markdown',
-    author='James Pickering',
-    author_email='api.management@nhs.net',
-    packages=['mesh_client'],
-    package_data={'mesh_client': ['*.pem']},
-    install_requires=[
-        'requests (>=2.9.0)',
-        'six (>=1.10.0)'
-    ],
-    use_scm_version=True,
-    setup_requires=['setuptools_scm'],
+    long_description_content_type="text/markdown",
+    author=poetry_cfg["authors"][0],
+    packages=["mesh_client"],
+    package_data={"mesh_client": ["py.typed", "*.pem"]},
+    install_requires=[f"{k} ({v})" for k, v in poetry_cfg["dependencies"].items() if k != "python"],
     entry_points={
-        'console_scripts': [
-            'mesh_auth=mesh_client.mesh_auth:main',
-            'mock_mesh_server=mesh_client.mock_server:main'
-        ]
+        "console_scripts": ["mesh_auth=mesh_client.mesh_auth:main", "mock_mesh_server=mesh_client.mock_server:main"]
     },
-    license='MIT',
-    python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*',
-    classifiers=[
-        'Programming Language :: Python :: 2',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.4',
-        'Programming Language :: Python :: 3.5',
-        'Programming Language :: Python :: 3.6',
-        'Programming Language :: Python :: 3.7',
-        'Programming Language :: Python :: 3.8',
-        'Programming Language :: Python :: 3.9'
-    ]
+    license=poetry_cfg["license"],
+    python_requires=poetry_cfg["dependencies"]["python"],
+    classifiers=poetry_cfg.get("classifiers"),
 )
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..0089c8f
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,11 @@
+sonar.projectVersion=2.0
+sonar.projectKey=NHSDigital_mesh-client
+sonar.organization=nhsdigital
+sonar.host.url=https://sonarcloud.io
+sonar.python.version=3.7
+#sonar.exclusions=
+sonar.test.inclusions=tests/**/*.py
+sonar.cpd.exclusions=tests/**/*.py
+sonar.testExecutionReportPaths=reports/sonar/tests.xml
+sonar.python.coverage.reportPaths=reports/sonar/coverage.xml
+sonar.coverage.exclusions=./scripts/**,scripts/*,scripts/**/*
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 37b53d8..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,7 +0,0 @@
-[tox]
-envlist = py27,py34,py35,py36,py37,py38,py39,pypy,pypy3
-[testenv]
-deps=requests>=2.9.0
-     six>=1.10.0
-     mock
-commands=python -m unittest discover . '*_test.py'


Test patch:
diff --git a/mesh_client/io_helpers_test.py b/mesh_client/io_helpers_test.py
deleted file mode 100644
index 719fc55..0000000
--- a/mesh_client/io_helpers_test.py
+++ /dev/null
@@ -1,303 +0,0 @@
-from __future__ import absolute_import, print_function
-from unittest import TestCase, main
-import io
-import gzip
-import six
-import tempfile
-from .io_helpers import GzipCompressStream, GzipDecompressStream, \
-    CombineStreams, SplitStream, IteratorMixin
-from six.moves.urllib.parse import urljoin
-from six.moves.urllib.request import pathname2url, urlopen
-from contextlib import closing
-
-
-def path2url(path):
-    return urljoin('file:', pathname2url(path))
-
-
-try:
-    from itertools import izip
-except ImportError:
-    izip = zip
-
-mebibyte = 1024 * 1024
-small_chunk = 10
-
-
-class FakeMixinUser(IteratorMixin):
-    def __init__(self, data, block_size):
-        self.backing_buffer = io.BytesIO(data)
-        self._IteratorMixin__block_size = block_size
-
-    def read(self, n):
-        return self.backing_buffer.read(n)
-
-MIXIN_DATA = b"""a
-aa
-aaa
-aaaa
-aaaaa
-aaaaaa
-"""
-
-
-class IOHelpersTest(TestCase):
-    def test_gzip_compress_stream(self):
-        underlying = io.BytesIO(b"This is a short test stream")
-        instance = GzipCompressStream(underlying, block_size=4)
-        result = b""
-        for i in range(1000):
-            read_result = instance.read(i)
-            result += read_result
-            if len(read_result) < i:
-                break
-        self.assertEqual(b"", instance.read(10))
-        self.assertEqual(b"", instance.read())
-
-        # Decode with GzipFile
-        test_decoder = gzip.GzipFile('', mode='r', fileobj=io.BytesIO(result))
-        self.assertEqual(b"This is a short test stream", test_decoder.read())
-
-    def test_gzip_compress_stream_read_all(self):
-        underlying = io.BytesIO(b"This is a short test stream")
-        instance = GzipCompressStream(underlying, block_size=4)
-        result = instance.read()
-        self.assertEqual(b"", instance.read(10))
-        self.assertEqual(b"", instance.read())
-
-        # Decode with GzipFile
-        test_decoder = gzip.GzipFile('', mode='r', fileobj=io.BytesIO(result))
-        self.assertEqual(b"This is a short test stream", test_decoder.read())
-
-    def test_gzip_decompress_stream(self):
-        underlying = io.BytesIO()
-        gzwriter = gzip.GzipFile(fileobj=underlying, mode='w')
-        gzwriter.write(b"This is a short test stream")
-        gzwriter.close()
-        underlying.seek(0)
-
-        instance = GzipDecompressStream(underlying, block_size=4)
-        result = b""
-        for i in range(1000):
-            read_result = instance.read(i)
-            result += read_result
-            if len(read_result) < i:
-                break
-        self.assertEqual(b"", instance.read(10))
-        self.assertEqual(b"", instance.read())
-
-        self.assertEqual(b"This is a short test stream", result)
-
-    def test_gzip_decompress_stream_read_all(self):
-        underlying = io.BytesIO()
-        gzwriter = gzip.GzipFile(fileobj=underlying, mode='w')
-        gzwriter.write(b"This is a short test stream")
-        gzwriter.close()
-        underlying.seek(0)
-
-        instance = GzipDecompressStream(underlying, block_size=4)
-        result = instance.read()
-        self.assertEqual(b"", instance.read(10))
-        self.assertEqual(b"", instance.read())
-
-        self.assertEqual(b"This is a short test stream", result)
-
-    def test_split_file(self):
-        with tempfile.TemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b" * mebibyte)
-            f.flush()
-            f.seek(0)
-            instance = SplitStream(f, mebibyte)
-            self.assertEqual(len(instance), 2)
-            for m, c in izip(instance, [b"a", b"b"]):
-                self.assertEqual(m.read(mebibyte), c * mebibyte)
-
-    def test_split_file_irregular_size(self):
-        with tempfile.TemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b")
-            f.flush()
-            f.seek(0)
-            instance = SplitStream(f, mebibyte)
-            self.assertEqual(len(instance), 2)
-            iterator = iter(instance)
-            chunk1 = six.next(iterator)
-            self.assertEqual(b"a" * mebibyte, chunk1.read(mebibyte))
-            chunk2 = six.next(iterator)
-            self.assertEqual(b"b", chunk2.read(mebibyte))
-
-    def test_split_file_irregular_size_2(self):
-        with tempfile.TemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b" * (mebibyte - 1))
-            f.flush()
-            f.seek(0)
-            instance = SplitStream(f, mebibyte)
-            self.assertEqual(len(instance), 2)
-            iterator = iter(instance)
-            chunk1 = six.next(iterator)
-            self.assertEqual(b"a" * mebibyte, chunk1.read(mebibyte))
-            chunk2 = six.next(iterator)
-            self.assertEqual(b"b" * (mebibyte - 1), chunk2.read(mebibyte))
-
-    def test_split_url(self):
-        with tempfile.NamedTemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b" * mebibyte)
-            f.flush()
-            with closing(urlopen(path2url(f.name))) as stream:
-                instance = SplitStream(stream, mebibyte)
-                self.assertEqual(len(instance), 2)
-                for m, c in izip(instance, [b"a", b"b"]):
-                    self.assertEqual(m.read(mebibyte), c * mebibyte)
-
-    def test_split_url_irregular_size(self):
-        with tempfile.NamedTemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b")
-            f.flush()
-            with closing(urlopen(path2url(f.name))) as stream:
-                instance = SplitStream(stream, mebibyte)
-                self.assertEqual(len(instance), 2)
-                iterator = iter(instance)
-                chunk1 = six.next(iterator)
-                self.assertEqual(b"a" * mebibyte, chunk1.read(mebibyte))
-                chunk2 = six.next(iterator)
-                self.assertEqual(b"b", chunk2.read(mebibyte))
-
-    def test_split_url_irregular_size_2(self):
-        with tempfile.NamedTemporaryFile() as f:
-            f.write(b"a" * mebibyte)
-            f.write(b"b" * (mebibyte - 1))
-            f.flush()
-            with closing(urlopen(path2url(f.name))) as stream:
-                instance = SplitStream(stream, mebibyte)
-                self.assertEqual(len(instance), 2)
-                iterator = iter(instance)
-                chunk1 = six.next(iterator)
-                self.assertEqual(b"a" * mebibyte, chunk1.read(mebibyte))
-                chunk2 = six.next(iterator)
-                self.assertEqual(b"b" * (mebibyte - 1), chunk2.read(mebibyte))
-
-    def test_split_bytes(self):
-        instance = SplitStream(b"a" * small_chunk + b"b" * small_chunk,
-                               small_chunk)
-        self.assertEqual(len(instance), 2)
-        for m, c in izip(instance, [b"a", b"b"]):
-            self.assertEqual(c * small_chunk, m.read(small_chunk))
-
-    def test_split_bytes_irregular_size(self):
-        instance = SplitStream(b"a" * small_chunk + b"b", small_chunk)
-        self.assertEqual(len(instance), 2)
-        iterator = iter(instance)
-        chunk1 = six.next(iterator)
-        self.assertEqual(b"a" * small_chunk, chunk1.read(small_chunk))
-        chunk2 = six.next(iterator)
-        self.assertEqual(b"b", chunk2.read(small_chunk))
-
-    def test_split_bytes_irregular_size_2(self):
-        instance = SplitStream(b"a" * small_chunk + b"b" * (small_chunk - 1),
-                               small_chunk)
-        self.assertEqual(len(instance), 2)
-        iterator = iter(instance)
-        chunk1 = six.next(iterator)
-        self.assertEqual(b"a" * small_chunk, chunk1.read(small_chunk))
-        chunk2 = six.next(iterator)
-        self.assertEqual(b"b" * (small_chunk - 1), chunk2.read(small_chunk))
-
-    def test_split_combine_stream_misaligned_with_chunk_size_1(self):
-        instance = SplitStream(dict(
-            Body=CombineStreams([io.BytesIO(b"1234"), io.BytesIO(b"567890123456789")]),
-            ContentLength=19
-        ), 5)
-        self.assertEqual(len(instance), 4)
-        iterator = iter(instance)
-        chunk1 = six.next(iterator)
-        self.assertEqual(b"12345", chunk1.read(5))
-        chunk2 = six.next(iterator)
-        self.assertEqual(b"67890", chunk2.read(5))
-        chunk3 = six.next(iterator)
-        self.assertEqual(b"12345", chunk3.read(5))
-        chunk4 = six.next(iterator)
-        self.assertEqual(b"6789", chunk4.read(5))
-
-    def test_split_combine_stream_misaligned_with_chunk_size_2(self):
-        instance = SplitStream(dict(
-            Body=CombineStreams([io.BytesIO(b"123456789"), io.BytesIO(b"012345678")]),
-            ContentLength=18
-        ), 5)
-        self.assertEqual(len(instance), 4)
-        iterator = iter(instance)
-        chunk1 = six.next(iterator)
-        self.assertEqual(b"12345", chunk1.read(5))
-        chunk2 = six.next(iterator)
-        self.assertEqual(b"67890", chunk2.read(5))
-        chunk3 = six.next(iterator)
-        self.assertEqual(b"12345", chunk3.read(5))
-        chunk4 = six.next(iterator)
-        self.assertEqual(b"678", chunk4.read(5))
-
-    def test_split_combine_stream_misaligned_with_chunk_size_3(self):
-        instance = SplitStream(dict(
-            Body=CombineStreams([io.BytesIO(b"123"), io.BytesIO(b"456"), io.BytesIO(b"789"), io.BytesIO(b"012")]),
-            ContentLength=12
-        ), 5)
-        self.assertEqual(len(instance), 3)
-        iterator = iter(instance)
-        chunk1 = six.next(iterator)
-        self.assertEqual(b"12345", chunk1.read(5))
-        chunk2 = six.next(iterator)
-        self.assertEqual(b"67890", chunk2.read(5))
-        chunk3 = six.next(iterator)
-        self.assertEqual(b"12", chunk3.read(5))
-
-    def test_combine_streams(self):
-        instance = CombineStreams(io.BytesIO(b"Hello") for i in range(20))
-        result = b""
-        for i in range(1000):
-            read_result = instance.read(i)
-            result += read_result
-            if len(read_result) < i:
-                break
-
-        self.assertEqual(instance.read(10), b"")
-        self.assertEqual(instance.read(), b"")
-        self.assertEqual(result, b"Hello" * 20)
-
-    def test_combine_streams_readall(self):
-        instance = CombineStreams(io.BytesIO(b"Hello") for i in range(20))
-        result = instance.read()
-
-        self.assertEqual(instance.read(10), b"")
-        self.assertEqual(instance.read(), b"")
-        self.assertEqual(result, b"Hello" * 20)
-
-    def test_iterator_mixin(self):
-        # import pudb
-        # pu.db
-        instance = FakeMixinUser(MIXIN_DATA, 4)
-        self.assertEqual(b'a\n', instance.readline())
-        self.assertEqual(b'aa\n', instance.readline())
-        self.assertEqual(b'aaa\n', instance.readline())
-        self.assertEqual(b'aaaa\n', instance.readline())
-        self.assertEqual(b'aaaaa\n', instance.readline())
-        self.assertEqual(b'aaaaaa\n', instance.readline())
-        self.assertEqual(b'', instance.readline())
-
-    def test_iterator_mixin_list(self):
-        # import pudb
-        # pu.db
-        instance = FakeMixinUser(MIXIN_DATA, 4)
-        self.assertEqual([
-            b'a\n',
-            b'aa\n',
-            b'aaa\n',
-            b'aaaa\n',
-            b'aaaaa\n',
-            b'aaaaaa\n',
-        ], instance.readlines())
-
-
-if __name__ == '__main__':
-    main()
diff --git a/mesh_client_test.py b/mesh_client_test.py
deleted file mode 100644
index e2c1b5b..0000000
--- a/mesh_client_test.py
+++ /dev/null
@@ -1,447 +0,0 @@
-from __future__ import absolute_import, print_function
-
-import io
-from unittest import TestCase, main
-import mock
-import requests
-import signal
-import traceback
-import sys
-
-from collections import namedtuple
-from mesh_client import MeshClient, MeshError, default_ssl_opts, CombineStreams, LOCAL_MOCK_ENDPOINT
-from mesh_client.mock_server import MockMeshApplication, MockMeshChunkRetryApplication, SlowMockMeshApplication
-from six.moves.urllib.error import HTTPError
-
-alice_mailbox = 'alice'
-alice_password = 'password'
-bob_mailbox = 'bob'
-bob_password = 'password'
-
-
-class MockResponse:
-    def __init__(self, json_data, status_code):
-        self.json_data = json_data
-        self.status_code = status_code
-
-    def json(self):
-        return self.json_data
-
-
-def print_stack_frames(signum=None, frame=None):
-    for frame in sys._current_frames().values():
-        traceback.print_stack(frame)
-        print()
-
-
-signal.signal(signal.SIGUSR1, print_stack_frames)
-
-
-class TestError(Exception):
-    pass
-
-
-class MeshClientTest(TestCase):
-    def run(self, result=None):
-        try:
-            with MockMeshApplication() as mock_app:
-                self.mock_app = mock_app
-                self.uri = mock_app.uri
-                with MeshClient(
-                        self.uri,
-                        alice_mailbox,
-                        alice_password,
-                        max_chunk_size=5,
-                        **default_ssl_opts) as alice:
-                    self.alice = alice
-                    self.bob = MeshClient(
-                        self.uri,
-                        bob_mailbox,
-                        bob_password,
-                        max_chunk_size=5,
-                        **default_ssl_opts)
-                    super(MeshClientTest, self).run(result)
-        except HTTPError as e:
-            print(e.read())
-            print_stack_frames()
-            print("Message store", self.mock_app.messages)
-            raise
-        except:
-            print_stack_frames()
-            print("Message store", self.mock_app.messages)
-            raise
-
-    def test_handshake(self):
-        alice = self.alice
-
-        hand_shook = alice.handshake()
-        self.assertEqual(hand_shook, b"hello")
-
-    def test_send_receive(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(bob_mailbox, b"Hello Bob 1")
-        self.assertEqual([message_id], bob.list_messages())
-        self.assertEqual(1, bob.count_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.read(), b"Hello Bob 1")
-        self.assertEqual(msg.sender, "alice")
-        self.assertEqual(msg.recipient, "bob")
-        msg.acknowledge()
-        self.assertEqual([], bob.list_messages())
-
-    def test_send_receive_combine_streams_part1_multiple_of_chunk_size(self):
-        alice = self.alice
-        bob = self.bob
-
-        part1_length = 10
-        part2_length = 23
-
-        stream = {
-            'Body': CombineStreams([io.BytesIO(b"H"*part1_length), io.BytesIO((b"W"*part2_length))]),
-            'ContentLength': part1_length + part2_length,
-        }
-
-        message_id = alice.send_message(bob_mailbox, stream)
-        self.assertEqual([message_id], bob.list_messages())
-        self.assertEqual(1, bob.count_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.read(), b"H" * part1_length + b"W"*part2_length)
-        self.assertEqual(msg.sender, "alice")
-        self.assertEqual(msg.recipient, "bob")
-        msg.acknowledge()
-        self.assertEqual([], bob.list_messages())
-
-
-    def test_send_receive_combine_chunked_small_chunk_size(self):
-        alice = self.alice
-        bob = self.bob
-
-        part1_length = 4
-        part2_length = 20
-
-        stream = {
-            'Body': CombineStreams([io.BytesIO(b"H"*part1_length), io.BytesIO((b"W"*part2_length))]),
-            'ContentLength': part1_length + part2_length,
-        }
-
-        message_id = alice.send_message(bob_mailbox, stream)
-        self.assertEqual([message_id], bob.list_messages())
-        self.assertEqual(1, bob.count_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.mex_header("chunk-range"), "1:5")
-
-    def test_send_receive_combine_chunked_override_chunk_size(self):
-        alice = self.alice
-        bob = self.bob
-
-        part1_length = 4
-        part2_length = 20
-
-        stream = {
-            'Body': CombineStreams([io.BytesIO(b"H"*part1_length), io.BytesIO((b"W"*part2_length))]),
-            'ContentLength': part1_length + part2_length,
-        }
-
-        message_id = alice.send_message(bob_mailbox, stream, max_chunk_size=1)
-        self.assertEqual([message_id], bob.list_messages())
-        self.assertEqual(1, bob.count_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.mex_header("chunk-range"), "1:24")
-
-    def test_send_receive_combine_streams_part1_not_multiple_of_chunk_size(self):
-        alice = self.alice
-        bob = self.bob
-
-        part1_length = 4
-        part2_length = 20
-
-        stream = {
-            'Body': CombineStreams([io.BytesIO(b"H"*part1_length), io.BytesIO((b"W"*part2_length))]),
-            'ContentLength': part1_length + part2_length,
-        }
-
-        message_id = alice.send_message(bob_mailbox, stream)
-        self.assertEqual([message_id], bob.list_messages())
-        self.assertEqual(1, bob.count_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.read(), b"H" * part1_length + b"W"*part2_length)
-        self.assertEqual(msg.sender, "alice")
-        self.assertEqual(msg.recipient, "bob")
-        msg.acknowledge()
-        self.assertEqual([], bob.list_messages())
-
-    def test_line_by_line(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
-        self.assertEqual([message_id], bob.list_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(list(iter(msg)), [b"Hello Bob 1\n", b"Hello Bob 2"])
-
-    def test_readline(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
-        self.assertEqual([message_id], bob.list_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.readline(), b"Hello Bob 1\n")
-
-    def test_readlines(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
-        self.assertEqual([message_id], bob.list_messages())
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.readlines(), [b"Hello Bob 1\n", b"Hello Bob 2"])
-
-    def test_transparent_compression(self):
-        alice = self.alice
-        bob = self.bob
-
-        print("Sending")
-        alice._transparent_compress = True
-        message_id = alice.send_message(
-            bob_mailbox, b"Hello Bob Compressed")
-        self.assertEqual([message_id], bob.list_messages())
-        print("Receiving")
-        msg = bob.retrieve_message(message_id)
-        self.assertEqual(msg.read(), b"Hello Bob Compressed")
-        self.assertEqual(msg.mex_header('from'), 'alice')
-        msg.acknowledge()
-        self.assertEqual([], bob.list_messages())
-
-    def test_iterate_and_context_manager(self):
-        alice = self.alice
-        bob = self.bob
-
-        alice.send_message(bob_mailbox, b"Hello Bob 2")
-        alice.send_message(bob_mailbox, b"Hello Bob 3")
-        messages_read = 0
-        for (msg, expected) in zip(bob.iterate_all_messages(),
-                                   [b"Hello Bob 2", b"Hello Bob 3"]):
-            with msg:
-                self.assertEqual(msg.read(), expected)
-                messages_read += 1
-        self.assertEqual(2, messages_read)
-        self.assertEqual([], bob.list_messages())
-
-    def test_context_manager_failure(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(bob_mailbox, b"Hello Bob 4")
-        try:
-            with bob.retrieve_message(message_id) as msg:
-                self.assertEqual(msg.read(), b"Hello Bob 4")
-                raise TestError()
-        except TestError:
-            pass
-        self.assertEqual([message_id], bob.list_messages())
-
-    def test_optional_args(self):
-        alice = self.alice
-        bob = self.bob
-
-        message_id = alice.send_message(
-            bob_mailbox,
-            b"Hello Bob 5",
-            subject="Hello World",
-            filename="upload.txt",
-            local_id="12345",
-            message_type="DATA",
-            process_id="321",
-            workflow_id="111",
-            encrypted=False,
-            compressed=False)
-
-        with bob.retrieve_message(message_id) as msg:
-            self.assertEqual(msg.subject, "Hello World")
-            self.assertEqual(msg.filename, "upload.txt")
-            self.assertEqual(msg.local_id, "12345")
-            self.assertEqual(msg.message_type, "DATA")
-            self.assertEqual(msg.process_id, "321")
-            self.assertEqual(msg.workflow_id, "111")
-            self.assertFalse(msg.encrypted)
-            self.assertFalse(msg.compressed)
-
-        message_id = alice.send_message(
-            bob_mailbox, b"Hello Bob 5", encrypted=True, compressed=True)
-
-        with bob.retrieve_message(message_id) as msg:
-            self.assertTrue(msg.encrypted)
-            self.assertTrue(msg.compressed)
-
-    def test_tracking(self):
-        alice = self.alice
-        bob = self.bob
-        tracking_id = 'Message1'
-        msg_id = alice.send_message(bob_mailbox, b'Hello World', local_id=tracking_id)
-        self.assertEqual(alice.get_tracking_info(tracking_id)['status'], 'Accepted')
-        bob.acknowledge_message(msg_id)
-        self.assertEqual(alice.get_tracking_info(tracking_id)['status'], 'Acknowledged')
-
-    def test_msg_id_tracking(self):
-        alice = self.alice
-        bob = self.bob
-        msg_id = alice.send_message(bob_mailbox, b'Hello World')
-        self.assertEqual(alice.get_tracking_info(message_id=msg_id)['status'], 'Accepted')
-        bob.acknowledge_message(msg_id)
-        self.assertEqual(alice.get_tracking_info(message_id=msg_id)['status'], 'Acknowledged')
-
-    def test_by_message_id_tracking(self):
-        alice = self.alice
-        bob = self.bob
-        msg_id = alice.send_message(bob_mailbox, b'Hello World')
-        self.assertEqual(alice.track_by_message_id(message_id=msg_id)['status'], 'Accepted')
-        bob.acknowledge_message(msg_id)
-        self.assertEqual(alice.track_by_message_id(message_id=msg_id)['status'], 'Acknowledged')
-
-    def test_endpoint_lookup(self):
-        result = self.alice.lookup_endpoint('ORG1', 'WF1')
-        result_list = result['results']
-        self.assertEqual(len(result_list), 1)
-        self.assertEqual(result_list[0]['address'], 'ORG1HC001')
-        self.assertEqual(result_list[0]['description'], 'ORG1 WF1 endpoint')
-        self.assertEqual(result_list[0]['endpoint_type'], 'MESH')
-
-
-    def test_error_handling(self):
-        alice = self.alice
-        with self.assertRaises(MeshError):
-            alice.send_message(bob_mailbox, b"")
-
-
-class EndpointTest(TestCase):
-    def test_handshake(self):
-        with MockMeshApplication() as mock_app:
-            endpoint = LOCAL_MOCK_ENDPOINT._replace(url=mock_app.uri)
-            client = MeshClient(
-                endpoint,
-                alice_mailbox,
-                alice_password,
-                max_chunk_size=5)
-
-            hand_shook = client.handshake()
-            self.assertEqual(hand_shook, b"hello")
-
-
-class TimeoutTest(TestCase):
-    def test_timeout(self):
-        with SlowMockMeshApplication() as mock_app:
-            endpoint = LOCAL_MOCK_ENDPOINT._replace(url=mock_app.uri)
-            client = MeshClient(
-                endpoint,
-                alice_mailbox,
-                alice_password,
-                max_chunk_size=5,
-                timeout=0.5)
-            with self.assertRaises(requests.exceptions.Timeout):
-                client.list_messages()
-
-
-
-class MeshChunkRetryClientTest(TestCase):
-    def run(self, result=None):
-        try:
-            with  MockMeshChunkRetryApplication() as mock_app:
-                self.mock_app = mock_app
-                self.uri = mock_app.uri
-                self.alice = MeshClient(
-                    self.uri,
-                    alice_mailbox,
-                    alice_password,
-                    max_chunk_size=5,
-                    max_chunk_retries=3,
-                    **default_ssl_opts)
-                self.bob = MeshClient(
-                    self.uri,
-                    bob_mailbox,
-                    bob_password,
-                    max_chunk_size=5,
-                    max_chunk_retries=3,
-                    **default_ssl_opts)
-                super(MeshChunkRetryClientTest, self).run(result)
-        except HTTPError as e:
-            print(e.read())
-            print_stack_frames()
-            print("Message store", self.mock_app.messages)
-            raise
-        except:
-            print_stack_frames()
-            print("Message store", self.mock_app.messages)
-            raise
-
-    def _count_chunk_retry_call_counts(self, mocked_post):
-        counts = {}
-        for call in mocked_post.call_args_list:
-            chunk = int(call.kwargs['headers']['Mex-Chunk-Range'].split(':')[0])
-            if chunk not in counts.keys():
-                counts[chunk] = 0
-            else:
-                counts[chunk] += 1
-        return counts
-
-
-    def test_chunk_retries(self):
-        alice = self.alice
-        bob = self.bob
-        mock_post = mock.Mock(wraps=alice._session.post)
-        alice._session.post = mock_post
-
-        chunk_options = namedtuple('Chunk', 'chunk_num num_retry_attempts')
-        options = [chunk_options(2, 2)]
-        self.mock_app.set_chunk_retry_options(options)
-
-        message_id = alice.send_message(bob_mailbox, b"Hello World")
-
-        received = bob.retrieve_message(message_id).read()
-        self.assertEqual(received, b'Hello World')
-
-        chunk_retry_call_counts = self._count_chunk_retry_call_counts(mock_post)
-        self.assertEqual(chunk_retry_call_counts[1], 0)
-        self.assertEqual(chunk_retry_call_counts[2], 3)
-        self.assertEqual(chunk_retry_call_counts[3], 0)
-
-    def test_chunk_all_retries_fail(self):
-        alice = self.alice
-        mock_post = mock.Mock(wraps=alice._session.post)
-        alice._session.post = mock_post
-
-        chunk_options = namedtuple('Chunk', 'chunk_num num_retry_attempts')
-        options = [chunk_options(2, 3)]
-        self.mock_app.set_chunk_retry_options(options)
-
-        self.assertRaises(requests.exceptions.HTTPError, alice.send_message, bob_mailbox, b"Hello World")
-
-        chunk_retry_call_counts = self._count_chunk_retry_call_counts(mock_post)
-        self.assertEqual(chunk_retry_call_counts[1], 0)
-        self.assertEqual(chunk_retry_call_counts[2], 3)
-
-    def test_chunk_retries_with_file(self):
-        alice = self.alice
-        bob = self.bob
-        mock_post = mock.Mock(wraps=alice._session.post)
-        alice._session.post = mock_post
-
-        chunk_options = namedtuple('Chunk', 'chunk_num num_retry_attempts')
-        options = [chunk_options(2, 2)]
-        self.mock_app.set_chunk_retry_options(options)
-
-        message_id = alice.send_message(bob_mailbox, open("test_chunk_retry_file", 'rb'))
-
-        received = bob.retrieve_message(message_id).read()
-        self.assertEqual(received, b'test1 test2 test3')
-
-        chunk_retry_call_counts = self._count_chunk_retry_call_counts(mock_post)
-        self.assertEqual(chunk_retry_call_counts[1], 0)
-        self.assertEqual(chunk_retry_call_counts[2], 3)
-        self.assertEqual(chunk_retry_call_counts[3], 0)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/mesh_endpoint_connectivity_test.py b/mesh_endpoint_connectivity_test.py
deleted file mode 100644
index 4b52191..0000000
--- a/mesh_endpoint_connectivity_test.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from unittest import TestCase, main, skip
-import mesh_client
-from requests import HTTPError
-
-
-class EndpointConnectivityTest(TestCase):
-    @skip("This test needs N3 connectivity, talks to real endpoints, and is Python 3 only, so don't run it by default")
-    def test_connectivity(self):
-        for key, endpoint in mesh_client.__dict__.items():
-            if key.endswith('_ENDPOINT') and not key.startswith('LOCAL_') and not '_OPENTEST_' in key:
-                with self.subTest(key):
-                    print("Testing", key)
-                    with self.assertRaises(HTTPError) as cm:
-                        client = mesh_client.MeshClient(endpoint, 'BADUSERNAME', 'BADPASSWORD')
-                        client.handshake()
-                    self.assertEqual(cm.exception.response.status_code, 400)
-
-
-if __name__ == '__main__':
-    main()
diff --git a/scripts/sonar_tests.py b/scripts/sonar_tests.py
new file mode 100755
index 0000000..7356acd
--- /dev/null
+++ b/scripts/sonar_tests.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python
+import os
+import re
+import xml.dom.minidom as dom
+from collections import defaultdict
+from typing import Dict, List, Tuple
+
+
+def _get_test_suites(test_src: str) -> List[dom.Element]:
+    src_tree: dom.Document = dom.parse(test_src)
+    src_root = src_tree.documentElement
+
+    if src_root.nodeName == "testsuite":
+        return [src_root]
+
+    if src_root.nodeName == "testsuites":
+        return [node for node in src_root.childNodes if node.nodeName == "testsuite"]
+
+    raise NotImplementedError(src_root.nodeName)
+
+
+def _add_test_detail_if_present(case_src: dom.Element, case_out: dom.Element, dom_out: dom.Document):
+    failures = [node for node in case_src.childNodes if node.nodeName == "failure"]
+    skipped = [node for node in case_src.childNodes if node.nodeName == "skipped"]
+    errors = [node for node in case_src.childNodes if node.nodeName == "error"]
+    detail = errors or failures or skipped
+    if not detail:
+        return
+
+    detail_node = detail[0]
+    message = detail_node.getAttribute("message") or ""
+    detail_type = detail_node.getAttribute("type") or ""
+    if detail_type:
+        if not message.startswith("("):
+            message = f"({message})"
+        message = f"{detail_type}{message}"
+    detail_out = dom_out.createElement(detail_node.nodeName)
+    detail_out.setAttribute("message", message)
+    detail_text = "".join([node.nodeValue for node in detail_node.childNodes if node.nodeValue])
+    if detail_text:
+        cdata = dom.CDATASection()
+        cdata.nodeValue = detail_text
+        detail_out.childNodes.append(cdata)
+    case_out.childNodes.append(detail_out)
+
+
+def _translate_test_case(
+    case_src: dom.Element, dom_out: dom.Document, is_feature_file: bool
+) -> Tuple[str, dom.Element]:
+    classname = case_src.getAttribute("classname")
+    test_name = case_src.getAttribute("name")
+    duration = round(float(case_src.getAttribute("time") or "0") * 1000, 0)
+    classname_dotsplit = classname.split(".")
+    test_file = f"features/{classname_dotsplit[0]}.py" if is_feature_file else f"{'/'.join(classname_dotsplit)}.py"
+    test_name = f"{'.'.join(classname_dotsplit[1:])} - {test_name}" if is_feature_file else test_name
+    case_out = dom_out.createElement("testCase")
+    case_out.setAttribute("name", test_name)
+    case_out.setAttribute("duration", str(duration))
+    _add_test_detail_if_present(case_src, case_out, dom_out)
+
+    return test_file, case_out
+
+
+def _get_tests_from_file(test_src: str, dom_out: dom.Document) -> Dict[str, List[dom.Element]]:
+    is_feature_file = os.path.basename(test_src).startswith("TESTS-")
+    test_suites = _get_test_suites(test_src)
+
+    test_cases = []
+    for suite in test_suites:
+        test_cases.extend([node for node in suite.childNodes if node.nodeName == "testcase"])
+
+    print(test_src, "suites", len(test_suites), "cases", len(test_cases))
+
+    tests: Dict[str, List[dom.Element]] = defaultdict(list)
+
+    for test_case in test_cases:
+        test_file, case_out = _translate_test_case(test_case, dom_out, is_feature_file)
+        tests[test_file].append(case_out)
+
+    return tests
+
+
+def _transform_coverage(reports_dir: str, output_sonar: str):
+    src_coverage = os.path.join(reports_dir, "coverage.xml")
+    out_coverage = os.path.join(output_sonar, "coverage.xml")
+
+    if not os.path.exists(src_coverage):
+        print("no coverage found")
+        return
+
+    print("transform:", src_coverage, out_coverage)
+    with open(src_coverage, "r", encoding="utf-8") as src:
+        coverage = src.read()
+        coverage = re.sub(r"<source>.*?</source>", "<source>.</source>", coverage)
+        with open(out_coverage, "w+", encoding="utf-8") as out:
+            out.write(coverage)
+
+
+def _transform_xunit_results(reports_dir: str, output_sonar: str):
+    src_junit = os.path.join(reports_dir, "junit")
+    out_tests = os.path.join(output_sonar, "tests.xml")
+
+    dom_out = dom.getDOMImplementation().createDocument(None, "testExecutions", None)
+    dom_out.documentElement.setAttribute("version", "1")
+    all_tests: Dict[str, List[dom.Element]] = defaultdict(list)
+
+    for source_file in os.listdir(src_junit):
+        if not source_file.endswith(".xml"):
+            continue
+
+        test_src = os.path.join(src_junit, source_file)
+        found_tests = _get_tests_from_file(test_src=test_src, dom_out=dom_out)
+        for path, tests in found_tests.items():
+            all_tests[path].extend(tests)
+
+    filenames = sorted(all_tests.keys())
+    for filename in filenames:
+        file_node = dom_out.createElement("file")
+        file_node.setAttribute("path", filename)
+        file_node.childNodes.extend(all_tests[filename])
+        dom_out.documentElement.childNodes.append(file_node)
+
+    with open(out_tests, "w+", encoding="utf-8") as writer:
+        dom_out.writexml(writer, indent="", addindent="\t", newl="\n", encoding=None)
+
+
+def main():
+    """
+    this is to convert the junit xml to a format compatible with sonar ...
+    sonar is from the java world so expects junit classnames to actually relate to python modules
+    it also doesn't understand .feature files ( 'unsupported language .feature' ) .. I pretend they are .py
+    format is from https://docs.sonarqube.org/latest/analysis/generic-test/
+
+    <testExecutions version="1">
+        <file path="testx/ClassOneTest.xoo">
+            <testCase name="test1" duration="5"/>
+            <testCase name="test2" duration="500">
+                <skipped message="short message">other</skipped>
+            </testCase>
+            <testCase name="test3" duration="100">
+                <failure message="short">stacktrace</failure>
+            </testCase>
+            <testCase name="test4" duration="500">
+                <error message="short">stacktrace</error>
+            </testCase>
+        </file>
+    </testExecutions>
+
+    """
+    root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
+    reports_dir = os.path.join(root_dir, "reports")
+    output_sonar = os.path.join(reports_dir, "sonar")
+    os.makedirs(output_sonar, exist_ok=True)
+
+    _transform_coverage(reports_dir, output_sonar)
+
+    _transform_xunit_results(reports_dir, output_sonar)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/test_chunk_retry_file b/test_chunk_retry_file
deleted file mode 100644
index 820192e..0000000
--- a/test_chunk_retry_file
+++ /dev/null
@@ -1,1 +0,0 @@
-test1 test2 test3
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mesh_client/ca.cert.pem b/tests/ca.cert.pem
similarity index 100%
rename from mesh_client/ca.cert.pem
rename to tests/ca.cert.pem
diff --git a/mesh_client/ca.key.pem b/tests/ca.key.pem
similarity index 100%
rename from mesh_client/ca.key.pem
rename to tests/ca.key.pem
diff --git a/mesh_client/client.cert.pem b/tests/client.cert.pem
similarity index 100%
rename from mesh_client/client.cert.pem
rename to tests/client.cert.pem
diff --git a/mesh_client/client.key.pem b/tests/client.key.pem
similarity index 100%
rename from mesh_client/client.key.pem
rename to tests/client.key.pem
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..bfc6023
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,41 @@
+import contextlib
+import os
+
+from mesh_client import Endpoint
+
+_PACKAGE_DIR = os.path.dirname(__file__)
+MOCK_CA_CERT = os.path.join(_PACKAGE_DIR, "ca.cert.pem")
+MOCK_CERT = os.path.join(_PACKAGE_DIR, "client.cert.pem")
+MOCK_KEY = os.path.join(_PACKAGE_DIR, "client.key.pem")
+
+MOCK_SSL_OPTS = {"verify": MOCK_CA_CERT, "cert": (MOCK_CERT, MOCK_KEY)}
+"""
+Usable default values for verify and cert, providing certificates and keys
+which should work with mock_server. Note that these certs will not work with
+any NHS Digital test environments - such certs must be obtained from
+NHS Digital.
+"""
+default_ssl_opts = MOCK_SSL_OPTS
+
+LOCAL_MOCK_ENDPOINT = Endpoint("https://localhost:8000", MOCK_CA_CERT, (MOCK_CERT, MOCK_KEY), False)
+LOCAL_FAKE_ENDPOINT = Endpoint("https://localhost:8829", MOCK_CA_CERT, (MOCK_CERT, MOCK_KEY), False)
+SANDBOX_ENDPOINT = Endpoint("https://localhost:8701", MOCK_CA_CERT, (MOCK_CERT, MOCK_KEY), False)
+
+
+@contextlib.contextmanager
+def temp_env_vars(**kwargs):
+    """
+    Temporarily set the process environment variables.
+    >>> with temp_env_vars(PLUGINS_DIR=u'test/plugins'):
+    ...   "PLUGINS_DIR" in os.environ
+    True
+    >>> "PLUGINS_DIR" in os.environ
+    """
+    old_environ = dict(os.environ)
+    kwargs = {k: str(v) for k, v in kwargs.items()}
+    os.environ.update(**kwargs)
+    try:
+        yield
+    finally:
+        os.environ.clear()
+        os.environ.update(old_environ)
diff --git a/tests/io_helpers_tests.py b/tests/io_helpers_tests.py
new file mode 100644
index 0000000..81d1b7e
--- /dev/null
+++ b/tests/io_helpers_tests.py
@@ -0,0 +1,322 @@
+from __future__ import absolute_import, print_function
+
+import gzip
+import io
+import tempfile
+from contextlib import closing
+from urllib.parse import urljoin
+from urllib.request import pathname2url, urlopen
+
+from mesh_client.io_helpers import (
+    CombineStreams,
+    GzipCompressStream,
+    GzipDecompressStream,
+    IteratorMixin,
+    SplitStream,
+)
+
+
+def path2url(path):
+    return urljoin("file:", pathname2url(path))
+
+
+try:
+    from itertools import izip  # type: ignore[attr-defined]
+except ImportError:
+    izip = zip
+
+mebibyte = 1024 * 1024
+small_chunk = 10
+
+
+class FakeMixinUser(IteratorMixin):
+    def __init__(self, data, block_size):
+        self.backing_buffer = io.BytesIO(data)
+        self._IteratorMixin__block_size = block_size
+
+    def read(self, n):
+        return self.backing_buffer.read(n)
+
+
+MIXIN_DATA = b"""a
+aa
+aaa
+aaaa
+aaaaa
+aaaaaa
+"""
+
+
+def test_gzip_compress_stream():
+    underlying = io.BytesIO(b"This is a short test stream")
+    instance = GzipCompressStream(underlying, block_size=4)
+    result = b""
+    for i in range(1000):
+        read_result = instance.read(i)
+        result += read_result
+        if len(read_result) < i:
+            break
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+
+    # Decode with GzipFile
+    test_decoder = gzip.GzipFile("", mode="r", fileobj=io.BytesIO(result))
+    assert test_decoder.read() == b"This is a short test stream"
+
+
+def test_gzip_compress_stream_read_all():
+    underlying = io.BytesIO(b"This is a short test stream")
+    instance = GzipCompressStream(underlying, block_size=4)
+    result = instance.read()
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+
+    # Decode with GzipFile
+    test_decoder = gzip.GzipFile("", mode="r", fileobj=io.BytesIO(result))
+    assert test_decoder.read() == b"This is a short test stream"
+
+
+def test_gzip_decompress_stream():
+    underlying = io.BytesIO()
+    gzwriter = gzip.GzipFile(fileobj=underlying, mode="w")
+    gzwriter.write(b"This is a short test stream")
+    gzwriter.close()
+    underlying.seek(0)
+
+    instance = GzipDecompressStream(underlying, block_size=4)
+    result = b""
+    for i in range(1000):
+        read_result = instance.read(i)
+        result += read_result
+        if len(read_result) < i:
+            break
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+
+    assert result == b"This is a short test stream"
+
+
+def test_gzip_decompress_stream_read_all():
+    underlying = io.BytesIO()
+    gzwriter = gzip.GzipFile(fileobj=underlying, mode="w")
+    gzwriter.write(b"This is a short test stream")
+    gzwriter.close()
+    underlying.seek(0)
+
+    instance = GzipDecompressStream(underlying, block_size=4)
+    result = instance.read()
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+
+    assert result == b"This is a short test stream"
+
+
+def test_split_file():
+    with tempfile.TemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b" * mebibyte)
+        f.flush()
+        f.seek(0)
+        instance = SplitStream(f, mebibyte)
+        assert len(instance) == 2
+        for m, c in izip(instance, [b"a", b"b"]):
+            assert m.read(mebibyte) == c * mebibyte
+
+
+def test_split_file_irregular_size():
+    with tempfile.TemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b")
+        f.flush()
+        f.seek(0)
+        instance = SplitStream(f, mebibyte)
+        assert len(instance) == 2
+        iterator = iter(instance)
+        chunk1 = next(iterator)
+        assert chunk1.read(mebibyte) == b"a" * mebibyte
+        chunk2 = next(iterator)
+        assert chunk2.read(mebibyte) == b"b"
+
+
+def test_split_file_irregular_size_2():
+    with tempfile.TemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b" * (mebibyte - 1))
+        f.flush()
+        f.seek(0)
+        instance = SplitStream(f, mebibyte)
+        assert len(instance) == 2
+        iterator = iter(instance)
+        chunk1 = next(iterator)
+        assert chunk1.read(mebibyte) == b"a" * mebibyte
+        chunk2 = next(iterator)
+        assert chunk2.read(mebibyte) == b"b" * (mebibyte - 1)
+
+
+def test_split_url():
+    with tempfile.NamedTemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b" * mebibyte)
+        f.flush()
+        with closing(urlopen(path2url(f.name))) as stream:
+            instance = SplitStream(stream, mebibyte)
+            assert len(instance) == 2
+            for m, c in izip(instance, [b"a", b"b"]):
+                assert m.read(mebibyte) == c * mebibyte
+
+
+def test_split_url_irregular_size():
+    with tempfile.NamedTemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b")
+        f.flush()
+        with closing(urlopen(path2url(f.name))) as stream:
+            instance = SplitStream(stream, mebibyte)
+            assert len(instance) == 2
+            iterator = iter(instance)
+            chunk1 = next(iterator)
+            assert chunk1.read(mebibyte) == b"a" * mebibyte
+            chunk2 = next(iterator)
+            assert chunk2.read(mebibyte) == b"b"
+
+
+def test_split_url_irregular_size_2():
+    with tempfile.NamedTemporaryFile() as f:
+        f.write(b"a" * mebibyte)
+        f.write(b"b" * (mebibyte - 1))
+        f.flush()
+        with closing(urlopen(path2url(f.name))) as stream:
+            instance = SplitStream(stream, mebibyte)
+            assert len(instance) == 2
+            iterator = iter(instance)
+            chunk1 = next(iterator)
+            assert chunk1.read(mebibyte) == b"a" * mebibyte
+            chunk2 = next(iterator)
+            assert chunk2.read(mebibyte) == b"b" * (mebibyte - 1)
+
+
+def test_split_bytes():
+    instance = SplitStream(b"a" * small_chunk + b"b" * small_chunk, small_chunk)
+    assert len(instance) == 2
+    for m, c in izip(instance, [b"a", b"b"]):
+        assert m.read(small_chunk) == c * small_chunk
+
+
+def test_split_bytes_irregular_size():
+    instance = SplitStream(b"a" * small_chunk + b"b", small_chunk)
+    assert len(instance) == 2
+    iterator = iter(instance)
+    chunk1 = next(iterator)
+    assert chunk1.read(small_chunk) == b"a" * small_chunk
+    chunk2 = next(iterator)
+    assert chunk2.read(small_chunk) == b"b"
+
+
+def test_split_bytes_irregular_size_2():
+    instance = SplitStream(b"a" * small_chunk + b"b" * (small_chunk - 1), small_chunk)
+    assert len(instance) == 2
+    iterator = iter(instance)
+    chunk1 = next(iterator)
+    assert chunk1.read(small_chunk) == b"a" * small_chunk
+    chunk2 = next(iterator)
+    assert chunk2.read(small_chunk) == b"b" * (small_chunk - 1)
+
+
+def test_split_combine_stream_misaligned_with_chunk_size_1():
+    instance = SplitStream(
+        dict(Body=CombineStreams([io.BytesIO(b"1234"), io.BytesIO(b"567890123456789")]), ContentLength=19), 5
+    )
+    assert len(instance) == 4
+    iterator = iter(instance)
+    chunk1 = next(iterator)
+    assert chunk1.read(5) == b"12345"
+    chunk2 = next(iterator)
+    assert chunk2.read(5) == b"67890"
+    chunk3 = next(iterator)
+    assert chunk3.read(5) == b"12345"
+    chunk4 = next(iterator)
+    assert chunk4.read(5) == b"6789"
+
+
+def test_split_combine_stream_misaligned_with_chunk_size_2():
+    instance = SplitStream(
+        dict(Body=CombineStreams([io.BytesIO(b"123456789"), io.BytesIO(b"012345678")]), ContentLength=18), 5
+    )
+    assert len(instance) == 4
+    iterator = iter(instance)
+    chunk1 = next(iterator)
+    assert chunk1.read(5) == b"12345"
+    chunk2 = next(iterator)
+    assert chunk2.read(5) == b"67890"
+    chunk3 = next(iterator)
+    assert chunk3.read(5) == b"12345"
+    chunk4 = next(iterator)
+    assert chunk4.read(5) == b"678"
+
+
+def test_split_combine_stream_misaligned_with_chunk_size_3():
+    instance = SplitStream(
+        dict(
+            Body=CombineStreams([io.BytesIO(b"123"), io.BytesIO(b"456"), io.BytesIO(b"789"), io.BytesIO(b"012")]),
+            ContentLength=12,
+        ),
+        5,
+    )
+    assert len(instance) == 3
+    iterator = iter(instance)
+    chunk1 = next(iterator)
+    assert chunk1.read(5) == b"12345"
+    chunk2 = next(iterator)
+    assert chunk2.read(5) == b"67890"
+    chunk3 = next(iterator)
+    assert chunk3.read(5) == b"12"
+
+
+def test_combine_streams():
+    instance = CombineStreams(io.BytesIO(b"Hello") for i in range(20))
+    result = b""
+    for i in range(1000):
+        read_result = instance.read(i)
+        result += read_result
+        if len(read_result) < i:
+            break
+
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+    assert result == b"Hello" * 20
+
+
+def test_combine_streams_readall():
+    instance = CombineStreams(io.BytesIO(b"Hello") for i in range(20))
+    result = instance.read()
+
+    assert instance.read(10) == b""
+    assert instance.read() == b""
+    assert result == b"Hello" * 20
+
+
+def test_iterator_mixin():
+    # import pudb
+    # pu.db
+    instance = FakeMixinUser(MIXIN_DATA, 4)
+    assert instance.readline() == b"a\n"
+    assert instance.readline() == b"aa\n"
+    assert instance.readline() == b"aaa\n"
+    assert instance.readline() == b"aaaa\n"
+    assert instance.readline() == b"aaaaa\n"
+    assert instance.readline() == b"aaaaaa\n"
+    assert instance.readline() == b""
+
+
+def test_iterator_mixin_list():
+    # import pudb
+    # pu.db
+    instance = FakeMixinUser(MIXIN_DATA, 4)
+    assert instance.readlines() == [
+        b"a\n",
+        b"aa\n",
+        b"aaa\n",
+        b"aaaa\n",
+        b"aaaaa\n",
+        b"aaaaaa\n",
+    ]
diff --git a/tests/mailboxes.jsonl b/tests/mailboxes.jsonl
new file mode 100644
index 0000000..28b5cc1
--- /dev/null
+++ b/tests/mailboxes.jsonl
@@ -0,0 +1,3 @@
+{"mailbox_id":  "ALICE", "mailbox_name": "TESTMB1", "billing_entity": "England", "ods_code": "X26", "org_code": "X26", "password": "password"}
+{"mailbox_id":  "BOB", "mailbox_name": "TESTMB2", "billing_entity": "Wales", "ods_code": "X26", "org_code": "X26", "password": "password"}
+{"mailbox_id":  "BARRY", "mailbox_name": "TESTMB3", "billing_entity": "England", "ods_code": "X27", "org_code": "X27", "password": "password"}
\ No newline at end of file
diff --git a/tests/mesh_chunk_retries_tests.py b/tests/mesh_chunk_retries_tests.py
new file mode 100644
index 0000000..bda6c0c
--- /dev/null
+++ b/tests/mesh_chunk_retries_tests.py
@@ -0,0 +1,121 @@
+import os.path
+from collections import namedtuple
+from typing import Dict
+from uuid import uuid4
+
+import mock
+import pytest
+import requests
+
+from mesh_client import MeshClient
+from tests.helpers import default_ssl_opts
+from tests.mock_server import MockMeshChunkRetryApplication
+
+alice_mailbox = "alice"
+alice_password = "password"
+bob_mailbox = "bob"
+bob_password = "password"
+
+
+@pytest.fixture(scope="function", name="mock_app")
+def _mock_mesh_app():
+    with MockMeshChunkRetryApplication() as mock_app:
+        yield mock_app
+
+
+@pytest.fixture(scope="function", name="alice")
+def _alice_mesh_client(mock_app: MockMeshChunkRetryApplication):
+    with MeshClient(
+        mock_app.uri,
+        alice_mailbox,
+        alice_password,
+        max_chunk_size=5,
+        max_chunk_retries=3,
+        **default_ssl_opts,  # type: ignore[arg-type]
+    ) as alice:
+        yield alice
+
+
+@pytest.fixture(scope="function", name="bob")
+def _bob_mesh_client(mock_app: MockMeshChunkRetryApplication):
+    with MeshClient(
+        mock_app.uri,
+        bob_mailbox,
+        bob_password,
+        max_chunk_size=5,
+        max_chunk_retries=3,
+        **default_ssl_opts,  # type: ignore[arg-type]
+    ) as bob:
+        yield bob
+
+
+def _count_chunk_retry_call_counts(mocked_post):
+    counts: Dict[int, int] = {}
+    for call in mocked_post.call_args_list:
+        chunk = int(call.kwargs["headers"]["Mex-Chunk-Range"].split(":")[0])
+        if chunk not in counts.keys():
+            counts[chunk] = 0
+        else:
+            counts[chunk] += 1
+    return counts
+
+
+def test_chunk_retries(mock_app: MockMeshChunkRetryApplication, alice: MeshClient, bob: MeshClient):
+    mock_post = mock.Mock(wraps=alice._session.post)
+    alice._session.post = mock_post  # type: ignore[method-assign]
+
+    chunk_options = namedtuple("chunk_options", "chunk_num num_retry_attempts")
+    options = [chunk_options(2, 2)]
+    mock_app.set_chunk_retry_options(options)
+
+    message_id = alice.send_message(bob_mailbox, b"Hello World")
+
+    received = bob.retrieve_message(message_id).read()
+    assert received == b"Hello World"
+
+    chunk_retry_call_counts = _count_chunk_retry_call_counts(mock_post)
+    assert chunk_retry_call_counts[1] == 0
+    assert chunk_retry_call_counts[2] == 3
+    assert chunk_retry_call_counts[3] == 0
+
+
+def test_chunk_all_retries_fail(mock_app: MockMeshChunkRetryApplication, alice: MeshClient, bob: MeshClient):
+    mock_post = mock.Mock(wraps=alice._session.post)
+    alice._session.post = mock_post  # type: ignore[method-assign]
+
+    chunk_options = namedtuple("chunk_options", "chunk_num num_retry_attempts")
+    options = [chunk_options(2, 3)]
+    mock_app.set_chunk_retry_options(options)
+
+    with pytest.raises(requests.exceptions.HTTPError):
+        alice.send_message(bob_mailbox, b"Hello World")
+
+    chunk_retry_call_counts = _count_chunk_retry_call_counts(mock_post)
+    assert chunk_retry_call_counts[1] == 0
+    assert chunk_retry_call_counts[2] == 3
+
+
+def test_chunk_retries_with_file(
+    mock_app: MockMeshChunkRetryApplication, alice: MeshClient, bob: MeshClient, tmpdir: str
+):
+    chunk_file = os.path.join(tmpdir, uuid4().hex)
+
+    with open(chunk_file, "wb+") as f:
+        f.write(b"test1 test2 test3")
+
+    mock_post = mock.Mock(wraps=alice._session.post)
+    alice._session.post = mock_post  # type: ignore[method-assign]
+
+    chunk_options = namedtuple("chunk_options", "chunk_num num_retry_attempts")
+    options = [chunk_options(2, 2)]
+    mock_app.set_chunk_retry_options(options)
+
+    message_id = alice.send_message(bob_mailbox, open(chunk_file, "rb"))
+
+    received = bob.retrieve_message(message_id).read()
+    assert received == b"test1 test2 test3"
+
+    chunk_retry_call_counts = _count_chunk_retry_call_counts(mock_post)
+    assert chunk_retry_call_counts[1] == 0
+    assert chunk_retry_call_counts[2] == 3
+    assert chunk_retry_call_counts[3] == 0
diff --git a/tests/mesh_client_tests.py b/tests/mesh_client_tests.py
new file mode 100644
index 0000000..faeb45e
--- /dev/null
+++ b/tests/mesh_client_tests.py
@@ -0,0 +1,297 @@
+import io
+import signal
+import sys
+import traceback
+from typing import List, cast
+from uuid import uuid4
+
+import pytest
+import requests
+from requests import HTTPError
+
+from mesh_client import CombineStreams, MeshClient, MeshError
+from tests.helpers import LOCAL_MOCK_ENDPOINT, default_ssl_opts
+from tests.mock_server import MockMeshApplication, SlowMockMeshApplication
+
+alice_mailbox = "alice"
+alice_password = "password"
+bob_mailbox = "bob"
+bob_password = "password"
+
+
+class MockResponse:
+    def __init__(self, json_data, status_code):
+        self.json_data = json_data
+        self.status_code = status_code
+
+    def json(self):
+        return self.json_data
+
+
+def print_stack_frames(signum=None, frame=None):
+    for frame in sys._current_frames().values():
+        traceback.print_stack(frame)
+        print()
+
+
+signal.signal(signal.SIGUSR1, print_stack_frames)
+
+
+class TestError(Exception):
+    pass
+
+
+@pytest.fixture(scope="function", name="mock_app")
+def _mock_mesh_app():
+    with MockMeshApplication() as mock_app:
+        yield mock_app
+
+
+@pytest.fixture(scope="function", name="alice")
+def _alice_mesh_client(mock_app: MockMeshApplication):
+    with MeshClient(
+        mock_app.uri, alice_mailbox, alice_password, max_chunk_size=5, **default_ssl_opts  # type: ignore[arg-type]
+    ) as alice:
+        yield alice
+
+
+@pytest.fixture(scope="function", name="bob")
+def _bob_mesh_client(mock_app: MockMeshApplication):
+    with MeshClient(
+        mock_app.uri, bob_mailbox, bob_password, max_chunk_size=5, **default_ssl_opts  # type: ignore[arg-type]
+    ) as bob:
+        yield bob
+
+
+def test_get_version():
+    from mesh_client import __version__
+
+    assert __version__ != "unknown"
+    assert __version__ != "0.0.0"
+
+
+def test_handshake(alice: MeshClient, bob: MeshClient):
+    hand_shook = alice.handshake()
+    assert hand_shook == b"hello"
+
+
+def test_send_receive(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1")
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"Hello Bob 1"
+    assert msg.sender == "alice"
+    assert msg.recipient == "bob"
+    assert msg.content_type == "application/octet-stream"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_send_receive_combine_streams_part1_multiple_of_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 10
+    part2_length = 23
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"H" * part1_length + b"W" * part2_length
+    assert msg.sender == "alice"
+    assert msg.recipient == "bob"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_send_receive_combine_chunked_small_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.mex_header("chunk-range") == "1:5"
+
+
+def test_send_receive_combine_chunked_override_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream, max_chunk_size=1)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.mex_header("chunk-range") == "1:24"
+
+
+def test_send_receive_combine_streams_part1_not_multiple_of_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"H" * part1_length + b"W" * part2_length
+    assert msg.sender == "alice"
+    assert msg.recipient == "bob"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_line_by_line(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert list(iter(msg)) == [b"Hello Bob 1\n", b"Hello Bob 2"]
+
+
+def test_readline(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert msg.readline() == b"Hello Bob 1\n"
+
+
+def test_readlines(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2")
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert msg.readlines() == [b"Hello Bob 1\n", b"Hello Bob 2"]
+
+
+def test_transparent_compression(alice: MeshClient, bob: MeshClient):
+    print("Sending")
+    alice._transparent_compress = True
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob Compressed")
+    assert bob.list_messages() == [message_id]
+    print("Receiving")
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"Hello Bob Compressed"
+    assert msg.mex_header("from") == "alice"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_iterate_and_context_manager(alice: MeshClient, bob: MeshClient):
+    alice.send_message(bob_mailbox, b"Hello Bob 2")
+    alice.send_message(bob_mailbox, b"Hello Bob 3")
+    messages_read = 0
+    for msg, expected in zip(bob.iterate_all_messages(), [b"Hello Bob 2", b"Hello Bob 3"]):
+        with msg:
+            assert msg.read() == expected
+            messages_read += 1
+    assert messages_read == 2
+    assert bob.list_messages() == []
+
+
+def test_context_manager_failure(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 4")
+    try:
+        with bob.retrieve_message(message_id) as msg:
+            assert msg.read() == b"Hello Bob 4"
+            raise TestError()
+    except TestError:
+        pass
+    assert bob.list_messages() == [message_id]
+
+
+def test_optional_args(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(
+        bob_mailbox,
+        b"Hello Bob 5",
+        subject="Hello World",
+        filename="upload.txt",
+        local_id="12345",
+        message_type="DATA",
+        workflow_id="111",
+        encrypted=False,
+        compressed=False,
+    )
+
+    with bob.retrieve_message(message_id) as msg:
+        assert msg.subject == "Hello World"
+        assert msg.filename == "upload.txt"
+        assert msg.local_id == "12345"
+        assert msg.message_type == "DATA"
+        assert msg.workflow_id == "111"
+        assert msg.encrypted is False
+        assert msg.compressed is False
+
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 5", encrypted=True, compressed=True)
+
+    with bob.retrieve_message(message_id) as msg:
+        assert msg.encrypted is True
+        assert msg.compressed is True
+
+
+def test_tracking(alice: MeshClient, bob: MeshClient):
+    tracking_id = "Message1"
+    msg_id = alice.send_message(bob_mailbox, b"Hello World", local_id=tracking_id)
+    assert alice.get_tracking_info(tracking_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.get_tracking_info(tracking_id)["status"] == "Acknowledged"
+
+
+def test_msg_id_tracking_message_not_found(alice: MeshClient, bob: MeshClient):
+    with pytest.raises(HTTPError) as err:
+        assert alice.get_tracking_info(message_id=uuid4().hex)
+
+    assert err.value.response.status_code == 404
+
+
+def test_msg_id_tracking(alice: MeshClient, bob: MeshClient):
+    msg_id = alice.send_message(bob_mailbox, b"Hello World")
+    assert alice.get_tracking_info(message_id=msg_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.get_tracking_info(message_id=msg_id)["status"] == "Acknowledged"
+
+
+def test_by_message_id_tracking(alice: MeshClient, bob: MeshClient):
+    msg_id = alice.send_message(bob_mailbox, b"Hello World")
+    assert alice.track_by_message_id(message_id=msg_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.track_by_message_id(message_id=msg_id)["status"] == "Acknowledged"
+
+
+def test_endpoint_lookup(alice: MeshClient, bob: MeshClient):
+    result = alice.lookup_endpoint("ORG1", "WF1")
+    result_list = cast(List[dict], result["results"])
+    assert len(result_list) == 1
+    assert result_list[0]["address"] == "ORG1HC001"
+    assert result_list[0]["description"] == "ORG1 WF1 endpoint"
+    assert result_list[0]["endpoint_type"] == "MESH"
+
+
+def test_error_handling(alice: MeshClient, bob: MeshClient):
+    with pytest.raises(MeshError):
+        alice.send_message(bob_mailbox, b"")
+
+
+def test_timeout():
+    with SlowMockMeshApplication() as mock_app:
+        endpoint = LOCAL_MOCK_ENDPOINT._replace(url=mock_app.uri)
+        with pytest.raises(requests.exceptions.Timeout):
+            with MeshClient(endpoint, alice_mailbox, alice_password, max_chunk_size=5, timeout=0.5) as client:
+                client.list_messages()
diff --git a/tests/mesh_endpoint_connectivity_tests.py b/tests/mesh_endpoint_connectivity_tests.py
new file mode 100644
index 0000000..f00b5d7
--- /dev/null
+++ b/tests/mesh_endpoint_connectivity_tests.py
@@ -0,0 +1,53 @@
+import socket
+from urllib.parse import urlparse
+
+import pytest
+from _socket import gaierror
+from requests.exceptions import HTTPError, SSLError
+
+import mesh_client
+from mesh_client import NHS_INT_ENDPOINT, Endpoint, MeshClient
+from tests.helpers import MOCK_CERT, MOCK_KEY
+
+
+def _host_resolves(endpoint: Endpoint):
+    parsed = urlparse(endpoint.url)
+    try:
+        socket.gethostbyname(str(parsed.hostname))
+    except gaierror as err:
+        if err.args[1] == "Name or service not known":
+            return False
+        raise
+    return True
+
+
+_ENDPOINTS = [
+    (name, endpoint)
+    for name, endpoint in mesh_client.__dict__.items()
+    if name.endswith("_ENDPOINT") and not name.startswith("LOCAL_") and "_OPENTEST_" not in name
+]
+
+_INTERNET_ENDPOINTS = [(name, endpoint) for name, endpoint in _ENDPOINTS if "_INTERNET_GATEWAY" in name]
+
+_HSCN_ENDPOINTS = [(name, endpoint) for name, endpoint in _ENDPOINTS if "_INTERNET_GATEWAY" not in name]
+
+
+@pytest.mark.parametrize("name, endpoint", _HSCN_ENDPOINTS)
+@pytest.mark.skipif(not _host_resolves(NHS_INT_ENDPOINT), reason="these hosts will only resolve on HSCN")
+def test_hscn_endpoints(name: str, endpoint: Endpoint):
+    with pytest.raises(HTTPError) as err:
+        with MeshClient(endpoint, "BADUSERNAME", "BADPASSWORD", cert=(MOCK_CERT, MOCK_KEY)) as client:
+            client.ping()
+
+    assert err.value.response.status_code == 400
+
+
+@pytest.mark.parametrize("name, endpoint", _INTERNET_ENDPOINTS)
+def test_live_internet_endpoint(name: str, endpoint: Endpoint):
+    with pytest.raises(SSLError) as err:
+        with MeshClient(endpoint, "BADUSERNAME", "BADPASSWORD", cert=(MOCK_CERT, MOCK_KEY)) as client:
+            client.ping()
+
+    # the internet endpoints behave differently they will not return a 400 bad request
+    # in this case, TLSV1_ALERT_UNKNOWN_CA actually means "I don't accept this client certificate"
+    assert err.value.args[0].reason.args[0].reason == "TLSV1_ALERT_UNKNOWN_CA"
diff --git a/tests/mesh_sandbox_tests.py b/tests/mesh_sandbox_tests.py
new file mode 100644
index 0000000..a1e6a72
--- /dev/null
+++ b/tests/mesh_sandbox_tests.py
@@ -0,0 +1,281 @@
+import io
+import os.path
+from typing import List, cast
+from uuid import uuid4
+
+import pytest
+import requests
+from requests import HTTPError
+
+from mesh_client import CombineStreams, MeshClient, MeshError
+from tests.helpers import SANDBOX_ENDPOINT, temp_env_vars
+from tests.mesh_client_tests import TestError
+
+alice_mailbox = "ALICE"
+alice_password = "password"
+bob_mailbox = "BOB"
+bob_password = "password"
+
+
+def sandbox_uri(path: str) -> str:
+    return os.path.join(SANDBOX_ENDPOINT.url, path)
+
+
+@pytest.fixture(scope="module", autouse=True)
+def _default_vars():
+    with temp_env_vars(MESH_CLIENT_SHARED_KEY="TestKey"):
+        yield
+
+
+@pytest.fixture(scope="function", autouse=True)
+def _resets():
+    res = requests.delete(sandbox_uri("admin/reset"), verify=SANDBOX_ENDPOINT.verify)
+    res.raise_for_status()
+
+
+@pytest.fixture(scope="function", name="alice")
+def _alice_client():
+    with MeshClient(SANDBOX_ENDPOINT, alice_mailbox, alice_password, max_chunk_size=5) as client:
+        yield client
+
+
+@pytest.fixture(scope="function", name="bob")
+def _bob_client():
+    with MeshClient(SANDBOX_ENDPOINT, bob_mailbox, bob_password, max_chunk_size=5) as client:
+        yield client
+
+
+def test_get_version():
+    from mesh_client import __version__
+
+    assert __version__ != "unknown"
+    assert __version__ != "0.0.0"
+
+
+def test_alice_ping(alice: MeshClient):
+    alice.ping()
+
+
+def test_bob_ping(bob: MeshClient):
+    bob.ping()
+
+
+def test_handshake(alice: MeshClient, bob: MeshClient):
+    hand_shook = alice.handshake()
+    assert hand_shook == b"hello"
+
+
+def test_send_receive(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1", workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"Hello Bob 1"
+    assert msg.sender == "ALICE"
+    assert msg.recipient == "BOB"
+    assert msg.content_type == "application/octet-stream"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_send_receive_combine_streams_part1_multiple_of_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 10
+    part2_length = 23
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream, workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"H" * part1_length + b"W" * part2_length
+    assert msg.sender == "ALICE"
+    assert msg.recipient == "BOB"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_send_receive_combine_chunked_small_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream, workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.mex_header("chunk-range") == "1:5"
+
+
+def test_send_receive_combine_chunked_override_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream, max_chunk_size=1, workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.mex_header("chunk-range") == "1:24"
+
+
+def test_send_receive_combine_streams_part1_not_multiple_of_chunk_size(alice: MeshClient, bob: MeshClient):
+    part1_length = 4
+    part2_length = 20
+
+    stream = {
+        "Body": CombineStreams([io.BytesIO(b"H" * part1_length), io.BytesIO((b"W" * part2_length))]),
+        "ContentLength": part1_length + part2_length,
+    }
+
+    message_id = alice.send_message(bob_mailbox, stream, workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    assert bob.count_messages() == 1
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"H" * part1_length + b"W" * part2_length
+    assert msg.sender == "ALICE"
+    assert msg.recipient == "BOB"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_line_by_line(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2", workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert list(iter(msg)) == [b"Hello Bob 1\n", b"Hello Bob 2"]
+
+
+def test_readline(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2", workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert msg.readline() == b"Hello Bob 1\n"
+
+
+def test_readlines(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 1\nHello Bob 2", workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    msg = bob.retrieve_message(message_id)
+    assert msg.readlines() == [b"Hello Bob 1\n", b"Hello Bob 2"]
+
+
+def test_transparent_compression(alice: MeshClient, bob: MeshClient):
+    print("Sending")
+    alice._transparent_compress = True
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob Compressed", workflow_id=uuid4().hex)
+    assert bob.list_messages() == [message_id]
+    print("Receiving")
+    msg = bob.retrieve_message(message_id)
+    assert msg.read() == b"Hello Bob Compressed"
+    assert msg.mex_header("from") == "ALICE"
+    msg.acknowledge()
+    assert bob.list_messages() == []
+
+
+def test_iterate_and_context_manager(alice: MeshClient, bob: MeshClient):
+    alice.send_message(bob_mailbox, b"Hello Bob 2", workflow_id=uuid4().hex)
+    alice.send_message(bob_mailbox, b"Hello Bob 3", workflow_id=uuid4().hex)
+    messages_read = 0
+    for msg, expected in zip(bob.iterate_all_messages(), [b"Hello Bob 2", b"Hello Bob 3"]):
+        with msg:
+            assert msg.read() == expected
+            messages_read += 1
+    assert messages_read == 2
+    assert bob.list_messages() == []
+
+
+def test_context_manager_failure(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(bob_mailbox, b"Hello Bob 4", workflow_id=uuid4().hex)
+    try:
+        with bob.retrieve_message(message_id) as msg:
+            assert msg.read() == b"Hello Bob 4"
+            raise TestError()
+    except TestError:
+        pass
+    assert bob.list_messages() == [message_id]
+
+
+def test_optional_args(alice: MeshClient, bob: MeshClient):
+    message_id = alice.send_message(
+        bob_mailbox,
+        b"Hello Bob 5",
+        subject="Hello World",
+        filename="upload.txt",
+        local_id="12345",
+        message_type="DATA",
+        workflow_id="111",
+        encrypted=False,
+        compressed=False,
+    )
+
+    with bob.retrieve_message(message_id) as msg:
+        assert msg.subject == "Hello World"
+        assert msg.filename == "upload.txt"
+        assert msg.local_id == "12345"
+        assert msg.message_type == "DATA"
+        assert msg.workflow_id == "111"
+        assert msg.encrypted is False
+        assert msg.compressed is False
+
+    message_id = alice.send_message(
+        bob_mailbox, b"Hello Bob 5", encrypted=True, compressed=True, workflow_id=uuid4().hex
+    )
+
+    with bob.retrieve_message(message_id) as msg:
+        assert msg.encrypted is True
+        assert msg.compressed is True
+
+
+def test_tracking(alice: MeshClient, bob: MeshClient):
+    tracking_id = "Message1"
+    msg_id = alice.send_message(bob_mailbox, b"Hello World", local_id=tracking_id, workflow_id=uuid4().hex)
+    assert alice.get_tracking_info(tracking_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.get_tracking_info(tracking_id)["status"] == "Acknowledged"
+
+
+def test_msg_id_tracking_message_not_found(alice: MeshClient, bob: MeshClient):
+    with pytest.raises(HTTPError) as err:
+        assert alice.get_tracking_info(message_id=uuid4().hex)
+
+    assert err.value.response.status_code == 404
+
+
+def test_msg_id_tracking(alice: MeshClient, bob: MeshClient):
+    msg_id = alice.send_message(bob_mailbox, b"Hello World", workflow_id=uuid4().hex)
+    assert alice.get_tracking_info(message_id=msg_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.get_tracking_info(message_id=msg_id)["status"] == "Acknowledged"
+
+
+def test_by_message_id_tracking(alice: MeshClient, bob: MeshClient):
+    msg_id = alice.send_message(bob_mailbox, b"Hello World", workflow_id=uuid4().hex)
+    assert alice.track_by_message_id(message_id=msg_id)["status"] == "Accepted"
+    bob.acknowledge_message(msg_id)
+    assert alice.track_by_message_id(message_id=msg_id)["status"] == "Acknowledged"
+
+
+def test_endpoint_lookup(alice: MeshClient, bob: MeshClient):
+    result = alice.lookup_endpoint("X26", "RESTRICTED_WORKFLOW")
+    result_list = cast(List[dict], result["results"])
+    assert len(result_list) == 1
+    assert result_list[0]["address"] == "BOB"
+    assert result_list[0]["description"] == "TESTMB2"
+    assert result_list[0]["endpoint_type"] == "MESH"
+
+
+def test_error_handling(alice: MeshClient, bob: MeshClient):
+    with pytest.raises(MeshError):
+        alice.send_message(bob_mailbox, b"")
diff --git a/mesh_client/mock_server.py b/tests/mock_server.py
similarity index 68%
rename from mesh_client/mock_server.py
rename to tests/mock_server.py
index 322993c..81d483c 100755
--- a/mesh_client/mock_server.py
+++ b/tests/mock_server.py
@@ -14,7 +14,7 @@
 package as default_ssl_opts. Since these certs and keys are publicly available,
 they should only be used in test environments.
 """
-from __future__ import print_function, absolute_import
+
 import datetime
 import hmac
 import json
@@ -24,15 +24,17 @@
 import time
 import traceback
 import zlib
+from collections import OrderedDict
 from contextlib import closing
 from hashlib import sha256
-from collections import OrderedDict
 from threading import Thread
+from typing import List, cast
+from urllib.parse import parse_qs
+from wsgiref.simple_server import WSGIServer, make_server
 from wsgiref.util import shift_path_info
-from wsgiref.simple_server import make_server, WSGIServer
-from six.moves.urllib.parse import parse_qs
-from .io_helpers import stream_from_wsgi_environ
-from .key_helper import get_shared_key_from_environ
+
+from mesh_client.io_helpers import ChunkedStream, FiniteLengthStream
+from mesh_client.key_helper import get_shared_key_from_environ
 
 _OPTIONAL_HEADERS = {
     "HTTP_CONTENT_ENCODING": "Content-Encoding",
@@ -40,8 +42,6 @@
     "HTTP_MEX_FILENAME": "Mex-FileName",
     "HTTP_MEX_LOCALID": "Mex-LocalID",
     "HTTP_MEX_MESSAGETYPE": "Mex-MessageType",
-    "HTTP_MEX_PROCESSID": "Mex-ProcessID",
-    "HTTP_MEX_PROCESSLID": "Mex-ProcesslID",
     "HTTP_MEX_SUBJECT": "Mex-Subject",
     "HTTP_MEX_CONTENT_ENCRYPTED": "Mex-Content-Encrypted",
     "HTTP_MEX_CONTENT_COMPRESS": "Mex-Content-Compress",
@@ -50,17 +50,26 @@
     "HTTP_MEX_CONTENT_COMPRESSED": "Mex-Content-Compressed",
     "HTTP_MEX_CHUNK_RANGE": "Mex-Chunk-Range",
     "HTTP_MEX_FROM": "Mex-From",
-    "HTTP_MEX_TO": "Mex-To"
+    "HTTP_MEX_TO": "Mex-To",
 }
 
 
-TIMESTAMP_FORMAT = '%Y%m%d%H%M%S%f'
+TIMESTAMP_FORMAT = "%Y%m%d%H%M%S%f"
+
+
+def stream_from_wsgi_environ(environ):
+    if environ.get("CONTENT_LENGTH"):
+        return FiniteLengthStream(environ["wsgi.input"], int(environ["CONTENT_LENGTH"]))
+    elif environ.get("HTTP_TRANSFER_ENCODING") == "chunked":
+        return ChunkedStream(environ["wsgi.input"])
+    else:
+        # terminated by close
+        return environ["wsgi.input"]
 
 
 def _dumb_response_code(code, message):
     def handle(environ, start_response):
-        start_response("{} {}".format(code, message),
-                       [("Content-Type", "text/plain")])
+        start_response("{} {}".format(code, message), [("Content-Type", "text/plain")])
         return [message.encode("UTF-8")]
 
     return handle
@@ -86,12 +95,18 @@ def _error(content_type, data, start_error_response):
 
 def _compose(**kwargs):
     def handle(environ, start_response):
-        path_component = shift_path_info(environ) or 'default'
-        if path_component in kwargs:
-            return kwargs[path_component](environ, start_response)
-        else:
+        path_component = shift_path_info(environ) or "none"
+        if path_component not in kwargs:
+            if "default" in kwargs:
+                remaining_path = environ["PATH_INFO"]
+                environ["PATH_INFO"] = "/".join(
+                    ["", path_component, remaining_path] if remaining_path else ["", path_component]
+                )
+                return kwargs["default"](environ, start_response)
             return _not_found(environ, start_response)
 
+        return kwargs[path_component](environ, start_response)
+
     return handle
 
 
@@ -122,16 +137,12 @@ def __init__(self, shared_key=get_shared_key_from_environ()):
 
     @property
     def __call__(self):
-        return _compose(
-            messageexchange=self.message_exchange,
-            endpointlookup=_compose(
-                mesh=self.endpoint_lookup
-            )
-        )
+        return _compose(messageexchange=self.message_exchange)
 
     def authenticated(self, handler):
         def handle(environ, start_response):
             requested_mailbox = shift_path_info(environ)
+
             authorization_header = environ.get("HTTP_AUTHORIZATION", "")
             if not authorization_header.startswith("NHSMESH "):
                 return _not_authorized(environ, start_response)
@@ -139,11 +150,8 @@ def handle(environ, start_response):
             auth_data = authorization_header[8:]
             mailbox, nonce, nonce_count, ts, hashed = auth_data.split(":")
             expected_password = "password"
-            hash_data = ":".join([
-                mailbox, nonce, nonce_count, expected_password, ts
-            ])
-            myhash = hmac.HMAC(self._shared_key, hash_data.encode("ASCII"),
-                               sha256).hexdigest()
+            hash_data = ":".join([mailbox, nonce, nonce_count, expected_password, ts])
+            myhash = hmac.HMAC(self._shared_key, hash_data.encode("ASCII"), sha256).hexdigest()
             if myhash == hashed and mailbox == requested_mailbox:
                 environ["mesh.mailbox"] = mailbox
                 return handler(environ, start_response)
@@ -154,39 +162,33 @@ def handle(environ, start_response):
 
     def handshake(self, environ, start_response):
         auth_headers = [
-            'HTTP_MEX_CLIENTVERSION',
-            'HTTP_MEX_JAVAVERSION',
-            'HTTP_MEX_OSARCHITECTURE',
-            'HTTP_MEX_OSNAME',
-            'HTTP_MEX_OSVERSION'
+            "HTTP_MEX_CLIENTVERSION",
+            "HTTP_MEX_JAVAVERSION",
+            "HTTP_MEX_OSARCHITECTURE",
+            "HTTP_MEX_OSNAME",
+            "HTTP_MEX_OSVERSION",
         ]
         for auth_header in auth_headers:
             if auth_header not in environ:
                 return _server_error(environ, start_response)
         mailbox_id = environ["mesh.mailbox"]
-        return _ok('application/json',
-                   [json.dumps({"mailboxId": mailbox_id}).encode("UTF-8")],
-                   start_response)
+        return _ok("application/json", [json.dumps({"mailboxId": mailbox_id}).encode("UTF-8")], start_response)
 
     @property
     def message_exchange(self):
-        return self.authenticated(
-            _compose(
-                inbox=self.inbox,
-                outbox=self.outbox,
-                count=self.count,
-                default=self.handshake
-            )
+        return _compose(
+            endpointlookup=self.endpoint_lookup,
+            default=self.authenticated(
+                _compose(inbox=self.inbox, outbox=self.outbox, count=self.count, none=self.handshake)
+            ),
         )
 
-
     def inbox(self, environ, start_response):
         request_method = environ["REQUEST_METHOD"]
         message_id = shift_path_info(environ)
         mailbox = environ["mesh.mailbox"]
 
-        if (request_method == "PUT" and
-                environ["PATH_INFO"] == "/status/acknowledged"):
+        if request_method == "PUT" and environ["PATH_INFO"] == "/status/acknowledged":
             del self.messages[mailbox][message_id]
             return _simple_ok(environ, start_response)
 
@@ -194,21 +196,16 @@ def inbox(self, environ, start_response):
             if message_id:
                 chunk_num = shift_path_info(environ)
                 if chunk_num:
-                    return self.download_chunk(
-                        message_id, chunk_num)(environ, start_response)
+                    return self.download_chunk(message_id, chunk_num)(environ, start_response)
                 message = self.messages[message_id]
-                if message['headers']['Mex-To'] != mailbox:
+                if message["headers"]["Mex-To"] != mailbox:
                     return _not_found(environ, start_response)
-                response_code = ("206 Partial Content"
-                                 if "chunks" in message else "200 OK")
+                response_code = "206 Partial Content" if "chunks" in message else "200 OK"
                 start_response(response_code, list(message["headers"].items()))
                 return [message["data"]]
             else:
-                messages = {"messages": list(
-                    self.messages.get(mailbox, {}).keys())}
-                return _ok("application/json",
-                           [json.dumps(messages).encode("UTF-8")],
-                           start_response)
+                messages = {"messages": list(self.messages.get(mailbox, {}).keys())}
+                return _ok("application/json", [json.dumps(messages).encode("UTF-8")], start_response)
         else:
             return _bad_request(environ, start_response)
 
@@ -220,17 +217,14 @@ def count(self, environ, start_response):
                 "count": len(self.messages.get(mailbox, {})),
                 "internalID": "12345",
                 "allResultsIncluded": True,
-
             }
-            return _ok("application/json",
-                       [json.dumps(result).encode("UTF-8")],
-                       start_response)
+            return _ok("application/json", [json.dumps(result).encode("UTF-8")], start_response)
         else:
             return _bad_request(environ, start_response)
 
     def outbox(self, environ, start_response):
         chunk_msg = shift_path_info(environ)
-        if chunk_msg == 'tracking':
+        if chunk_msg == "tracking":
             return self.tracking(environ, start_response)
         if chunk_msg:
             return self.upload_chunk(chunk_msg)(environ, start_response)
@@ -241,37 +235,35 @@ def outbox(self, environ, start_response):
             assert mailbox_id == sender
         except Exception as e:
             traceback.print_exc()
-            start_response("417 Expectation Failed",
-                           [('Content-Type', 'application/json')])
-            return [json.dumps({
-                "errorCode": "02",
-                "errorDescription": str(e),
-                "errorEvent": "COLLECT",
-                "messageID": "99999"
-            })]
+            start_response("417 Expectation Failed", [("Content-Type", "application/json")])
+            return [
+                json.dumps(
+                    {"errorCode": "02", "errorDescription": str(e), "errorEvent": "COLLECT", "messageID": "99999"}
+                )
+            ]
 
         mailbox = self.messages.setdefault(recipient, OrderedDict())
         with closing(stream_from_wsgi_environ(environ)) as stream:
             data = stream.read()
         if not data:
-            start_response("417 Expectation Failed",
-                           [('Content-Type', 'application/json')])
-            return [json.dumps({
-                "errorCode": "02",
-                "errorDescription": "Data file is missing or inaccessible.",
-                "errorEvent": "COLLECT",
-                "messageID": "99999"
-            }).encode("utf-8")]
-        headers = {_OPTIONAL_HEADERS[key]: value
-                   for key, value in environ.items()
-                   if key in _OPTIONAL_HEADERS}
+            start_response("417 Expectation Failed", [("Content-Type", "application/json")])
+            return [
+                json.dumps(
+                    {
+                        "errorCode": "02",
+                        "errorDescription": "Data file is missing or inaccessible.",
+                        "errorEvent": "COLLECT",
+                        "messageID": "99999",
+                    }
+                ).encode("utf-8")
+            ]
+        headers = {"Content-Type": "application/octet-stream"}  # defaults
+        headers.update({_OPTIONAL_HEADERS[key]: value for key, value in environ.items() if key in _OPTIONAL_HEADERS})
         msg_id = self.make_message_id()
-        headers['Mex-MessageID'] = msg_id
+        headers["Mex-MessageID"] = msg_id
         mailbox[msg_id] = {"headers": headers, "data": data}
         self.messages[msg_id] = mailbox[msg_id]
-        return _ok("application/json",
-                   [json.dumps({"messageID": msg_id}).encode("UTF-8")],
-                   start_response)
+        return _ok("application/json", [json.dumps({"messageID": msg_id}).encode("UTF-8")], start_response)
 
     def upload_chunk(self, chunk_msg):
         def handle(environ, start_response):
@@ -280,8 +272,8 @@ def handle(environ, start_response):
             chunks = msg.setdefault("chunks", {})
             with closing(stream_from_wsgi_environ(environ)) as stream:
                 data = stream.read()
-            chunks[chunk_num] = zlib.compress(data) if not environ.get('HTTP_CONTENT_ENCODING', None) else data
-            start_response('202 Accepted', [])
+            chunks[chunk_num] = zlib.compress(data) if not environ.get("HTTP_CONTENT_ENCODING", None) else data
+            start_response("202 Accepted", [])
             return []
 
         return handle
@@ -291,22 +283,25 @@ def handle(environ, start_response):
             msg = self.messages[chunk_msg]
             chunks = msg["chunks"]
             chunk = chunks[chunk_num]
-            if environ['HTTP_ACCEPT_ENCODING'] != "gzip":
+            if environ["HTTP_ACCEPT_ENCODING"] != "gzip":
                 chunk = zlib.decompress(chunk)
 
             chunk_header = "{}:{}".format(chunk_num, len(chunks) + 1)
-            start_response('200 OK', [
-                ('Content-Type', 'application/octet-stream'),
-                ('Content-Encoding', 'gzip'),
-                ('Mex-Chunk-Range', chunk_header)
-            ])
+            start_response(
+                "200 OK",
+                [
+                    ("Content-Type", msg["headers"]["Content-Type"]),
+                    ("Content-Encoding", "gzip"),
+                    ("Mex-Chunk-Range", chunk_header),
+                ],
+            )
             return [chunk]
 
         return handle
 
     def tracking(self, environ, start_response):
         tracking_id = shift_path_info(environ)
-        msg_id = parse_qs(environ['QUERY_STRING']).get('messageID', [None])[0]
+        msg_id = parse_qs(environ["QUERY_STRING"]).get("messageID", cast(List[str], [None]))[0]
         if tracking_id:
             message = self._find_message_by_local_id(tracking_id)
         elif msg_id:
@@ -315,39 +310,37 @@ def tracking(self, environ, start_response):
             return _bad_request(environ, start_response)
 
         if message:
-            headers = message['headers']
-            recipient = headers['Mex-To']
-            message_id = headers['Mex-MessageID']
+            headers = message["headers"]
+            recipient = headers["Mex-To"]
+            message_id = headers["Mex-MessageID"]
             acked = message_id not in self.messages[recipient]
             now = datetime.datetime.now().strftime(TIMESTAMP_FORMAT)
             response = {
                 "localId": tracking_id,
                 "dtsId": message_id,
                 "messageType": "DATA",
-                "chunkCount": len(message['chunks']),
-                "subject": headers.get('Mex-Subject'),
+                "chunkCount": len(message["chunks"]),
+                "subject": headers.get("Mex-Subject"),
                 "statusEvent": None,
                 "version": "1.0",
                 "downloadTimestamp": now,
                 "statusDescription": None,
                 "status": "Acknowledged" if acked else "Accepted",
-                "workflowId": headers.get('Mex-WorkflowID'),
-                "fileName": headers.get('Mex-FileName'),
+                "workflowId": headers.get("Mex-WorkflowID"),
+                "fileName": headers.get("Mex-FileName"),
                 "uploadTimestamp": now,
-                "recipient": headers['Mex-To'],
-                "sender": headers['Mex-From'],
+                "recipient": headers["Mex-To"],
+                "sender": headers["Mex-From"],
                 "messageId": message_id,
-                "fileSize": 0
+                "fileSize": 0,
             }
-            return _ok('application/json',
-                       [json.dumps(response).encode('UTF-8')],
-                       start_response)
+            return _ok("application/json", [json.dumps(response).encode("UTF-8")], start_response)
         else:
             return _not_found(environ, start_response)
 
     def _find_message_by_local_id(self, local_id):
         for message in self.messages.values():
-            if message.get('headers', {}).get('Mex-LocalID') == local_id:
+            if message.get("headers", {}).get("Mex-LocalID") == local_id:
                 return message
         else:
             return None
@@ -359,19 +352,17 @@ def endpoint_lookup(self, environ, start_response):
             return _not_found(environ, start_response)
         result = {
             "query_id": "{ts:%Y%m%d%H%M%S%f}_{rnd:06x}_{ts:%s}".format(
-                ts=datetime.datetime.now(),
-                rnd=random.randint(0, 0xffffff)),
+                ts=datetime.datetime.now(), rnd=random.randint(0, 0xFFFFFF)
+            ),
             "results": [
                 {
                     "address": "{}HC001".format(org_code),
                     "description": "{} {} endpoint".format(org_code, workflow_id),
-                    "endpoint_type": "MESH"
+                    "endpoint_type": "MESH",
                 }
-            ]
+            ],
         }
-        return _ok('application/json',
-                   [json.dumps(result).encode('UTF-8')],
-                   start_response)
+        return _ok("application/json", [json.dumps(result).encode("UTF-8")], start_response)
 
     def make_message_id(self):
         """
@@ -381,10 +372,7 @@ def make_message_id(self):
         """
         n = self._msg_count
         self._msg_count += 1
-        return "{ts:%Y%m%d%H%M%S%f}_{num:06d}".format(
-            ts=datetime.datetime.now(),
-            num=n
-        )
+        return f"{datetime.datetime.now():%Y%m%d%H%M%S%f}_{n:06d}"
 
     def __enter__(self):
         self.start_server()
@@ -402,9 +390,7 @@ def start_server(self):
         self.server = make_server("", 0, self, server_class=SSLWSGIServer)
         port = self.server.server_address[1]
         self.uri = "https://localhost:{}".format(port)
-        thread = Thread(
-            target=self.server.serve_forever,
-            kwargs={"poll_interval": 0.01})
+        thread = Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01})
         thread.daemon = True
         thread.start()
         return self
@@ -427,14 +413,14 @@ def set_chunk_retry_options(self, options):
         self.retry_options = options
 
     def outbox(self, environ, start_response):
-        current_chunk = int(environ['HTTP_MEX_CHUNK_RANGE'].split(':')[0])
+        current_chunk = int(environ["HTTP_MEX_CHUNK_RANGE"].split(":")[0])
         if current_chunk == 1:
             self.allowed_retries = {p[0]: p[1] for p in self.retry_options}
 
         if current_chunk in self.allowed_retries:
             if self.allowed_retries[current_chunk] >= 0:
                 self.allowed_retries[current_chunk] -= 1
-                return _error("application/json", '', start_response)
+                return _error("application/json", "", start_response)
 
         return super(MockMeshChunkRetryApplication, self).outbox(environ, start_response)
 
@@ -447,10 +433,11 @@ def __call__(self, environ, start_response):
 
 _data_dir = os.path.dirname(__file__)
 default_server_context = ssl.create_default_context(
-    ssl.Purpose.CLIENT_AUTH, cafile=os.path.join(_data_dir, "ca.cert.pem"))
+    ssl.Purpose.CLIENT_AUTH, cafile=os.path.join(_data_dir, "ca.cert.pem")
+)
 default_server_context.load_cert_chain(
-    os.path.join(_data_dir, 'server.cert.pem'),
-    os.path.join(_data_dir, 'server.key.pem'))
+    os.path.join(_data_dir, "server.cert.pem"), os.path.join(_data_dir, "server.key.pem")
+)
 default_server_context.check_hostname = False
 default_server_context.verify_mode = ssl.CERT_REQUIRED
 
@@ -461,14 +448,3 @@ class SSLWSGIServer(WSGIServer, object):
     def get_request(self):
         (socket, addr) = super(SSLWSGIServer, self).get_request()
         return self.__context.wrap_socket(socket, server_side=True), addr
-
-
-def main():
-    print("Serving on port 8000")
-    server = make_server(
-        "", 8000, MockMeshApplication(), server_class=SSLWSGIServer)
-    server.serve_forever(0.01)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/mesh_client/server.cert.pem b/tests/server.cert.pem
similarity index 100%
rename from mesh_client/server.cert.pem
rename to tests/server.cert.pem
diff --git a/mesh_client/server.key.pem b/tests/server.key.pem
similarity index 100%
rename from mesh_client/server.key.pem
rename to tests/server.key.pem
diff --git a/tests/workflows.jsonl b/tests/workflows.jsonl
new file mode 100644
index 0000000..302945b
--- /dev/null
+++ b/tests/workflows.jsonl
@@ -0,0 +1,2 @@
+{"workflow_id": "RESTRICTED_WORKFLOW", "senders":  ["ALICE"], "receivers":  ["BOB"]}
+{"workflow_id": "ANOTHER_WORKFLOW", "senders":  ["BOB"], "receivers":  ["BARRY", "ALICE"]}