diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 99c6ed31b..000000000 --- a/.coveragerc +++ /dev/null @@ -1,12 +0,0 @@ -[report] -omit = - */virtualenv/* - */.buildout/eggs/* - bin/test - buildout-cache/eggs/* - eggs/* - parts/* - src/crate/client/_pep440.py -exclude_lines = - # pragma: no cover - raise NotImplemented diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 91abb11fd..8efce62c1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,8 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/cla-check.yml b/.github/workflows/cla-check.yml new file mode 100644 index 000000000..164b0ed3c --- /dev/null +++ b/.github/workflows/cla-check.yml @@ -0,0 +1,12 @@ +name: Check PR Author has signed CLA +on: + pull_request_target: + branches: + - 'master' + types: [opened, synchronize, unlabeled] +jobs: + check-author-signed-cla: + uses: crate/actions/.github/workflows/cla-check-workflow.yml@main + # with: + # simulate_no_cla: true + secrets: inherit diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 621d914c3..c7d3dad90 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] schedule: - cron: "46 2 * * 5" @@ -19,7 +19,7 @@ concurrency: jobs: analyze: - name: "Analyze with SQLAlchemy ${{ matrix.sqla-version }}" + name: "Analyze Python code" runs-on: ubuntu-latest permissions: actions: read @@ -30,28 +30,58 @@ jobs: fail-fast: false matrix: language: [ python ] - sqla-version: ['<1.4', '<1.5', '<2.1'] + python-version: ['3.13'] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + cache-dependency-glob: | + setup.py + cache-suffix: ${{ matrix.python-version }} + enable-cache: true + version: "latest" - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql.yml queries: +security-and-quality + # run an 'alert-suppression' query + packs: "codeql/${{ matrix.language }}-queries:AlertSuppression.ql" #- name: Autobuild # uses: github/codeql-action/autobuild@v2 - name: Install project run: | - pip install --editable=.[sqlalchemy,test] - pip install "sqlalchemy${{ matrix.sqla-version }}" --upgrade --pre + uv pip install --system '.[test]' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + id: analyze + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" + # define the output folder for SARIF files + output: sarif-results + + # Unlock inline mechanism to suppress CodeQL warnings. + # https://github.com/github/codeql/issues/11427#issuecomment-1721059096 + - name: Dismiss alerts + # if: github.ref == 'refs/heads/main' + uses: advanced-security/dismiss-alerts@v2 with: - category: "/language:${{ matrix.language }}/sqla-version:${{ matrix.sqla-version }}" + # specify a 'sarif-id' and 'sarif-file' + sarif-id: ${{ steps.analyze.outputs.sarif-id }} + sarif-file: sarif-results/${{ matrix.language }}.sarif + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0ac965965..2e4eb21e2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,11 +1,11 @@ +--- name: docs - on: workflow_dispatch: pull_request: ~ push: branches: - - master + - main schedule: - cron: '0 7 * * *' @@ -13,23 +13,35 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + checks: write + statuses: write + jobs: documentation: - name: Run link checker + name: Build docs runs-on: ubuntu-latest steps: - name: Acquire sources - uses: actions/checkout@v3 + uses: actions/checkout@v6 + with: + persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: 'setup.py' + cache-dependency-glob: | + pyproject.toml + enable-cache: true + activate-environment: true + version: "latest" + + - name: Setup env + run: uv pip install --group docs - name: Build docs run: | - cd docs && make check + cd docs && make html diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index 4f8424b59..000000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Nightly - -on: - workflow_dispatch: - schedule: - - cron: '0 2 * * *' - - -jobs: - nightly: - name: "Python: ${{ matrix.python-version }} - SQLA: ${{ matrix.sqla-version }} - CrateDB: ${{ matrix.cratedb-version }} - on ${{ matrix.os }}" - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ['ubuntu-latest'] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - cratedb-version: ['nightly'] - sqla-version: ['latest'] - pip-allow-prerelease: ['false'] - - # Another CI test matrix slot to test against prerelease versions of Python packages. - include: - - os: 'ubuntu-latest' - python-version: '3.11' - cratedb-version: 'nightly' - sqla-version: 'latest' - pip-allow-prerelease: 'true' - - fail-fast: false - - env: - CRATEDB_VERSION: ${{ matrix.cratedb-version }} - SQLALCHEMY_VERSION: ${{ matrix.sqla-version }} - PIP_ALLOW_PRERELEASE: ${{ matrix.pip-allow-prerelease }} - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: 'setup.py' - - - name: Invoke tests - run: | - - # Propagate build matrix information. - ./devtools/setup_ci.sh - - # Bootstrap environment. - source bootstrap.sh - - # Report about the test matrix slot. - echo "Invoking tests with CrateDB ${CRATEDB_VERSION} and SQLAlchemy ${SQLALCHEMY_VERSION}" - - # Run linter. - flake8 src bin - - # Run tests. - export SQLALCHEMY_WARN_20=1 - bin/test -vvv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dad813f2..e8524c9f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,28 +3,40 @@ name: release on: push +permissions: + contents: read + checks: write + statuses: write + jobs: pypi: - name: Build & publish package to pypi - runs-on: ubuntu-latest - if: startsWith(github.event.ref, 'refs/tags') + name: 'Build & publish package to PyPI' + runs-on: 'ubuntu-latest' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - python-version: '3.9' - cache: 'pip' - cache-dependency-path: 'setup.py' + activate-environment: true + enable-cache: true + python-version: "3.14" + + - name: Set up Hatch and Twine + run: uv pip install hatch twine - name: Build package - run: | - python -m pip install twine wheel - python setup.py sdist bdist_wheel - twine check dist/*.tar.gz + run: hatch build + + - name: Validate package + run: twine check dist/* - name: Publish package to PyPI + if: startsWith(github.event.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b09c1bfc5..2df1eada3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,90 +1,84 @@ +--- name: Tests on: push: - branches: [ master ] - pull_request: - branches: [ master ] + branches: + - main + pull_request: ~ workflow_dispatch: + schedule: + - cron: '0 2 * * *' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + checks: write + statuses: write + jobs: test: name: "Python: ${{ matrix.python-version }} - SQLA: ${{ matrix.sqla-version }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: ['ubuntu-latest', 'macos-latest'] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - cratedb-version: ['5.2.2'] - sqla-version: ['<1.4', '<1.5', '<2.1'] - pip-allow-prerelease: ['false'] - - # To save resources, only use the most recent Python version on macOS. - exclude: - - os: 'macos-latest' - python-version: '3.7' - - os: 'macos-latest' - python-version: '3.8' - - os: 'macos-latest' - python-version: '3.9' - - os: 'macos-latest' - python-version: '3.10' + os: ["ubuntu-24.04"] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] - # Another CI test matrix slot to test against prerelease versions of Python packages. + # To save resources, only verify other variants particularly. include: - - os: 'ubuntu-latest' - python-version: '3.11' - cratedb-version: '5.2.2' - sqla-version: 'latest' - pip-allow-prerelease: 'true' - fail-fast: false + # Linux/ARM + - os: 'ubuntu-24.04-arm' + python-version: '3.14' + + # macOS/Intel + - os: 'macos-15-intel' + python-version: '3.14' + + # macOS/ARM + - os: 'macos-latest' + python-version: '3.14' env: - CRATEDB_VERSION: ${{ matrix.cratedb-version }} - SQLALCHEMY_VERSION: ${{ matrix.sqla-version }} - PIP_ALLOW_PRERELEASE: ${{ matrix.pip-allow-prerelease }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v6 with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: setup.py - - - name: Invoke tests - run: | - - # Propagate build matrix information. - ./devtools/setup_ci.sh + persist-credentials: false - # Bootstrap environment. - source bootstrap.sh + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + cache-suffix: ${{ matrix.python-version }} + enable-cache: true + activate-environment: true + version: "latest" - # Report about the test matrix slot. - echo "Invoking tests with CrateDB ${CRATEDB_VERSION} and SQLAlchemy ${SQLALCHEMY_VERSION}" + - name: Setup env + run: uv pip install --group dev -e . - # Run linter. - flake8 src bin - - # Run tests. - export SQLALCHEMY_WARN_20=1 - coverage run bin/test -vvv + - name: Run Linters + run: | + ruff check . + mypy - # Set the stage for uploading the coverage report. + - name: Run tests + run: | + coverage run -m pytest + coverage combine coverage xml - # https://github.com/codecov/codecov-action - name: Upload coverage results to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v7 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 3b32ddeb8..247f4e415 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage.xml .installed.cfg .tox/ *.DS_Store +*.lock *.pyc bin/* !bin/test @@ -19,3 +20,4 @@ htmlcov/ out/ parts/ tmp/ +env/ diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..bfc1d655c --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,25 @@ +# .readthedocs.yml +# Read the Docs configuration file + +# Details +# - https://docs.readthedocs.io/en/stable/config-file/v2.html + +# Required +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt + +# Optionally build your docs in additional formats such as PDF +# formats: +# - pdf diff --git a/CHANGES.txt b/CHANGES.rst similarity index 68% rename from CHANGES.txt rename to CHANGES.rst index 1de74471b..d2afd0ad2 100644 --- a/CHANGES.txt +++ b/CHANGES.rst @@ -2,8 +2,234 @@ Changes for crate ================= -Unreleased +2026/06/17 2.2.1 +================ + +- Fixed ``cursor.execute()`` with ``bulk_parameters`` and pyformat SQL: when + rows are dicts, both the SQL template and the rows are now converted to + positional format before sending to CrateDB. Positional-list rows + continue to work as before. + +2026/06/04 2.2.0 ========== +- Added JSON serialization support for Python's ``datetime.time`` type, + encoding it as an ISO 8601 string compatible with CrateDB's ``TIMETZ`` + column type. + +- Added gzip compression for outgoing request bodies via the ``compress`` + parameter (default: ``8192`` bytes). + Pass ``True`` to always compress, ``False`` to disable, or an integer + as a byte threshold. The driver always sends ``Accept-Encoding: gzip, + deflate`` to negotiate compressed responses from the server when + compression is enabled. + +- Added named parameter support (``pyformat`` paramstyle). Passing a + :class:`py:dict` as ``parameters`` to ``cursor.execute()`` now accepts + ``%(name)s`` placeholders and converts them to positional ``?`` markers + client-side. Positional parameters using ``?`` continue to work unchanged. + +2026/03/09 2.1.2 +================ + +- Fixed a regression introduced in 2.1.0 that caused fetching blobs to fail + with a ``DigestNotFoundException`` if it required following a redirect. + +2026/03/04 2.1.1 +================ + +- Fixed JWT token positional argument order. + +2026/03/04 2.1.0 +================ + +- Exceptions from the BLOB API now include their full names. + +- Dropped support for Python versions earlier than 3.10 as they've reached + their end of life. + +- Parse path prefixes from server URLs and propagate them to all requests. + +- Fixed compatibility with ``urllib3-future``. + +- Added JWT token authentication. + +2025/01/30 2.0.0 +================ + +- Switched JSON encoder to use the `orjson`_ library, to improve JSON + marshalling performance. Thanks, @widmogrod. + + orjson is fast and in some spots even more correct when compared against + Python's stdlib ``json`` module. Contrary to the stdlib variant, orjson + will serialize to ``bytes`` instead of ``str``. When sending data to CrateDB, + ``crate-python`` uses a custom encoder to add support for additional data + types. + + - Python's ``Decimal`` type will be serialized to ``str``. + - Python's ``dt.datetime`` and ``dt.date`` types will be serialized to + ``int`` (``LONG``) after converting to milliseconds since epoch, to + optimally accommodate CrateDB's `TIMESTAMP`_ representation. + - NumPy's data types will be handled by ``orjson`` without any ado. + +.. _orjson: https://github.com/ijl/orjson +.. _TIMESTAMP: https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#type-timestamp + +2024/11/23 1.0.1 +================ + +- Python: Fixed "implicit namespace packages" migration by omitting + ``__init__.py`` from ``crate`` namespace package, see `PEP 420`_ + and `Package Discovery and Namespace Package » Finding namespace packages`_. + + +2024/11/05 1.0.0 +================ + +- BREAKING CHANGE: The SQLAlchemy dialect has been split off into + the `sqlalchemy-cratedb`_ package, see notice below. +- Feature: Returned Python ``datetime`` objects are now always timezone-aware, + using UTC by default. + It may be a breaking change for some users of the library that don't expect + to receive "aware" instead of "naive" Python ``datetime`` objects from now + on, i.e. instances with or without the ``tzinfo`` attribute set. + When no ``time_zone`` information is specified when creating a database + connection or cursor, ``datetime`` objects will now use Coordinated + Universal Time (UTC), like CrateDB is storing timestamp values in this + format. + This update is coming from a deprecation of Python's + ``datetime.utcfromtimestamp()``, which is effectively also phasing out + the use of "naive" timestamp objects in Python, in favor of using + timezone-aware objects, also to represent datetimes in UTC. +- Feature: Configured DB API interface attribute ``threadsafety = 1``, + which signals "Threads may share the module, but not connections." +- Feature: Added ``error_trace`` to string representation of an Error, + to relay server stacktraces into exception messages. +- Refactoring: The module namespace ``crate.client.test_util`` has been + renamed to ``crate.testing.util``. +- Error handling: At two spots in cursor / value converter handling, where + ``assert`` statements have been used, ``ValueError`` exceptions are raised + now. +- Python: Migrated to use "implicit namespace packages" instead of "declared + namespaces" for the ``crate`` namespace package, see `PEP 420`_. + + +.. note:: + + For learning about the transition to `sqlalchemy-cratedb`_, + we recommend to read the enumeration of necessary migration steps + at `Migrate from crate.client to sqlalchemy-cratedb`_. + + +.. _Migrate from crate.client to sqlalchemy-cratedb: https://cratedb.com/docs/sqlalchemy-cratedb/migrate-from-crate-client.html +.. _Package Discovery and Namespace Package » Finding namespace packages: https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#namespace-packages +.. _PEP 420: https://peps.python.org/pep-0420/ +.. _sqlalchemy-cratedb: https://pypi.org/project/sqlalchemy-cratedb/ + + +2024/01/18 0.35.2 +================= + +- Test compatibility: Permit installation of pandas 2.1. + + +2024/01/18 0.35.1 +================= + +- Compatibility: Re-add ``crate.client._pep440.Version`` from ``verlib2``. + It is needed the prevent breaking ``crash``. + + +2024/01/17 0.35.0 +================= + +- Permit ``urllib3.Timeout`` instances for defining timeout values. + This way, both ``connect`` and ``read`` socket timeout settings can be + configured. The unit is seconds. + + +2023/09/29 0.34.0 +================= + +- Properly handle Python-native UUID types in SQL parameters. Thanks, + @SStorm. +- SQLAlchemy: Fix handling URL parameters ``timeout`` and ``pool_size`` +- Permit installation with urllib3 v2, see also `urllib3 v2.0 roadmap`_ + and `urllib3 v2.0 migration guide`_. You can optionally retain support + for TLS 1.0 and TLS 1.1, but a few other outdated use-cases of X.509 + certificate details are immanent, like no longer accepting the long + deprecated ``commonName`` attribute. Instead, going forward, only the + ``subjectAltName`` attribute will be used. +- SQLAlchemy: Improve DDL compiler to ignore foreign key and uniqueness + constraints. +- DBAPI: Properly raise ``IntegrityError`` exceptions instead of + ``ProgrammingError``, when CrateDB raises a ``DuplicateKeyException``. +- SQLAlchemy: Ignore SQL's ``FOR UPDATE`` clause. Thanks, @surister. + +.. _urllib3 v2.0 migration guide: https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html +.. _urllib3 v2.0 roadmap: https://urllib3.readthedocs.io/en/stable/v2-roadmap.html + + +2023/07/17 0.33.0 +================= + +- SQLAlchemy: Rename leftover occurrences of ``Object``. The new symbol to represent + CrateDB's ``OBJECT`` column type is now ``ObjectType``. + +- SQLAlchemy DQL: Use CrateDB's native ``ILIKE`` operator instead of using SA's + generic implementation ``lower() LIKE lower()``. Thanks, @hlcianfagna. + + +2023/07/06 0.32.0 +================= + +- SQLAlchemy DDL: Allow turning off column store using ``crate_columnstore=False``. + Thanks, @fetzerms. + +- SQLAlchemy DDL: Allow setting ``server_default`` on columns to enable + server-generated defaults. Thanks, @JanLikar. + +- Allow handling datetime values tagged with time zone info when inserting or updating. + +- SQLAlchemy: Fix SQL statement caching for CrateDB's ``OBJECT`` type. Thanks, @faymarie. + +- SQLAlchemy: Refactor ``OBJECT`` type to use SQLAlchemy's JSON type infrastructure. + +- SQLAlchemy: Added ``insert_bulk`` fast-path ``INSERT`` method for pandas, in + order to support efficient batch inserts using CrateDB's "bulk operations" endpoint. + +- SQLAlchemy: Add documentation and software tests for usage with Dask + + +2023/04/18 0.31.1 +================= + +- SQLAlchemy Core: Re-enable support for ``INSERT/UPDATE...RETURNING`` in + SQLAlchemy 2.0 by adding the new ``insert_returning`` and ``update_returning`` flags + in the CrateDB dialect. + + +2023/03/30 0.31.0 +================= + +- SQLAlchemy Core: Support ``INSERT...VALUES`` with multiple value sets by enabling + ``supports_multivalues_insert`` on the CrateDB dialect, it is used by pandas' + ``method="multi"`` option + +- SQLAlchemy Core: Enable the ``insertmanyvalues`` feature, which lets you control + the batch size of ``INSERT`` operations using the ``insertmanyvalues_page_size`` + engine-, connection-, and statement-options. + +- SQLAlchemy ORM: Remove support for the legacy ``session.bulk_save_objects`` API + on SQLAlchemy 2.0, in favor of the new ``insertmanyvalues`` feature. Performance + optimizations from ``bulk_save()`` have been made inherently part of ``add_all()``. + Note: The legacy mode will still work on SQLAlchemy 1.x, while SQLAlchemy 2.x users + MUST switch to the new method now. + + +2023/03/02 0.30.1 +================= + +- Fixed SQLAlchemy 2.0 incompatibility with ``CrateDialect.{has_schema,has_table}`` 2023/02/16 0.30.0 diff --git a/DEVELOP.rst b/DEVELOP.rst index 7424eafe7..d39affdb1 100644 --- a/DEVELOP.rst +++ b/DEVELOP.rst @@ -1,81 +1,59 @@ -=============== -Developer guide -=============== +============================== +CrateDB Python developer guide +============================== Setup ===== -To start things off, bootstrap the sandbox environment:: +Clone the repository:: - source bootstrap.sh + git clone https://github.com/crate/crate-python + cd crate-python -This command should automatically install all prerequisites for the development -sandbox and drop you into the virtualenv, ready for invoking further commands. +Setup a virtualenv and install the package:: + python -m venv .venv + source .venv/bin/activate + python -m pip install --group dev --group docs -e . -Running tests -============= - -All tests will be invoked using the Python interpreter that was used when -creating the Python virtualenv. The test runner is `zope.testrunner`_. - -Some examples are outlined below. In order to learn about more details, -see, for example, `useful command-line options for zope-testrunner`_. - -Run all tests:: - - ./bin/test -vvvv - -Run specific tests:: +Or if using `uv`_:: - ./bin/test -vvvv -t SqlAlchemyCompilerTest - ./bin/test -vvvv -t test_score - ./bin/test -vvvv -t sqlalchemy + uv venv .venv + source .venv/bin/activate + uv pip install --group dev --group docs -e . -Ignore specific test directories:: - ./bin/test -vvvv --ignore_dir=testing - -The ``LayerTest`` test cases have quite some overhead. Omitting them will save -a few cycles (~70 seconds runtime):: - - ./bin/test -t '!LayerTest' - -Invoke all tests without integration tests (~15 seconds runtime):: +Running tests +============= - ./bin/test --layer '!crate.testing.layer.crate' --test '!LayerTest' +Ensure the virtualenv is active and run tests using `pytest`_:: -Yet ~130 test cases, but only ~5 seconds runtime:: + python -m pytest - ./bin/test --layer '!crate.testing.layer.crate' --test '!LayerTest' \ - -t '!test_client_threaded' -t '!test_no_retry_on_read_timeout' \ - -t '!test_wait_for_http' -t '!test_table_clustered_by' -To inspect the whole list of test cases, run:: +See also: - ./bin/test --list-tests +- `How to invoke pytest ` for more information. -You can run the tests against multiple Python interpreters with `tox`_:: - tox +Formatting and linting code +=========================== -To do this, you will need the respective Python interpreter versions available -on your ``$PATH``. +Use `ruff`_ for code formatting and linting:: -To run against a single interpreter, you can also invoke:: + ruff format --check . + ruff check . - tox -e py37 -*Note*: Before running the tests, make sure to stop all CrateDB instances which -are listening on the default CrateDB transport port to avoid side effects with -the test layer. +Use ``mypy`` for type checking:: + mypy Renew certificates ================== For conducting TLS connectivity tests, there are a few X.509 certificates at -`src/crate/client/pki/*.pem`_. In order to renew them, follow the instructions +`tests/assets/pki/*.pem`_. In order to renew them, follow the instructions within the README file in this folder. @@ -90,18 +68,18 @@ To create a new release, you must: In the release branch: -- Update ``__version__`` in ``src/crate/client/__init__.py`` - -- Add a section for the new version in the ``CHANGES.txt`` file +- Add a section for the new version in the ``CHANGES.rst`` file -- Commit your changes with a message like "prepare release x.y.z" +- Commit your changes with a message like "Release x.y.z" - Push to origin/ -- Create a tag by running ``./devtools/create_tag.sh``. This will trigger a - Github action which releases the new version to PyPi. +- Create a tag by running ``git tag -s `` and push it ``git push --tags``. + This will trigger a Github action which releases the new version to PyPI. + +- Announce the new release on the `GitHub Releases`_ page. -On master: +On branch ``main``: - Update the release notes to reflect the release @@ -125,7 +103,7 @@ Writing documentation The docs live under the ``docs`` directory. -The docs are written written with ReStructuredText_ and processed with Sphinx_. +The docs are written with ReStructuredText_ and processed with Sphinx_. Build the docs by running:: @@ -137,14 +115,16 @@ The docs are automatically built from Git by `Read the Docs`_ and there is nothing special you need to do to get the live docs to update. .. _@crate/docs: https://github.com/orgs/crate/teams/docs -.. _buildout: https://pypi.python.org/pypi/zc.buildout +.. _GitHub Releases: https://github.com/crate/crate-python/releases .. _PyPI: https://pypi.python.org/pypi +.. _Python versions: https://docs.astral.sh/uv/concepts/python-versions/ .. _Read the Docs: http://readthedocs.org .. _ReStructuredText: http://docutils.sourceforge.net/rst.html .. _Sphinx: http://sphinx-doc.org/ -.. _src/crate/client/pki/*.pem: https://github.com/crate/crate-python/tree/master/src/crate/client/pki -.. _tox: http://testrun.org/tox/latest/ +.. _tests/assets/pki/*.pem: https://github.com/crate/crate-python/tree/main/tests/assets/pki .. _twine: https://pypi.python.org/pypi/twine .. _useful command-line options for zope-testrunner: https://pypi.org/project/zope.testrunner/#some-useful-command-line-options-to-get-you-started +.. _uv: https://docs.astral.sh/uv/ +.. _UV_PYTHON: https://docs.astral.sh/uv/configuration/environment/#uv_python .. _versions hosted on ReadTheDocs: https://readthedocs.org/projects/crate-python/versions/ -.. _zope.testrunner: https://pypi.org/project/zope.testrunner/ +.. _pytest: https://docs.pytest.org/en/stable/ diff --git a/LICENSE b/LICENSE index 75570724a..a16c46afd 100644 --- a/LICENSE +++ b/LICENSE @@ -176,73 +176,3 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License 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. - - -=============================================================================== - -For the `docs` directory: - -The source files for the documentation are licensed under the Apache License -Version 2.0. These source files are used by the project maintainers to build -online documentation for end-users: - - - -If you want to make contributions to the documentation, it may be necessary for -you to build the documentation yourself by following the instructions in the -`DEVELOP.rst` file. If you do this, a number of third-party software components -are necessary. - -We do not ship the source code for these optional third-party software -components or their dependencies, so we cannot make any guarantees about the -licensing status of these components. - -However, for convenience, the documentation build system explicitly references -the following software components (grouped by license): - -PSF License: - - - Python 3 - -MIT License: - - - pip - - setuptools - - sphinx-autobuild - -BSD License: - - - alabaster - - sphinx - -Apache License 2.0: - - - crate-docs-theme - -Please note that each of these components may specify its own dependencies and -those dependencies may be licensed differently. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b674f5da5..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include *.rst -recursive-include docs *.txt -recursive-include src *.txt *.rst -recursive-exclude src tests*.py diff --git a/NOTICE b/NOTICE index cd2e19fdd..c81db3c42 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,5 @@ CrateDB Python Adapter -Copyright 2013-2022 Crate.IO GmbH ("Crate") +Copyright 2013-2024 Crate.IO GmbH ("Crate") Licensed to Crate.IO GmbH (referred to in this notice as "Crate") under one or diff --git a/README.rst b/README.rst index c2b7ccba6..84e7a24be 100644 --- a/README.rst +++ b/README.rst @@ -6,12 +6,12 @@ CrateDB Python Client :target: https://github.com/crate/crate-python/actions?workflow=Tests :alt: Build status -.. image:: https://codecov.io/gh/crate/crate-python/branch/master/graph/badge.svg +.. image:: https://codecov.io/gh/crate/crate-python/branch/main/graph/badge.svg :target: https://app.codecov.io/gh/crate/crate-python :alt: Coverage .. image:: https://readthedocs.org/projects/crate-python/badge/ - :target: https://crate.io/docs/python/ + :target: https://cratedb.com/docs/python/ :alt: Build status (documentation) .. image:: https://img.shields.io/pypi/v/crate.svg @@ -22,8 +22,8 @@ CrateDB Python Client :target: https://pypi.org/project/crate/ :alt: Python Version -.. image:: https://img.shields.io/pypi/dw/crate.svg - :target: https://pypi.org/project/crate/ +.. image:: https://static.pepy.tech/badge/crate/month + :target: https://pepy.tech/project/crate :alt: PyPI Downloads .. image:: https://img.shields.io/pypi/wheel/crate.svg @@ -41,54 +41,64 @@ CrateDB Python Client | -A Python client library for CrateDB_. +A Python client library for `CrateDB`_, implementing the Python `DB API 2.0`_ +specification. -This library: +The CrateDB dialect for `SQLAlchemy`_ is provided by the `sqlalchemy-cratedb`_ +package, see also `sqlalchemy-cratedb documentation`_. -- Implements the Python `DB API 2.0`_ specification -- Includes support for SQLAlchemy_ (>= 1.3.0) -Prerequisites -============= +Installation +============ -Recent versions of this library are validated on Python 3 (>= 3.7). -It may also work on earlier versions of Python. +The CrateDB Python client is available as package `crate`_ on `PyPI`_. +To install the most recent driver version, run:: -Installation -============ + $ pip install --upgrade crate -The CrateDB Python client is available as a pip_ package. -To install the most recent driver version, including the SQLAlchemy dialect -extension, run:: +Migration Notes +=============== - $ pip install "crate[sqlalchemy]" --upgrade +If you are migrating from previous versions of ``crate[sqlalchemy]<1.0.0``, you +will find that the newer releases ``crate>=1.0.0`` no longer include the +SQLAlchemy dialect for CrateDB. +See `migrate to sqlalchemy-cratedb`_ for relevant guidelines about how to +successfully migrate to the `sqlalchemy-cratedb`_ package. -Contributing -============ -This project is primarily maintained by Crate.io_, but we welcome community -contributions! +Documentation and Help +====================== -See the `developer docs`_ and the `contribution docs`_ for more information. +- `CrateDB Python Client documentation`_ +- `CrateDB reference documentation`_ +- `Developer documentation`_ +- `Contributing`_ +- Other `support channels`_ -Help -==== -Looking for more help? +Contributions +============= + +The CrateDB Python client library is an open source project, and is `managed on +GitHub`_. We appreciate contributions of any kind. -- Read the `project docs`_ -- Check out our `support channels`_ -.. _contribution docs: CONTRIBUTING.rst -.. _Crate.io: https://crate.io/ +.. _Contributing: CONTRIBUTING.rst +.. _crate: https://pypi.org/project/crate/ +.. _Crate.io: https://cratedb.com/ .. _CrateDB: https://github.com/crate/crate -.. _DB API 2.0: http://www.python.org/dev/peps/pep-0249/ -.. _developer docs: DEVELOP.rst -.. _pip: https://pypi.python.org/pypi/pip -.. _SQLAlchemy: https://www.sqlalchemy.org +.. _CrateDB Python Client documentation: https://cratedb.com/docs/python/ +.. _CrateDB reference documentation: https://crate.io/docs/reference/ +.. _DB API 2.0: https://peps.python.org/pep-0249/ +.. _Developer documentation: DEVELOP.rst +.. _managed on GitHub: https://github.com/crate/crate-python +.. _migrate to sqlalchemy-cratedb: https://cratedb.com/docs/sqlalchemy-cratedb/migrate-from-crate-client.html +.. _PyPI: https://pypi.org/ +.. _SQLAlchemy: https://www.sqlalchemy.org/ +.. _sqlalchemy-cratedb: https://github.com/crate/sqlalchemy-cratedb +.. _sqlalchemy-cratedb documentation: https://cratedb.com/docs/sqlalchemy-cratedb/ .. _StackOverflow: https://stackoverflow.com/tags/cratedb -.. _support channels: https://crate.io/support/ -.. _project docs: https://crate.io/docs/python/ +.. _support channels: https://cratedb.com/support/ diff --git a/bin/test b/bin/test deleted file mode 100755 index 054074179..000000000 --- a/bin/test +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env python -import os -import sys -import zope.testrunner - -join = os.path.join -base = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) -base = os.path.dirname(base) - - -sys.argv[0] = os.path.abspath(sys.argv[0]) - -if __name__ == '__main__': - zope.testrunner.run([ - '-vvv', '--auto-color', - '--test-path', join(base, 'src')], - ) diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100644 index d5b6f500f..000000000 --- a/bootstrap.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/bash -# -# Bootstrap sandbox environment for crate-python -# -# - Create a Python virtualenv -# - Install all dependency packages and modules -# - Install package in editable mode -# - Drop user into an activated virtualenv -# -# Synopsis:: -# -# source bootstrap.sh -# - - -# Trace all invocations. -# set -x - -# Default variables. -CRATEDB_VERSION=${CRATEDB_VERSION:-5.2.2} -SQLALCHEMY_VERSION=${SQLALCHEMY_VERSION:-<2.1} - - -function print_header() { - printf '=%.0s' {1..42}; echo - echo "$1" - printf '=%.0s' {1..42}; echo -} - -function ensure_virtualenv() { - # Create a Python virtualenv with current version of Python 3. - # TODO: Maybe take `pyenv` into account. - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi -} - -function activate_virtualenv() { - # Activate Python virtualenv. - source .venv/bin/activate -} - -function before_setup() { - - # When `wheel` is installed, Python will build `wheel` packages from all - # acquired `sdist` packages and will store them into `~/.cache/pip`, where - # they will be picked up by the caching machinery and will be reused on - # subsequent invocations when run on CI. This makes a *significant* - # difference on total runtime on CI, it is about 2x faster. - # - # Otherwise, there will be admonitions like: - # Using legacy 'setup.py install' for foobar, since package 'wheel' is - # not installed. - # - pip install wheel - - # Install Buildout with designated version, allowing pre-releases. - pip install --pre --requirement=requirements.txt - -} - -function setup_package() { - - # Upgrade `pip` to support `--pre` option. - pip install --upgrade pip - - # Conditionally add `--pre` option, to allow installing prerelease packages. - PIP_OPTIONS="${PIP_OPTIONS:-}" - if [ "${PIP_ALLOW_PRERELEASE}" == "true" ]; then - PIP_OPTIONS+=" --pre" - fi - - # Install package in editable mode. - pip install ${PIP_OPTIONS} --editable='.[sqlalchemy,test]' - - # Install designated SQLAlchemy version. - if [ -n "${SQLALCHEMY_VERSION}" ]; then - if [ "${SQLALCHEMY_VERSION}" = "latest" ]; then - pip install ${PIP_OPTIONS} --upgrade "sqlalchemy" - else - pip install ${PIP_OPTIONS} --upgrade "sqlalchemy${SQLALCHEMY_VERSION}" - fi - fi - -} - -function run_buildout() { - buildout -N -} - -function finalize() { - - # Some steps before dropping into the activated virtualenv. - echo - echo "Sandbox environment ready" - echo -n "Using SQLAlchemy version: " - python -c 'import sqlalchemy; print(sqlalchemy.__version__)' - echo - -} - -function main() { - ensure_virtualenv - activate_virtualenv - before_setup - setup_package - run_buildout - finalize -} - -function lint() { - flake8 "$@" src bin -} - -main diff --git a/buildout.cfg b/buildout.cfg deleted file mode 100644 index edd92a7f3..000000000 --- a/buildout.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[buildout] -extends = versions.cfg -versions = versions -show-picked-versions = true -parts = crate - -[crate:linux] -recipe = hexagonit.recipe.download -url = https://cdn.crate.io/downloads/releases/crate-${versions:crate_server}.tar.gz -strip-top-level-dir = true - -[crate:macosx] -recipe = hexagonit.recipe.download -url = https://cdn.crate.io/downloads/releases/cratedb/x64_mac/crate-${versions:crate_server}.tar.gz -strip-top-level-dir = true - -[crate:windows] -recipe = hexagonit.recipe.download -url = https://cdn.crate.io/downloads/releases/cratedb/x64_windows/crate-${versions:crate_server}.zip -strip-top-level-dir = true diff --git a/devtools/create_tag.sh b/devtools/create_tag.sh deleted file mode 100755 index 1ee0f68d7..000000000 --- a/devtools/create_tag.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License 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. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -# check if everything is committed -CLEAN=`git status -s` -if [ ! -z "$CLEAN" ] -then - echo "Working directory not clean. Please commit all changes before tagging" - echo "Aborting." - exit -1 -fi - -echo "Fetching origin..." -git fetch origin > /dev/null - -# get current branch -BRANCH=`git branch | grep "^*" | cut -d " " -f 2` -echo "Current branch is $BRANCH." - -# check if master == origin/master -LOCAL_COMMIT=`git show --format="%H" $BRANCH` -ORIGIN_COMMIT=`git show --format="%H" origin/$BRANCH` - -if [ "$LOCAL_COMMIT" != "$ORIGIN_COMMIT" ] -then - echo "Local $BRANCH is not up to date. " - echo "Aborting." - exit -1 -fi - -# check if tag to create has already been created -WORKING_DIR=`dirname $0` -VERSION=`python setup.py --version` -EXISTS=`git tag | grep $VERSION` - -if [ "$VERSION" == "$EXISTS" ] -then - echo "Revision $VERSION already tagged." - echo "Aborting." - exit -1 -fi - -# check if VERSION is in head of CHANGES.txt -REV_NOTE=`grep "[0-9/]\{10\} $VERSION" CHANGES.txt` -if [ -z "$REV_NOTE" ] -then - echo "No notes for revision $VERSION found in CHANGES.txt" - echo "Aborting." - exit -1 -fi - -echo "Creating tag $VERSION..." -git tag -a "$VERSION" -m "Tag release for revision $VERSION" -git push --tags -echo "Done." diff --git a/devtools/setup_ci.sh b/devtools/setup_ci.sh deleted file mode 100755 index 5a02a4799..000000000 --- a/devtools/setup_ci.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash - -set -e - -function main() { - - # Sanity checks. - [ -z ${CRATEDB_VERSION} ] && { - echo "Environment variable 'CRATEDB_VERSION' needed" - exit 1 - } - - # Replace CrateDB version. - if [ ${CRATEDB_VERSION} = "nightly" ]; then - sed -ir "s/releases/releases\/nightly/g" buildout.cfg - sed -ir "s/crate_server.*/crate_server = latest/g" versions.cfg - else - sed -ir "s/crate_server.*/crate_server = ${CRATEDB_VERSION}/g" versions.cfg - fi - -} - -main "$@" diff --git a/docs/Makefile b/docs/Makefile index f26df7fc4..20478b01b 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -86,7 +86,7 @@ SRC_MAKE := $(MAKE) -f $(SRC_DIR)/rules.mk # Parse the JSON file BUILD_VERSION := $(shell cat $(BUILD_JSON) | \ - python -c 'import json, sys; print(json.load(sys.stdin)["message"])') + python3 -c 'import json, sys; print(json.load(sys.stdin)["message"])') ifeq ($(BUILD_VERSION),) $(error No build version specified in `$(BUILD_JSON)`.) diff --git a/docs/_extra/robots.txt b/docs/_extra/robots.txt index baa43f3c4..63c25edc7 100644 --- a/docs/_extra/robots.txt +++ b/docs/_extra/robots.txt @@ -1,2 +1,4 @@ -Sitemap: https://crate.io/docs/python/en/latest/site.xml User-agent: * +Disallow: / + +Sitemap: https://cratedb.com/docs/python/en/latest/site.xml diff --git a/docs/appendices/index.rst b/docs/appendices/index.rst deleted file mode 100644 index ae83ef5fb..000000000 --- a/docs/appendices/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -.. _appendices: - -========== -Appendices -========== - -Supplementary information for the CrateDB Python client library. - -.. rubric:: Table of contents - -.. toctree:: - :maxdepth: 2 - - data-types diff --git a/docs/blobs.rst b/docs/blobs.rst index 365865eb4..48c6cf060 100644 --- a/docs/blobs.rst +++ b/docs/blobs.rst @@ -8,11 +8,6 @@ The CrateDB Python client library provides full access to the powerful :ref:`blob storage capabilities ` of your CrateDB cluster. -.. rubric:: Table of contents - -.. contents:: - :local: - Get a blob container ==================== diff --git a/docs/build.json b/docs/build.json index 49cbd2be2..beb02da91 100644 --- a/docs/build.json +++ b/docs/build.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "label": "docs build", - "message": "2.1.0" + "message": "2.1.5" } diff --git a/docs/by-example/client.rst b/docs/by-example/client.rst index c9046d68d..995ee745a 100644 --- a/docs/by-example/client.rst +++ b/docs/by-example/client.rst @@ -7,12 +7,6 @@ Python. This section of the documentation outlines different methods to connect to the database cluster, as well as how to run basic inquiries to the database, and closing the connection again. -.. rubric:: Table of Contents - -.. contents:: - :local: - - Connect to a database ===================== @@ -31,7 +25,7 @@ replication. In order for clients to make use of this property it is recommended to specify all hosts of the cluster. This way if a server does not respond, the request is automatically routed to the next server: - >>> invalid_host = 'http://not_responding_host:4200' + >>> invalid_host = 'http://127.0.0.1:4201' >>> connection = client.connect([invalid_host, crate_host]) >>> connection.close() @@ -48,10 +42,23 @@ traceback if a server error occurs: >>> connection = client.connect([crate_host], error_trace=True) >>> connection.close() +Network Timeouts +---------------- + It's possible to define a default timeout value in seconds for all servers -using the optional parameter ``timeout``: +using the optional parameter ``timeout``. In this case, it will serve as a +total timeout (connect and read): - >>> connection = client.connect([crate_host, invalid_host], timeout=5) + >>> connection = client.connect([crate_host, invalid_host], timeout=1) + >>> connection.close() + +If you want to adjust the connect- vs. read-timeout values individually, +please use the ``urllib3.Timeout`` object like: + + >>> import urllib3 + >>> connection = client.connect( + ... [crate_host, invalid_host], + ... timeout=urllib3.Timeout(connect=1, read=None)) >>> connection.close() Authentication @@ -139,6 +146,25 @@ Refresh locations: >>> cursor.execute("REFRESH TABLE locations") +Updating Data +============= + +Values for ``TIMESTAMP`` columns can be obtained as a string literal, ``date``, +or ``datetime`` object. If it contains timezone information, it is converted to +UTC, and the timezone information is discarded. + + >>> import datetime as dt + >>> timestamp_full = "2023-06-26T09:24:00.123+02:00" + >>> timestamp_date = "2023-06-26" + >>> datetime_aware = dt.datetime.fromisoformat("2023-06-26T09:24:00.123+02:00") + >>> datetime_naive = dt.datetime.fromisoformat("2023-06-26T09:24:00.123") + >>> datetime_date = dt.date.fromisoformat("2023-06-26") + >>> cursor.execute("UPDATE locations SET date=? WHERE name='Cloverleaf'", (timestamp_full, )) + >>> cursor.execute("UPDATE locations SET date=? WHERE name='Cloverleaf'", (timestamp_date, )) + >>> cursor.execute("UPDATE locations SET date=? WHERE name='Cloverleaf'", (datetime_aware, )) + >>> cursor.execute("UPDATE locations SET date=? WHERE name='Cloverleaf'", (datetime_naive, )) + >>> cursor.execute("UPDATE locations SET date=? WHERE name='Cloverleaf'", (datetime_date, )) + Selecting Data ============== diff --git a/docs/by-example/connection.rst b/docs/by-example/connection.rst index 4b89db7db..c678d079f 100644 --- a/docs/by-example/connection.rst +++ b/docs/by-example/connection.rst @@ -9,19 +9,13 @@ The examples use an instance of ``ClientMocked`` instead of a real ``Client`` instance. This allows us to verify the examples without needing a real database connection. -.. rubric:: Table of Contents - -.. contents:: - :local: - - connect() ========= This section sets up a connection object, and inspects some of its attributes. >>> from crate.client import connect - >>> from crate.client.test_util import ClientMocked + >>> from crate.testing.util import ClientMocked >>> connection = connect(client=ClientMocked()) >>> connection.lowest_server_version.version diff --git a/docs/by-example/cursor.rst b/docs/by-example/cursor.rst index 7fc7da7df..90521db63 100644 --- a/docs/by-example/cursor.rst +++ b/docs/by-example/cursor.rst @@ -8,12 +8,6 @@ behaviors of the ``crate.client.cursor.Cursor`` object. The example code uses ``ClientMocked`` and ``set_next_response`` for demonstration purposes, so they don't need a real database connection. -.. rubric:: Table of Contents - -.. contents:: - :local: - - Introduction ============ @@ -23,7 +17,7 @@ up the response for subsequent cursor operations. >>> from crate.client import connect >>> from crate.client.converter import DefaultTypeConverter >>> from crate.client.cursor import Cursor - >>> from crate.client.test_util import ClientMocked + >>> from crate.testing.util import ClientMocked >>> connection = connect(client=ClientMocked()) >>> cursor = connection.cursor() @@ -272,6 +266,37 @@ For completeness' sake the cursor description is updated nonetheless: >>> [ desc[0] for desc in cursor.description ] ['name', 'position'] +executemany() with named parameters +==================================== + +``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` when +the SQL statement contains ``%(name)s`` placeholders. The client converts both the SQL +template and all rows to positional format before sending them to CrateDB: + +.. Hidden: set up mocked response + + >>> connection.client.set_next_response({ + ... "results": [ + ... {"rowcount": 1}, + ... {"rowcount": 1} + ... ], + ... "duration": 123, + ... "cols": [], + ... }) + + >>> cursor = connection.cursor() + + >>> cursor.executemany( + ... "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)", + ... [{"id": 1, "val": "foo"}, {"id": 2, "val": "bar"}]) + [{'rowcount': 1}, {'rowcount': 1}] + + >>> cursor.rowcount + 2 + + >>> cursor.duration + 123 + >>> connection.client.set_next_response({ ... "rows":[ [ "North West Ripple", 1 ], [ "Arkintoofle Minor", 3 ], [ "Alpha Centauri", 3 ] ], ... "cols":[ "name", "position" ], @@ -317,8 +342,8 @@ Python data type conversion =========================== The cursor object can optionally convert database types to native Python data -types. Currently, this is implemented for the CrateDB data types ``IP`` and -``TIMESTAMP`` on behalf of the ``DefaultTypeConverter``. +types. Currently, this is implemented for the CrateDB data types ``IP``, +``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``. >>> cursor = connection.cursor(converter=DefaultTypeConverter()) @@ -333,7 +358,25 @@ types. Currently, this is implemented for the CrateDB data types ``IP`` and >>> cursor.execute('') >>> cursor.fetchone() - ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000)] + ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc)] + +CrateDB's ``TIMETZ`` type is returned over HTTP as ``[microseconds_since_midnight, tz_offset_seconds]`` +and decoded to a ``datetime.time`` object with the appropriate timezone: + + >>> cursor = connection.cursor(converter=DefaultTypeConverter()) + + >>> connection.client.set_next_response({ + ... "col_types": [20], + ... "rows":[ [ [45045000000, 0] ] ], + ... "cols":[ "t" ], + ... "rowcount":1, + ... "duration":1 + ... }) + + >>> cursor.execute('') + + >>> cursor.fetchone() + [datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)] Custom data type conversion @@ -374,8 +417,7 @@ Proof that the converter works correctly, ``B\'0110\'`` should be converted to ======================================= Based on the data type converter functionality, the driver offers a convenient -interface to make it return timezone-aware ``datetime`` objects, using the -desired time zone. +interface to make it return ``datetime`` objects using the desired time zone. For your reference, in the following examples, epoch 1658167836758 is ``Mon, 18 Jul 2022 18:10:36 GMT``. diff --git a/docs/by-example/http.rst b/docs/by-example/http.rst index 494e7b65e..6a067eaa8 100644 --- a/docs/by-example/http.rst +++ b/docs/by-example/http.rst @@ -2,12 +2,6 @@ HTTP client =========== -.. rubric:: Table of Contents - -.. contents:: - :local: - - Introduction ============ @@ -40,47 +34,47 @@ If no ``server`` argument (or no argument at all) is passed, the default one When using a list of servers, the servers are selected by round-robin: - >>> invalid_host = "invalid_host:9999" - >>> even_more_invalid_host = "even_more_invalid_host:9999" - >>> http_client = HttpClient([crate_host, invalid_host, even_more_invalid_host]) + >>> invalid_host1 = "192.0.2.1:9999" + >>> invalid_host2 = "192.0.2.2:9999" + >>> http_client = HttpClient([crate_host, invalid_host1, invalid_host2], timeout=0.3) >>> http_client._get_server() 'http://127.0.0.1:44209' >>> http_client._get_server() - 'http://invalid_host:9999' + 'http://192.0.2.1:9999' >>> http_client._get_server() - 'http://even_more_invalid_host:9999' + 'http://192.0.2.2:9999' >>> http_client.close() Servers with connection errors will be removed from the active server list: - >>> http_client = HttpClient([invalid_host, even_more_invalid_host, crate_host]) + >>> http_client = HttpClient([invalid_host1, invalid_host2, crate_host], timeout=0.3) >>> result = http_client.sql('select name from locations') >>> http_client._active_servers ['http://127.0.0.1:44209'] Inactive servers will be re-added after a given time interval. -To validate this, set the interval very short and sleep for that interval: +To validate this, set the interval and timeout very short, and +sleep after the first request:: >>> http_client.retry_interval = 1 - >>> import time; time.sleep(1) >>> result = http_client.sql('select name from locations') + >>> import time; time.sleep(1) + >>> server = http_client._get_server() >>> http_client._active_servers - ['http://invalid_host:9999', - 'http://even_more_invalid_host:9999', - 'http://127.0.0.1:44209'] + ['http://127.0.0.1:44209', 'http://192.0.2.2:9999', 'http://192.0.2.1:9999'] >>> http_client.close() If no active servers are available and the retry interval is not reached, just use the oldest inactive one: - >>> http_client = HttpClient([invalid_host, even_more_invalid_host, crate_host]) + >>> http_client = HttpClient([invalid_host1, invalid_host2, crate_host], timeout=0.3) >>> result = http_client.sql('select name from locations') >>> http_client._active_servers = [] >>> http_client._get_server() - 'http://invalid_host:9999' + 'http://192.0.2.1:9999' >>> http_client.close() SQL Statements @@ -122,7 +116,7 @@ Trying to get a non-existing blob throws an exception: >>> http_client.blob_get('myfiles', '041f06fd774092478d450774f5ba30c5da78acc8') Traceback (most recent call last): ... - crate.client.exceptions.DigestNotFoundException: myfiles/041f06fd774092478d450774f5ba30c5da78acc8 + crate.client.exceptions.DigestNotFoundException: DigestNotFoundException('myfiles/041f06fd774092478d450774f5ba30c5da78acc8') Creating a new blob - this method returns ``True`` if the blob was newly created: @@ -177,7 +171,7 @@ Uploading a blob to a table with disabled blob support throws an exception: ... 'locations', '040f06fd774092478d450774f5ba30c5da78acc8', f) Traceback (most recent call last): ... - crate.client.exceptions.BlobLocationNotFoundException: locations/040f06fd774092478d450774f5ba30c5da78acc8 + crate.client.exceptions.BlobLocationNotFoundException: BlobLocationNotFoundException('locations/040f06fd774092478d450774f5ba30c5da78acc8') >>> http_client.close() >>> f.close() @@ -199,8 +193,8 @@ timeout exception: {...} >>> http_client.close() -It's possible to define a HTTP timeout in seconds on client instantiation, so -an exception is raised when the timeout is reached: +It is possible to define a HTTP timeout in seconds when creating a client +object, so an exception is raised when the timeout expires: >>> http_client = HttpClient(crate_host, timeout=0.01) >>> http_client.sql('select fib(32)') @@ -209,13 +203,24 @@ an exception is raised when the timeout is reached: crate.client.exceptions.ConnectionError: No more Servers available, exception from last server: ... >>> http_client.close() +In order to adjust the connect- vs. read-timeout values individually, +please use the ``urllib3.Timeout`` object like: + + >>> import urllib3 + >>> http_client = HttpClient(crate_host, timeout=urllib3.Timeout(connect=1.11, read=0.01)) + >>> http_client.sql('select fib(32)') + Traceback (most recent call last): + ... + crate.client.exceptions.ConnectionError: No more Servers available, exception from last server: ... + >>> http_client.close() + When connecting to non-CrateDB servers, the HttpClient will raise a ConnectionError like this: >>> http_client = HttpClient(["https://example.org/"]) >>> http_client.server_infos(http_client._get_server()) Traceback (most recent call last): ... - crate.client.exceptions.ProgrammingError: Invalid server response of content-type 'text/html; charset=UTF-8': + crate.client.exceptions.ProgrammingError: Invalid server response of content-type 'text/html': ... >>> http_client.close() diff --git a/docs/by-example/https.rst b/docs/by-example/https.rst index cc6da50b2..116dabb89 100644 --- a/docs/by-example/https.rst +++ b/docs/by-example/https.rst @@ -7,12 +7,6 @@ HTTPS connection support This documentation section outlines different options to connect to CrateDB using SSL/TLS. -.. rubric:: Table of Contents - -.. contents:: - :local: - - Introduction ============ @@ -42,22 +36,22 @@ With certificate verification When using a valid CA certificate, the connection will be successful: - >>> client = HttpClient([crate_host], ca_cert=cacert_valid) + >>> client = HttpClient([https_host], ca_cert=cacert_valid) >>> client.server_infos(client._get_server()) ('https://localhost:65534', 'test', '0.0.0') When not providing a ``ca_cert`` file, the connection will fail: - >>> client = HttpClient([crate_host]) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host]) + >>> client.server_infos(https_host) Traceback (most recent call last): ... crate.client.exceptions.ConnectionError: Server not available, ...certificate verify failed... Also, when providing an invalid ``ca_cert``, an error is raised: - >>> client = HttpClient([crate_host], ca_cert=cacert_invalid) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], ca_cert=cacert_invalid) + >>> client.server_infos(https_host) Traceback (most recent call last): ... crate.client.exceptions.ConnectionError: Server not available, ...certificate verify failed... @@ -69,15 +63,15 @@ Without certificate verification When turning off certificate verification, calling the server will succeed, even when not providing a valid CA certificate: - >>> client = HttpClient([crate_host], verify_ssl_cert=False) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], verify_ssl_cert=False) + >>> client.server_infos(https_host) ('https://localhost:65534', 'test', '0.0.0') Without verification, calling the server will even work when using an invalid ``ca_cert``: - >>> client = HttpClient([crate_host], verify_ssl_cert=False, ca_cert=cacert_invalid) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], verify_ssl_cert=False, ca_cert=cacert_invalid) + >>> client.server_infos(https_host) ('https://localhost:65534', 'test', '0.0.0') @@ -91,22 +85,37 @@ The ``HttpClient`` constructor takes two keyword arguments: ``cert_file`` and ``key_file``. Both should be strings pointing to the path of the client certificate and key file: - >>> client = HttpClient([crate_host], ca_cert=cacert_valid, cert_file=clientcert_valid, key_file=clientcert_valid) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], ca_cert=cacert_valid, cert_file=clientcert_valid, key_file=clientcert_valid) + >>> client.server_infos(https_host) ('https://localhost:65534', 'test', '0.0.0') When using an invalid client certificate, the connection will fail: - >>> client = HttpClient([crate_host], ca_cert=cacert_valid, cert_file=clientcert_invalid, key_file=clientcert_invalid) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], ca_cert=cacert_valid, cert_file=clientcert_invalid, key_file=clientcert_invalid) + >>> client.server_infos(https_host) Traceback (most recent call last): ... crate.client.exceptions.ConnectionError: Server not available, exception: HTTPSConnectionPool... The connection will also fail when providing an invalid CA certificate: - >>> client = HttpClient([crate_host], ca_cert=cacert_invalid, cert_file=clientcert_valid, key_file=clientcert_valid) - >>> client.server_infos(crate_host) + >>> client = HttpClient([https_host], ca_cert=cacert_invalid, cert_file=clientcert_valid, key_file=clientcert_valid) + >>> client.server_infos(https_host) Traceback (most recent call last): ... crate.client.exceptions.ConnectionError: Server not available, exception: HTTPSConnectionPool... + + +Relaxing minimum SSL version +============================ + +urrlib3 v2 dropped support for TLS 1.0 and TLS 1.1 by default, see `Modern security by default - +HTTPS requires TLS 1.2+`_. If you need to re-enable it, use the ``ssl_relax_minimum_version`` flag, +which will configure ``kwargs["ssl_minimum_version"] = ssl.TLSVersion.MINIMUM_SUPPORTED``. + + >>> client = HttpClient([https_host], ssl_relax_minimum_version=True, verify_ssl_cert=False) + >>> client.server_infos(https_host) + ('https://localhost:65534', 'test', '0.0.0') + + +.. _Modern security by default - HTTPS requires TLS 1.2+: https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html#https-requires-tls-1-2 diff --git a/docs/by-example/index.rst b/docs/by-example/index.rst index dcb9be4cd..5cf1f06f6 100644 --- a/docs/by-example/index.rst +++ b/docs/by-example/index.rst @@ -1,20 +1,10 @@ +.. _by-example: + ########## By example ########## - -***** -About -***** - -This part of the documentation contains examples how to use the CrateDB Python -client. - - -DBAPI, HTTP, and BLOB interfaces -================================ - -The examples in this section are all about CrateDB's `Python DBAPI`_ interface, +The examples in this section are all about CrateDB's `Python DB API`_ interface, the plain HTTP API interface, and a convenience interface for working with :ref:`blob tables `. It details attributes, methods, and behaviors of the ``Connection`` and ``Cursor`` objects. @@ -30,23 +20,4 @@ methods, and behaviors of the ``Connection`` and ``Cursor`` objects. blob -.. _sqlalchemy-by-example: - -SQLAlchemy by example -===================== - -The examples in this section are all about CrateDB's `SQLAlchemy`_ dialect, and -its corresponding API interfaces, see also :ref:`sqlalchemy-support`. - -.. toctree:: - :maxdepth: 1 - - sqlalchemy/getting-started - sqlalchemy/crud - sqlalchemy/working-with-types - sqlalchemy/advanced-querying - sqlalchemy/inspection-reflection - - -.. _Python DBAPI: https://peps.python.org/pep-0249/ -.. _SQLAlchemy: https://www.sqlalchemy.org/ +.. _Python DB API: https://peps.python.org/pep-0249/ diff --git a/docs/by-example/sqlalchemy/advanced-querying.rst b/docs/by-example/sqlalchemy/advanced-querying.rst deleted file mode 100644 index 863373e4f..000000000 --- a/docs/by-example/sqlalchemy/advanced-querying.rst +++ /dev/null @@ -1,267 +0,0 @@ -.. _sqlalchemy-advanced-querying: - -============================= -SQLAlchemy: Advanced querying -============================= - -This section of the documentation demonstrates running queries using a fulltext -index with analyzer, queries using counting and aggregations, and support for -the ``INSERT...FROM SELECT`` construct, all using the CrateDB SQLAlchemy dialect. - - -.. rubric:: Table of Contents - -.. contents:: - :local: - - -Introduction -============ - -Import the relevant symbols: - - >>> import sqlalchemy as sa - >>> from sqlalchemy.orm import sessionmaker - >>> try: - ... from sqlalchemy.orm import declarative_base - ... except ImportError: - ... from sqlalchemy.ext.declarative import declarative_base - >>> from uuid import uuid4 - -Establish a connection to the database, see also :ref:`sa:engines_toplevel` -and :ref:`connect`: - - >>> engine = sa.create_engine(f"crate://{crate_host}") - >>> connection = engine.connect() - -Create an SQLAlchemy :doc:`Session `: - - >>> session = sessionmaker(bind=engine)() - >>> Base = declarative_base() - - -Introduction to fulltext indexes -================================ - -:ref:`crate-reference:fulltext-indices` take the contents of one or more fields -and split it up into tokens that are used for fulltext-search. The -transformation from a text to separate tokens is done by an analyzer. In order -to conduct fulltext search queries, we need to create a table with a -:ref:`fulltext index with an analyzer `. - -.. code-block:: sql - - CREATE TABLE characters ( - id STRING PRIMARY KEY, - name STRING, - quote STRING, - INDEX name_ft USING fulltext(name) WITH (analyzer = 'english'), - INDEX quote_ft USING fulltext(quote) WITH (analyzer = 'english') - ) - -We have to create this table using SQL because it is currently not possible to -create ``INDEX`` fields using SQLAlchemy's :ref:`sa:orm_declarative_mapping`. -However, we can define the table to use all other operations: - - >>> def gen_key(): - ... return str(uuid4()) - - >>> class Character(Base): - ... __tablename__ = 'characters' - ... id = sa.Column(sa.String, primary_key=True, default=gen_key) - ... name = sa.Column(sa.String) - ... quote = sa.Column(sa.String) - ... name_ft = sa.Column(sa.String) - ... quote_ft = sa.Column(sa.String) - ... __mapper_args__ = { - ... 'exclude_properties': ['name_ft', 'quote_ft'] - ... } - -We define ``name_ft`` and ``quote_ft`` as regular columns, but add them under -``__mapper_args__.exclude_properties`` to ensure they're excluded from insert -or update operations. - -In order to support fulltext query operations, the CrateDB SQLAlchemy dialect -provides the :ref:`crate-reference:predicates_match` through its ``match`` -function. - -Let's add two records we use for testing. - - >>> arthur = Character(name='Arthur Dent') - >>> arthur.quote = "Let's go somewhere." - >>> session.add(arthur) - - >>> trillian = Character(name='Tricia McMillan') - >>> trillian.quote = "We're on a space ship Arthur. In space." - >>> session.add(trillian) - - >>> session.commit() - -After ``INSERT`` statements are submitted to the database, the newly inserted -records aren't immediately available for retrieval, because the index is only -updated periodically (default: each second). In order to synchronize that, -explicitly refresh the table: - - >>> _ = connection.execute(sa.text("REFRESH TABLE characters")) - - -Fulltext search with MATCH predicate -==================================== - -Fulltext search in CrateDB is performed using :ref:`crate-reference:predicates_match`. -The CrateDB SQLAlchemy dialect comes with a ``match`` function, which can be used to -search on one or multiple fields. - - >>> from crate.client.sqlalchemy.predicates import match - - >>> session.query(Character.name) \ - ... .filter(match(Character.name_ft, 'Arthur')) \ - ... .all() - [('Arthur Dent',)] - -To get the relevance of a matching row, you can select the ``_score`` system -column. It is a numeric value which is relative to the other rows. -The higher the score value, the more relevant the row. - -In most cases, ``_score`` is not part of the SQLAlchemy table definition, -so it must be passed as a verbatim string, using ``literal_column``: - - >>> session.query(Character.name, sa.literal_column('_score')) \ - ... .filter(match(Character.quote_ft, 'space')) \ - ... .all() - [('Tricia McMillan', ...)] - -To search multiple columns, use a dictionary where the keys are the columns and -the values are a ``boost``. A ``boost`` is a factor that increases the relevance -of a column in respect to the other columns: - - >>> session.query(Character.name) \ - ... .filter(match({Character.name_ft: 1.5, Character.quote_ft: 0.1}, - ... 'Arthur')) \ - ... .order_by(sa.desc(sa.literal_column('_score'))) \ - ... .all() - [('Arthur Dent',), ('Tricia McMillan',)] - -The ``match_type`` argument determines how a single ``query_term`` is applied, -and how the resulting ``_score`` is computed. Thus, it influences which -documents are considered more relevant. The default selection is ``best_fields``. -For more information, see :ref:`crate-reference:predicates_match_types`. - -If you want to sort the results by ``_score``, you can use the ``order_by()`` -function. - - >>> session.query(Character.name) \ - ... .filter( - ... match(Character.name_ft, 'Arth', - ... match_type='phrase', - ... options={'fuzziness': 3}) - ... ) \ - ... .all() - [('Arthur Dent',)] - -It is not possible to specify options without the ``match_type`` argument: - - >>> session.query(Character.name) \ - ... .filter( - ... match(Character.name_ft, 'Arth', - ... options={'fuzziness': 3}) - ... ) \ - ... .all() - Traceback (most recent call last): - ValueError: missing match_type. It's not allowed to specify options without match_type - - -Aggregates: Counting and grouping -================================= - -SQLAlchemy supports different approaches to issue a query with a count -aggregate function. Take a look at the `count result rows`_ documentation -for a full overview. - -CrateDB currently does not support all variants as it can not handle the -sub-queries yet. - -This means that queries using ``count()`` have to be written in one of the -following ways: - - >>> session.query(sa.func.count(Character.id)).scalar() - 2 - - >>> session.query(sa.func.count('*')).select_from(Character).scalar() - 2 - -Using the ``group_by`` clause is similar: - - >>> session.query(sa.func.count(Character.id), Character.name) \ - ... .group_by(Character.name) \ - ... .order_by(sa.desc(sa.func.count(Character.id))) \ - ... .order_by(Character.name).all() - [(1, 'Arthur Dent'), (1, 'Tricia McMillan')] - - -``INSERT...FROM SELECT`` -======================== - -In SQLAlchemy, the ``insert().from_select()`` function returns a new ``Insert`` -construct, which represents an ``INSERT...FROM SELECT`` statement. This -functionality is supported by the CrateDB client library. Here is an example -that uses ``insert().from_select()``. - -First, let's define and create the tables: - - >>> from sqlalchemy import select, insert - - >>> class Todos(Base): - ... __tablename__ = 'todos' - ... __table_args__ = { - ... 'crate_number_of_replicas': '0' - ... } - ... id = sa.Column(sa.String, primary_key=True, default=gen_key) - ... content = sa.Column(sa.String) - ... status = sa.Column(sa.String) - - >>> class ArchivedTasks(Base): - ... __tablename__ = 'archived_tasks' - ... __table_args__ = { - ... 'crate_number_of_replicas': '0' - ... } - ... id = sa.Column(sa.String, primary_key=True) - ... content = sa.Column(sa.String) - - >>> Base.metadata.create_all(bind=engine) - -Let's add a task to the ``Todo`` table: - - >>> task = Todos(content='Write Tests', status='done') - >>> session.add(task) - >>> session.commit() - >>> _ = connection.execute(sa.text("REFRESH TABLE todos")) - -Now, let's use ``insert().from_select()`` to archive the task into the -``ArchivedTasks`` table: - - >>> sel = select(Todos.id, Todos.content).where(Todos.status == "done") - >>> ins = insert(ArchivedTasks).from_select(['id', 'content'], sel) - >>> result = session.execute(ins) - >>> session.commit() - -This will emit the following ``INSERT`` statement to the database: - - INSERT INTO archived_tasks (id, content) - (SELECT todos.id, todos.content FROM todos WHERE todos.status = 'done') - -Now, verify that the data is present in the database: - - >>> _ = connection.execute(sa.text("REFRESH TABLE archived_tasks")) - >>> pprint([str(r) for r in session.execute(sa.text("SELECT content FROM archived_tasks"))]) - ["('Write Tests',)"] - - -.. hidden: Disconnect from database - - >>> session.close() - >>> connection.close() - >>> engine.dispose() - - -.. _count result rows: https://docs.sqlalchemy.org/en/14/orm/tutorial.html#counting diff --git a/docs/by-example/sqlalchemy/crud.rst b/docs/by-example/sqlalchemy/crud.rst deleted file mode 100644 index 5a62df401..000000000 --- a/docs/by-example/sqlalchemy/crud.rst +++ /dev/null @@ -1,301 +0,0 @@ -.. _sqlalchemy-crud: - -================================================ -SQLAlchemy: Create, retrieve, update, and delete -================================================ - -This section of the documentation shows how to query, insert, update and delete -records using CrateDB's SQLAlchemy integration, it includes common scenarios -like: - -- Filtering records -- Limiting result sets -- Inserts and updates with default values - - -.. rubric:: Table of Contents - -.. contents:: - :local: - - -Introduction -============ - -Import the relevant symbols: - - >>> import sqlalchemy as sa - >>> from datetime import datetime - >>> from sqlalchemy import delete, func, text - >>> from sqlalchemy.orm import sessionmaker - >>> try: - ... from sqlalchemy.orm import declarative_base - ... except ImportError: - ... from sqlalchemy.ext.declarative import declarative_base - >>> from crate.client.sqlalchemy.types import ObjectArray - -Establish a connection to the database, see also :ref:`sa:engines_toplevel` -and :ref:`connect`: - - >>> engine = sa.create_engine(f"crate://{crate_host}") - >>> connection = engine.connect() - -Define the ORM schema for the ``Location`` entity using SQLAlchemy's -:ref:`sa:orm_declarative_mapping`: - - >>> Base = declarative_base() - - >>> class Location(Base): - ... __tablename__ = 'locations' - ... name = sa.Column(sa.String, primary_key=True) - ... kind = sa.Column(sa.String) - ... date = sa.Column(sa.Date, default=lambda: datetime.utcnow().date()) - ... datetime_tz = sa.Column(sa.DateTime, default=datetime.utcnow) - ... datetime_notz = sa.Column(sa.DateTime, default=datetime.utcnow) - ... nullable_datetime = sa.Column(sa.DateTime) - ... nullable_date = sa.Column(sa.Date) - ... flag = sa.Column(sa.Boolean) - ... details = sa.Column(ObjectArray) - -Create an SQLAlchemy :doc:`Session `: - - >>> session = sessionmaker(bind=engine)() - - -Create -====== - -Insert a new location: - - >>> location = Location() - >>> location.name = 'Earth' - >>> location.kind = 'Planet' - >>> location.flag = True - - >>> session.add(location) - >>> session.flush() - -Refresh "locations" table: - - >>> _ = connection.execute(text("REFRESH TABLE locations")) - -Inserted location is available: - - >>> location = session.query(Location).filter_by(name='Earth').one() - >>> location.name - 'Earth' - -Retrieve the location from the database: - - >>> session.refresh(location) - >>> location.name - 'Earth' - -Three ``date``/``datetime`` columns are defined with default values, so -creating a new record will automatically set them: - - >>> type(location.date) - - - >>> type(location.datetime_tz) - - - >>> type(location.datetime_notz) - - -The location instance also has other ``date`` and ``datetime`` attributes which -are nullable. Because there is no default value defined in the ORM schema for -them, they are not set when the record is inserted: - - >>> location.nullable_datetime is None - True - - >>> location.nullable_date is None - True - -.. hidden: - - >>> from datetime import datetime, timedelta - >>> now = datetime.utcnow() - - >>> (now - location.datetime_tz).seconds < 4 - True - - >>> (now.date() - location.date) == timedelta(0) - True - - -Retrieve -======== - -Using the connection to execute a select statement: - - >>> result = connection.execute(text('select name from locations order by name')) - >>> result.rowcount - 14 - - >>> result.first() - ('Aldebaran',) - -Using the ORM to query the locations: - - >>> locations = session.query(Location).order_by('name') - >>> [l.name for l in locations if l is not None][:2] - ['Aldebaran', 'Algol'] - -With limit and offset: - - >>> locations = session.query(Location).order_by('name').offset(1).limit(2) - >>> [l.name for l in locations if l is not None] - ['Algol', 'Allosimanius Syneca'] - -With filter: - - >>> location = session.query(Location).filter_by(name='Algol').one() - >>> location.name - 'Algol' - -Order by: - - >>> locations = session.query(Location).filter(Location.name is not None).order_by(sa.desc(Location.name)) - >>> locations = locations.limit(2) - >>> [l.name for l in locations] - ['Outer Eastern Rim', 'North West Ripple'] - - -Update -====== - -Back to our original object ``Location(Earth)``. - - >>> location = session.query(Location).filter_by(name='Earth').one() - -The datetime and date can be set using an update statement: - - >>> location.nullable_date = datetime.utcnow().date() - >>> location.nullable_datetime = datetime.utcnow() - >>> session.flush() - -Refresh "locations" table: - - >>> _ = connection.execute(text("REFRESH TABLE locations")) - -Boolean values get set natively: - - >>> location.flag - True - -Reload the object from the database: - - >>> session.refresh(location) - -And verify that the date and datetime was persisted: - - >>> location.nullable_datetime is not None - True - - >>> location.nullable_date is not None - True - -Update a record using SQL: - - >>> with engine.begin() as conn: - ... result = conn.execute(text("update locations set kind='Heimat' where name='Earth'")) - ... result.rowcount - 1 - -Update multiple records: - - >>> for x in range(10): - ... loc = Location() - ... loc.name = 'Ort %d' % x - ... loc.kind = 'Update' - ... session.add(loc) - >>> session.flush() - -Refresh table: - - >>> _ = connection.execute(text("REFRESH TABLE locations")) - -Update multiple records using SQL: - - >>> with engine.begin() as conn: - ... result = conn.execute(text("update locations set flag=true where kind='Update'")) - ... result.rowcount - 10 - -Update all records using SQL, and check that the number of documents affected -of an update without ``where-clause`` matches the number of all documents in -the table: - - >>> with engine.begin() as conn: - ... result = conn.execute(text(u"update locations set kind='Überall'")) - ... result.rowcount == conn.execute(text("select * from locations limit 100")).rowcount - True - - >>> session.commit() - -Refresh "locations" table: - - >>> _ = connection.execute(text("REFRESH TABLE locations")) - -Objects can be used within lists, too: - - >>> location = session.query(Location).filter_by(name='Folfanga').one() - >>> location.details = [{'size': 'huge'}, {'clima': 'cold'}] - - >>> session.commit() - >>> session.refresh(location) - - >>> location.details - [{'size': 'huge'}, {'clima': 'cold'}] - -Update the record: - - >>> location.details[1] = {'clima': 'hot'} - - >>> session.commit() - >>> session.refresh(location) - - >>> location.details - [{'size': 'huge'}, {'clima': 'hot'}] - -Reset the record: - - >>> location.details = [] - >>> session.commit() - >>> session.refresh(location) - - >>> location.details - [] - -.. seealso:: - - The documentation section :ref:`sqlalchemy-working-with-types` has more - details about this topic. - - -Delete -====== - -Deleting a record with SQLAlchemy works like this. - - >>> session.query(Location).count() - 24 - - >>> location = session.query(Location).first() - >>> session.delete(location) - >>> session.commit() - >>> session.flush() - - >>> _ = connection.execute(text("REFRESH TABLE locations")) - - >>> session.query(Location).count() - 23 - - -.. hidden: Disconnect from database - - >>> session.close() - >>> connection.close() - >>> engine.dispose() diff --git a/docs/by-example/sqlalchemy/getting-started.rst b/docs/by-example/sqlalchemy/getting-started.rst deleted file mode 100644 index c64964dc7..000000000 --- a/docs/by-example/sqlalchemy/getting-started.rst +++ /dev/null @@ -1,176 +0,0 @@ -.. _sqlalchemy-getting-started: - -=========================== -SQLAlchemy: Getting started -=========================== - -This section of the documentation shows how to connect to CrateDB using its -SQLAlchemy dialect, and how to run basic DDL statements based on an SQLAlchemy -ORM schema definition. - -Subsequent sections of the documentation will cover: - -- :ref:`sqlalchemy-crud` -- :ref:`sqlalchemy-working-with-types` -- :ref:`sqlalchemy-advanced-querying` -- :ref:`sqlalchemy-inspection-reflection` - - -.. rubric:: Table of Contents - -.. contents:: - :local: - - -Introduction -============ - -Import the relevant symbols: - - >>> import sqlalchemy as sa - >>> from sqlalchemy.orm import sessionmaker - >>> try: - ... from sqlalchemy.orm import declarative_base - ... except ImportError: - ... from sqlalchemy.ext.declarative import declarative_base - -Establish a connection to the database, see also :ref:`sa:engines_toplevel` -and :ref:`connect`: - - >>> engine = sa.create_engine(f"crate://{crate_host}") - >>> connection = engine.connect() - -Create an SQLAlchemy :doc:`Session `: - - >>> session = sessionmaker(bind=engine)() - >>> Base = declarative_base() - - -Connection string -================= - -In SQLAlchemy, a connection is established using the ``create_engine`` function. -This function takes a connection string, actually an `URL`_, that varies from -database to database. - -In order to connect to a CrateDB cluster, the following connection strings are -valid: - - >>> sa.create_engine('crate://') - Engine(crate://) - -This will connect to the default server ('127.0.0.1:4200'). In order to connect -to a different server the following syntax can be used: - - >>> sa.create_engine('crate://otherserver:4200') - Engine(crate://otherserver:4200) - -Since CrateDB is a clustered database running on multiple servers, it is -recommended to connect to all of them. This enables the DB-API layer to -use round-robin to distribute the load and skip a server if it becomes -unavailable. In order to make the driver aware of multiple servers, use -the ``connect_args`` parameter like so: - - >>> sa.create_engine('crate://', connect_args={ - ... 'servers': ['host1:4200', 'host2:4200'] - ... }) - Engine(crate://) - -As defined in :ref:`https_connection`, the client validates SSL server -certificates by default. To configure this further, use e.g. the ``ca_cert`` -attribute within the ``connect_args``, like: - - >>> ssl_engine = sa.create_engine( - ... 'crate://', - ... connect_args={ - ... 'servers': ['https://host1:4200'], - ... 'ca_cert': '/path/to/cacert.pem', - ... }) - -In order to disable SSL verification, use ``verify_ssl_cert = False``, like: - - >>> ssl_engine = sa.create_engine( - ... 'crate://', - ... connect_args={ - ... 'servers': ['https://host1:4200'], - ... 'verify_ssl_cert': False, - ... }) - - -Basic DDL operations -==================== - -.. note:: - - CrateDB currently does not know about different "databases". Instead, - tables can be created in different *schemas*. Schemas are created - implicitly on table creation and cannot be created explicitly. If a schema - does not exist yet, it will be created. - - The default CrateDB schema is ``doc``, and if you do not specify a schema, - this is what will be used. - - See also :ref:`schema-selection` and :ref:`crate-reference:ddl-create-table-schemas`. - - -Create tables -------------- - -First the table definition as class, using SQLAlchemy's :ref:`sa:orm_declarative_mapping`: - - >>> class Department(Base): - ... __tablename__ = 'departments' - ... __table_args__ = { - ... 'crate_number_of_replicas': '0' - ... } - ... id = sa.Column(sa.String, primary_key=True) - ... name = sa.Column(sa.String) - ... code = sa.Column(sa.Integer) - -As seen below, the table doesn't exist yet: - - >>> engine.dialect.has_table(connection, table_name='departments') - False - -In order to create all missing tables, the ``create_all`` method can be used: - - >>> Base.metadata.create_all(bind=engine) - -With that, the table has been created: - - >>> engine.dialect.has_table(connection, table_name='departments') - True - -Let's also verify that by inquiring the ``information_schema.columns`` table: - - >>> stmt = ("select table_name, column_name, ordinal_position, data_type " - ... "from information_schema.columns " - ... "where table_name = 'departments' " - ... "order by column_name") - >>> pprint([str(r) for r in connection.execute(sa.text(stmt))]) - ["('departments', 'code', 3, 'integer')", - "('departments', 'id', 1, 'text')", - "('departments', 'name', 2, 'text')"] - - -Drop tables ------------ - -In order to delete all tables reference within the ORM schema, invoke -``Base.metadata.drop_all()``. To delete a single table, use -``drop(...)``, as shown below: - - >>> Base.metadata.tables['departments'].drop(engine) - - >>> engine.dialect.has_table(connection, table_name='departments') - False - - -.. hidden: Disconnect from database - - >>> session.close() - >>> connection.close() - >>> engine.dispose() - - -.. _URL: https://en.wikipedia.org/wiki/Uniform_Resource_Locator diff --git a/docs/by-example/sqlalchemy/inspection-reflection.rst b/docs/by-example/sqlalchemy/inspection-reflection.rst deleted file mode 100644 index bb291157d..000000000 --- a/docs/by-example/sqlalchemy/inspection-reflection.rst +++ /dev/null @@ -1,126 +0,0 @@ -.. _sqlalchemy-inspection-reflection: - -===================================================== -SQLAlchemy: Database schema inspection and reflection -===================================================== - -This section shows you how to inspect the schema of a database using CrateDB's -SQLAlchemy integration. - - -Introduction -============ - -The CrateDB SQLAlchemy integration provides different ways to inspect the -database. - -1) The :ref:`runtime inspection API ` allows you to get - an ``Inspector`` instance that can be used to fetch schema names, table names - and other information. - -2) Reflection capabilities allow you to create ``Table`` instances from - existing tables to inspect their columns and constraints. - -3) A ``CrateDialect`` allows you to get connection information and it contains - low level function to check the existence of schemas and tables. - -All approaches require an ``Engine`` instance, which you can create like this: - - >>> import sqlalchemy as sa - >>> engine = sa.create_engine(f"crate://{crate_host}") - -This effectively establishes a connection to the database, see also -:ref:`sa:engines_toplevel` and :ref:`connect`. - - -Inspector -========= - -The :ref:`SQLAlchemy inspector ` is a low -level interface which provides a backend-agnostic system of loading lists of -schema, table, column, and constraint descriptions from a given database. -You can create an inspector like this: - - >>> inspector = sa.inspect(engine) - -List all schemas: - - >>> inspector.get_schema_names() - ['blob', 'doc', 'information_schema', 'pg_catalog', 'sys'] - -List all tables: - - >>> set(['characters', 'cities', 'locations']).issubset(inspector.get_table_names()) - True - - >>> set(['checks', 'cluster', 'jobs', 'jobs_log']).issubset(inspector.get_table_names(schema='sys')) - True - -List all views: - - >>> inspector.get_view_names() - ['characters_view'] - -Get default schema name: - - >>> inspector.default_schema_name - 'doc' - - -Schema-supported reflection -=========================== - -A ``Table`` object can load its own schema information from the corresponding -table in the database. This process is called *reflection*, see -:ref:`sa:metadata_reflection`. - -In the most simple case you need only specify the table name, a ``MetaData`` -object, and the ``autoload_with`` argument. - -Create a SQLAlchemy table object: - - >>> meta = sa.MetaData() - >>> table = sa.Table( - ... "characters", meta, - ... autoload_with=engine) - -Reflect column data types from the table metadata: - - >>> table.columns.get('name') - Column('name', String(), table=) - - >>> table.primary_key - PrimaryKeyConstraint(Column('id', String(), table=, primary_key=True... - - -CrateDialect -============ - -After initializing the dialect instance with a connection instance, - - >>> from crate.client.sqlalchemy.dialect import CrateDialect - >>> dialect = CrateDialect() - - >>> connection = engine.connect() - >>> dialect.initialize(connection) - -the database server version and default schema name can be inquired. - - >>> dialect.server_version_info >= (1, 0, 0) - True - -Check if a schema exists: - - >>> dialect.has_schema(connection, 'doc') - True - -Check if a table exists: - - >>> dialect.has_table(connection, 'locations') - True - - -.. hidden: Disconnect from database - - >>> connection.close() - >>> engine.dispose() diff --git a/docs/by-example/sqlalchemy/working-with-types.rst b/docs/by-example/sqlalchemy/working-with-types.rst deleted file mode 100644 index bcddf8f85..000000000 --- a/docs/by-example/sqlalchemy/working-with-types.rst +++ /dev/null @@ -1,265 +0,0 @@ -.. _sqlalchemy-working-with-types: - -============================================== -SQLAlchemy: Working with special CrateDB types -============================================== - -This section of the documentation shows how to work with special data types -from the CrateDB SQLAlchemy dialect. Currently, these are: - -- Container types ``Object`` and ``ObjectArray``. -- Geospatial types ``Geopoint`` and ``Geoshape``. - - -.. rubric:: Table of Contents - -.. contents:: - :local: - - -Introduction -============ - -Import the relevant symbols: - - >>> import sqlalchemy as sa - >>> from datetime import datetime - >>> from geojson import Point, Polygon - >>> from sqlalchemy import delete, func, text - >>> from sqlalchemy.orm import sessionmaker - >>> from sqlalchemy.sql import operators - >>> try: - ... from sqlalchemy.orm import declarative_base - ... except ImportError: - ... from sqlalchemy.ext.declarative import declarative_base - >>> from uuid import uuid4 - >>> from crate.client.sqlalchemy.types import Object, ObjectArray - >>> from crate.client.sqlalchemy.types import Geopoint, Geoshape - -Establish a connection to the database, see also :ref:`sa:engines_toplevel` -and :ref:`connect`: - - >>> engine = sa.create_engine(f"crate://{crate_host}") - >>> connection = engine.connect() - -Create an SQLAlchemy :doc:`Session `: - - >>> session = sessionmaker(bind=engine)() - >>> Base = declarative_base() - - -Introduction to container types -=============================== - -In a document oriented database, it is a common pattern to store objects within -a single field. For such cases, the CrateDB SQLAlchemy dialect provides the -``Object`` and ``ObjectArray`` types. - -The ``Object`` type effectively implements a dictionary- or map-like type. The -``ObjectArray`` type maps to a Python list of dictionaries. - -For exercising those features, let's define a schema using SQLAlchemy's -:ref:`sa:orm_declarative_mapping`: - - >>> def gen_key(): - ... return str(uuid4()) - - >>> class Character(Base): - ... __tablename__ = 'characters' - ... id = sa.Column(sa.String, primary_key=True, default=gen_key) - ... name = sa.Column(sa.String) - ... quote = sa.Column(sa.String) - ... details = sa.Column(Object) - ... more_details = sa.Column(ObjectArray) - -In CrateDB's SQL dialect, those container types map to :ref:`crate-reference:type-object` -and :ref:`crate-reference:type-array`. - - -``Object`` -========== - -Let's add two records which have additional items within the ``details`` field. -Note that item keys have not been defined in the DDL schema, effectively -demonstrating the :ref:`DYNAMIC column policy `. - - >>> arthur = Character(name='Arthur Dent') - >>> arthur.details = {} - >>> arthur.details['gender'] = 'male' - >>> arthur.details['species'] = 'human' - >>> session.add(arthur) - - >>> trillian = Character(name='Tricia McMillan') - >>> trillian.details = {} - >>> trillian.quote = "We're on a space ship Arthur. In space." - >>> trillian.details['gender'] = 'female' - >>> trillian.details['species'] = 'human' - >>> trillian.details['female_only_attribute'] = 1 - >>> session.add(trillian) - - >>> session.commit() - -After ``INSERT`` statements are submitted to the database, the newly inserted -records aren't immediately available for retrieval because the index is only -updated periodically (default: each second). In order to synchronize that, -refresh the table: - - >>> _ = connection.execute(text("REFRESH TABLE characters")) - -A subsequent select query will see all the records: - - >>> query = session.query(Character).order_by(Character.name) - >>> [(c.name, c.details['gender']) for c in query] - [('Arthur Dent', 'male'), ('Tricia McMillan', 'female')] - -It is also possible to just select a part of the document, even inside the -``Object`` type: - - >>> sorted(session.query(Character.details['gender']).all()) - [('female',), ('male',)] - -In addition, filtering on the attributes inside the ``details`` column is also -possible: - - >>> query = session.query(Character.name) - >>> query.filter(Character.details['gender'] == 'male').all() - [('Arthur Dent',)] - -Update dictionary ------------------ - -The SQLAlchemy CrateDB dialect supports change tracking deep down the nested -levels of a ``Object`` type field. For example, the following query will only -update the ``gender`` key. The ``species`` key which is on the same level will -be left untouched. - - >>> char = session.query(Character).filter_by(name='Arthur Dent').one() - >>> char.details['gender'] = 'manly man' - >>> session.commit() - >>> session.refresh(char) - - >>> char.details['gender'] - 'manly man' - - >>> char.details['species'] - 'human' - -Update nested dictionary ------------------------- - - >>> char_nested = Character(id='1234id') - >>> char_nested.details = {"name": {"first": "Arthur", "last": "Dent"}} - >>> session.add(char_nested) - >>> session.commit() - - >>> char_nested = session.query(Character).filter_by(id='1234id').one() - >>> char_nested.details['name']['first'] = 'Trillian' - >>> char_nested.details['size'] = 45 - >>> session.commit() - -Refresh and query "characters" table: - - >>> _ = connection.execute(text("REFRESH TABLE characters")) - >>> session.refresh(char_nested) - - >>> char_nested = session.query(Character).filter_by(id='1234id').one() - >>> pprint(char_nested.details) - {'name': {'first': 'Trillian', 'last': 'Dent'}, 'size': 45} - - -``ObjectArray`` -=============== - -Note that opposed to the ``Object`` type, the ``ObjectArray`` type isn't smart -and doesn't have intelligent change tracking. Therefore, the generated -``UPDATE`` statement will affect the whole list: - - >>> char.more_details = [{'foo': 1, 'bar': 10}, {'foo': 2}] - >>> session.commit() - - >>> char.more_details.append({'foo': 3}) - >>> session.commit() - -This will generate an ``UPDATE`` statement which looks roughly like this:: - - "UPDATE characters SET more_details = ? ...", ([{'foo': 1, 'bar': 10}, {'foo': 2}, {'foo': 3}],) - -.. hidden: - - >>> _ = connection.execute(text("REFRESH TABLE characters")) - >>> session.refresh(char) - -To run queries against fields of ``ObjectArray`` types, use the -``.any(value, operator=operators.eq)`` method on a subscript, because accessing -fields of object arrays (e.g. ``Character.more_details['foo']``) returns an -array of the field type. - -Only one of the objects inside the array has to match in order for the result -to be returned: - - >>> query = session.query(Character.name) - >>> query.filter(Character.more_details['foo'].any(1, operator=operators.eq)).all() - [('Arthur Dent',)] - -Querying a field of an object array will result in an array of -all values of that field of all objects in that object array: - - >>> query = session.query(Character.more_details['foo']).order_by(Character.name) - >>> query.all() - [([1, 2, 3],), (None,), (None,)] - - -Geospatial types -================ - -CrateDB's geospatial types, such as :ref:`crate-reference:type-geo_point` -and :ref:`crate-reference:type-geo_shape`, can also be used within an -SQLAlchemy declarative schema: - - >>> class City(Base): - ... __tablename__ = 'cities' - ... name = sa.Column(sa.String, primary_key=True) - ... coordinate = sa.Column(Geopoint) - ... area = sa.Column(Geoshape) - -One way of inserting these types is using the `geojson`_ library, to create -points or shapes: - - >>> area = Polygon( - ... [ - ... [ - ... (139.806, 35.515), - ... (139.919, 35.703), - ... (139.768, 35.817), - ... (139.575, 35.760), - ... (139.584, 35.619), - ... (139.806, 35.515), - ... ] - ... ] - ... ) - >>> point = Point(coordinates=(139.76, 35.68)) - -These two objects can then be added to an SQLAlchemy model and added to the -session: - - >>> tokyo = City(coordinate=point, area=area, name='Tokyo') - >>> session.add(tokyo) - >>> session.commit() - >>> _ = connection.execute(text("REFRESH TABLE cities")) - -When reading them back, they are retrieved as the corresponding `geojson`_ -objects: - - >>> query = session.query(City.name, City.coordinate, City.area) - >>> query.all() - [('Tokyo', (139.75999999791384, 35.67999996710569), {"coordinates": [[[139.806, 35.515], [139.919, 35.703], [139.768, 35.817], [139.575, 35.76], [139.584, 35.619], [139.806, 35.515]]], "type": "Polygon"})] - - -.. hidden: Disconnect from database - - >>> session.close() - >>> connection.close() - >>> engine.dispose() - - -.. _geojson: https://pypi.org/project/geojson/ diff --git a/docs/conf.py b/docs/conf.py index 8267b131f..47cc4ae96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,6 @@ +# ruff: noqa: F403, F405 from crate.theme.rtd.conf.python import * - if "sphinx.ext.intersphinx" not in extensions: extensions += ["sphinx.ext.intersphinx"] @@ -9,15 +9,25 @@ intersphinx_mapping = {} -intersphinx_mapping.update({ - 'py': ('https://docs.python.org/3/', None), - 'sa': ('https://docs.sqlalchemy.org/en/14/', None), - 'urllib3': ('https://urllib3.readthedocs.io/en/1.26.13/', None) - }) +intersphinx_mapping.update( + { + "py": ("https://docs.python.org/3/", None), + "urllib3": ("https://urllib3.readthedocs.io/en/1.26.13/", None), + } +) linkcheck_anchors = True - +linkcheck_ignore = [] + +# Disable version chooser. +html_context.update( + { + "display_version": False, + "current_version": None, + "versions": [], + } +) rst_prolog = """ .. |nbsp| unicode:: 0xA0 diff --git a/docs/connect.rst b/docs/connect.rst index 44c25b045..afc8f59c5 100644 --- a/docs/connect.rst +++ b/docs/connect.rst @@ -10,18 +10,13 @@ Connect to CrateDB `Python Database API Specification v2.0`_ (PEP 249). For help using the `SQLAlchemy`_ dialect, consult the - :ref:`SQLAlchemy dialect documentation `. + :ref:`SQLAlchemy dialect documentation `. .. SEEALSO:: Supplementary information about the CrateDB Database API client can be found in the :ref:`data types appendix `. -.. rubric:: Table of contents - -.. contents:: - :local: - .. _single-node: Connect to a single node @@ -139,6 +134,16 @@ Here, replace ```` with the path to the client certificate file, and verification. In such circumstances, you can combine the two methods above to do both at once. +Relaxing minimum SSL version +............................ + +urrlib3 v2 dropped support for TLS 1.0 and TLS 1.1 by default, see `Modern security by default - +HTTPS requires TLS 1.2+`_. If you need to re-enable it, use the ``ssl_relax_minimum_version`` flag, +which will configure ``kwargs["ssl_minimum_version"] = ssl.TLSVersion.MINIMUM_SUPPORTED``. + + >>> connection = client.connect(..., ssl_relax_minimum_version=True) + + Timeout ------- @@ -236,6 +241,12 @@ and password. authenticate as the CrateDB superuser, which is ``crate``. The superuser does not have a password, so you can omit the ``password`` argument. +Alternatively, authenticate using a JWT token: + + >>> connection = client.connect(..., jwt_token="") + +Here, replace ```` with the appropriate JWT token. + .. _schema-selection: Schema selection @@ -255,6 +266,32 @@ with the rest of your arguments. However, you can query any schema you like by specifying it in the query. +.. _compression: + +Request and response compression +================================= + +The ``compress`` parameter controls gzip compression of outgoing request +bodies. The default ``8192`` compresses payloads larger than 8 KB:: + + >>> connection = client.connect('localhost:4200') + # compress=8192 is the default — payloads > 8 KB are gzip-compressed + +To always compress, regardless of payload size:: + + >>> connection = client.connect('localhost:4200', compress=True) + +To disable compression entirely:: + + >>> connection = client.connect('localhost:4200', compress=False) + +To use a custom threshold (bytes):: + + >>> connection = client.connect('localhost:4200', compress=4096) + +The driver always sends ``Accept-Encoding: gzip, deflate`` so the server +may return compressed responses if compression is enabled. + Next steps ========== @@ -268,6 +305,7 @@ Once you're connected, you can :ref:`query CrateDB `. .. _client-side random load balancing: https://en.wikipedia.org/wiki/Load_balancing_(computing)#Client-side_random_load_balancing +.. _Modern security by default - HTTPS requires TLS 1.2+: https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html#https-requires-tls-1-2 .. _Python Database API Specification v2.0: https://www.python.org/dev/peps/pep-0249/ .. _round-robin DNS: https://en.wikipedia.org/wiki/Round-robin_DNS .. _sample application: https://github.com/crate/crate-sample-apps/tree/main/python-flask diff --git a/docs/appendices/data-types.rst b/docs/data-types.rst similarity index 51% rename from docs/appendices/data-types.rst rename to docs/data-types.rst index d6a34e3b6..90fd92345 100644 --- a/docs/appendices/data-types.rst +++ b/docs/data-types.rst @@ -4,14 +4,7 @@ Data types ========== -The :ref:`Database API client ` and the :ref:`SQLAlchemy dialect -` use different Python data types. Consult the corresponding -section for further information. - -.. rubric:: Table of contents - -.. contents:: - :local: +The data types of the :ref:`CrateDB DBAPI database API client `. .. _data-types-db-api: @@ -47,7 +40,7 @@ CrateDB Python ============= =========== __ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#boolean -__ https://docs.python.org/3/library/stdtypes.html#boolean-values +__ https://docs.python.org/3/library/stdtypes.html#boolean-type-bool __ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#character-data __ https://docs.python.org/3/library/stdtypes.html#str __ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data @@ -94,65 +87,19 @@ __ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#c .. NOTE:: - The type that ``date`` and ``datetime`` objects are mapped depends on the + The type that ``date`` and ``datetime`` objects are mapped to, depends on the CrateDB column type. -.. _data-types-sqlalchemy: - -SQLAlchemy -========== - -This section documents data types for the CrateDB :ref:`SQLAlchemy dialect -`. - -.. _sqlalchemy-type-map: - -Type map --------- +.. NOTE:: -The CrateDB dialect maps between data types like so: + When using ``date`` or ``datetime`` objects with ``timezone`` information, + the value is implicitly converted to a `Unix time`_ (epoch) timestamp, i.e. + the number of seconds which have passed since 00:00:00 UTC on + Thursday, 1 January 1970. -================= ========================================= -CrateDB SQLAlchemy -================= ========================================= -`boolean`__ `Boolean`__ -`byte`__ `SmallInteger`__ -`short`__ `SmallInteger`__ -`integer`__ `Integer`__ -`long`__ `NUMERIC`__ -`float`__ `Float`__ -`double`__ `DECIMAL`__ -`timestamp`__ `TIMESTAMP`__ -`string`__ `String`__ -`array`__ `ARRAY`__ -`object`__ :ref:`object` |nbsp| (extension type) -`array(object)`__ :ref:`objectarray` |nbsp| (extension type) -`geo_point`__ :ref:`geopoint` |nbsp| (extension type) -`geo_shape`__ :ref:`geoshape` |nbsp| (extension type) -================= ========================================= + This means, when inserting or updating records using timezone-aware Python + ``date`` or ``datetime`` objects, timezone information will not be + preserved. If you need to store it, you will need to use a separate column. -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#boolean -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.Boolean -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.SmallInteger -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.SmallInteger -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.Integer -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.NUMERIC -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.Float -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#numeric-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.DECIMAL -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#dates-and-times -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.TIMESTAMP -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#character-data -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.String -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#array -__ http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.ARRAY -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#object -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#array -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#geo-point -__ https://crate.io/docs/crate/reference/en/latest/general/ddl/data-types.html#geo-shape +.. _Unix time: https://en.wikipedia.org/wiki/Unix_time diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 699b12532..c510ef8de 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -4,33 +4,19 @@ Getting started =============== -Learn how to install and get started the :ref:`CrateDB Python client library -`. - -.. rubric:: Table of contents - -.. contents:: - :local: - -Prerequisites -============= - -Recent versions of this library are validated on Python 3 (>= 3.7). -It may also work on earlier versions of Python. - -`Pip`_ should be installed on your system. +Learn how to install and get started with the Python client library for +`CrateDB`_. Install ======= .. highlight:: sh -The CrateDB Python client is `available`_ as a `PyPI`_ package. +The CrateDB Python client is available as package `crate`_ on `PyPI`_. -To install the most recent driver version, including the SQLAlchemy dialect -extension, run:: +To install the most recent driver version, run:: - pip install "crate[sqlalchemy]" --upgrade + pip install --upgrade crate After that is done, you can import the library, like so: @@ -43,9 +29,9 @@ Interactive use Python provides a REPL_, also known as an interactive language shell. It's a handy way to experiment with code and try out new libraries. We recommend -`iPython`_, which you can install, like so:: +`IPython`_, which you can install, like so:: - pip install iPython + pip install ipython Once installed, you can start it up, like this:: @@ -53,27 +39,32 @@ Once installed, you can start it up, like this:: From there, try importing the CrateDB Python client library and seeing how far you get with the built-in ``help()`` function (that can be called on any -object), iPython's autocompletion, and many other features. +object), IPython's autocompletion, and many other features. .. SEEALSO:: - `The iPython Documentation`_ + `The IPython Documentation`_ Set up as a dependency ====================== -In order to handle Python project dependencies, there are `many ways`_. -The official PyPI package should be compatible with all of them. +There are `many ways`_ to add the ``crate`` package as a dependency to your +project. All of them work equally well. Please note that you may want to employ +package version pinning in order to keep the environment of your project stable +and reproducible, achieving `repeatable installations`_. + Next steps ========== Learn how to :ref:`connect to CrateDB `. -.. _available: https://pypi.python.org/pypi/pip -.. _iPython: https://ipython.org/ + +.. _crate: https://pypi.org/project/crate/ +.. _CrateDB: https://crate.io/products/cratedb/ +.. _IPython: https://ipython.org/ .. _many ways: https://packaging.python.org/key_projects/ -.. _Pip: https://pip.pypa.io/en/stable/installing/ .. _PyPI: https://pypi.org/ +.. _repeatable installations: https://pip.pypa.io/en/latest/topics/repeatable-installs/ .. _REPL: https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop -.. _The iPython Documentation: https://ipython.readthedocs.io/en/stable/ +.. _The IPython Documentation: https://ipython.readthedocs.io/ diff --git a/docs/index-all.rst b/docs/index-all.rst new file mode 100644 index 000000000..5d9244d5a --- /dev/null +++ b/docs/index-all.rst @@ -0,0 +1,21 @@ +:orphan: + +.. _index-all: + +################################## +CrateDB Python Client -- all pages +################################## + + +.. rubric:: Table of contents + +.. toctree:: + :maxdepth: 2 + + getting-started + connect + query + blobs + data-types + by-example/index + other-options diff --git a/docs/index.rst b/docs/index.rst index 147353a02..ca9b5ff67 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,51 +1,151 @@ .. _index: -===================== +##################### CrateDB Python Client -===================== +##################### -A Python client library for `CrateDB`_. -This client library implements the `Python Database API Specification v2.0`_ -(PEP 249), which defines a common interface for accessing databases in Python. +************ +Introduction +************ -It also includes the :ref:`CrateDB dialect ` for `SQLAlchemy`_. +The Python client library for `CrateDB`_ implements the Python Database API +Specification v2.0 (`PEP 249`_). -.. NOTE:: +The Python driver can be used to connect to both `CrateDB`_ and `CrateDB +Cloud`_, and is verified to work on Linux, macOS, and Windows. It is used by +the `Crash CLI`_, as well as other libraries and applications connecting to +CrateDB from the Python ecosystem. It is verified to work with CPython, but +it has also been tested successfully with `PyPy`_. - This is a basic CrateDB driver reference. +Please make sure to also visit the section about :ref:`other-options`, using +the :ref:`crate-reference:interface-postgresql` interface of `CrateDB`_. - Check out the `sample application`_ (and the corresponding `sample - application documentation`_) for a practical demonstration of this driver - in use. - For general help using the Python Database API or SQLAlchemy, please consult - `PEP 249`_, the `SQLAlchemy tutorial`_, or the `SQLAlchemy documentation`_. +************* +Documentation +************* -.. SEEALSO:: +For general help about the Python Database API, please consult `PEP 249`_. +For more detailed information about how to install the client driver, how to +connect to a CrateDB cluster, and how to run queries, consult the resources +referenced below. - The CrateDB Python client library is an open source project and is `hosted - on GitHub`_. +.. toctree:: + :titlesonly: + + getting-started + connect + query + blobs + + +DB API +====== + +Install package from PyPI. + +.. code-block:: shell + + pip install crate + +Connect to CrateDB instance running on ``localhost``. + +.. code-block:: python + + # Connect using DB API. + from crate import client + from pprint import pp + + query = "SELECT country, mountain, coordinates, height FROM sys.summits ORDER BY country;" + + with client.connect("localhost:4200", username="crate") as connection: + cursor = connection.cursor() + cursor.execute(query) + pp(cursor.fetchall()) + cursor.close() + +Connect to `CrateDB Cloud`_. + +.. code-block:: python + + # Connect using DB API. + from crate import client + connection = client.connect( + servers="https://example.aks1.westeurope.azure.cratedb.net:4200", + username="admin", + password="") + + +Data types +========== + +The DB API driver supports :ref:`CrateDB's data types +` to different degrees. For more information, +please consult the :ref:`data-types` documentation page. + +.. toctree:: + :maxdepth: 2 + + data-types + +Migration Notes +=============== + +The :ref:`CrateDB dialect ` for `SQLAlchemy`_ is +provided by the `sqlalchemy-cratedb`_ package. + +If you are migrating from previous versions of ``crate[sqlalchemy]<1.0.0``, you +will find that the newer releases ``crate>=1.0.0`` no longer include the +SQLAlchemy dialect for CrateDB. + +See `migrate to sqlalchemy-cratedb`_ for relevant guidelines about how to +successfully migrate to the `sqlalchemy-cratedb`_ package. + + +Examples +======== + +- The :ref:`by-example` section enumerates concise examples demonstrating the + different API interfaces of the CrateDB Python client library. Those are + DB API, HTTP, and BLOB interfaces. + +- Executable code examples are maintained within the `cratedb-examples repository`_. + `sqlalchemy-cratedb`_, `python-dataframe-examples`_, and `python-sqlalchemy-examples`_ + provide relevant code snippets about how to connect to CrateDB using + `SQLAlchemy`_, `pandas`_, or `Dask`_, and how to load and export data. + +- The `sample application`_ and the corresponding `sample application + documentation`_ demonstrate the use of the driver on behalf of an example + "guestbook" application, using Flask. -.. rubric:: Table of contents .. toctree:: - :maxdepth: 2 - - getting-started - connect - query - blobs - sqlalchemy - by-example/index - appendices/index - -.. _CrateDB: https://crate.io/products/cratedb/ -.. _hosted on GitHub: https://github.com/crate/crate-python -.. _PEP 249: https://www.python.org/dev/peps/pep-0249/ -.. _Python Database API Specification v2.0: https://www.python.org/dev/peps/pep-0249/ + :maxdepth: 2 + + by-example/index + + +.. seealso:: + + The CrateDB Python client library is an open source project and is `managed + on GitHub`_. Contributions, feedback, or patches are highly welcome! + + +.. _CrateDB: https://crate.io/products/cratedb +.. _CrateDB Cloud: https://console.cratedb.cloud/ +.. _Crash CLI: https://crate.io/docs/crate/crash/ +.. _Dask: https://en.wikipedia.org/wiki/Dask_(software) +.. _cratedb-examples repository: https://github.com/crate/cratedb-examples +.. _managed on GitHub: https://github.com/crate/crate-python +.. _migrate to sqlalchemy-cratedb: https://cratedb.com/docs/sqlalchemy-cratedb/migrate-from-crate-client.html +.. _pandas: https://en.wikipedia.org/wiki/Pandas_(software) +.. _PEP 249: https://peps.python.org/pep-0249/ +.. _PyPy: https://www.pypy.org/ +.. _python-dataframe-examples: https://github.com/crate/cratedb-examples/tree/main/by-dataframe +.. _python-sqlalchemy-examples: https://github.com/crate/cratedb-examples/tree/main/by-language/python-sqlalchemy .. _sample application: https://github.com/crate/crate-sample-apps/tree/main/python-flask .. _sample application documentation: https://github.com/crate/crate-sample-apps/blob/main/python-flask/documentation.md -.. _SQLAlchemy: https://www.sqlalchemy.org/ -.. _SQLAlchemy documentation: https://docs.sqlalchemy.org/ -.. _SQLAlchemy tutorial: https://docs.sqlalchemy.org/en/latest/orm/tutorial.html +.. _SQLAlchemy: https://en.wikipedia.org/wiki/Sqlalchemy +.. _sqlalchemy-cratedb: https://github.com/crate/sqlalchemy-cratedb +.. _Use CrateDB with pandas: https://github.com/crate/crate-qa/pull/246 diff --git a/docs/other-options.rst b/docs/other-options.rst new file mode 100644 index 000000000..95e5a0ad6 --- /dev/null +++ b/docs/other-options.rst @@ -0,0 +1,55 @@ +.. _other-options: + +##################################### +Other connectivity options for Python +##################################### + + +************ +Introduction +************ + +Using the :ref:`crate-reference:interface-postgresql` interface of `CrateDB`_, +there are a few other connectivity options for Python. This section enumerates +the verified drivers, together with some example and test case code using them. + + +******* +Details +******* + +- `asyncpg`_, see `testing CrateDB with asyncpg`_. + +- `psycopg2`_ + + The `CrateDB Astronomer/Airflow tutorials`_ repository includes a few + orchestration examples implemented using `Apache Airflow`_ DAGs for different + import and export tasks, and for automating recurrent queries. It accompanies + a series of articles starting with `CrateDB and Apache Airflow » Automating + Data Export to S3`_. + +- `psycopg3`_, see `testing CrateDB with psycopg3`_. + +- ODBC connectivity is offered by `pyodbc`_ and `turbodbc`_, see + `testing CrateDB with pyodbc`_ and `using CrateDB with turbodbc`_. + +- `connector-x`_ promises to be the fastest library to load data from DB to + DataFrames in Rust and Python. It is the designated database connector + library for `Apache Arrow DataFusion`_. + + +.. _asyncpg: https://github.com/MagicStack/asyncpg +.. _Apache Airflow: https://github.com/apache/airflow +.. _Apache Arrow DataFusion: https://github.com/apache/arrow-datafusion +.. _connector-x: https://github.com/sfu-db/connector-x +.. _CrateDB: https://github.com/crate/crate +.. _CrateDB Astronomer/Airflow tutorials: https://github.com/crate/crate-airflow-tutorial +.. _CrateDB and Apache Airflow » Automating Data Export to S3: https://community.crate.io/t/cratedb-and-apache-airflow-automating-data-export-to-s3/901 +.. _pyodbc: https://github.com/mkleehammer/pyodbc +.. _psycopg2: https://github.com/psycopg/psycopg2 +.. _psycopg3: https://github.com/psycopg/psycopg +.. _Testing CrateDB with asyncpg: https://github.com/crate/crate-qa/blob/master/tests/client_tests/python/asyncpg/test_asyncpg.py +.. _Testing CrateDB with psycopg3: https://github.com/crate/crate-qa/blob/master/tests/client_tests/python/psycopg3/test_psycopg3.py +.. _Testing CrateDB with pyodbc: https://github.com/crate/crate-qa/blob/master/tests/client_tests/odbc/test_pyodbc.py +.. _turbodbc: https://github.com/blue-yonder/turbodbc +.. _Using CrateDB with turbodbc: https://github.com/crate/cratedb-examples/pull/18 diff --git a/docs/query.rst b/docs/query.rst index a408f3691..f99c78013 100644 --- a/docs/query.rst +++ b/docs/query.rst @@ -10,18 +10,13 @@ Query CrateDB `Python Database API Specification v2.0`_ (PEP 249). For help using the `SQLAlchemy`_ dialect, consult - :ref:`the SQLAlchemy dialect documentation `. + :ref:`the SQLAlchemy dialect documentation `. .. SEEALSO:: Supplementary information about the CrateDB Database API client can be found in the :ref:`data types appendix `. -.. rubric:: Table of contents - -.. contents:: - :local: - .. _cursor: Using a cursor @@ -59,11 +54,37 @@ characters appear, in the order they appear. Always use the parameter interpolation feature of the client library to guard against malicious input, as demonstrated in the example above. +Named parameters +---------------- + +For queries with many parameters or repeated values, named parameters improve +readability. Pass a :class:`py:dict` as the second argument using +``%(name)s`` placeholders: + + >>> cursor.execute( + ... "INSERT INTO locations (name, date, kind, position) " + ... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)", + ... {"name": "Einstein Cross", "date": "2007-03-11", "kind": "Quasar", "pos": 7}) + +The same parameter name may appear multiple times in the query: + + >>> cursor.execute( + ... "SELECT * FROM locations WHERE name = %(q)s OR kind = %(q)s", + ... {"q": "Quasar"}) + +The client converts the ``%(name)s`` placeholders to ``$N`` positional +markers before sending the query to CrateDB. + Bulk inserts ------------ :ref:`Bulk inserts ` are possible with the -``executemany()`` method, which takes a :class:`py:list` of tuples to insert: +``executemany()`` method. + +Positional parameters +..................... + +Pass a :class:`py:list` of sequences using ``?`` placeholders: >>> cursor.executemany( ... "INSERT INTO locations (name, date, kind, position) VALUES (?, ?, ?, ?)", @@ -72,10 +93,58 @@ Bulk inserts [{'rowcount': 1}, {'rowcount': 1}] The ``executemany()`` method returns a result :class:`dictionary ` -for every tuple. This dictionary always has a ``rowcount`` key, indicating +for every row. This dictionary always has a ``rowcount`` key, indicating how many rows were inserted. If an error occurs, the ``rowcount`` value is ``-2``, and the dictionary may additionally have an ``error_message`` key. +Named parameters +................ + +``executemany()`` also accepts a :class:`py:list` of :class:`py:dict` using +``%(name)s`` placeholders. The client converts both the SQL template and all +rows to positional format before sending to CrateDB: + + >>> cursor.executemany( + ... "INSERT INTO locations (name, date, kind, position) " + ... "VALUES (%(name)s, %(date)s, %(kind)s, %(pos)s)", + ... [{"name": "Cloverleaf", "date": "2007-03-11", "kind": "Quasar", "pos": 7}, + ... {"name": "Old Faithful", "date": "2007-03-11", "kind": "Quasar", "pos": 8}]) + [{'rowcount': 1}, {'rowcount': 1}] + +Using ``bulk_parameters`` directly +................................... + +``execute()`` accepts a ``bulk_parameters`` keyword argument directly: + +.. NOTE:: + Please prefer ``executemany()`` for bulk inserts, it is the standard DB-API 2.0 + interface. The ``bulk_parameters`` argument is a lower-level alternative. + + >>> cursor.execute( + ... "INSERT INTO locations (name, kind, position) VALUES (?, ?, ?)", + ... bulk_parameters=[('Cloverleaf', 'Quasar', 7), + ... ('Old Faithful', 'Quasar', 8)]) + +Named ``%(name)s`` placeholders are also supported. When the rows are +:class:`py:dict` objects the SQL template and rows are fully converted, +identical to the ``executemany()`` path: + + >>> cursor.execute( + ... "INSERT INTO locations (name, kind, position) " + ... "VALUES (%(name)s, %(kind)s, %(pos)s)", + ... bulk_parameters=[{"name": "Cloverleaf", "kind": "Quasar", "pos": 7}, + ... {"name": "Old Faithful", "kind": "Quasar", "pos": 8}]) + +When the rows are already positional lists (e.g. data coming from a +DataFrame), only the SQL template is rewritten. In this case the caller must +ensure the value order in each row matches the placeholder order in the SQL: + + >>> cursor.execute( + ... "INSERT INTO locations (name, kind, position) " + ... "VALUES (%(name)s, %(kind)s, %(pos)s)", + ... bulk_parameters=[['Cloverleaf', 'Quasar', 7], + ... ['Old Faithful', 'Quasar', 8]]) + .. _selects: Selecting data @@ -244,8 +313,7 @@ converter function defined as ``lambda``, which assigns ``yes`` for boolean ======================================= Based on the data type converter functionality, the driver offers a convenient -interface to make it return timezone-aware ``datetime`` objects, using the -desired time zone. +interface to make it return ``datetime`` objects using the desired time zone. For your reference, in the following examples, epoch 1658167836758 is ``Mon, 18 Jul 2022 18:10:36 GMT``. diff --git a/docs/sqlalchemy.rst b/docs/sqlalchemy.rst deleted file mode 100644 index fd19be30a..000000000 --- a/docs/sqlalchemy.rst +++ /dev/null @@ -1,664 +0,0 @@ -.. _sqlalchemy-support: -.. _using-sqlalchemy: - -================== -SQLAlchemy support -================== - -.. rubric:: Table of contents - -.. contents:: - :local: - :depth: 2 - - -Introduction -============ - -`SQLAlchemy`_ is a popular `Object-Relational Mapping`_ (ORM) library for -Python. - -The CrateDB Python client library provides support for SQLAlchemy. An -:ref:`SQLAlchemy dialect ` for CrateDB is registered at -installation time and can be used without further configuration. - -The CrateDB SQLAlchemy dialect is validated to work with SQLAlchemy versions -``1.3`` and ``1.4``. - -.. SEEALSO:: - - For general help using SQLAlchemy, consult the :ref:`SQLAlchemy tutorial - ` or the `SQLAlchemy library`_. - - Supplementary information about the CrateDB SQLAlchemy dialect can be found - in the :ref:`data types appendix `. - - Code examples for using the CrateDB SQLAlchemy dialect can be found at - :ref:`sqlalchemy-by-example`. - - -.. _connecting: - -Connecting -========== - -.. _database-urls: - -Database URLs -------------- - -In an SQLAlchemy context, database addresses are represented by *Uniform Resource -Locators* (URL_) called :ref:`sa:database_urls`. - -The simplest database URL for CrateDB looks like this:: - - crate:///[?option=value] - -Here, ```` is the node *host string*. After the host, additional query -parameters can be specified to adjust some connection settings. - -A host string looks like this:: - - [:@]: - -Here, ```` is the hostname or IP address of the CrateDB node and -```` is a valid :ref:`crate-reference:psql.port` number. - -When authentication is needed, the credentials can be optionally supplied using -``:@``. For connecting to an SSL-secured HTTP endpoint, you -can add the query parameter ``?ssl=true`` to the database URI. - -Example database URIs: - -- ``crate://localhost:4200`` -- ``crate://crate-1.vm.example.com:4200`` -- ``crate://username:password@crate-2.vm.example.com:4200/?ssl=true`` -- ``crate://198.51.100.1:4200`` - -.. TIP:: - - If ```` is blank (i.e. the database URI is just ``crate://``), then - ``localhost:4200`` will be assumed. - -Getting a connection --------------------- - -Create an engine -................ - -You can connect to CrateDB using the ``create_engine`` method. This method -takes a :ref:`database URL `. - -Import the ``sa`` module, like so: - - >>> import sqlalchemy as sa - -To connect to ``localhost:4200``, you can do this: - - >>> engine = sa.create_engine('crate://') - -To connect to ``crate-1.vm.example.com:4200``, you would do this: - - >>> engine = sa.create_engine('crate://crate-1.vm.example.com:4200') - -If your CrateDB cluster has multiple nodes, however, we recommend that you -configure all of them. You can do that by specifying the ``crate://`` database -URL and passing in a list of :ref:`host strings ` passed using -the ``connect_args`` argument, like so: - - >>> engine = sa.create_engine('crate://', connect_args={ - ... 'servers': ['198.51.100.1:4200', '198.51.100.2:4200'] - ... }) - -When you do this, the Database API layer will use its :ref:`round-robin -` implementation. - -The client validates :ref:`SSL server certificates ` -by default. For further adjusting this behaviour, SSL verification options can -be passed in by using the ``connect_args`` dictionary. - -For example, use ``ca_cert`` for providing a path to the CA certificate used -for signing the server certificate: - - >>> engine = sa.create_engine( - ... 'crate://', - ... connect_args={ - ... 'servers': ['198.51.100.1:4200', '198.51.100.2:4200'], - ... 'ca_cert': '', - ... } - ... ) - -In order to disable SSL verification, use ``verify_ssl_cert = False``, like: - - >>> engine = sa.create_engine( - ... 'crate://', - ... connect_args={ - ... 'servers': ['198.51.100.1:4200', '198.51.100.2:4200'], - ... 'verify_ssl_cert': False, - ... } - ... ) - - -Get a session -............. - -Once you have an CrateDB ``engine`` set up, you can create and use an SQLAlchemy -``Session`` object to execute queries: - - >>> from sqlalchemy.orm import sessionmaker - - >>> Session = sessionmaker(bind=engine) - >>> session = Session() - -.. SEEALSO:: - - SQLAlchemy has more documentation about this topic on :doc:`sa:orm/session_basics`. - -.. _tables: - -Tables -====== - -.. _table-definition: - -Table definition ----------------- - -Here is an example SQLAlchemy table definition using the :ref:`declarative -system `: - - >>> from sqlalchemy.ext import declarative - >>> from crate.client.sqlalchemy import types - >>> from uuid import uuid4 - - >>> def gen_key(): - ... return str(uuid4()) - - >>> Base = declarative.declarative_base(bind=engine) - - >>> class Character(Base): - ... - ... __tablename__ = 'characters' - ... __table_args__ = { - ... 'crate_number_of_shards': 3 - ... } - ... - ... id = sa.Column(sa.String, primary_key=True, default=gen_key) - ... name = sa.Column(sa.String, crate_index=False) - ... name_normalized = sa.Column(sa.String, sa.Computed("lower(name)")) - ... quote = sa.Column(sa.String, nullable=False) - ... details = sa.Column(types.Object) - ... more_details = sa.Column(types.ObjectArray) - ... name_ft = sa.Column(sa.String) - ... quote_ft = sa.Column(sa.String) - ... - ... __mapper_args__ = { - ... 'exclude_properties': ['name_ft', 'quote_ft'] - ... } - -In this example, we: - -- Define a ``gen_key`` function that produces :py:mod:`UUIDs ` -- Set up a ``Base`` class for the table -- Create the ``Characters`` class for the ``characters`` table -- Use the ``gen_key`` function to provide a default value for the ``id`` column - (which is also the primary key) -- Use standard SQLAlchemy types for the ``id``, ``name``, and ``quote`` columns -- Use ``nullable=False`` to define a ``NOT NULL`` constraint -- Disable indexing of the ``name`` column using ``crate_index=False`` -- Define a computed column ``name_normalized`` (based on ``name``) that - translates into a generated column -- Use the `Object`_ extension type for the ``details`` column -- Use the `ObjectArray`_ extension type for the ``more_details`` column -- Set up the ``name_ft`` and ``quote_ft`` fulltext indexes, but exclude them from - the mapping (so SQLAlchemy doesn't try to update them as if they were columns) - -.. TIP:: - - This example table is used throughout the rest of this document. - -.. SEEALSO:: - - The SQLAlchemy documentation has more information about - :ref:`sa:metadata_describing`. - - -Additional ``__table_args__`` -............................. - - -The example also shows the optional usage of ``__table_args__`` to configure -table-wide attributes. The following attributes can optionally be configured: - -- ``crate_number_of_shards``: The number of primary shards the table will be - split into -- ``crate_clustered_by``: The routing column to use for sharding -- ``crate_number_of_replicas``: The number of replicas to allocate for each - primary shard -- ``crate_partitioned_by``: One or more columns to use as a partition key - -.. SEEALSO:: - - The :ref:`CREATE TABLE ` documentation - contains more information on each of the attributes. - - -``_id`` as primary key -...................... - -As with version 4.2 CrateDB supports the ``RETURNING`` clause, which makes it -possible to use the ``_id`` column as fetched value for the ``PRIMARY KEY`` -constraint, since the SQLAlchemy ORM always **requires** a primary key. - -A table schema like this - -.. code-block:: sql - - CREATE TABLE "doc"."logs" ( - "ts" TIMESTAMP WITH TIME ZONE NOT NULL, - "level" TEXT, - "message" TEXT - ) - -would translate into the following declarative model: - - >>> from sqlalchemy.schema import FetchedValue - - >>> class Log(Base): - ... - ... __tablename__ = 'logs' - ... __mapper_args__ = { - ... 'exclude_properties': ['id'] - ... } - ... - ... id = sa.Column("_id", sa.String, server_default=FetchedValue(), primary_key=True) - ... ts = sa.Column(sa.DateTime, server_default=sa.func.current_timestamp()) - ... level = sa.Column(sa.String) - ... message = sa.Column(sa.String) - - >>> log = Log(level="info", message="Hello World") - >>> session.add(log) - >>> session.commit() - >>> log.id - ... - -.. _using-extension-types: - -Extension types ---------------- - -In the :ref:`example SQLAlchemy table definition ` above, we -are making use of the two extension data types that the CrateDB SQLAlchemy -dialect provides. - -.. SEEALSO:: - - The appendix has a full :ref:`data types reference `. - -.. _object: - -``Object`` -.......... - -Objects are a common, and useful, data type when using CrateDB, so the CrateDB -SQLAlchemy dialect provides a custom ``Object`` type extension for working with -these values. - -Here's how you use the :doc:`SQLAlchemy Session ` to -insert two records: - - >>> # use the crate engine from earlier examples - >>> Session = sessionmaker(bind=crate) - >>> session = Session() - - >>> arthur = Character(name='Arthur Dent') - >>> arthur.details = {} - >>> arthur.details['gender'] = 'male' - >>> arthur.details['species'] = 'human' - >>> session.add(arthur) - - >>> trillian = Character(name='Tricia McMillan') - >>> trillian.details = {} - >>> trillian.quote = "We're on a space ship Arthur. In space." - >>> trillian.details['gender'] = 'female' - >>> trillian.details['species'] = 'human' - >>> trillian.details['female_only_attribute'] = 1 - >>> session.add(trillian) - >>> session.commit() - -.. NOTE:: - - The information we supply via the ``details`` column isn't defined in the - :ref:`original SQLAlchemy table definition ` schema. - These details can be specified as *object column policy* when you create - the column in CrateDB, you can either use the :ref:`STRICT column policy - `, or the :ref:`DYNAMIC column - policy `. - -.. NOTE:: - - Behind the scenes, if you update an ``Object`` property and ``commit`` that - change, the :ref:`UPDATE ` statement sent - to CrateDB will only include the data necessary to update the changed - sub-columns. - -.. _objectarray: - -``ObjectArray`` -............... - -In addition to the `Object`_ type, the CrateDB SQLAlchemy dialect also provides -an ``ObjectArray`` type, which is structured as a :class:`py:list` of -:class:`dictionaries `. - -Here's how you might set the value of an ``ObjectArray`` column: - - >>> arthur.more_details = [{'foo': 1, 'bar': 10}, {'foo': 2}] - >>> session.commit() - -If you append an object, like this: - - >>> arthur.more_details.append({'foo': 3}) - >>> session.commit() - -The resulting object will look like this: - - >>> arthur.more_details - [{'foo': 1, 'bar': 10}, {'foo': 2}, {'foo': 3}] - -.. CAUTION:: - - Behind the scenes, if you update an ``ObjectArray`` and ``commit`` that - change, the :ref:`UPDATE ` statement - sent to CrateDB will include all of the ``ObjectArray`` data. - -.. _geopoint: -.. _geoshape: - -``Geopoint`` and ``Geoshape`` -............................. - -The CrateDB SQLAlchemy dialect provides two geospatial types: - -- ``Geopoint``, which represents a longitude and latitude coordinate -- ``Geoshape``, which is used to store geometric `GeoJSON geometry objects`_ - -To use these types, you can create columns, like so: - - >>> class City(Base): - ... - ... __tablename__ = 'cities' - ... name = sa.Column(sa.String, primary_key=True) - ... coordinate = sa.Column(types.Geopoint) - ... area = sa.Column(types.Geoshape) - -A geopoint can be created in multiple ways. Firstly, you can define it as a -:py:class:`py:tuple` of ``(longitude, latitude)``: - - >>> point = (139.76, 35.68) - -Secondly, you can define it as a geojson ``Point`` object: - - >>> from geojson import Point - >>> point = Point(coordinates=(139.76, 35.68)) - -To create a geoshape, you can use a geojson shape object, such as a ``Polygon``: - - >>> from geojson import Point, Polygon - >>> area = Polygon( - ... [ - ... [ - ... (139.806, 35.515), - ... (139.919, 35.703), - ... (139.768, 35.817), - ... (139.575, 35.760), - ... (139.584, 35.619), - ... (139.806, 35.515), - ... ] - ... ] - ... ) - -You can then set the values of the ``Geopoint`` and ``Geoshape`` columns: - - >>> tokyo = City(name="Tokyo", coordinate=point, area=area) - >>> session.add(tokyo) - >>> session.commit() - -Querying -======== - -When the ``commit`` method is called, two ``INSERT`` statements are sent to -CrateDB. However, the newly inserted rows aren't immediately available for -querying because the table index is only updated periodically (one second, by -default, which is a short time for me and you, but a long time for your code). - -You can request a :ref:`table refresh ` to update -the index manually: - - >>> connection = engine.connect() - >>> _ = connection.execute(text("REFRESH TABLE characters")) - -.. NOTE:: - - Newly inserted rows can still be queried immediately if a lookup by primary - key is done. - -Here's what a regular select might look like: - - >>> query = session.query(Character).order_by(Character.name) - >>> [(c.name, c.details['gender']) for c in query] - [('Arthur Dent', 'male'), ('Tricia McMillan', 'female')] - -You can also select a portion of each record, and this even works inside -`Object`_ columns: - - >>> sorted(session.query(Character.details['gender']).all()) - [('female',), ('male',)] - -You can also filter on attributes inside the `Object`_ column: - - >>> query = session.query(Character.name) - >>> query.filter(Character.details['gender'] == 'male').all() - [('Arthur Dent',)] - -To filter on an `ObjectArray`_, you have to do something like this: - - >>> from sqlalchemy.sql import operators - - >>> query = session.query(Character.name) - >>> query.filter(Character.more_details['foo'].any(1, operator=operators.eq)).all() - [(u'Arthur Dent',)] - -Here, we're using SQLAlchemy's :py:meth:`any ` -method along with Python's :py:func:`py:operator.eq` function, in order to -match the value ``1`` against the key ``foo`` of any dictionary in the -``more_details`` list. - -Only one of the keys has to match for the row to be returned. - -This works, because ``ObjectArray`` keys return a list of all values for that -key, like so: - - >>> arthur.more_details['foo'] - [1, 2, 3] - -Querying a key of an ``ObjectArray`` column will return all values for that key -for all matching rows: - - >>> query = session.query(Character.more_details['foo']).order_by(Character.name) - >>> query.all() - [([1, 2, 3],), (None,)] - -.. _aggregate-functions: - -Aggregate functions -------------------- - -SQLAlchemy supports different ways to `count result rows`_. However, because -CrateDB doesn't support subqueries, counts must be written in one of the -following two ways. - -This counts the number of character records by counting the number of ``id`` -values in the table: - - >>> session.query(sa.func.count(Character.id)).scalar() - 2 - -.. NOTE:: - - If you're doing it like this, the column you select must be the primary - key. - -And this counts the number of character records by selecting all columns, and -then counting the number of rows: - - >>> session.query(sa.func.count('*')).select_from(Character).scalar() - 2 - -You can layer in calls to ``group_by`` and ``order_by`` when you use one of -these methods, like so: - - >>> session.query(sa.func.count(Character.id), Character.name) \ - ... .group_by(Character.name) \ - ... .order_by(sa.desc(sa.func.count(Character.id))) \ - ... .order_by(Character.name).all() - [(1, u'Arthur Dent'), (1, u'Tricia McMillan')] - -Fulltext search ---------------- - -Matching -........ - -Fulltext Search in CrateDB is done with the :ref:`crate-reference:predicates_match`. - -The CrateDB SQLAlchemy dialect provides a ``match`` function in the -``predicates`` module, which can be used to search one or multiple fields. - -Here's an example use of the ``match`` function: - - >>> from crate.client.sqlalchemy.predicates import match - - >>> session.query(Character.name) \ - ... .filter(match(Character.name_ft, 'Arthur')) \ - ... .all() - [('Arthur Dent',)] - -In this example, we're selecting character ``name`` values, and returning all -rows where the ``name_ft`` index matches the string ``Arthur``. - -.. NOTE:: - - To use fulltext searches on a column, an explicit fulltext index with an - analyzer must be created on the column. Consult the documentation about - :ref:`crate-reference:fulltext-indices` for more information. - -The ``match`` function takes the following options:: - - match(column, term, match_type=None, options=None) - -:``column``: - - A reference to a column or an index:: - - match(Character.name_ft, 'Trillian') - - Or a subcolumn:: - - match(Character.details['name']['first'], 'Trillian') - - Or a dictionary of the same, with `boost values`_:: - - match({Character.name_ft: 0.5, - Character.details['name']['first']: 0.8, - Character.details['name']['last']: 0.2}, - 'Trillian') - - .. SEEALSO:: - - The `arguments reference`_ of the :ref:`crate-reference:predicates_match` - has more in-depth information. - -:``term``: - - The term to match against. - - This string is analyzed and the resulting tokens are compared to the index. - -:``match_type``: *(optional)* - - The :ref:`crate-reference:predicates_match_types`. - - Determine how the ``term`` is applied and the :ref:`_score - ` gets calculated. - See also `score usage`_. - - Here's an example:: - - match({Character.name_ft: 0.5, - Character.details['name']['first']: 0.8, - Character.details['name']['last']: 0.2}, - 'Trillian', - match_type='phrase') - -:``options``: *(optional)* - - The `match options`_. - - Specify match type behaviour. (Not possible without a specified match type.) - - Match options must be supplied as a dictionary:: - - match({Character.name_ft: 0.5, - Character.details['name']['first']: 0.8, - Character.details['name']['last']: 0.2}, - 'Trillian', - match_type='phrase' - options={ - 'fuzziness': 3, - 'analyzer': 'english'}) - -Relevance -......... - -To get the relevance of a matching row, the row :ref:`_score -` can be used. -See also `score usage`_. - -The score is relative to other result rows produced by your query. The higher -the score, the more relevant the result row. - - .. COMMENT - - Keep this anonymous link in place so it doesn't get lost. We have to use - this link format because of the leading underscore. - -The score is made available via the ``_score`` column, which is a virtual -column, meaning that it doesn't exist on the source table, and in most cases, -should not be included in your :ref:`table definition `. - -You can select ``_score`` as part of a query, like this: - - >>> session.query(Character.name, '_score') \ - ... .filter(match(Character.quote_ft, 'space')) \ - ... .all() - [('Tricia McMillan', ...)] - -Here, we're matching the term ``space`` against the ``quote_ft`` fulltext -index. And we're selecting the ``name`` column of the character by using the -table definition But notice that we select the associated score by passing in -the virtual column name as a string (``_score``) instead of using a defined -column on the ``Character`` class. - - -.. _arguments reference: https://crate.io/docs/crate/reference/en/latest/general/dql/fulltext.html#arguments -.. _boost values: https://crate.io/docs/crate/reference/en/latest/general/dql/fulltext.html#arguments -.. _count result rows: https://docs.sqlalchemy.org/en/14/orm/tutorial.html#counting -.. _Database API: https://www.python.org/dev/peps/pep-0249/ -.. _geojson geometry objects: https://www.rfc-editor.org/rfc/rfc7946#section-3.1 -.. _match options: https://crate.io/docs/crate/reference/en/latest/general/dql/fulltext.html#options -.. _Object-Relational Mapping: https://en.wikipedia.org/wiki/Object-relational_mapping -.. _score usage: https://crate.io/docs/crate/reference/en/latest/general/dql/fulltext.html#usage -.. _SQLAlchemy: https://www.sqlalchemy.org/ -.. _SQLAlchemy library: https://www.sqlalchemy.org/library.html -.. _URL: https://en.wikipedia.org/wiki/Uniform_Resource_Locator diff --git a/examples/README.rst b/examples/README.rst new file mode 100644 index 000000000..558d412ea --- /dev/null +++ b/examples/README.rst @@ -0,0 +1,9 @@ +############################## +CrateDB Python driver examples +############################## + + +Executable code examples are maintained within the `cratedb-examples repository`_. + + +.. _cratedb-examples repository: https://github.com/crate/cratedb-examples/tree/main/by-language diff --git a/pyproject.toml b/pyproject.toml index 2f6fe486e..9173127d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,183 @@ +[build-system] +requires = ["hatch", "versioningit"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = [ + "/docs", + "/src/crate/*.py", + "/tests", + "*.rst", + "*.txt", +] +exclude = [ + "/docs/.crate-docs", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/crate"] + +[tool.hatch.version] +source = "versioningit" +default-version = "0.0.0+unknown" + +[project] +name = "crate" +dynamic = ["version"] +description = "CrateDB Python Client" +authors = [{ name = "Crate.io", email = "office@crate.io" }] +requires-python = ">=3.10" +readme = "README.rst" +license = "Apache-2.0" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: SQL", + "Topic :: Database", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", + "Topic :: System :: Networking", +] +dependencies = [ + "orjson", + "urllib3", + "verlib2", +] + +[dependency-groups] +dev = [ + "certifi", + "coverage", + "mypy<2.2", + "pytest<10", + "pytz", + "ruff<0.16", +] +docs = [ + "sphinx", + "crate-docs-theme", +] + + +[tool.coverage.run] +branch = false +parallel = true +source = ["src"] + +[tool.coverage.report] +fail_under = 0 +show_missing = true +exclude_lines = [ + "# pragma: no cover", + "raise NotImplemented", +] +omit = [ + "parts/*", + "src/crate/client/_pep440.py", +] + + [tool.mypy] +mypy_path = "src" +packages = [ + "crate", +] +exclude = [ +] +check_untyped_defs = true +explicit_package_bases = true +ignore_missing_imports = true +implicit_optional = true +install_types = true +namespace_packages = true +non_interactive = true + + +[tool.pytest.ini_options] +addopts = "-rA --verbosity=3 --doctest-modules" +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS" +minversion = "2.0" +testpaths = [ + "tests", +] +xfail_strict = true + + +[tool.ruff] +line-length = 80 + +extend-exclude = [ + "/example_*", +] + +lint.select = [ + # Builtins + "A", + # Bugbear + "B", + # comprehensions + "C4", + # Pycodestyle + "E", + # eradicate + "ERA", + # Pyflakes + "F", + # isort + "I", + # pandas-vet + "PD", + # return + "RET", + # Bandit + "S", + # print + "T20", + "W", + # flake8-2020 + "YTT", +] + +lint.extend-ignore = [ + # Unnecessary variable assignment before `return` statement + "RET504", + # Unnecessary `elif` after `return` statement + "RET505", +] -# Needed until `mypy-0.990` for `ConverterDefinition` in `converter.py`. -# https://github.com/python/mypy/issues/731#issuecomment-1260976955 -enable_recursive_aliases = true +lint.per-file-ignores."example_*" = [ + "ERA001", # Found commented-out code + "T201", # Allow `print` +] +lint.per-file-ignores."devtools/*" = [ + "T201", # Allow `print` +] +lint.per-file-ignores."examples/*" = [ + "ERA001", # Found commented-out code + "T201", # Allow `print` +] +lint.per-file-ignores."tests/*" = [ + "S101", # Asserts. + "S105", # Possible hardcoded password assigned to: "password" + "S106", # Possible hardcoded password assigned to argument: "password" + "S202", # CrateDB tarballs are trusted + "S310", # False positive; it's a https url + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes +] +lint.per-file-ignores."src/crate/client/{connection.py,http.py}" = [ + "A004", # Import `ConnectionError` is shadowing a Python builtin + "A005", # Import `ConnectionError` is shadowing a Python builtin +] +lint.per-file-ignores."tests/client/test_http.py" = [ + "A004", # Import `ConnectionError` is shadowing a Python builtin +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d476bdc70..000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -zc.buildout==3.0.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f60de556a..000000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[wheel] -universal = 1 - -[flake8] -ignore = E501, C901, W503, W504 diff --git a/setup.py b/setup.py deleted file mode 100644 index 3d4653243..000000000 --- a/setup.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8; -*- -# -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License 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. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -from setuptools import setup, find_packages -import os -import re - - -def read(path): - with open(os.path.join(os.path.dirname(__file__), path)) as f: - return f.read() - - -long_description = read('README.rst') -versionf_content = read("src/crate/client/__init__.py") -version_rex = r'^__version__ = [\'"]([^\'"]*)[\'"]$' -m = re.search(version_rex, versionf_content, re.M) -if m: - version = m.group(1) -else: - raise RuntimeError('Unable to find version string') - -setup( - name='crate', - version=version, - url='https://github.com/crate/crate-python', - author='Crate.io', - author_email='office@crate.io', - package_dir={'': 'src'}, - description='CrateDB Python Client', - long_description=long_description, - long_description_content_type='text/x-rst', - platforms=['any'], - license='Apache License 2.0', - keywords='crate db api sqlalchemy', - packages=find_packages('src'), - namespace_packages=['crate'], - entry_points={ - 'sqlalchemy.dialects': [ - 'crate = crate.client.sqlalchemy:CrateDialect' - ] - }, - install_requires=['urllib3>=1.9,<2'], - extras_require=dict( - sqlalchemy=['sqlalchemy>=1.0,<2.1', - 'geojson>=2.5.0,<4', - 'backports.zoneinfo<1; python_version<"3.9"'], - test=['tox>=3,<5', - 'zope.testing>=4,<6', - 'zope.testrunner>=5,<6', - 'zc.customdoctests>=1.0.1,<2', - 'createcoverage>=1,<2', - 'stopit>=1.1.2,<2', - 'flake8>=4,<7', - 'pytz', - # `test_http.py` needs `setuptools.ssl_support` - 'setuptools<57', - ], - doc=['sphinx>=3.5,<7', - 'crate-docs-theme>=0.26.5'], - ), - python_requires='>=3.4', - package_data={'': ['*.txt']}, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - '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', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Database' - ], -) diff --git a/src/crate/__init__.py b/src/crate/__init__.py deleted file mode 100644 index 1fcff2bbe..000000000 --- a/src/crate/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8; -*- -# -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License 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. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -# this is a namespace package -try: - import pkg_resources - pkg_resources.declare_namespace(__name__) -except ImportError: - import pkgutil - __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/src/crate/client/__init__.py b/src/crate/client/__init__.py index fedcfb198..b66358a74 100644 --- a/src/crate/client/__init__.py +++ b/src/crate/client/__init__.py @@ -23,14 +23,27 @@ from .exceptions import Error __all__ = [ - connect, - Error, + "connect", + "Error", ] -# version string read from setup.py using a regex. Take care not to break the -# regex! -__version__ = "0.30.0" +# ruff: noqa: E402 +try: + from importlib.metadata import PackageNotFoundError, version +except (ImportError, ModuleNotFoundError): # pragma: no cover + from importlib_metadata import ( # type: ignore[assignment,no-redef,unused-ignore] + PackageNotFoundError, + version, + ) +__appname__ = "crate" + +try: + __version__ = version(__appname__) +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" + +# codeql[py/unused-global-variable] apilevel = "2.0" -threadsafety = 2 -paramstyle = "qmark" +threadsafety = 1 +paramstyle = "pyformat" diff --git a/src/crate/client/_pep440.py b/src/crate/client/_pep440.py index 83a611015..cfc163d08 100644 --- a/src/crate/client/_pep440.py +++ b/src/crate/client/_pep440.py @@ -1,501 +1 @@ -"""Utility to compare pep440 compatible version strings. - -The LooseVersion and StrictVersion classes that distutils provides don't -work; they don't recognize anything like alpha/beta/rc/dev versions. - -This specific file has been vendored from NumPy on 2023-02-10 [1]. -Its reference location is in `packaging` [2,3]. - -[1] https://github.com/numpy/numpy/blob/v1.25.0.dev0/numpy/compat/_pep440.py -[2] https://github.com/pypa/packaging/blob/23.0/src/packaging/_structures.py -[3] https://github.com/pypa/packaging/blob/23.0/src/packaging/version.py -""" - -# Copyright (c) Donald Stufft and individual contributors. -# All rights reserved. - -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: - -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. - -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. - -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -import collections -import itertools -import re - - -__all__ = [ - "parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN", -] - - -# BEGIN packaging/_structures.py - - -class Infinity: - def __repr__(self): - return "Infinity" - - def __hash__(self): - return hash(repr(self)) - - def __lt__(self, other): - return False - - def __le__(self, other): - return False - - def __eq__(self, other): - return isinstance(other, self.__class__) - - def __ne__(self, other): - return not isinstance(other, self.__class__) - - def __gt__(self, other): - return True - - def __ge__(self, other): - return True - - def __neg__(self): - return NegativeInfinity - - -Infinity = Infinity() - - -class NegativeInfinity: - def __repr__(self): - return "-Infinity" - - def __hash__(self): - return hash(repr(self)) - - def __lt__(self, other): - return True - - def __le__(self, other): - return True - - def __eq__(self, other): - return isinstance(other, self.__class__) - - def __ne__(self, other): - return not isinstance(other, self.__class__) - - def __gt__(self, other): - return False - - def __ge__(self, other): - return False - - def __neg__(self): - return Infinity - - -# BEGIN packaging/version.py - - -NegativeInfinity = NegativeInfinity() - -_Version = collections.namedtuple( - "_Version", - ["epoch", "release", "dev", "pre", "post", "local"], -) - - -def parse(version): - """ - Parse the given version string and return either a :class:`Version` object - or a :class:`LegacyVersion` object depending on if the given version is - a valid PEP 440 version or a legacy version. - """ - try: - return Version(version) - except InvalidVersion: - return LegacyVersion(version) - - -class InvalidVersion(ValueError): - """ - An invalid version was found, users should refer to PEP 440. - """ - - -class _BaseVersion: - - def __hash__(self): - return hash(self._key) - - def __lt__(self, other): - return self._compare(other, lambda s, o: s < o) - - def __le__(self, other): - return self._compare(other, lambda s, o: s <= o) - - def __eq__(self, other): - return self._compare(other, lambda s, o: s == o) - - def __ge__(self, other): - return self._compare(other, lambda s, o: s >= o) - - def __gt__(self, other): - return self._compare(other, lambda s, o: s > o) - - def __ne__(self, other): - return self._compare(other, lambda s, o: s != o) - - def _compare(self, other, method): - if not isinstance(other, _BaseVersion): - return NotImplemented - - return method(self._key, other._key) - - -class LegacyVersion(_BaseVersion): - - def __init__(self, version): - self._version = str(version) - self._key = _legacy_cmpkey(self._version) - - def __str__(self): - return self._version - - def __repr__(self): - return "".format(repr(str(self))) - - @property - def public(self): - return self._version - - @property - def base_version(self): - return self._version - - @property - def local(self): - return None - - @property - def is_prerelease(self): - return False - - @property - def is_postrelease(self): - return False - - -_legacy_version_component_re = re.compile( - r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE, -) - -_legacy_version_replacement_map = { - "pre": "c", "preview": "c", "-": "final-", "rc": "c", "dev": "@", -} - - -def _parse_version_parts(s): - for part in _legacy_version_component_re.split(s): - part = _legacy_version_replacement_map.get(part, part) - - if not part or part == ".": - continue - - if part[:1] in "0123456789": - # pad for numeric comparison - yield part.zfill(8) - else: - yield "*" + part - - # ensure that alpha/beta/candidate are before final - yield "*final" - - -def _legacy_cmpkey(version): - # We hardcode an epoch of -1 here. A PEP 440 version can only have an epoch - # greater than or equal to 0. This will effectively put the LegacyVersion, - # which uses the defacto standard originally implemented by setuptools, - # as before all PEP 440 versions. - epoch = -1 - - # This scheme is taken from pkg_resources.parse_version setuptools prior to - # its adoption of the packaging library. - parts = [] - for part in _parse_version_parts(version.lower()): - if part.startswith("*"): - # remove "-" before a prerelease tag - if part < "*final": - while parts and parts[-1] == "*final-": - parts.pop() - - # remove trailing zeros from each series of numeric parts - while parts and parts[-1] == "00000000": - parts.pop() - - parts.append(part) - parts = tuple(parts) - - return epoch, parts - - -# Deliberately not anchored to the start and end of the string, to make it -# easier for 3rd party code to reuse -VERSION_PATTERN = r""" - v? - (?: - (?:(?P[0-9]+)!)? # epoch - (?P[0-9]+(?:\.[0-9]+)*) # release segment - (?P
                                          # pre-release
-            [-_\.]?
-            (?P(a|b|c|rc|alpha|beta|pre|preview))
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-        (?P                                         # post release
-            (?:-(?P[0-9]+))
-            |
-            (?:
-                [-_\.]?
-                (?Ppost|rev|r)
-                [-_\.]?
-                (?P[0-9]+)?
-            )
-        )?
-        (?P                                          # dev release
-            [-_\.]?
-            (?Pdev)
-            [-_\.]?
-            (?P[0-9]+)?
-        )?
-    )
-    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-"""
-
-
-class Version(_BaseVersion):
-
-    _regex = re.compile(
-        r"^\s*" + VERSION_PATTERN + r"\s*$",
-        re.VERBOSE | re.IGNORECASE,
-    )
-
-    def __init__(self, version):
-        # Validate the version and parse it into pieces
-        match = self._regex.search(version)
-        if not match:
-            raise InvalidVersion("Invalid version: '{0}'".format(version))
-
-        # Store the parsed out pieces of the version
-        self._version = _Version(
-            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
-            release=tuple(int(i) for i in match.group("release").split(".")),
-            pre=_parse_letter_version(
-                match.group("pre_l"),
-                match.group("pre_n"),
-            ),
-            post=_parse_letter_version(
-                match.group("post_l"),
-                match.group("post_n1") or match.group("post_n2"),
-            ),
-            dev=_parse_letter_version(
-                match.group("dev_l"),
-                match.group("dev_n"),
-            ),
-            local=_parse_local_version(match.group("local")),
-        )
-
-        # Generate a key which will be used for sorting
-        self._key = _cmpkey(
-            self._version.epoch,
-            self._version.release,
-            self._version.pre,
-            self._version.post,
-            self._version.dev,
-            self._version.local,
-        )
-
-    def __repr__(self):
-        return "".format(repr(str(self)))
-
-    def __str__(self):
-        parts = []
-
-        # Epoch
-        if self._version.epoch != 0:
-            parts.append("{0}!".format(self._version.epoch))
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self._version.release))
-
-        # Pre-release
-        if self._version.pre is not None:
-            parts.append("".join(str(x) for x in self._version.pre))
-
-        # Post-release
-        if self._version.post is not None:
-            parts.append(".post{0}".format(self._version.post[1]))
-
-        # Development release
-        if self._version.dev is not None:
-            parts.append(".dev{0}".format(self._version.dev[1]))
-
-        # Local version segment
-        if self._version.local is not None:
-            parts.append(
-                "+{0}".format(".".join(str(x) for x in self._version.local))
-            )
-
-        return "".join(parts)
-
-    @property
-    def public(self):
-        return str(self).split("+", 1)[0]
-
-    @property
-    def base_version(self):
-        parts = []
-
-        # Epoch
-        if self._version.epoch != 0:
-            parts.append("{0}!".format(self._version.epoch))
-
-        # Release segment
-        parts.append(".".join(str(x) for x in self._version.release))
-
-        return "".join(parts)
-
-    @property
-    def local(self):
-        version_string = str(self)
-        if "+" in version_string:
-            return version_string.split("+", 1)[1]
-
-    @property
-    def is_prerelease(self):
-        return bool(self._version.dev or self._version.pre)
-
-    @property
-    def is_postrelease(self):
-        return bool(self._version.post)
-
-    @property
-    def version(self) -> tuple:
-        """
-        PATCH: Return version tuple for backward-compatibility.
-        """
-        return self._version.release
-
-
-def _parse_letter_version(letter, number):
-    if letter:
-        # We assume there is an implicit 0 in a pre-release if there is
-        # no numeral associated with it.
-        if number is None:
-            number = 0
-
-        # We normalize any letters to their lower-case form
-        letter = letter.lower()
-
-        # We consider some words to be alternate spellings of other words and
-        # in those cases we want to normalize the spellings to our preferred
-        # spelling.
-        if letter == "alpha":
-            letter = "a"
-        elif letter == "beta":
-            letter = "b"
-        elif letter in ["c", "pre", "preview"]:
-            letter = "rc"
-        elif letter in ["rev", "r"]:
-            letter = "post"
-
-        return letter, int(number)
-    if not letter and number:
-        # We assume that if we are given a number but not given a letter,
-        # then this is using the implicit post release syntax (e.g., 1.0-1)
-        letter = "post"
-
-        return letter, int(number)
-
-
-_local_version_seperators = re.compile(r"[\._-]")
-
-
-def _parse_local_version(local):
-    """
-    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
-    """
-    if local is not None:
-        return tuple(
-            part.lower() if not part.isdigit() else int(part)
-            for part in _local_version_seperators.split(local)
-        )
-
-
-def _cmpkey(epoch, release, pre, post, dev, local):
-    # When we compare a release version, we want to compare it with all of the
-    # trailing zeros removed. So we'll use a reverse the list, drop all the now
-    # leading zeros until we come to something non-zero, then take the rest,
-    # re-reverse it back into the correct order, and make it a tuple and use
-    # that for our sorting key.
-    release = tuple(
-        reversed(list(
-            itertools.dropwhile(
-                lambda x: x == 0,
-                reversed(release),
-            )
-        ))
-    )
-
-    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
-    # We'll do this by abusing the pre-segment, but we _only_ want to do this
-    # if there is no pre- or a post-segment. If we have one of those, then
-    # the normal sorting rules will handle this case correctly.
-    if pre is None and post is None and dev is not None:
-        pre = -Infinity
-    # Versions without a pre-release (except as noted above) should sort after
-    # those with one.
-    elif pre is None:
-        pre = Infinity
-
-    # Versions without a post-segment should sort before those with one.
-    if post is None:
-        post = -Infinity
-
-    # Versions without a development segment should sort after those with one.
-    if dev is None:
-        dev = Infinity
-
-    if local is None:
-        # Versions without a local segment should sort before those with one.
-        local = -Infinity
-    else:
-        # Versions with a local segment need that segment parsed to implement
-        # the sorting rules in PEP440.
-        # - Alphanumeric segments sort before numeric segments
-        # - Alphanumeric segments sort lexicographically
-        # - Numeric segments sort numerically
-        # - Shorter versions sort before longer versions when the prefixes
-        #   match exactly
-        local = tuple(
-            (i, "") if isinstance(i, int) else (-Infinity, i)
-            for i in local
-        )
-
-    return epoch, release, pre, post, dev, local
+from verlib2 import Version  # noqa: F401
diff --git a/src/crate/client/blob.py b/src/crate/client/blob.py
index 73d733ef0..be7b14a01 100644
--- a/src/crate/client/blob.py
+++ b/src/crate/client/blob.py
@@ -22,8 +22,8 @@
 import hashlib
 
 
-class BlobContainer(object):
-    """ class that represents a blob collection in crate.
+class BlobContainer:
+    """class that represents a blob collection in crate.
 
     can be used to download, upload and delete blobs
     """
@@ -34,7 +34,7 @@ def __init__(self, container_name, connection):
 
     def _compute_digest(self, f):
         f.seek(0)
-        m = hashlib.sha1()
+        m = hashlib.sha1()  # noqa: S324
         while True:
             d = f.read(1024 * 32)
             if not d:
@@ -56,7 +56,7 @@ def put(self, f, digest=None):
             read.
         :return:
             The hex digest of the uploaded blob if not provided in the call.
-            Otherwise a boolean indicating if the blob has been newly created.
+            Otherwise, a boolean indicating if the blob has been newly created.
         """
 
         if digest:
@@ -64,8 +64,9 @@ def put(self, f, digest=None):
         else:
             actual_digest = self._compute_digest(f)
 
-        created = self.conn.client.blob_put(self.container_name,
-                                            actual_digest, f)
+        created = self.conn.client.blob_put(
+            self.container_name, actual_digest, f
+        )
         if digest:
             return created
         return actual_digest
@@ -78,8 +79,9 @@ def get(self, digest, chunk_size=1024 * 128):
         :param chunk_size: the size of the chunks returned on each iteration
         :return: generator returning chunks of data
         """
-        return self.conn.client.blob_get(self.container_name, digest,
-                                         chunk_size)
+        return self.conn.client.blob_get(
+            self.container_name, digest, chunk_size
+        )
 
     def delete(self, digest):
         """
diff --git a/src/crate/client/connection.py b/src/crate/client/connection.py
index db4ce473a..f722b8481 100644
--- a/src/crate/client/connection.py
+++ b/src/crate/client/connection.py
@@ -19,40 +19,46 @@
 # with Crate these terms will supersede the license and you may use the
 # software solely pursuant to the terms of the relevant commercial agreement.
 
+from typing import Union
+
+from verlib2 import Version
+
+from .blob import BlobContainer
 from .cursor import Cursor
-from .exceptions import ProgrammingError, ConnectionError
+from .exceptions import ConnectionError, ProgrammingError
 from .http import Client
-from .blob import BlobContainer
-from ._pep440 import Version
-
-
-class Connection(object):
-
-    def __init__(self,
-                 servers=None,
-                 timeout=None,
-                 backoff_factor=0,
-                 client=None,
-                 verify_ssl_cert=True,
-                 ca_cert=None,
-                 error_trace=False,
-                 cert_file=None,
-                 key_file=None,
-                 username=None,
-                 password=None,
-                 schema=None,
-                 pool_size=None,
-                 socket_keepalive=True,
-                 socket_tcp_keepidle=None,
-                 socket_tcp_keepintvl=None,
-                 socket_tcp_keepcnt=None,
-                 converter=None,
-                 time_zone=None,
-                 ):
+
+
+class Connection:
+    def __init__(
+        self,
+        servers=None,
+        timeout=None,
+        backoff_factor=0,
+        client=None,
+        verify_ssl_cert=True,
+        ca_cert=None,
+        error_trace=False,
+        cert_file=None,
+        key_file=None,
+        ssl_relax_minimum_version=False,
+        username=None,
+        password=None,
+        schema=None,
+        pool_size=None,
+        socket_keepalive=True,
+        socket_tcp_keepidle=None,
+        socket_tcp_keepintvl=None,
+        socket_tcp_keepcnt=None,
+        converter=None,
+        time_zone=None,
+        jwt_token=None,
+        compress: Union[int, bool] = 8192,
+    ):
         """
         :param servers:
-            either a string in the form of ':'
-            or a list of servers in the form of [':', '...']
+            either a string in the form of ':/'
+            or a list of servers in the form of [':/', '...']
         :param timeout:
             (optional)
             define the retry timeout for unreachable servers in seconds
@@ -117,12 +123,24 @@ def __init__(self,
             - ``zoneinfo.ZoneInfo("Australia/Sydney")``
             - ``+0530`` (UTC offset in string format)
 
-            When `time_zone` is `None`, the returned `datetime` objects are
-            "naive", without any `tzinfo`, converted using ``datetime.utcfromtimestamp(...)``.
+            The driver always returns timezone-"aware" `datetime` objects,
+            with their `tzinfo` attribute set.
 
-            When `time_zone` is given, the returned `datetime` objects are "aware",
-            with `tzinfo` set, converted using ``datetime.fromtimestamp(..., tz=...)``.
-        """
+            When `time_zone` is `None`, the returned `datetime` objects are
+            using Coordinated Universal Time (UTC), because CrateDB is storing
+            timestamp values in this format.
+
+            When `time_zone` is given, the timestamp values will be transparently
+            converted from UTC to use the given time zone.
+        :param jwt_token:
+            the JWT token to authenticate with the server.
+        :param compress:
+            (optional, defaults to ``8192``)
+            Controls gzip compression of outgoing request bodies.
+            ``False`` disables compression entirely.
+            ``True`` compresses every request regardless of size.
+            An integer compresses only when the payload exceeds that many bytes.
+        """  # noqa: E501
 
         self._converter = converter
         self.time_zone = time_zone
@@ -130,23 +148,27 @@ def __init__(self,
         if client:
             self.client = client
         else:
-            self.client = Client(servers,
-                                 timeout=timeout,
-                                 backoff_factor=backoff_factor,
-                                 verify_ssl_cert=verify_ssl_cert,
-                                 ca_cert=ca_cert,
-                                 error_trace=error_trace,
-                                 cert_file=cert_file,
-                                 key_file=key_file,
-                                 username=username,
-                                 password=password,
-                                 schema=schema,
-                                 pool_size=pool_size,
-                                 socket_keepalive=socket_keepalive,
-                                 socket_tcp_keepidle=socket_tcp_keepidle,
-                                 socket_tcp_keepintvl=socket_tcp_keepintvl,
-                                 socket_tcp_keepcnt=socket_tcp_keepcnt,
-                                 )
+            self.client = Client(
+                servers,
+                timeout=timeout,
+                backoff_factor=backoff_factor,
+                verify_ssl_cert=verify_ssl_cert,
+                ca_cert=ca_cert,
+                error_trace=error_trace,
+                cert_file=cert_file,
+                key_file=key_file,
+                ssl_relax_minimum_version=ssl_relax_minimum_version,
+                username=username,
+                password=password,
+                schema=schema,
+                pool_size=pool_size,
+                socket_keepalive=socket_keepalive,
+                socket_tcp_keepidle=socket_tcp_keepidle,
+                socket_tcp_keepintvl=socket_tcp_keepintvl,
+                socket_tcp_keepcnt=socket_tcp_keepcnt,
+                jwt_token=jwt_token,
+                compress=compress,
+            )
         self.lowest_server_version = self._lowest_server_version()
         self._closed = False
 
@@ -180,7 +202,7 @@ def commit(self):
             raise ProgrammingError("Connection closed")
 
     def get_blob_container(self, container_name):
-        """ Retrieve a BlobContainer for `container_name`
+        """Retrieve a BlobContainer for `container_name`
 
         :param container_name: the name of the BLOB container.
         :returns: a :class:ContainerObject
@@ -197,10 +219,10 @@ def _lowest_server_version(self):
                 continue
             if not lowest or version < lowest:
                 lowest = version
-        return lowest or Version('0.0.0')
+        return lowest or Version("0.0.0")
 
     def __repr__(self):
-        return ''.format(repr(self.client))
+        return f"<{self.__class__.__qualname__} {self.client!r}>"
 
     def __enter__(self):
         return self
diff --git a/src/crate/client/converter.py b/src/crate/client/converter.py
index c4dbf598e..71c06aee7 100644
--- a/src/crate/client/converter.py
+++ b/src/crate/client/converter.py
@@ -23,9 +23,10 @@
 
 https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types
 """
+
+import datetime as dt
 import ipaddress
 from copy import deepcopy
-from datetime import datetime
 from enum import Enum
 from typing import Any, Callable, Dict, List, Optional, Union
 
@@ -33,7 +34,9 @@
 ColTypesDefinition = Union[int, List[Union[int, "ColTypesDefinition"]]]
 
 
-def _to_ipaddress(value: Optional[str]) -> Optional[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]:
+def _to_ipaddress(
+    value: Optional[str],
+) -> Optional[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]:
     """
     https://docs.python.org/3/library/ipaddress.html
     """
@@ -42,20 +45,38 @@ def _to_ipaddress(value: Optional[str]) -> Optional[Union[ipaddress.IPv4Address,
     return ipaddress.ip_address(value)
 
 
-def _to_datetime(value: Optional[float]) -> Optional[datetime]:
+def _to_datetime(value: Optional[float]) -> Optional[dt.datetime]:
     """
     https://docs.python.org/3/library/datetime.html
     """
     if value is None:
         return None
-    return datetime.utcfromtimestamp(value / 1e3)
+    return dt.datetime.fromtimestamp(value / 1e3, tz=dt.timezone.utc)
+
+
+def _to_time(value: Optional[list]) -> Optional[dt.time]:
+    """
+    Convert a CrateDB TIMETZ wire value to a Python ``datetime.time``.
+
+    CrateDB returns TIMETZ as
+    ``[microseconds_since_midnight, tz_offset_seconds]``
+    via the HTTP interface.
+
+    https://docs.python.org/3/library/datetime.html#datetime.time
+    """
+    if value is None:
+        return None
+    microseconds, tz_offset_seconds = value
+    tz = dt.timezone(dt.timedelta(seconds=int(tz_offset_seconds)))
+    t = (dt.datetime.min + dt.timedelta(microseconds=int(microseconds))).time()
+    return t.replace(tzinfo=tz)
 
 
 def _to_default(value: Optional[Any]) -> Optional[Any]:
     return value
 
 
-# Symbolic aliases for the numeric data type identifiers defined by the CrateDB HTTP interface.
+# Data type identifiers defined by the CrateDB HTTP interface.
 # https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types
 class DataType(Enum):
     NULL = 0
@@ -95,6 +116,7 @@ class DataType(Enum):
     DataType.IP: _to_ipaddress,
     DataType.TIMESTAMP_WITH_TZ: _to_datetime,
     DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime,
+    DataType.TIME: _to_time,
 }
 
 
@@ -112,7 +134,9 @@ def get(self, type_: ColTypesDefinition) -> ConverterFunction:
             return self._mappings.get(DataType(type_), self._default)
         type_, inner_type = type_
         if DataType(type_) is not DataType.ARRAY:
-            raise ValueError(f"Data type {type_} is not implemented as collection type")
+            raise ValueError(
+                f"Data type {type_} is not implemented as collection type"
+            )
 
         inner_convert = self.get(inner_type)
 
@@ -128,11 +152,11 @@ def set(self, type_: DataType, converter: ConverterFunction):
 
 
 class DefaultTypeConverter(Converter):
-    def __init__(self, more_mappings: Optional[ConverterMapping] = None) -> None:
+    def __init__(
+        self, more_mappings: Optional[ConverterMapping] = None
+    ) -> None:
         mappings: ConverterMapping = {}
         mappings.update(deepcopy(_DEFAULT_CONVERTERS))
         if more_mappings:
             mappings.update(deepcopy(more_mappings))
-        super().__init__(
-            mappings=mappings, default=_to_default
-        )
+        super().__init__(mappings=mappings, default=_to_default)
diff --git a/src/crate/client/cursor.py b/src/crate/client/cursor.py
index c458ae1b9..0e3312233 100644
--- a/src/crate/client/cursor.py
+++ b/src/crate/client/cursor.py
@@ -18,21 +18,107 @@
 # However, if you have executed another commercial license agreement
 # with Crate these terms will supersede the license and you may use the
 # software solely pursuant to the terms of the relevant commercial agreement.
-from datetime import datetime, timedelta, timezone
-
-from .converter import DataType
-import warnings
+import re
 import typing as t
+import warnings
+from datetime import datetime, timedelta, timezone
+from itertools import count
 
-from .converter import Converter
+from .converter import Converter, DataType
 from .exceptions import ProgrammingError
 
+_NAMED_PARAM_RE = re.compile(r"%\(([^)]+)\)s")
+
+
+def _rewrite_pyformat_sql(sql: str) -> str:
+    """Replace %(name)s placeholders with $N positional markers (1-indexed)."""
+    counter = count(1)
+    return _NAMED_PARAM_RE.sub(lambda _: f"${next(counter)}", sql)
+
+
+def _convert_named_to_positional(
+    sql: str, params: t.Dict[str, t.Any]
+) -> t.Tuple[str, t.List[t.Any]]:
+    """Convert pyformat-style named parameters to positional parameters.
+
+    Converts ``%(name)s`` placeholders to ``$N`` (1-indexed) and returns an
+    ordered list of corresponding values extracted from ``params``.
+
+    The same name may appear multiple times; each occurrence appends the
+    value to the positional list independently.
+
+    Raises ``ProgrammingError`` if a placeholder name is absent from ``params``.
+    Extra keys in ``params`` are silently ignored.
+
+    Example::
+
+        sql = "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s"
+        params = {"a": 1, "b": 2}
+        # returns: ("SELECT * FROM t WHERE a = $1 AND b = $2", [1, 2])
+    """
+    positions = {}
+    idx = 1
+    new_params = []
+    for k, v in params.items():
+        positions[k] = idx
+        new_params.append(v)
+        idx += 1
+
+    def _replace(match: "re.Match[str]") -> str:
+        name = match.group(1)
+        if name not in params:
+            raise ProgrammingError(
+                f"Named parameter '{name}' not found in the parameters dict"
+            )
+        position = positions[name]
+        return f"${position}"
+
+    converted_sql = _NAMED_PARAM_RE.sub(_replace, sql)
+    return converted_sql, new_params
 
-class Cursor(object):
+
+def _convert_named_bulk_params(
+    sql: str, seq_of_dicts: t.Sequence[t.Dict[str, t.Any]]
+) -> t.Tuple[str, t.List[t.List[t.Any]]]:
+    """Convert pyformat SQL and a sequence of dicts to positional bulk args.
+
+    Uses the first row to determine the SQL template and position map, then
+    builds a positional argument list for every row.
+
+    Raises ``ProgrammingError`` if a placeholder name is absent from any row.
+    Extra keys in each row are silently ignored (consistent with
+    ``_convert_named_to_positional``).
+    """
+    first = seq_of_dicts[0]
+    converted_sql, _ = _convert_named_to_positional(sql, first)
+    positions = {k: i + 1 for i, k in enumerate(first)}
+    n = len(positions)
+
+    bulk_args: t.List[t.List[t.Any]] = []
+    for row in seq_of_dicts:
+        if not isinstance(row, dict):
+            raise ProgrammingError(
+                "All bulk parameter rows must be dicts when SQL uses "
+                "pyformat (%(name)s) placeholders; got a non-dict row"
+            )
+        positional: t.List[t.Any] = [None] * n
+        for name, pos in positions.items():
+            if name not in row:
+                raise ProgrammingError(
+                    f"Named parameter '{name}' not found in the parameters dict"
+                )
+            positional[pos - 1] = row[name]
+        bulk_args.append(positional)
+
+    return converted_sql, bulk_args
+
+
+class Cursor:
     """
     not thread-safe by intention
     should not be shared between different threads
     """
+
     lastrowid = None  # currently not supported
 
     def __init__(self, connection, converter: Converter, **kwargs):
@@ -40,7 +126,7 @@ def __init__(self, connection, converter: Converter, **kwargs):
         self.connection = connection
         self._converter = converter
         self._closed = False
-        self._result = None
+        self._result: t.Dict[str, t.Any] = {}
         self.rows = None
         self._time_zone = None
         self.time_zone = kwargs.get("time_zone")
@@ -55,8 +141,19 @@ def execute(self, sql, parameters=None, bulk_parameters=None):
         if self._closed:
             raise ProgrammingError("Cursor closed")
 
-        self._result = self.connection.client.sql(sql, parameters,
-                                                  bulk_parameters)
+        if isinstance(parameters, dict):
+            sql, parameters = _convert_named_to_positional(sql, parameters)
+        elif bulk_parameters is not None and _NAMED_PARAM_RE.search(sql):
+            if bulk_parameters and isinstance(bulk_parameters[0], dict):
+                sql, bulk_parameters = _convert_named_bulk_params(
+                    sql, bulk_parameters
+                )
+            else:
+                sql = _rewrite_pyformat_sql(sql)
+
+        self._result = self.connection.client.sql(
+            sql, parameters, bulk_parameters
+        )
         if "rows" in self._result:
             if self._converter is None:
                 self.rows = iter(self._result["rows"])
@@ -71,11 +168,20 @@ def executemany(self, sql, seq_of_parameters):
         """
         row_counts = []
         durations = []
-        self.execute(sql, bulk_parameters=seq_of_parameters)
+        bulk_parameters = seq_of_parameters
+        if (
+            bulk_parameters
+            and isinstance(bulk_parameters[0], dict)
+            and _NAMED_PARAM_RE.search(sql)
+        ):
+            sql, bulk_parameters = _convert_named_bulk_params(
+                sql, bulk_parameters
+            )
+        self.execute(sql, bulk_parameters=bulk_parameters)
 
-        for result in self._result.get('results', []):
-            if result.get('rowcount') > -1:
-                row_counts.append(result.get('rowcount'))
+        for result in self._result.get("results", []):
+            if result.get("rowcount") > -1:
+                row_counts.append(result.get("rowcount"))
         if self.duration > -1:
             durations.append(self.duration)
 
@@ -85,7 +191,7 @@ def executemany(self, sql, seq_of_parameters):
             "rows": [],
             "cols": self._result.get("cols", []),
             "col_types": self._result.get("col_types", []),
-            "results": self._result.get("results")
+            "results": self._result.get("results"),
         }
         if self._converter is None:
             self.rows = iter(self._result["rows"])
@@ -112,7 +218,7 @@ def __iter__(self):
         This iterator is shared. Advancing this iterator will advance other
         iterators created from this cursor.
         """
-        warnings.warn("DB-API extension cursor.__iter__() used")
+        warnings.warn("DB-API extension cursor.__iter__() used", stacklevel=2)
         return self
 
     def fetchmany(self, count=None):
@@ -126,7 +232,7 @@ def fetchmany(self, count=None):
         if count == 0:
             return self.fetchall()
         result = []
-        for i in range(count):
+        for _ in range(count):
             try:
                 result.append(self.next())
             except StopIteration:
@@ -153,7 +259,7 @@ def close(self):
         Close the cursor now
         """
         self._closed = True
-        self._result = None
+        self._result = {}
 
     def setinputsizes(self, sizes):
         """
@@ -174,7 +280,7 @@ def rowcount(self):
         .execute*() produced (for DQL statements like ``SELECT``) or affected
         (for DML statements like ``UPDATE`` or ``INSERT``).
         """
-        if (self._closed or not self._result or "rows" not in self._result):
+        if self._closed or not self._result or "rows" not in self._result:
             return -1
         return self._result.get("rowcount", -1)
 
@@ -185,15 +291,16 @@ def next(self):
         """
         if self.rows is None:
             raise ProgrammingError(
-                "No result available. " +
-                "execute() or executemany() must be called first."
+                "No result available. "
+                + "execute() or executemany() must be called first."
             )
-        elif not self._closed:
+        if not self._closed:
             return next(self.rows)
         else:
             raise ProgrammingError("Cursor closed")
 
-    __next__ = next
+    def __next__(self):
+        return self.next()
 
     @property
     def description(self):
@@ -201,17 +308,11 @@ def description(self):
         This read-only attribute is a sequence of 7-item sequences.
         """
         if self._closed:
-            return
+            return None
 
         description = []
         for col in self._result["cols"]:
-            description.append((col,
-                                None,
-                                None,
-                                None,
-                                None,
-                                None,
-                                None))
+            description.append((col, None, None, None, None, None, None))
         return tuple(description)
 
     @property
@@ -220,9 +321,7 @@ def duration(self):
         This read-only attribute specifies the server-side duration of a query
         in milliseconds.
         """
-        if self._closed or \
-                not self._result or \
-                "duration" not in self._result:
+        if self._closed or not self._result or "duration" not in self._result:
             return -1
         return self._result.get("duration", 0)
 
@@ -230,21 +329,23 @@ def _convert_rows(self):
         """
         Iterate rows, apply type converters, and generate converted rows.
         """
-        assert "col_types" in self._result and self._result["col_types"], \
-               "Unable to apply type conversion without `col_types` information"
+        if not ("col_types" in self._result and self._result["col_types"]):
+            raise ValueError(
+                "Unable to apply type conversion "
+                "without `col_types` information"
+            )
 
-        # Resolve `col_types` definition to converter functions. Running the lookup
-        # redundantly on each row loop iteration would be a huge performance hog.
+        # Resolve `col_types` definition to converter functions. Running
+        # the lookup redundantly on each row loop iteration would be a
+        # huge performance hog.
         types = self._result["col_types"]
-        converters = [
-            self._converter.get(type) for type in types
-        ]
+        converters = [self._converter.get(type_) for type_ in types]
 
         # Process result rows with conversion.
         for row in self._result["rows"]:
             yield [
                 convert(value)
-                for convert, value in zip(converters, row)
+                for convert, value in zip(converters, row, strict=False)
             ]
 
     @property
@@ -267,11 +368,15 @@ def time_zone(self, tz):
         - ``zoneinfo.ZoneInfo("Australia/Sydney")``
         - ``+0530`` (UTC offset in string format)
 
+        The driver always returns timezone-"aware" `datetime` objects,
+        with their `tzinfo` attribute set.
+
         When `time_zone` is `None`, the returned `datetime` objects are
-        "naive", without any `tzinfo`, converted using ``datetime.utcfromtimestamp(...)``.
+        using Coordinated Universal Time (UTC), because CrateDB is storing
+        timestamp values in this format.
 
-        When `time_zone` is given, the returned `datetime` objects are "aware",
-        with `tzinfo` set, converted using ``datetime.fromtimestamp(..., tz=...)``.
+        When `time_zone` is given, the timestamp values will be transparently
+        converted from UTC to use the given time zone.
         """
 
         # Do nothing when time zone is reset.
@@ -279,18 +384,22 @@ def time_zone(self, tz):
             self._time_zone = None
             return
 
-        # Requesting datetime-aware `datetime` objects needs the data type converter.
+        # Requesting datetime-aware `datetime` objects
+        # needs the data type converter.
         # Implicitly create one, when needed.
         if self._converter is None:
             self._converter = Converter()
 
-        # When the time zone is given as a string, assume UTC offset format, e.g. `+0530`.
+        # When the time zone is given as a string,
+        # assume UTC offset format, e.g. `+0530`.
         if isinstance(tz, str):
             tz = self._timezone_from_utc_offset(tz)
 
         self._time_zone = tz
 
-        def _to_datetime_with_tz(value: t.Optional[float]) -> t.Optional[datetime]:
+        def _to_datetime_with_tz(
+            value: t.Optional[float],
+        ) -> t.Optional[datetime]:
             """
             Convert CrateDB's `TIMESTAMP` value to a native Python `datetime`
             object, with timezone-awareness.
@@ -306,12 +415,17 @@ def _to_datetime_with_tz(value: t.Optional[float]) -> t.Optional[datetime]:
     @staticmethod
     def _timezone_from_utc_offset(tz) -> timezone:
         """
-        Convert UTC offset in string format (e.g. `+0530`) into `datetime.timezone` object.
+        UTC offset in string format (e.g. `+0530`) to `datetime.timezone`.
         """
-        assert len(tz) == 5, f"Time zone '{tz}' is given in invalid UTC offset format"
+        if len(tz) != 5:
+            raise ValueError(
+                f"Time zone '{tz}' is given in invalid UTC offset format"
+            )
         try:
             hours = int(tz[:3])
             minutes = int(tz[0] + tz[3:])
             return timezone(timedelta(hours=hours, minutes=minutes), name=tz)
         except Exception as ex:
-            raise ValueError(f"Time zone '{tz}' is given in invalid UTC offset format: {ex}")
+            raise ValueError(
+                f"Time zone '{tz}' is given in invalid UTC offset format: {ex}"
+            ) from ex
diff --git a/src/crate/client/exceptions.py b/src/crate/client/exceptions.py
index 71bf5d8dd..a77f91918 100644
--- a/src/crate/client/exceptions.py
+++ b/src/crate/client/exceptions.py
@@ -21,7 +21,6 @@
 
 
 class Error(Exception):
-
     def __init__(self, msg=None, error_trace=None):
         # for compatibility reasons we want to keep the exception message
         # attribute because clients may depend on it
@@ -30,8 +29,14 @@ def __init__(self, msg=None, error_trace=None):
         super(Error, self).__init__(msg)
         self.error_trace = error_trace
 
+    def __str__(self):
+        if self.error_trace is None:
+            return super().__str__()
+        return "\n".join([super().__str__(), str(self.error_trace)])
+
 
-class Warning(Exception):
+# A001 Variable `Warning` is shadowing a Python builtin
+class Warning(Exception):  # noqa: A001
     pass
 
 
@@ -69,7 +74,9 @@ class NotSupportedError(DatabaseError):
 
 # exceptions not in db api
 
-class ConnectionError(OperationalError):
+
+# A001 Variable `ConnectionError` is shadowing a Python builtin
+class ConnectionError(OperationalError):  # noqa: A001
     pass
 
 
@@ -79,7 +86,7 @@ def __init__(self, table, digest):
         self.digest = digest
 
     def __str__(self):
-        return "{table}/{digest}".format(table=self.table, digest=self.digest)
+        return f"{self.__class__.__qualname__}('{self.table}/{self.digest}')"
 
 
 class DigestNotFoundException(BlobException):
diff --git a/src/crate/client/http.py b/src/crate/client/http.py
index e932f732e..7492ecf2d 100644
--- a/src/crate/client/http.py
+++ b/src/crate/client/http.py
@@ -21,20 +21,24 @@
 
 
 import calendar
+import datetime as dt
+import gzip
 import heapq
 import io
-import json
 import logging
 import os
 import re
 import socket
 import ssl
 import threading
-from urllib.parse import urlparse
+import typing as t
 from base64 import b64encode
-from time import time
-from datetime import datetime, date
 from decimal import Decimal
+from time import time
+from urllib.parse import SplitResult, urlparse
+
+import orjson
+import urllib3
 from urllib3 import connection_from_url
 from urllib3.connection import HTTPConnection
 from urllib3.exceptions import (
@@ -46,118 +50,189 @@
     SSLError,
 )
 from urllib3.util.retry import Retry
+from verlib2 import Version
+
 from crate.client.exceptions import (
-    ConnectionError,
     BlobLocationNotFoundException,
+    ConnectionError,
     DigestNotFoundException,
+    IntegrityError,
     ProgrammingError,
 )
 
-
 logger = logging.getLogger(__name__)
 
 
-_HTTP_PAT = pat = re.compile('https?://.+', re.I)
-SRV_UNAVAILABLE_STATUSES = set((502, 503, 504, 509))
-PRESERVE_ACTIVE_SERVER_EXCEPTIONS = set((ConnectionResetError, BrokenPipeError))
-SSL_ONLY_ARGS = set(('ca_certs', 'cert_reqs', 'cert_file', 'key_file'))
+_HTTP_PAT = pat = re.compile("https?://.+", re.I)
+SRV_UNAVAILABLE_STATUSES = {502, 503, 504, 509}
+PRESERVE_ACTIVE_SERVER_EXCEPTIONS = {ConnectionResetError, BrokenPipeError}
+SSL_ONLY_ARGS = {"ca_certs", "cert_reqs", "cert_file", "key_file"}
 
 
 def super_len(o):
-    if hasattr(o, '__len__'):
+    if hasattr(o, "__len__"):
         return len(o)
-    if hasattr(o, 'len'):
+    if hasattr(o, "len"):
         return o.len
-    if hasattr(o, 'fileno'):
+    if hasattr(o, "fileno"):
         try:
             fileno = o.fileno()
         except io.UnsupportedOperation:
             pass
         else:
             return os.fstat(fileno).st_size
-    if hasattr(o, 'getvalue'):
+    if hasattr(o, "getvalue"):
         # e.g. BytesIO, cStringIO.StringI
         return len(o.getvalue())
+    return None
 
 
-class CrateJsonEncoder(json.JSONEncoder):
+epoch_aware = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
+epoch_naive = dt.datetime(1970, 1, 1)
 
-    epoch = datetime(1970, 1, 1)
 
-    def default(self, o):
-        if isinstance(o, Decimal):
-            return str(o)
-        if isinstance(o, datetime):
-            delta = o - self.epoch
-            return int(delta.microseconds / 1000.0 +
-                       (delta.seconds + delta.days * 24 * 3600) * 1000.0)
-        if isinstance(o, date):
-            return calendar.timegm(o.timetuple()) * 1000
-        return json.JSONEncoder.default(self, o)
+def json_encoder(obj: t.Any) -> t.Union[int, str]:
+    """
+    Encoder function for orjson, with additional type support.
 
+    - Python's `Decimal` type will be serialized to `str`.
+    - Python's `dt.datetime` and `dt.date` types will be
+      serialized to `int` after converting to milliseconds
+      since epoch.
+    - Python's `dt.time` will be serialized to `str`, following
+    the ISO format.
 
-class Server(object):
+    https://github.com/ijl/orjson#default
+    https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#type-timestamp
+    """
+    if isinstance(obj, Decimal):
+        return str(obj)
+    if isinstance(obj, dt.datetime):
+        if obj.tzinfo is not None:
+            delta = obj - epoch_aware
+        else:
+            delta = obj - epoch_naive
+        return int(
+            delta.microseconds / 1000.0
+            + (delta.seconds + delta.days * 24 * 3600) * 1000.0
+        )
+    if isinstance(obj, dt.time):
+        return obj.isoformat()
+    if isinstance(obj, dt.date):
+        return calendar.timegm(obj.timetuple()) * 1000
+    raise TypeError
 
+
+def json_dumps(obj: t.Any) -> bytes:
+    """
+    Serialize to JSON format, using `orjson`, with additional type support.
+
+    https://github.com/ijl/orjson
+    """
+    return orjson.dumps(
+        obj,
+        default=json_encoder,
+        option=(
+            orjson.OPT_PASSTHROUGH_DATETIME
+            | orjson.OPT_NON_STR_KEYS
+            | orjson.OPT_SERIALIZE_NUMPY
+        ),
+    )
+
+
+class Server:
     def __init__(self, server, **pool_kw):
         socket_options = _get_socket_opts(
-            pool_kw.pop('socket_keepalive', False),
-            pool_kw.pop('socket_tcp_keepidle', None),
-            pool_kw.pop('socket_tcp_keepintvl', None),
-            pool_kw.pop('socket_tcp_keepcnt', None),
+            pool_kw.pop("socket_keepalive", False),
+            pool_kw.pop("socket_tcp_keepidle", None),
+            pool_kw.pop("socket_tcp_keepintvl", None),
+            pool_kw.pop("socket_tcp_keepcnt", None),
         )
+        self.path_prefix = ""
+        try:
+            parsed_url = urlparse(server)
+        except Exception as e:
+            parsed_url = SplitResult("", "", "", "", "")
+            logger.warning(
+                "Unable to extract path prefix from server url: {ex}".format(
+                    ex=e
+                )
+            )
+        if parsed_url.path:
+            self.path_prefix = parsed_url.path.strip("/")
         self.pool = connection_from_url(
             server,
             socket_options=socket_options,
             **pool_kw,
         )
 
-    def request(self,
-                method,
-                path,
-                data=None,
-                stream=False,
-                headers=None,
-                username=None,
-                password=None,
-                schema=None,
-                backoff_factor=0,
-                **kwargs):
+    def request(
+        self,
+        method,
+        path,
+        data=None,
+        stream=False,
+        headers=None,
+        username=None,
+        password=None,
+        schema=None,
+        backoff_factor=0,
+        jwt_token=None,
+        **kwargs,
+    ):
         """Send a request
 
         Always set the Content-Length and the Content-Type header.
         """
+        if self.path_prefix:
+            path = "/{path_prefix}/{path}".format(
+                path_prefix=self.path_prefix, path=path.strip("/")
+            )
         if headers is None:
             headers = {}
-        if 'Content-Length' not in headers:
+        if "Content-Length" not in headers:
             length = super_len(data)
             if length is not None:
-                headers['Content-Length'] = length
+                headers["Content-Length"] = str(length)
+
+        # Sanity checks.
+        if jwt_token is not None and username is not None:
+            raise ValueError(
+                "Either JWT tokens are accepted, "
+                "or user credentials, but not both"
+            )
+
+        # Authentication token
+        if jwt_token is not None and "Authorization" not in headers:
+            headers["Authorization"] = "Bearer %s" % jwt_token
 
         # Authentication credentials
         if username is not None:
-            if 'Authorization' not in headers and username is not None:
-                credentials = username + ':'
+            if "Authorization" not in headers and username is not None:
+                credentials = username + ":"
                 if password is not None:
                     credentials += password
-                headers['Authorization'] = 'Basic %s' % b64encode(credentials.encode('utf-8')).decode('utf-8')
+                headers["Authorization"] = "Basic %s" % b64encode(
+                    credentials.encode("utf-8")
+                ).decode("utf-8")
             # For backwards compatibility with Crate <= 2.2
-            if 'X-User' not in headers:
-                headers['X-User'] = username
+            if "X-User" not in headers:
+                headers["X-User"] = username
 
         if schema is not None:
-            headers['Default-Schema'] = schema
-        headers['Accept'] = 'application/json'
-        headers['Content-Type'] = 'application/json'
-        kwargs['assert_same_host'] = False
-        kwargs['redirect'] = False
-        kwargs['retries'] = Retry(read=0, backoff_factor=backoff_factor)
+            headers["Default-Schema"] = schema
+        headers["Accept"] = "application/json"
+        headers["Content-Type"] = "application/json"
+        kwargs["assert_same_host"] = False
+        kwargs["redirect"] = False
+        kwargs["retries"] = Retry(read=0, backoff_factor=backoff_factor)
         return self.pool.urlopen(
             method,
             path,
             body=data,
             preload_content=not stream,
             headers=headers,
-            **kwargs
+            **kwargs,
         )
 
     def close(self):
@@ -166,45 +241,64 @@ def close(self):
 
 def _json_from_response(response):
     try:
-        return json.loads(response.data.decode('utf-8'))
-    except ValueError:
+        return orjson.loads(response.data)
+    except ValueError as ex:
         raise ProgrammingError(
-            "Invalid server response of content-type '{}':\n{}"
-            .format(response.headers.get("content-type", "unknown"), response.data.decode('utf-8')))
+            "Invalid server response of content-type '{}':\n{}".format(
+                response.headers.get("content-type", "unknown"),
+                response.data.decode("utf-8"),
+            )
+        ) from ex
 
 
 def _blob_path(table, digest):
-    return '/_blobs/{table}/{digest}'.format(table=table, digest=digest)
+    return "/_blobs/{table}/{digest}".format(table=table, digest=digest)
 
 
 def _ex_to_message(ex):
-    return getattr(ex, 'message', None) or str(ex) or repr(ex)
+    return getattr(ex, "message", None) or str(ex) or repr(ex)
 
 
 def _raise_for_status(response):
-    """ make sure that only crate.exceptions are raised that are defined in
-    the DB-API specification """
-    message = ''
+    """
+    Raise `IntegrityError` exceptions for `DuplicateKeyException` errors.
+    """
+    try:
+        return _raise_for_status_real(response)
+    except ProgrammingError as ex:
+        if "DuplicateKeyException" in ex.message:
+            raise IntegrityError(ex.message, error_trace=ex.error_trace) from ex
+        raise
+
+
+def _raise_for_status_real(response):
+    """make sure that only crate.exceptions are raised that are defined in
+    the DB-API specification"""
+    message = ""
     if 400 <= response.status < 500:
-        message = '%s Client Error: %s' % (response.status, response.reason)
+        message = "%s Client Error: %s" % (response.status, response.reason)
     elif 500 <= response.status < 600:
-        message = '%s Server Error: %s' % (response.status, response.reason)
+        message = "%s Server Error: %s" % (response.status, response.reason)
     else:
         return
     if response.status == 503:
         raise ConnectionError(message)
     if response.headers.get("content-type", "").startswith("application/json"):
-        data = json.loads(response.data.decode('utf-8'))
-        error = data.get('error', {})
-        error_trace = data.get('error_trace', None)
+        data = orjson.loads(response.data)
+        error = data.get("error", {})
+        error_trace = data.get("error_trace", None)
         if "results" in data:
-            errors = [res["error_message"] for res in data["results"]
-                      if res.get("error_message")]
+            errors = [
+                res["error_message"]
+                for res in data["results"]
+                if res.get("error_message")
+            ]
             if errors:
                 raise ProgrammingError("\n".join(errors))
         if isinstance(error, dict):
-            raise ProgrammingError(error.get('message', ''),
-                                   error_trace=error_trace)
+            raise ProgrammingError(
+                error.get("message", ""), error_trace=error_trace
+            )
         raise ProgrammingError(error, error_trace=error_trace)
     raise ProgrammingError(message)
 
@@ -215,20 +309,27 @@ def _server_url(server):
 
     >>> print(_server_url('a'))
     http://a
+    >>> print(_server_url('a/path'))
+    http://a/path
     >>> print(_server_url('a:9345'))
     http://a:9345
+    >>> print(_server_url('a:9345/path'))
+    http://a:9345/path
     >>> print(_server_url('https://a:9345'))
     https://a:9345
+    >>> print(_server_url('https://a:9345/path'))
+    https://a:9345/path
     >>> print(_server_url('https://a'))
     https://a
+    >>> print(_server_url('https://a/path'))
+    https://a/path
     >>> print(_server_url('demo.crate.io'))
     http://demo.crate.io
     """
     if not _HTTP_PAT.match(server):
-        server = 'http://%s' % server
+        server = "http://%s" % server
     parsed = urlparse(server)
-    url = '%s://%s' % (parsed.scheme, parsed.netloc)
-    return url
+    return parsed.geturl()
 
 
 def _to_server_list(servers):
@@ -237,27 +338,39 @@ def _to_server_list(servers):
     return [_server_url(s) for s in servers]
 
 
-def _pool_kw_args(verify_ssl_cert, ca_cert, client_cert, client_key,
-                  timeout=None, pool_size=None):
-    ca_cert = ca_cert or os.environ.get('REQUESTS_CA_BUNDLE', None)
+def _pool_kw_args(
+    verify_ssl_cert,
+    ca_cert,
+    client_cert,
+    client_key,
+    timeout=None,
+    pool_size=None,
+):
+    ca_cert = ca_cert or os.environ.get("REQUESTS_CA_BUNDLE", None)
     if ca_cert and not os.path.exists(ca_cert):
         # Sanity check
         raise IOError('CA bundle file "{}" does not exist.'.format(ca_cert))
 
     kw = {
-        'ca_certs': ca_cert,
-        'cert_reqs': ssl.CERT_REQUIRED if verify_ssl_cert else ssl.CERT_NONE,
-        'cert_file': client_cert,
-        'key_file': client_key,
-        'timeout': timeout,
+        "ca_certs": ca_cert,
+        "cert_reqs": ssl.CERT_REQUIRED if verify_ssl_cert else ssl.CERT_NONE,
+        "cert_file": client_cert,
+        "key_file": client_key,
     }
+    if timeout is not None:
+        if isinstance(timeout, str):
+            timeout = float(timeout)
+        kw["timeout"] = timeout
     if pool_size is not None:
-        kw['maxsize'] = pool_size
+        kw["maxsize"] = int(pool_size)
     return kw
 
 
-def _remove_certs_for_non_https(server, kwargs):
-    if server.lower().startswith('https'):
+def _remove_certs_for_non_https(server: str, kwargs: dict) -> dict:
+    """
+    Removes certificates for http requests.
+    """
+    if server.lower().startswith("https"):
         return kwargs
     used_ssl_args = SSL_ONLY_ARGS & set(kwargs.keys())
     if used_ssl_args:
@@ -267,26 +380,37 @@ def _remove_certs_for_non_https(server, kwargs):
     return kwargs
 
 
-def _create_sql_payload(stmt, args, bulk_args):
+def _update_pool_kwargs_for_ssl_minimum_version(server, kwargs):
+    """
+    On urllib3 v2, re-add support for TLS 1.0 and TLS 1.1.
+
+    https://urllib3.readthedocs.io/en/latest/v2-migration-guide.html#https-requires-tls-1-2
+    """
+    if Version(urllib3.__version__) >= Version("2"):
+        from urllib3.util import parse_url
+
+        scheme, _, host, port, *_ = parse_url(server)
+        if scheme == "https":
+            kwargs["ssl_minimum_version"] = ssl.TLSVersion.MINIMUM_SUPPORTED
+
+
+def _create_sql_payload(stmt, args, bulk_args) -> bytes:
     if not isinstance(stmt, str):
-        raise ValueError('stmt is not a string')
+        raise ValueError("stmt is not a string")
     if args and bulk_args:
-        raise ValueError('Cannot provide both: args and bulk_args')
+        raise ValueError("Cannot provide both: args and bulk_args")
 
-    data = {
-        'stmt': stmt
-    }
+    data = {"stmt": stmt}
     if args:
-        data['args'] = args
+        data["args"] = args
     if bulk_args:
-        data['bulk_args'] = bulk_args
-    return json.dumps(data, cls=CrateJsonEncoder)
+        data["bulk_args"] = bulk_args
+    return json_dumps(data)
 
 
-def _get_socket_opts(keepalive=True,
-                     tcp_keepidle=None,
-                     tcp_keepintvl=None,
-                     tcp_keepcnt=None):
+def _get_socket_opts(
+    keepalive=True, tcp_keepidle=None, tcp_keepintvl=None, tcp_keepcnt=None
+):
     """
     Return an optional list of socket options for urllib3's HTTPConnection
     constructor.
@@ -297,25 +421,25 @@ def _get_socket_opts(keepalive=True,
     # always use TCP keepalive
     opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
 
-    # hasattr check because some of the options depend on system capabilities
+    # hasattr check because some options depend on system capabilities
     # see https://docs.python.org/3/library/socket.html#socket.SOMAXCONN
-    if hasattr(socket, 'TCP_KEEPIDLE') and tcp_keepidle is not None:
+    if hasattr(socket, "TCP_KEEPIDLE") and tcp_keepidle is not None:
         opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, tcp_keepidle))
-    if hasattr(socket, 'TCP_KEEPINTVL') and tcp_keepintvl is not None:
+    if hasattr(socket, "TCP_KEEPINTVL") and tcp_keepintvl is not None:
         opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, tcp_keepintvl))
-    if hasattr(socket, 'TCP_KEEPCNT') and tcp_keepcnt is not None:
+    if hasattr(socket, "TCP_KEEPCNT") and tcp_keepcnt is not None:
         opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, tcp_keepcnt))
 
     # additionally use urllib3's default socket options
-    return HTTPConnection.default_socket_options + opts
+    return list(HTTPConnection.default_socket_options) + opts
 
 
-class Client(object):
+class Client:
     """
     Crate connection client using CrateDB's HTTP API.
     """
 
-    SQL_PATH = '/_sql?types=true'
+    SQL_PATH = "/_sql?types=true"
     """Crate URI path for issuing SQL statements."""
 
     retry_interval = 30
@@ -324,24 +448,28 @@ class Client(object):
     default_server = "http://127.0.0.1:4200"
     """Default server to use if no servers are given on instantiation."""
 
-    def __init__(self,
-                 servers=None,
-                 timeout=None,
-                 backoff_factor=0,
-                 verify_ssl_cert=True,
-                 ca_cert=None,
-                 error_trace=False,
-                 cert_file=None,
-                 key_file=None,
-                 username=None,
-                 password=None,
-                 schema=None,
-                 pool_size=None,
-                 socket_keepalive=True,
-                 socket_tcp_keepidle=None,
-                 socket_tcp_keepintvl=None,
-                 socket_tcp_keepcnt=None,
-                 ):
+    def __init__(
+        self,
+        servers=None,
+        timeout=None,
+        backoff_factor=0,
+        verify_ssl_cert=True,
+        ca_cert=None,
+        error_trace=False,
+        cert_file=None,
+        key_file=None,
+        ssl_relax_minimum_version=False,
+        username=None,
+        password=None,
+        schema=None,
+        pool_size=None,
+        socket_keepalive=True,
+        socket_tcp_keepidle=None,
+        socket_tcp_keepintvl=None,
+        socket_tcp_keepcnt=None,
+        jwt_token=None,
+        compress: t.Union[int, bool] = 8192,
+    ):
         if not servers:
             servers = [self.default_server]
         else:
@@ -352,40 +480,57 @@ def __init__(self,
         if servers and not username:
             try:
                 url = urlparse(servers[0])
+
                 if url.username is not None:
                     username = url.username
                 if url.password is not None:
                     password = url.password
             except Exception as ex:
-                logger.warning("Unable to decode credentials from database "
-                               "URI, so connecting to CrateDB without "
-                               "authentication: {ex}"
-                               .format(ex=ex))
+                logger.warning(
+                    "Unable to decode credentials from database "
+                    "URI, so connecting to CrateDB without "
+                    "authentication: {ex}".format(ex=ex)
+                )
 
         self._active_servers = servers
-        self._inactive_servers = []
+        self._inactive_servers: t.List[t.Tuple[float, str, str]] = []
         pool_kw = _pool_kw_args(
-            verify_ssl_cert, ca_cert, cert_file, key_file, timeout, pool_size,
+            verify_ssl_cert,
+            ca_cert,
+            cert_file,
+            key_file,
+            timeout,
+            pool_size,
+        )
+        pool_kw.update(
+            {
+                "socket_keepalive": socket_keepalive,
+                "socket_tcp_keepidle": socket_tcp_keepidle,
+                "socket_tcp_keepintvl": socket_tcp_keepintvl,
+                "socket_tcp_keepcnt": socket_tcp_keepcnt,
+            }
         )
-        pool_kw.update({
-            'socket_keepalive': socket_keepalive,
-            'socket_tcp_keepidle': socket_tcp_keepidle,
-            'socket_tcp_keepintvl': socket_tcp_keepintvl,
-            'socket_tcp_keepcnt': socket_tcp_keepcnt,
-        })
+        self.ssl_relax_minimum_version = ssl_relax_minimum_version
         self.backoff_factor = backoff_factor
-        self.server_pool = {}
+        self.server_pool: t.Dict[str, Server] = {}
         self._update_server_pool(servers, **pool_kw)
         self._pool_kw = pool_kw
         self._lock = threading.RLock()
         self._local = threading.local()
         self.username = username
         self.password = password
+        self.jwt_token = jwt_token
         self.schema = schema
 
+        if not isinstance(compress, (bool, int)):
+            raise TypeError(
+                f"compress must be bool or int, got {type(compress).__name__!r}"
+            )
+        self.compress = compress
+
         self.path = self.SQL_PATH
         if error_trace:
-            self.path += '&error_trace=true'
+            self.path += "&error_trace=true"
 
     def close(self):
         for server in self.server_pool.values():
@@ -393,6 +538,11 @@ def close(self):
 
     def _create_server(self, server, **pool_kw):
         kwargs = _remove_certs_for_non_https(server, pool_kw)
+        # After updating to urllib3 v2, optionally retain support
+        # for TLS 1.0 and TLS 1.1, in order to support connectivity
+        # to older versions of CrateDB.
+        if self.ssl_relax_minimum_version:
+            _update_pool_kwargs_for_ssl_minimum_version(server, kwargs)
         self.server_pool[server] = Server(server, **kwargs)
 
     def _update_server_pool(self, servers, **pool_kw):
@@ -407,28 +557,26 @@ def sql(self, stmt, parameters=None, bulk_parameters=None):
             return None
 
         data = _create_sql_payload(stmt, parameters, bulk_parameters)
-        logger.debug(
-            'Sending request to %s with payload: %s', self.path, data)
-        content = self._json_request('POST', self.path, data=data)
+        logger.debug("Sending request to %s with payload: %s", self.path, data)
+        content = self._json_request("POST", self.path, data=data)
         logger.debug("JSON response for stmt(%s): %s", stmt, content)
 
         return content
 
     def server_infos(self, server):
-        response = self._request('GET', '/', server=server)
+        response = self._request("GET", "/", server=server)
         _raise_for_status(response)
         content = _json_from_response(response)
         node_name = content.get("name")
-        node_version = content.get('version', {}).get('number', '0.0.0')
+        node_version = content.get("version", {}).get("number", "0.0.0")
         return server, node_name, node_version
 
-    def blob_put(self, table, digest, data):
+    def blob_put(self, table, digest, data) -> bool:
         """
         Stores the contents of the file like @data object in a blob under the
         given table and digest.
         """
-        response = self._request('PUT', _blob_path(table, digest),
-                                 data=data)
+        response = self._request("PUT", _blob_path(table, digest), data=data)
         if response.status == 201:
             # blob created
             return True
@@ -438,40 +586,43 @@ def blob_put(self, table, digest, data):
         if response.status in (400, 404):
             raise BlobLocationNotFoundException(table, digest)
         _raise_for_status(response)
+        return False
 
-    def blob_del(self, table, digest):
+    def blob_del(self, table, digest) -> bool:
         """
         Deletes the blob with given digest under the given table.
         """
-        response = self._request('DELETE', _blob_path(table, digest))
+        response = self._request("DELETE", _blob_path(table, digest))
         if response.status == 204:
             return True
         if response.status == 404:
             return False
         _raise_for_status(response)
+        return False
 
     def blob_get(self, table, digest, chunk_size=1024 * 128):
         """
         Returns a file like object representing the contents of the blob
         with the given digest.
         """
-        response = self._request('GET', _blob_path(table, digest), stream=True)
+        response = self._request("GET", _blob_path(table, digest), stream=True)
         if response.status == 404:
             raise DigestNotFoundException(table, digest)
         _raise_for_status(response)
         return response.stream(amt=chunk_size)
 
-    def blob_exists(self, table, digest):
+    def blob_exists(self, table, digest) -> bool:
         """
         Returns true if the blob with the given digest exists
         under the given table.
         """
-        response = self._request('HEAD', _blob_path(table, digest))
+        response = self._request("HEAD", _blob_path(table, digest))
         if response.status == 200:
             return True
         elif response.status == 404:
             return False
         _raise_for_status(response)
+        return False
 
     def _add_server(self, server):
         with self._lock:
@@ -493,49 +644,62 @@ def _request(self, method, path, server=None, **kwargs):
                     password=self.password,
                     backoff_factor=self.backoff_factor,
                     schema=self.schema,
-                    **kwargs
+                    jwt_token=self.jwt_token,
+                    **kwargs,
                 )
                 redirect_location = response.get_redirect_location()
                 if redirect_location and 300 <= response.status <= 308:
-                    redirect_server = _server_url(redirect_location)
+                    redirect_url = urlparse(redirect_location)
+                    redirect_server = f"{redirect_url.scheme}://{redirect_url.netloc}"
                     self._add_server(redirect_server)
                     return self._request(
-                        method, path, server=redirect_server, **kwargs)
+                        method, path, server=redirect_server, **kwargs
+                    )
                 if not server and response.status in SRV_UNAVAILABLE_STATUSES:
                     with self._lock:
                         # drop server from active ones
                         self._drop_server(next_server, response.reason)
                 else:
                     return response
-            except (MaxRetryError,
-                    ReadTimeoutError,
-                    SSLError,
-                    HTTPError,
-                    ProxyError,) as ex:
+            except (
+                MaxRetryError,
+                ReadTimeoutError,
+                SSLError,
+                HTTPError,
+                ProxyError,
+            ) as ex:
                 ex_message = _ex_to_message(ex)
                 if server:
                     raise ConnectionError(
                         "Server not available, exception: %s" % ex_message
-                    )
+                    ) from ex
                 preserve_server = False
                 if isinstance(ex, ProtocolError):
                     preserve_server = any(
                         t in [type(arg) for arg in ex.args]
                         for t in PRESERVE_ACTIVE_SERVER_EXCEPTIONS
                     )
-                if (not preserve_server):
+                if not preserve_server:
                     with self._lock:
                         # drop server from active ones
                         self._drop_server(next_server, ex_message)
             except Exception as e:
-                raise ProgrammingError(_ex_to_message(e))
+                raise ProgrammingError(_ex_to_message(e)) from e
 
     def _json_request(self, method, path, data):
         """
         Issue request against the crate HTTP API.
         """
+        headers = {"Accept-Encoding": "gzip, deflate"}
+
+        compress_enabled = self.compress is True or (
+            not isinstance(self.compress, bool) and len(data) >= self.compress
+        )
+        if compress_enabled:
+            data = gzip.compress(data, compresslevel=6)
+            headers["Content-Encoding"] = "gzip"
 
-        response = self._request(method, path, data=data)
+        response = self._request(method, path, data=data, headers=headers)
         _raise_for_status(response)
         if len(response.data) > 0:
             return _json_from_response(response)
@@ -548,7 +712,7 @@ def _get_server(self):
         """
         with self._lock:
             inactive_server_count = len(self._inactive_servers)
-            for i in range(inactive_server_count):
+            for _ in range(inactive_server_count):
                 try:
                     ts, server, message = heapq.heappop(self._inactive_servers)
                 except IndexError:
@@ -556,12 +720,14 @@ def _get_server(self):
                 else:
                     if (ts + self.retry_interval) > time():
                         # Not yet, put it back
-                        heapq.heappush(self._inactive_servers,
-                                       (ts, server, message))
+                        heapq.heappush(
+                            self._inactive_servers, (ts, server, message)
+                        )
                     else:
                         self._active_servers.append(server)
-                        logger.warning("Restored server %s into active pool",
-                                       server)
+                        logger.warning(
+                            "Restored server %s into active pool", server
+                        )
 
             # if none is old enough, use oldest
             if not self._active_servers:
@@ -595,8 +761,9 @@ def _drop_server(self, server, message):
         # if this is the last server raise exception, otherwise try next
         if not self._active_servers:
             raise ConnectionError(
-                ("No more Servers available, "
-                 "exception from last server: %s") % message)
+                ("No more Servers available, exception from last server: %s")
+                % message
+            )
 
     def _roundrobin(self):
         """
@@ -605,4 +772,4 @@ def _roundrobin(self):
         self._active_servers.append(self._active_servers.pop(0))
 
     def __repr__(self):
-        return ''.format(str(self._active_servers))
+        return "".format(str(self._active_servers))
diff --git a/src/crate/client/pki/cacert_valid.pem b/src/crate/client/pki/cacert_valid.pem
deleted file mode 100644
index e169a7fda..000000000
--- a/src/crate/client/pki/cacert_valid.pem
+++ /dev/null
@@ -1,49 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDN4PgIebnsjVfY
-cwop20PNA2w8vRZvU3M9Ust7YP6ZrOaGFrg0l4uipDSyJa8G4jlbmIpr0ypwf7bZ
-2uE8ICLQ0QBRmywA6KsOoiC3JP1/c6AD0veiAl8587S4i8+HtKd5CVjxsZkHmvqo
-fuLK4/xporLES1+RU5E6d64gafbSI0dUJFIVfUySTaDvzJIPl6kfZFJqA1NQNJ24
-B0aa6y0dbXfUtMialRNCJyIuVny0kWKyPIVho+pVgJxEp8xyrkDld0urOG8EkQkU
-Hk9MHcPaeKwQ0T0l+qzKRjyTG3ymx+af68xU6s4dM2g9YM0+2tkZCeDoWkTA9OQE
-+GmOJAc/AgMBAAECggEAFHQoUDc/uHemZZOwS85D4ydW5oXmp7LDvTDvlFdjlALw
-eBvjux3fOo5Tyesig22QQ0BZaDL3gWu+z9AGFoIe014gSPtAmOqErBSBaZCcOsBT
-Am5AOfFAYrKKntcNDC9vf//kvUZmrLHB+2F3yK5z0k7esc/HM9n4kLV5MDE221OG
-EsEx4peGpizFn5K7O9Ek4caVTYcDVMjBp2dug6N626cMBfcIdSiZKkdGOROHEWZk
-DdGf3oWoGGVQ8wzMYyw4ZV2B6TNDFB0afaEkF5Z7Yj86Z1KC+uK8nJMVtqJmZbtt
-DfjvrflB4uJf91ddDpq0o15AxyczGtCMfWvRnk0HoQKBgQD+sWfouFMX7mtaY47G
-rV7M9RFHKsrdYhHqiMdtef1cIIO1srYhX3tjUis90WJdDWyEqC6xbMZhpgafCeXE
-MwPTcOswrP6irITaTRzwPE7as7nrj2axbTRJUYxktWFruYE8uPWidGz/g0vfDhu8
-kIP5Bfee2Qe9kmH3MK3zFgP/jwKBgQDO729cwE8Tld6+sLbw6S+r4slZVAv1Shja
-au5OQSF19h1lTWZRgooBmx5GkNz/ur7l0OXur+F7FE+rWtn1sRMR5lZs53QssqEY
-iKlPTk0phpQbCI5GJwiy17DoHZMt5iqZVWgK28aXHikBoIg+fOL0wL5Y92DoiG3q
-MjWpdcelUQKBgQCS9F3WI3SeXEzI7KTW8fW/ILAFdiVzM0DPKHiZLEgJviEA18rK
-2sLg/epBUu0Eb9hrenbmnLKiaR9s6FMQr7bHa2HoxghuaEiHhPLrkoCVJBpkVmuU
-eEQxAcKV4SoC9BgjpzzjrXWuwF0oqIVMeb4ME2ta1jLnKO0pqYbUuaE49QKBgBHg
-wcBDpRFOG2ZiIgwCOWoiN78N8dKJSkhkgJ4mJlvonXWJEFPucTneSulRzqYRXjjA
-qXzLmTFm+dMWEEqXt8wOGF1kSbcq35wdAnOlkikKRXVocdJBwRCibdg/5d1LS1bf
-+BMoFaosouJPGjY71+fJVyichrTQRJ69I8G2OT9hAoGBAKSTyncv8s8Z1M44PCZA
-IozDM+tj1I6ipZv2QihweI1goATDcaRhqLGlS++WI5SOCtXcW57VzLBXi05eFJoK
-ylYmuLOFzELE8J9l5uERzEag6ioov69hne4drHxpY7Tr2zmEE6TKKczIKtx+oipD
-wNTpdf2QRgs5LjpQ7UqM4n9B
------END PRIVATE KEY-----
------BEGIN CERTIFICATE-----
-MIIDhTCCAm2gAwIBAgIJALDywhQYMNucMA0GCSqGSIb3DQEBCwUAMFkxCzAJBgNV
-BAYTAkFUMRMwEQYDVQQIDApWb3JhcmxiZXJnMREwDwYDVQQHDAhEb3JuYmlybjEO
-MAwGA1UECgwFQ3JhdGUxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xNjA1MTMxNTM1
-NDdaFw0zNjA1MDgxNTM1NDdaMFkxCzAJBgNVBAYTAkFUMRMwEQYDVQQIDApWb3Jh
-cmxiZXJnMREwDwYDVQQHDAhEb3JuYmlybjEOMAwGA1UECgwFQ3JhdGUxEjAQBgNV
-BAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM3g
-+Ah5ueyNV9hzCinbQ80DbDy9Fm9Tcz1Sy3tg/pms5oYWuDSXi6KkNLIlrwbiOVuY
-imvTKnB/ttna4TwgItDRAFGbLADoqw6iILck/X9zoAPS96ICXznztLiLz4e0p3kJ
-WPGxmQea+qh+4srj/GmissRLX5FTkTp3riBp9tIjR1QkUhV9TJJNoO/Mkg+XqR9k
-UmoDU1A0nbgHRprrLR1td9S0yJqVE0InIi5WfLSRYrI8hWGj6lWAnESnzHKuQOV3
-S6s4bwSRCRQeT0wdw9p4rBDRPSX6rMpGPJMbfKbH5p/rzFTqzh0zaD1gzT7a2RkJ
-4OhaRMD05AT4aY4kBz8CAwEAAaNQME4wHQYDVR0OBBYEFBQy5IK2vjhzQHSPkONV
-qG1AhiV5MB8GA1UdIwQYMBaAFBQy5IK2vjhzQHSPkONVqG1AhiV5MAwGA1UdEwQF
-MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAMk8XevGvMzpr1CitS7+lpHuL97pjNAe
-/a5xWNRDkjlppyD1MybaiwwTzlIIfdL35mK2LkzVaT+0q0TzK5aSspMx3/KeM9P9
-A9a4cGcY7qYIabEz1m3etqHse1SvBA/GhxfPL7/xHILhFP2fL1Ds2bSxREbQTP1M
-O3nWPlgW3TWOPGnHYpUpbqBT2LdGBaA6H/abycvAcV9ihCy2+fMupvhqiA0ARqQt
-yWyX4OEXcCEaIHHobhpXzu9qNLoi9IP1SaqUHZ1w8ave/URP+gwMAc6J+QTc06xI
-9hg0DKKizjNgnjmzPgHh7M8B7OHStO4BeWyMy7Kp9mcqU9lEVcILUPU=
------END CERTIFICATE-----
diff --git a/src/crate/client/pki/client_invalid.pem b/src/crate/client/pki/client_invalid.pem
deleted file mode 100644
index 785473904..000000000
--- a/src/crate/client/pki/client_invalid.pem
+++ /dev/null
@@ -1,49 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDN4lSw+BESq83X
-tfWcFfvXSKrErh+DpN06XpL0tCP/wGobnHtjzEMXSrFC+zO2bqU8HwmdjuAGSVEy
-3Gtn1krBr1LUhMDNXKywRlh69JylAFgXhllCCe7e4U06cV1Mn3TgMKeFr8TEZJIp
-EKP74HxCKYgnp068h0P/JpDZDjUpjoOHRYR3Fw6PlCR6Y8N4jkOxz6p7M9gBumx8
-cIZDI4wEIZ1nWGj4Sw+x54EdrmBweFYb8jTSOcjsPbEhcPcyP2uxo2lcaXtjz3/F
-an278pGXsdfDCS5L5XCdxTcppDqMFd7WH1309H767mjxFlI1wqA3GF7ilEjlSVIr
-MOREzQEJAgMBAAECggEAW1kupo6KVLRx0mHjpeuDIBQX4Mg5J1nA5qMLpRNAXbtr
-2PBnNvJsWitD1ypJ2YniOniy9XttHpztMBnoddv4s1Ms9yonuXaDEHtFytg5oGQ0
-sctkUw7BM5bXgzTFZrfhTY+I5KIGNfVBfILrn1gNCfYPeTickL2bh9v+rK/HGrZa
-xnCTZRVsaYyZohpNcSrGj8QIKg/4LjLY/Y1dxPkZ7nck6CwZJhLakuefeUWwkfkK
-R9gO6eIdpopDS7t0e5RQ35NtAMlWIMUv5qwjKz80ueyhsTwfJZE0W5EI5eAaRJ9y
-P4iJZIZDUY2A4DsadoxX/oryygDUQ2iZ2+kXXbVquQKBgQDzZyF8DubkQrDVmPSt
-H4LyDY3/31yZEyB2D3DeWs7X8IAEmjDFeVRqq09BHvt9jEBGyBxlUtLZLoci5cYk
-Fi8OgLwDBgIxQmFhQUCXH0OVuW4iZbyLzwLhpIUaUjgQi+k9hHrXioo9odThuy7F
-81k1wZI7hDC6G+qXp/FS/+aLrwKBgQDYihw76a+4fhNC6zBxVQKWe8DZKbkv6T1V
-xMC7Re0k10egYPatY9jvPqyDY8Zlqu008pWSEIRB8jhqJ5r5yO4u6yyg6rYKkMC2
-J+8BC/quGieBDYUeAmbmRDHUIX1iHELleFr4FRczWfkOOsgzKr1h+17KY1cNyief
-X+IqFI9UxwKBgQDyAaNoXsSxRaHe7jKwgzlGA7YhJ2tBA6Rt3hJh8rXgPE58xPYj
-EeyeFnA5ll2EydMKzWJ2V/AuYjWYvA7SyH/HErZc5zd81LxP33oiB8LB9lmLt83M
-0GnUAikZL5Bw2ztvn+4nqqaieupX+i2aQcd8TFdh96AfGyyX1zJ5TNhkHwKBgEaE
-A1nHaf/snp0mNepSQrt6pXySx8nAbMbngdP6m5VpvduOeAZTA6w1frxy24L0PLcH
-YInmcwt+s7xuFVvOgTIqR6hHhuy94uPu8TgoDIRx4/d0zarOIXBPOOLZ3Rj8FxTf
-MtCjHaENZbuqjNOM0Yt87ot9+jV1ZZ3S/bWyaFK1AoGBAJREK4guQ0qmTjzWuT6S
-U/3SodR0kiRkfEM7u73dGMrRKk+9m1C0z7wY4hN8BIjvBzzwABXzwScuSFhIH0rY
-B4GNWdzAOZwKRyV9YjtM+pK6bY+Kx5hRnGmTWsaAEt14SjGYOdU6lwdhuP5EWdoc
-MU0GzRm9x98HSQD1dyrtT4MX
------END PRIVATE KEY-----
------BEGIN CERTIFICATE-----
-MIIDdTCCAl2gAwIBAgIUNtmyUs7LRi9mvG1Y7NtDAbHlyJMwDQYJKoZIhvcNAQEL
-BQAwWTELMAkGA1UEBhMCQVQxEzARBgNVBAgMClZvcmFybGJlcmcxETAPBgNVBAcM
-CERvcm5iaXJuMQ4wDAYDVQQKDAVDcmF0ZTESMBAGA1UEAwwJbG9jYWxob3N0MCAX
-DTIzMDEyNjE1MzQxOFoYDzE5MjMwMjIwMTUzNDE4WjB4MQswCQYDVQQGEwJBVDET
-MBEGA1UECAwKVm9yYXJsYmVyZzERMA8GA1UEBwwIRG9ybmJpcm4xETAPBgNVBAoM
-CENyYXRlLmlvMQ4wDAYDVQQDDAVob3JzdDEeMBwGCSqGSIb3DQEJARYPbm9ib2R5
-QGNyYXRlLmlvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzeJUsPgR
-EqvN17X1nBX710iqxK4fg6TdOl6S9LQj/8BqG5x7Y8xDF0qxQvsztm6lPB8JnY7g
-BklRMtxrZ9ZKwa9S1ITAzVyssEZYevScpQBYF4ZZQgnu3uFNOnFdTJ904DCnha/E
-xGSSKRCj++B8QimIJ6dOvIdD/yaQ2Q41KY6Dh0WEdxcOj5QkemPDeI5Dsc+qezPY
-AbpsfHCGQyOMBCGdZ1ho+EsPseeBHa5gcHhWG/I00jnI7D2xIXD3Mj9rsaNpXGl7
-Y89/xWp9u/KRl7HXwwkuS+VwncU3KaQ6jBXe1h9d9PR++u5o8RZSNcKgNxhe4pRI
-5UlSKzDkRM0BCQIDAQABoxQwEjAQBgNVHREECTAHggVob3JzdDANBgkqhkiG9w0B
-AQsFAAOCAQEAJvqhvFiMpXTzH5dE/t2UFqMy7UPd4mypI2YqBelvN7pw/wQI1OIZ
-N9bk52N6M9CuaENpgxkUAFVuPFSOa9Bp2/qA+TysyWC4+iSukL9+pQg8fmd3Ul7e
-DYVHsOLjB2DwiK+og/P/kUvBEJ2z13EmtjNr4id1cWAD9r2Eh+dAbKS1MtvXCFMc
-USJzAJ/QKw5h1x+ddub38zxgXgIQiDlWt4uwN9M6d/T+dAMm5FIU3MHQsdADBMtG
-iXT5F/wz7f7UBbGK3kt3EuhBXHN8i84eTxZKb4a9g8L6AIzuSx4dg4q6vg9lee7P
-umKNSySbfHF8ex/R2CLoDPW8NbXzOoBdCg==
------END CERTIFICATE-----
diff --git a/src/crate/client/pki/client_valid.pem b/src/crate/client/pki/client_valid.pem
deleted file mode 100644
index 8473b9dd0..000000000
--- a/src/crate/client/pki/client_valid.pem
+++ /dev/null
@@ -1,49 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCguOpnDbcZvnH2
-trOBWGCkwTfEjLSHYA3S+f799Kiyc33CrvqRjxH9m+0N2Lt5JIrV2ToiRS49lxc0
-nEhbGAIXSjLVq1UMhTeXTWP1Rkl2v7Q01dQc1Oe/895mjGMrKoA+Mj+x4mkJfiAx
-HDIhZ+YDs/DCR6ONKb/3xpr85YuBDEpXNINYAov/B6DSH4/N57F/5o6/PQhaa51b
-8eMhF90aPPWNGxLBOzOJPGbp+ci9gee7tQimihtk5UDhFJn1CXRd7+3VoL4PM4JI
-rq7veZDEA4JFEqcBPcYuwd+R+MdIK3kPy+Y+tXIqAL9weC66xImUvafRBH9Bae1o
-hzOzNXItAgMBAAECggEAepjzPI7Kt5l9BwuQW77VYXd9mbEW2BjeEqvd5UvmDtFo
-AcRFoBi5SXHSXniPcLX+LWeZH6ETx6lj6x63Vr02gxt3MWOS6Y0IvaIr+GGYEjvj
-M1ZUiXWiHdrhL+owjzHJRyg/S+p/4tzRo4R1fOPrIbH1mczZpglNxKw7d2OFiXZn
-5Y4RcFn2rx3O6Qzldo6Am+pPm0FLugvzQ8pFlWyO1OI9NcAJ9fMjMNjXwmis6bUj
-gZTXESsBLHaljefWC5CK2iDUo0VwZKJG15b2rskPShStlSIwhmnP46osizfZ2gCK
-9C0GJ0PVYbHp15SnbWzoYx3Ny3sXGA6PxpRl7Xj7xQKBgQDSn9rXhxBqSdXFqAUn
-6bGuhumN2KmezQOSGiMFVjZLZT6DR/fyh+5pah8v7okZHUxI+pTzQu0aBzWMpZqp
-dPuVPyJyfyK/J8c5MiFbf7jqKhlEBTFDkhDvtoTTXc1+iSpusz76WMwnm5yJNq9/
-M/uj0z23ufUgl/+rHScZX/jNDwKBgQDDWOkRNqKRVp550QP1TFr37LVvm2QVuJux
-JpEwJhLjMFipQjJPlli7FiruPPsKnMXmqKAEJuzFreGCkRnsI+fCcJHuiZ/KV6zf
-jTxL0rCoY0EkTYNztJpWPpY46uwpz7YsPjQ5tYInDibdlHz/liqR3WMnUqhtEkCZ
-4xeRR5pFAwKBgHCEGOI+MtS08NAQPADgZJz5UVcHQUWl+5xW/hJhxcttIIH9NkWH
-vCLwIAz4/qA9+Hyb8GorfIIFC+RAq2iPJ77I5VwI8sTvOQwi7ZL1nhDpwGmH5JNW
-Oln0ROytFZPdLp/IfYI7YYRfKrZaUlI/sNQJitTVME/jIx+ECVkS3dSdAoGAUGqy
-j36BT/lrhdRQn9OOA0/zpP1AJ1z24udwj3StA8+sQAlbMr4+ys2mYKrD4auGIJ08
-OllX0Uzyb9CR7k8dokK7IIqRODf9l43Jy6DxTnCFqY8rVR99BZIAP3AeRlwWr4Hv
-9+3LpY5C26a8Cm9kGOYdYlu5sCT6aR8+XXUvgccCgYEArsqN2ialQsUseutRn/kf
-apBHxGm9ihPDZ9csnnX7OPlQL3Y4X4B5p3k98pwZ9dJlm/HAm0cM2KoXo7DdR2O+
-XDBkTvPLPJaNA9y/AS2Lam9IVqVfx5vkOSwLdWXh1wgNDnSn4Q3x63MbCkFbNEuH
-jZ3cOn6SqwD0tiV+Dg45NRM=
------END PRIVATE KEY-----
------BEGIN CERTIFICATE-----
-MIIDejCCAmKgAwIBAgIUNtmyUs7LRi9mvG1Y7NtDAbHlyI4wDQYJKoZIhvcNAQEL
-BQAwWTELMAkGA1UEBhMCQVQxEzARBgNVBAgMClZvcmFybGJlcmcxETAPBgNVBAcM
-CERvcm5iaXJuMQ4wDAYDVQQKDAVDcmF0ZTESMBAGA1UEAwwJbG9jYWxob3N0MCAX
-DTIzMDEyNjE1MTMyMVoYDzMwMDMwMzMwMTUxMzIxWjB5MQswCQYDVQQGEwJBVDET
-MBEGA1UECAwKVm9yYXJsYmVyZzERMA8GA1UEBwwIRG9ybmJpcm4xDjAMBgNVBAoM
-BUNyYXRlMRIwEAYDVQQDDAlsb2NhbGhvc3QxHjAcBgkqhkiG9w0BCQEWD25vYm9k
-eUBjcmF0ZS5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKC46mcN
-txm+cfa2s4FYYKTBN8SMtIdgDdL5/v30qLJzfcKu+pGPEf2b7Q3Yu3kkitXZOiJF
-Lj2XFzScSFsYAhdKMtWrVQyFN5dNY/VGSXa/tDTV1BzU57/z3maMYysqgD4yP7Hi
-aQl+IDEcMiFn5gOz8MJHo40pv/fGmvzli4EMSlc0g1gCi/8HoNIfj83nsX/mjr89
-CFprnVvx4yEX3Ro89Y0bEsE7M4k8Zun5yL2B57u1CKaKG2TlQOEUmfUJdF3v7dWg
-vg8zgkiuru95kMQDgkUSpwE9xi7B35H4x0greQ/L5j61cioAv3B4LrrEiZS9p9EE
-f0Fp7WiHM7M1ci0CAwEAAaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqG
-SIb3DQEBCwUAA4IBAQBCTZ3nMg+Y2ymj9DgNPW5KAMGwdphv8ugO5fCRoGUtYc1B
-Yz6ZGYUIbIDImgSr/czE2O7BVOwOkWmeXOCTRL8n30Wm2yVT90NZ9jG6dOX2eF2M
-7Lyh7+Vy4XuDcura+/5y3PjTsApNUeCZWQgwrLSV8xNvrSH8Cbv3yS4b3rzMVb4l
-RipVO9V75SNcduvLDR3VNK3c+mlhX03FYuJ6XZjgX/hvf8fZdCrUqfmM2NSwvQdj
-QH3m1Fh5rh3xi+ReiBVP4R4uF2mSDqaqd+iTpLzV6VSwfT58m+AgRss5xgzCWxlf
-Xwwb1pa5q7eZXxZOjnfWaIgJmkdGYLco/ZVvWV11
------END CERTIFICATE-----
diff --git a/src/crate/client/pki/readme.rst b/src/crate/client/pki/readme.rst
deleted file mode 100644
index 74c75e1a8..000000000
--- a/src/crate/client/pki/readme.rst
+++ /dev/null
@@ -1,91 +0,0 @@
-#########################
-Generate new certificates
-#########################
-
-
-*****
-About
-*****
-
-For conducting TLS connectivity tests, there are a few X.509 certificates at
-`src/crate/client/pki/*.pem`_. The instructions here outline how to renew them.
-
-In order to invoke the corresponding test cases, run::
-
-    ./bin/test -t https.rst
-
-
-*******
-Details
-*******
-
-
-``*_valid.pem``
-===============
-
-By example, this will renew the ``client_valid.pem`` X.509 certificate. The
-``server_valid.pem`` certificate can be generated in the same manner.
-
-Create RSA private key and certificate request::
-
-    openssl req -nodes \
-        -newkey rsa:2048 -keyout key.pem -out request.csr \
-        -subj "/C=AT/ST=Vorarlberg/L=Dornbirn/O=Crate.io/CN=localhost/emailAddress=nobody@crate.io"
-
-Display the certificate request::
-
-    openssl req -in request.csr -text
-
-Sign certificate request::
-
-    openssl x509 -req -in request.csr \
-        -CA cacert_valid.pem -CAkey cacert_valid.pem -CAcreateserial -sha256 \
-        -days 358000 -extfile <(printf "subjectAltName=DNS:localhost") -out client.pem
-
-Display the certificate::
-
-    openssl x509 -in client.pem -text
-
-Combine private key and certificate into single PEM file::
-
-    cat key.pem > client_valid.pem
-    cat client.pem >> client_valid.pem
-
-
-``client_invalid.pem``
-======================
-
-This will renew the ``client_invalid.pem`` X.509 certificate. Please note that,
-in order to create an invalid certificate, two attributes are used:
-
-- ``CN=horst`` and ``subjectAltName=DNS:horst`` do not match ``localhost``.
-- The validity end date will be adjusted a few years into the past, by using
-  ``-days -36500``.
-
-Create RSA private key and certificate request::
-
-    openssl req -nodes \
-        -newkey rsa:2048 -keyout invalid_key.pem -out invalid.csr \
-        -subj "/C=AT/ST=Vorarlberg/L=Dornbirn/O=Crate.io/CN=horst/emailAddress=nobody@crate.io"
-
-Display the certificate request::
-
-    openssl req -in invalid.csr -text
-
-Sign certificate request::
-
-    openssl x509 -req -in invalid.csr \
-        -CA cacert_valid.pem -CAkey cacert_valid.pem -CAcreateserial -sha256 \
-        -days -36500 -extfile <(printf "subjectAltName=DNS:horst") -out invalid_cert.pem
-
-Display the certificate::
-
-    openssl x509 -in invalid_cert.pem -text
-
-Combine private key and certificate into single PEM file::
-
-    cat invalid_key.pem > client_invalid.pem
-    cat invalid_cert.pem >> client_invalid.pem
-
-
-.. _src/crate/client/pki/*.pem: https://github.com/crate/crate-python/tree/master/src/crate/client/pki
diff --git a/src/crate/client/pki/server_valid.pem b/src/crate/client/pki/server_valid.pem
deleted file mode 100644
index 7b1cf8f4e..000000000
--- a/src/crate/client/pki/server_valid.pem
+++ /dev/null
@@ -1,49 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDOORP/4SVgETya
-ve7+HQqJa/Aye6qw9K1YL1t86FL82TIUwyAk86YQ8yrdCVzrGb52ZVF/4YDgNBpV
-SHfCgLhGUSjcdmLxpKtKoTiwnb7z3KqdRUPFF7LegN0MHX6fCAalGr89pAE1AcUb
-MQ57FV2uwVikq3DFRAufoA7xNXrOVc5N0Q+r4GEHp3HPCxd1bqneSCmDZe3nZwp9
-y8nllOPChB9CcUXz2yY0ZkJUd+2CwYXjfKR+09UnaoYlmkSm6+8BlzHOvneBp37z
-68m9Dsuref10L7nOpH7nKUrYoJZy6+hq+pzNvtFJbrdgqfUmxseFtfYZH6+5kmcM
-CpATIvFVAgMBAAECggEAUO3Xamh52WyKQxPckX7mHq3sUnNztgQfbucO2UL3JmE6
-JSm6GKZBeo9jN+EvxNeShjYWuL/Paq0n1GYfEYagSoAZMAOJqtj7m8sPS0hsopjr
-n9KJ2PQG7wjVNqbwhQqKSQrpGBCfpKSwLI6g5y6oWCdqWR0qoU+l3BvdIU3ihswz
-UgDCw1boSoki9paZsdHNiYfTNON6Wpx6yibmYBt9lJgHsmqds/a1XEU1j09un4MN
-MkCc9MsIPSj9qKYdxGllMeg/Kmfg4qXZvKeK0AS8NCeWqVhoJLPmmt+WPC6LhhOb
-4glnOjHJ2KnTEqPbiCXLaO+Izxbmo/tTN/eO9LOzQQKBgQDozxshxDBIKFR7i2NK
-A3RVUBa4mhP+zyy77/W5VsIkBKbqFA+tRaQXp1XOjTeHJ14jskDzUo0loJ8ZL82S
-yQr2LqxUtvEwDctp+/kkN6WkXtEEDjP0ACVtsBgR5kZygi6pWT44Ae8nUjmhZ/M5
-/JD2KoXH8aAHtJ8kRE7aVFlFMQKBgQDiw/9uFAZraguCVf6du5nD/nzNqcI4Nr9f
-0UabNA5261i4fw2Yft77piv+9MeugI8GDC/x0YIW1ZshxfRJXgBo5l9hnEaP+uyr
-wzXPSAphv7UUbGScR1B1+BF103U6OHl2PIOiiwrHWsIF+KUSTL8Virzb4WhJo6SV
-pu2PGi+1ZQKBgQDWQj0LL37r+cn+xcLETDeViJbQoGUEnnDiKi6wuysDcRCY34uq
-ASzK5CMxbIANL+sQ2S2zgmcKmS+zQ25jyAkBluTdNlz0x81Mpiyd62TTyLt+iv+/
-cR8BOw578r0lB7CgBNUhQI50VtVZOcz8sfhLxcjHwhVw4geQnhkgEH70EQKBgFHf
-jiPCWycBHLKsNcfhaf0XrxvaRONi8Om5d5Kl0usgweGrDc+XTw7wykW9PzND+1+l
-mtHmYN+5s88X18F9jQxS0PE/KULmx/ij/JOgYQ811j1PfWvnW6ecL0GpXVPt+/yy
-kJxpMzUTEaZyRbc7umoes114Ht0nlk7p/C+EtuD5AoGAYKMYspyKgYeiPS9nfdP2
-nzfNjCx4WiPMw3zvQ7QF3T+icu4+oTPU8aYBEHZc3lnhFdGzIm27ww/pmAbEqiJ9
-72QM9jFlGObGVm+Dypbrj1eFMxm0tcAyKg4f2nYC1CH2n8JMiLERFpmXZQacLHZx
-O7sBCdj90ycqRiUcRDXvzlk=
------END PRIVATE KEY-----
------BEGIN CERTIFICATE-----
-MIIDfTCCAmWgAwIBAgIUNtmyUs7LRi9mvG1Y7NtDAbHlyJQwDQYJKoZIhvcNAQEL
-BQAwWTELMAkGA1UEBhMCQVQxEzARBgNVBAgMClZvcmFybGJlcmcxETAPBgNVBAcM
-CERvcm5iaXJuMQ4wDAYDVQQKDAVDcmF0ZTESMBAGA1UEAwwJbG9jYWxob3N0MCAX
-DTIzMDEyNjE2MjAyN1oYDzMwMDMwMzMwMTYyMDI3WjB8MQswCQYDVQQGEwJBVDET
-MBEGA1UECAwKVm9yYXJsYmVyZzERMA8GA1UEBwwIRG9ybmJpcm4xETAPBgNVBAoM
-CENyYXRlLmlvMRIwEAYDVQQDDAlsb2NhbGhvc3QxHjAcBgkqhkiG9w0BCQEWD25v
-Ym9keUBjcmF0ZS5pbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM45
-E//hJWARPJq97v4dColr8DJ7qrD0rVgvW3zoUvzZMhTDICTzphDzKt0JXOsZvnZl
-UX/hgOA0GlVId8KAuEZRKNx2YvGkq0qhOLCdvvPcqp1FQ8UXst6A3Qwdfp8IBqUa
-vz2kATUBxRsxDnsVXa7BWKSrcMVEC5+gDvE1es5Vzk3RD6vgYQencc8LF3Vuqd5I
-KYNl7ednCn3LyeWU48KEH0JxRfPbJjRmQlR37YLBheN8pH7T1SdqhiWaRKbr7wGX
-Mc6+d4GnfvPryb0Oy6t5/XQvuc6kfucpStiglnLr6Gr6nM2+0Ulut2Cp9SbGx4W1
-9hkfr7mSZwwKkBMi8VUCAwEAAaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0G
-CSqGSIb3DQEBCwUAA4IBAQBOM7AfDYDzPGOsNya5s6+58PHhPEfRFixCSLGSK5Pm
-sfvg7rRre5P+I37863B1S52E5QWzOlVJM+POKiNKp64846eWvk4TYenW0KOxjL75
-R0Y5LQVNM80x1rw9j5iBdMSYgkMPwSccO6WGOdTV+6X077QgLpmqnEgmmfZj0CMz
-+k33sbJ4H7HC7bl6+bSQBwxSQIVmuXTTmHIpv6Kz4lLIezjuYikkeiEMBPp+XF9Q
-ZqaBfGvnvUE9KBUoxQZe0jzTTQE31FsnKtDyaMcyV3rMoBDmD6B6CaSo7yfj2fpI
-EueW/Mx4EtMLTU4QY5DJsXsszBpB3+8YhuWFpHqP5jpu
------END CERTIFICATE-----
diff --git a/src/crate/client/sqlalchemy/__init__.py b/src/crate/client/sqlalchemy/__init__.py
deleted file mode 100644
index 2a7a1da75..000000000
--- a/src/crate/client/sqlalchemy/__init__.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from .compat.api13 import monkeypatch_add_exec_driver_sql
-from .dialect import CrateDialect
-from .sa_version import SA_1_4, SA_VERSION
-
-
-if SA_VERSION < SA_1_4:
-    import textwrap
-    import warnings
-
-    # SQLAlchemy 1.3 is effectively EOL.
-    SA13_DEPRECATION_WARNING = textwrap.dedent("""
-    WARNING: SQLAlchemy 1.3 is effectively EOL.
-
-    SQLAlchemy 1.3 is EOL since 2023-01-27.
-    Future versions of the CrateDB SQLAlchemy dialect will drop support for SQLAlchemy 1.3.
-    It is recommended that you transition to using SQLAlchemy 1.4 or 2.0:
-
-    - https://docs.sqlalchemy.org/en/14/changelog/migration_14.html
-    - https://docs.sqlalchemy.org/en/20/changelog/migration_20.html
-    """.lstrip("\n"))
-    warnings.warn(message=SA13_DEPRECATION_WARNING, category=DeprecationWarning)
-
-    # SQLAlchemy 1.3 does not have the `exec_driver_sql` method, so add it.
-    monkeypatch_add_exec_driver_sql()
-
-
-__all__ = [
-    CrateDialect,
-]
diff --git a/src/crate/client/sqlalchemy/compat/api13.py b/src/crate/client/sqlalchemy/compat/api13.py
deleted file mode 100644
index bcd2a6ed0..000000000
--- a/src/crate/client/sqlalchemy/compat/api13.py
+++ /dev/null
@@ -1,156 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-"""
-Compatibility module for running a subset of SQLAlchemy 2.0 programs on
-SQLAlchemy 1.3. By using monkey-patching, it can do two things:
-
-1. Add the `exec_driver_sql` method to SA's `Connection` and `Engine`.
-2. Amend the `sql.select` function to accept the calling semantics of
-   the modern variant.
-
-Reason: `exec_driver_sql` gets used within the CrateDB dialect already,
-and the new calling semantics of `sql.select` already get used within
-many of the test cases already. Please note that the patch for
-`sql.select` is only applied when running the test suite.
-"""
-
-import collections.abc as collections_abc
-
-from sqlalchemy import exc
-from sqlalchemy.sql import Select
-from sqlalchemy.sql import select as original_select
-from sqlalchemy.util import immutabledict
-
-
-# `_distill_params_20` copied from SA14's `sqlalchemy.engine.{base,util}`.
-_no_tuple = ()
-_no_kw = immutabledict()
-
-
-def _distill_params_20(params):
-    if params is None:
-        return _no_tuple, _no_kw
-    elif isinstance(params, list):
-        # collections_abc.MutableSequence): # avoid abc.__instancecheck__
-        if params and not isinstance(params[0], (collections_abc.Mapping, tuple)):
-            raise exc.ArgumentError(
-                "List argument must consist only of tuples or dictionaries"
-            )
-
-        return (params,), _no_kw
-    elif isinstance(
-        params,
-        (tuple, dict, immutabledict),
-        # only do abc.__instancecheck__ for Mapping after we've checked
-        # for plain dictionaries and would otherwise raise
-    ) or isinstance(params, collections_abc.Mapping):
-        return (params,), _no_kw
-    else:
-        raise exc.ArgumentError("mapping or sequence expected for parameters")
-
-
-def exec_driver_sql(self, statement, parameters=None, execution_options=None):
-    """
-    Adapter for `exec_driver_sql`, which is available since SA14, for SA13.
-    """
-    if execution_options is not None:
-        raise ValueError(
-            "SA13 backward-compatibility: "
-            "`exec_driver_sql` does not support `execution_options`"
-        )
-    args_10style, kwargs_10style = _distill_params_20(parameters)
-    return self.execute(statement, *args_10style, **kwargs_10style)
-
-
-def monkeypatch_add_exec_driver_sql():
-    """
-    Transparently add SA14's `exec_driver_sql()` method to SA13.
-
-    AttributeError: 'Connection' object has no attribute 'exec_driver_sql'
-    AttributeError: 'Engine' object has no attribute 'exec_driver_sql'
-    """
-    from sqlalchemy.engine.base import Connection, Engine
-
-    # Add `exec_driver_sql` method to SA's `Connection` and `Engine` classes.
-    Connection.exec_driver_sql = exec_driver_sql
-    Engine.exec_driver_sql = exec_driver_sql
-
-
-def select_sa14(*columns, **kw) -> Select:
-    """
-    Adapt SA14/SA20's calling semantics of `sql.select()` to SA13.
-
-    With SA20, `select()` no longer accepts varied constructor arguments, only
-    the "generative" style of `select()` will be supported. The list of columns
-    / tables to select from should be passed positionally.
-
-    Derived from https://github.com/sqlalchemy/alembic/blob/b1fad6b6/alembic/util/sqla_compat.py#L557-L558
-
-    sqlalchemy.exc.ArgumentError: columns argument to select() must be a Python list or other iterable
-    """
-    if isinstance(columns, tuple) and isinstance(columns[0], list):
-        if "whereclause" in kw:
-            raise ValueError(
-                "SA13 backward-compatibility: "
-                "`whereclause` is both in kwargs and columns tuple"
-            )
-        columns, whereclause = columns
-        kw["whereclause"] = whereclause
-    return original_select(columns, **kw)
-
-
-def monkeypatch_amend_select_sa14():
-    """
-    Make SA13's `sql.select()` transparently accept calling semantics of SA14
-    and SA20, by swapping in the newer variant of `select_sa14()`.
-
-    This supports the test suite of `crate-python`, because it already uses the
-    modern calling semantics.
-    """
-    import sqlalchemy
-
-    sqlalchemy.select = select_sa14
-    sqlalchemy.sql.select = select_sa14
-    sqlalchemy.sql.expression.select = select_sa14
-
-
-@property
-def connectionfairy_driver_connection_sa14(self):
-    """The connection object as returned by the driver after a connect.
-
-    .. versionadded:: 1.4.24
-
-    .. seealso::
-
-        :attr:`._ConnectionFairy.dbapi_connection`
-
-        :attr:`._ConnectionRecord.driver_connection`
-
-        :ref:`faq_dbapi_connection`
-
-    """
-    return self.connection
-
-
-def monkeypatch_add_connectionfairy_driver_connection():
-    import sqlalchemy.pool.base
-    sqlalchemy.pool.base._ConnectionFairy.driver_connection = connectionfairy_driver_connection_sa14
diff --git a/src/crate/client/sqlalchemy/compat/core10.py b/src/crate/client/sqlalchemy/compat/core10.py
deleted file mode 100644
index 92c62dd88..000000000
--- a/src/crate/client/sqlalchemy/compat/core10.py
+++ /dev/null
@@ -1,264 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import sqlalchemy as sa
-from sqlalchemy.dialects.postgresql.base import PGCompiler
-from sqlalchemy.sql.crud import (REQUIRED, _create_bind_param,
-                                 _extend_values_for_multiparams,
-                                 _get_multitable_params,
-                                 _get_stmt_parameters_params,
-                                 _key_getters_for_crud_column, _scan_cols,
-                                 _scan_insert_from_select_cols)
-
-from crate.client.sqlalchemy.compiler import CrateCompiler
-
-
-class CrateCompilerSA10(CrateCompiler):
-
-    def returning_clause(self, stmt, returning_cols):
-        """
-        Generate RETURNING clause, PostgreSQL-compatible.
-        """
-        return PGCompiler.returning_clause(self, stmt, returning_cols)
-
-    def visit_update(self, update_stmt, **kw):
-        """
-        used to compile  expressions
-        Parts are taken from the SQLCompiler base class.
-        """
-
-        # [10] CrateDB patch.
-        if not update_stmt.parameters and \
-                not hasattr(update_stmt, '_crate_specific'):
-            return super().visit_update(update_stmt, **kw)
-
-        self.isupdate = True
-
-        extra_froms = update_stmt._extra_froms
-
-        text = 'UPDATE '
-
-        if update_stmt._prefixes:
-            text += self._generate_prefixes(update_stmt,
-                                            update_stmt._prefixes, **kw)
-
-        table_text = self.update_tables_clause(update_stmt, update_stmt.table,
-                                               extra_froms, **kw)
-
-        dialect_hints = None
-        if update_stmt._hints:
-            dialect_hints, table_text = self._setup_crud_hints(
-                update_stmt, table_text
-            )
-
-        # [10] CrateDB patch.
-        crud_params = _get_crud_params(self, update_stmt, **kw)
-
-        text += table_text
-
-        text += ' SET '
-
-        # [10] CrateDB patch begin.
-        include_table = \
-            extra_froms and self.render_table_with_column_in_update_from
-
-        set_clauses = []
-
-        for k, v in crud_params:
-            clause = k._compiler_dispatch(self,
-                                          include_table=include_table) + \
-                ' = ' + v
-            set_clauses.append(clause)
-
-        for k, v in update_stmt.parameters.items():
-            if isinstance(k, str) and '[' in k:
-                bindparam = sa.sql.bindparam(k, v)
-                set_clauses.append(k + ' = ' + self.process(bindparam))
-
-        text += ', '.join(set_clauses)
-        # [10] CrateDB patch end.
-
-        if self.returning or update_stmt._returning:
-            if not self.returning:
-                self.returning = update_stmt._returning
-            if self.returning_precedes_values:
-                text += " " + self.returning_clause(
-                    update_stmt, self.returning)
-
-        if extra_froms:
-            extra_from_text = self.update_from_clause(
-                update_stmt,
-                update_stmt.table,
-                extra_froms,
-                dialect_hints,
-                **kw)
-            if extra_from_text:
-                text += " " + extra_from_text
-
-        if update_stmt._whereclause is not None:
-            t = self.process(update_stmt._whereclause)
-            if t:
-                text += " WHERE " + t
-
-        limit_clause = self.update_limit_clause(update_stmt)
-        if limit_clause:
-            text += " " + limit_clause
-
-        if self.returning and not self.returning_precedes_values:
-            text += " " + self.returning_clause(
-                update_stmt, self.returning)
-
-        return text
-
-
-def _get_crud_params(compiler, stmt, **kw):
-    """create a set of tuples representing column/string pairs for use
-    in an INSERT or UPDATE statement.
-
-    Also generates the Compiled object's postfetch, prefetch, and
-    returning column collections, used for default handling and ultimately
-    populating the ResultProxy's prefetch_cols() and postfetch_cols()
-    collections.
-
-    """
-
-    compiler.postfetch = []
-    compiler.insert_prefetch = []
-    compiler.update_prefetch = []
-    compiler.returning = []
-
-    # no parameters in the statement, no parameters in the
-    # compiled params - return binds for all columns
-    if compiler.column_keys is None and stmt.parameters is None:
-        return [
-            (c, _create_bind_param(compiler, c, None, required=True))
-            for c in stmt.table.columns
-        ]
-
-    if stmt._has_multi_parameters:
-        stmt_parameters = stmt.parameters[0]
-    else:
-        stmt_parameters = stmt.parameters
-
-    # getters - these are normally just column.key,
-    # but in the case of mysql multi-table update, the rules for
-    # .key must conditionally take tablename into account
-    (
-        _column_as_key,
-        _getattr_col_key,
-        _col_bind_name,
-    ) = _key_getters_for_crud_column(compiler, stmt)
-
-    # if we have statement parameters - set defaults in the
-    # compiled params
-    if compiler.column_keys is None:
-        parameters = {}
-    else:
-        parameters = dict(
-            (_column_as_key(key), REQUIRED)
-            for key in compiler.column_keys
-            if not stmt_parameters or key not in stmt_parameters
-        )
-
-    # create a list of column assignment clauses as tuples
-    values = []
-
-    if stmt_parameters is not None:
-        _get_stmt_parameters_params(
-            compiler, parameters, stmt_parameters, _column_as_key, values, kw
-        )
-
-    check_columns = {}
-
-    # special logic that only occurs for multi-table UPDATE
-    # statements
-    if compiler.isupdate and stmt._extra_froms and stmt_parameters:
-        _get_multitable_params(
-            compiler,
-            stmt,
-            stmt_parameters,
-            check_columns,
-            _col_bind_name,
-            _getattr_col_key,
-            values,
-            kw,
-        )
-
-    if compiler.isinsert and stmt.select_names:
-        _scan_insert_from_select_cols(
-            compiler,
-            stmt,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            kw,
-        )
-    else:
-        _scan_cols(
-            compiler,
-            stmt,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            kw,
-        )
-
-    # [10] CrateDB patch.
-    #
-    # This sanity check performed by SQLAlchemy currently needs to be
-    # deactivated in order to satisfy the rewriting logic of the CrateDB
-    # dialect in `rewrite_update` and `visit_update`.
-    #
-    # It can be quickly reproduced by activating this section and running the
-    # test cases::
-    #
-    #   ./bin/test -vvvv -t dict_test
-    #
-    # That croaks like::
-    #
-    #   sqlalchemy.exc.CompileError: Unconsumed column names: characters_name, data['nested']
-    #
-    # TODO: Investigate why this is actually happening and eventually mitigate
-    #       the root cause.
-    """
-    if parameters and stmt_parameters:
-        check = (
-            set(parameters)
-            .intersection(_column_as_key(k) for k in stmt_parameters)
-            .difference(check_columns)
-        )
-        if check:
-            raise exc.CompileError(
-                "Unconsumed column names: %s"
-                % (", ".join("%s" % c for c in check))
-            )
-    """
-
-    if stmt._has_multi_parameters:
-        values = _extend_values_for_multiparams(compiler, stmt, values, kw)
-
-    return values
diff --git a/src/crate/client/sqlalchemy/compat/core14.py b/src/crate/client/sqlalchemy/compat/core14.py
deleted file mode 100644
index 2dd6670a9..000000000
--- a/src/crate/client/sqlalchemy/compat/core14.py
+++ /dev/null
@@ -1,359 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import sqlalchemy as sa
-from sqlalchemy.dialects.postgresql.base import PGCompiler
-from sqlalchemy.sql import selectable
-from sqlalchemy.sql.crud import (REQUIRED, _create_bind_param,
-                                 _extend_values_for_multiparams,
-                                 _get_stmt_parameter_tuples_params,
-                                 _get_update_multitable_params,
-                                 _key_getters_for_crud_column, _scan_cols,
-                                 _scan_insert_from_select_cols)
-
-from crate.client.sqlalchemy.compiler import CrateCompiler
-
-
-class CrateCompilerSA14(CrateCompiler):
-
-    def returning_clause(self, stmt, returning_cols):
-        """
-        Generate RETURNING clause, PostgreSQL-compatible.
-        """
-        return PGCompiler.returning_clause(self, stmt, returning_cols)
-
-    def visit_update(self, update_stmt, **kw):
-
-        compile_state = update_stmt._compile_state_factory(
-            update_stmt, self, **kw
-        )
-        update_stmt = compile_state.statement
-
-        # [14] CrateDB patch.
-        if not compile_state._dict_parameters and \
-                not hasattr(update_stmt, '_crate_specific'):
-            return super().visit_update(update_stmt, **kw)
-
-        toplevel = not self.stack
-        if toplevel:
-            self.isupdate = True
-            if not self.compile_state:
-                self.compile_state = compile_state
-
-        extra_froms = compile_state._extra_froms
-        is_multitable = bool(extra_froms)
-
-        if is_multitable:
-            # main table might be a JOIN
-            main_froms = set(selectable._from_objects(update_stmt.table))
-            render_extra_froms = [
-                f for f in extra_froms if f not in main_froms
-            ]
-            correlate_froms = main_froms.union(extra_froms)
-        else:
-            render_extra_froms = []
-            correlate_froms = {update_stmt.table}
-
-        self.stack.append(
-            {
-                "correlate_froms": correlate_froms,
-                "asfrom_froms": correlate_froms,
-                "selectable": update_stmt,
-            }
-        )
-
-        text = "UPDATE "
-
-        if update_stmt._prefixes:
-            text += self._generate_prefixes(
-                update_stmt, update_stmt._prefixes, **kw
-            )
-
-        table_text = self.update_tables_clause(
-            update_stmt, update_stmt.table, render_extra_froms, **kw
-        )
-
-        # [14] CrateDB patch.
-        crud_params = _get_crud_params(
-            self, update_stmt, compile_state, **kw
-        )
-
-        if update_stmt._hints:
-            dialect_hints, table_text = self._setup_crud_hints(
-                update_stmt, table_text
-            )
-        else:
-            dialect_hints = None
-
-        if update_stmt._independent_ctes:
-            for cte in update_stmt._independent_ctes:
-                cte._compiler_dispatch(self, **kw)
-
-        text += table_text
-
-        text += " SET "
-
-        # [14] CrateDB patch begin.
-        include_table = \
-            extra_froms and self.render_table_with_column_in_update_from
-
-        set_clauses = []
-
-        for c, expr, value in crud_params:
-            key = c._compiler_dispatch(self, include_table=include_table)
-            clause = key + ' = ' + value
-            set_clauses.append(clause)
-
-        for k, v in compile_state._dict_parameters.items():
-            if isinstance(k, str) and '[' in k:
-                bindparam = sa.sql.bindparam(k, v)
-                clause = k + ' = ' + self.process(bindparam)
-                set_clauses.append(clause)
-
-        text += ', '.join(set_clauses)
-        # [14] CrateDB patch end.
-
-        if self.returning or update_stmt._returning:
-            if self.returning_precedes_values:
-                text += " " + self.returning_clause(
-                    update_stmt, self.returning or update_stmt._returning
-                )
-
-        if extra_froms:
-            extra_from_text = self.update_from_clause(
-                update_stmt,
-                update_stmt.table,
-                render_extra_froms,
-                dialect_hints,
-                **kw
-            )
-            if extra_from_text:
-                text += " " + extra_from_text
-
-        if update_stmt._where_criteria:
-            t = self._generate_delimited_and_list(
-                update_stmt._where_criteria, **kw
-            )
-            if t:
-                text += " WHERE " + t
-
-        limit_clause = self.update_limit_clause(update_stmt)
-        if limit_clause:
-            text += " " + limit_clause
-
-        if (
-                self.returning or update_stmt._returning
-        ) and not self.returning_precedes_values:
-            text += " " + self.returning_clause(
-                update_stmt, self.returning or update_stmt._returning
-            )
-
-        if self.ctes:
-            nesting_level = len(self.stack) if not toplevel else None
-            text = self._render_cte_clause(nesting_level=nesting_level) + text
-
-        self.stack.pop(-1)
-
-        return text
-
-
-def _get_crud_params(compiler, stmt, compile_state, **kw):
-    """create a set of tuples representing column/string pairs for use
-    in an INSERT or UPDATE statement.
-
-    Also generates the Compiled object's postfetch, prefetch, and
-    returning column collections, used for default handling and ultimately
-    populating the CursorResult's prefetch_cols() and postfetch_cols()
-    collections.
-
-    """
-
-    compiler.postfetch = []
-    compiler.insert_prefetch = []
-    compiler.update_prefetch = []
-    compiler.returning = []
-
-    # getters - these are normally just column.key,
-    # but in the case of mysql multi-table update, the rules for
-    # .key must conditionally take tablename into account
-    (
-        _column_as_key,
-        _getattr_col_key,
-        _col_bind_name,
-    ) = getters = _key_getters_for_crud_column(compiler, stmt, compile_state)
-
-    compiler._key_getters_for_crud_column = getters
-
-    # no parameters in the statement, no parameters in the
-    # compiled params - return binds for all columns
-    if compiler.column_keys is None and compile_state._no_parameters:
-        return [
-            (
-                c,
-                compiler.preparer.format_column(c),
-                _create_bind_param(compiler, c, None, required=True),
-            )
-            for c in stmt.table.columns
-        ]
-
-    if compile_state._has_multi_parameters:
-        spd = compile_state._multi_parameters[0]
-        stmt_parameter_tuples = list(spd.items())
-    elif compile_state._ordered_values:
-        spd = compile_state._dict_parameters
-        stmt_parameter_tuples = compile_state._ordered_values
-    elif compile_state._dict_parameters:
-        spd = compile_state._dict_parameters
-        stmt_parameter_tuples = list(spd.items())
-    else:
-        stmt_parameter_tuples = spd = None
-
-    # if we have statement parameters - set defaults in the
-    # compiled params
-    if compiler.column_keys is None:
-        parameters = {}
-    elif stmt_parameter_tuples:
-        parameters = dict(
-            (_column_as_key(key), REQUIRED)
-            for key in compiler.column_keys
-            if key not in spd
-        )
-    else:
-        parameters = dict(
-            (_column_as_key(key), REQUIRED) for key in compiler.column_keys
-        )
-
-    # create a list of column assignment clauses as tuples
-    values = []
-
-    if stmt_parameter_tuples is not None:
-        _get_stmt_parameter_tuples_params(
-            compiler,
-            compile_state,
-            parameters,
-            stmt_parameter_tuples,
-            _column_as_key,
-            values,
-            kw,
-        )
-
-    check_columns = {}
-
-    # special logic that only occurs for multi-table UPDATE
-    # statements
-    if compile_state.isupdate and compile_state.is_multitable:
-        _get_update_multitable_params(
-            compiler,
-            stmt,
-            compile_state,
-            stmt_parameter_tuples,
-            check_columns,
-            _col_bind_name,
-            _getattr_col_key,
-            values,
-            kw,
-        )
-
-    if compile_state.isinsert and stmt._select_names:
-        _scan_insert_from_select_cols(
-            compiler,
-            stmt,
-            compile_state,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            kw,
-        )
-    else:
-        _scan_cols(
-            compiler,
-            stmt,
-            compile_state,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            kw,
-        )
-
-    # [14] CrateDB patch.
-    #
-    # This sanity check performed by SQLAlchemy currently needs to be
-    # deactivated in order to satisfy the rewriting logic of the CrateDB
-    # dialect in `rewrite_update` and `visit_update`.
-    #
-    # It can be quickly reproduced by activating this section and running the
-    # test cases::
-    #
-    #   ./bin/test -vvvv -t dict_test
-    #
-    # That croaks like::
-    #
-    #   sqlalchemy.exc.CompileError: Unconsumed column names: characters_name, data['nested']
-    #
-    # TODO: Investigate why this is actually happening and eventually mitigate
-    #       the root cause.
-    """
-    if parameters and stmt_parameter_tuples:
-        check = (
-            set(parameters)
-            .intersection(_column_as_key(k) for k, v in stmt_parameter_tuples)
-            .difference(check_columns)
-        )
-        if check:
-            raise exc.CompileError(
-                "Unconsumed column names: %s"
-                % (", ".join("%s" % (c,) for c in check))
-            )
-    """
-
-    if compile_state._has_multi_parameters:
-        values = _extend_values_for_multiparams(
-            compiler,
-            stmt,
-            compile_state,
-            values,
-            _column_as_key,
-            kw,
-        )
-    elif (
-            not values
-            and compiler.for_executemany  # noqa: W503
-            and compiler.dialect.supports_default_metavalue  # noqa: W503
-    ):
-        # convert an "INSERT DEFAULT VALUES"
-        # into INSERT (firstcol) VALUES (DEFAULT) which can be turned
-        # into an in-place multi values.  This supports
-        # insert_executemany_returning mode :)
-        values = [
-            (
-                stmt.table.columns[0],
-                compiler.preparer.format_column(stmt.table.columns[0]),
-                "DEFAULT",
-            )
-        ]
-
-    return values
diff --git a/src/crate/client/sqlalchemy/compat/core20.py b/src/crate/client/sqlalchemy/compat/core20.py
deleted file mode 100644
index 6f1288767..000000000
--- a/src/crate/client/sqlalchemy/compat/core20.py
+++ /dev/null
@@ -1,447 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union
-
-import sqlalchemy as sa
-from sqlalchemy import ColumnClause, ValuesBase, cast, exc
-from sqlalchemy.sql import dml
-from sqlalchemy.sql.base import _from_objects
-from sqlalchemy.sql.compiler import SQLCompiler
-from sqlalchemy.sql.crud import (REQUIRED, _as_dml_column, _create_bind_param,
-                                 _CrudParamElement, _CrudParams,
-                                 _extend_values_for_multiparams,
-                                 _get_stmt_parameter_tuples_params,
-                                 _get_update_multitable_params,
-                                 _key_getters_for_crud_column, _scan_cols,
-                                 _scan_insert_from_select_cols,
-                                 _setup_delete_return_defaults)
-from sqlalchemy.sql.dml import DMLState, _DMLColumnElement
-from sqlalchemy.sql.dml import isinsert as _compile_state_isinsert
-
-from crate.client.sqlalchemy.compiler import CrateCompiler
-
-
-class CrateCompilerSA20(CrateCompiler):
-
-    def visit_update(self, update_stmt, **kw):
-        compile_state = update_stmt._compile_state_factory(
-            update_stmt, self, **kw
-        )
-        update_stmt = compile_state.statement
-
-        # [20] CrateDB patch.
-        if not compile_state._dict_parameters and \
-                not hasattr(update_stmt, '_crate_specific'):
-            return super().visit_update(update_stmt, **kw)
-
-        toplevel = not self.stack
-        if toplevel:
-            self.isupdate = True
-            if not self.dml_compile_state:
-                self.dml_compile_state = compile_state
-            if not self.compile_state:
-                self.compile_state = compile_state
-
-        extra_froms = compile_state._extra_froms
-        is_multitable = bool(extra_froms)
-
-        if is_multitable:
-            # main table might be a JOIN
-            main_froms = set(_from_objects(update_stmt.table))
-            render_extra_froms = [
-                f for f in extra_froms if f not in main_froms
-            ]
-            correlate_froms = main_froms.union(extra_froms)
-        else:
-            render_extra_froms = []
-            correlate_froms = {update_stmt.table}
-
-        self.stack.append(
-            {
-                "correlate_froms": correlate_froms,
-                "asfrom_froms": correlate_froms,
-                "selectable": update_stmt,
-            }
-        )
-
-        text = "UPDATE "
-
-        if update_stmt._prefixes:
-            text += self._generate_prefixes(
-                update_stmt, update_stmt._prefixes, **kw
-            )
-
-        table_text = self.update_tables_clause(
-            update_stmt, update_stmt.table, render_extra_froms, **kw
-        )
-        # [20] CrateDB patch.
-        crud_params_struct = _get_crud_params(
-            self, update_stmt, compile_state, toplevel, **kw
-        )
-        crud_params = crud_params_struct.single_params
-
-        if update_stmt._hints:
-            dialect_hints, table_text = self._setup_crud_hints(
-                update_stmt, table_text
-            )
-        else:
-            dialect_hints = None
-
-        if update_stmt._independent_ctes:
-            self._dispatch_independent_ctes(update_stmt, kw)
-
-        text += table_text
-
-        text += " SET "
-
-        # [20] CrateDB patch begin.
-        include_table = extra_froms and \
-            self.render_table_with_column_in_update_from
-
-        set_clauses = []
-
-        for c, expr, value, _ in crud_params:
-            key = c._compiler_dispatch(self, include_table=include_table)
-            clause = key + ' = ' + value
-            set_clauses.append(clause)
-
-        for k, v in compile_state._dict_parameters.items():
-            if isinstance(k, str) and '[' in k:
-                bindparam = sa.sql.bindparam(k, v)
-                clause = k + ' = ' + self.process(bindparam)
-                set_clauses.append(clause)
-
-        text += ', '.join(set_clauses)
-        # [20] CrateDB patch end.
-
-        if self.implicit_returning or update_stmt._returning:
-            if self.returning_precedes_values:
-                text += " " + self.returning_clause(
-                    update_stmt,
-                    self.implicit_returning or update_stmt._returning,
-                    populate_result_map=toplevel,
-                )
-
-        if extra_froms:
-            extra_from_text = self.update_from_clause(
-                update_stmt,
-                update_stmt.table,
-                render_extra_froms,
-                dialect_hints,
-                **kw,
-            )
-            if extra_from_text:
-                text += " " + extra_from_text
-
-        if update_stmt._where_criteria:
-            t = self._generate_delimited_and_list(
-                update_stmt._where_criteria, **kw
-            )
-            if t:
-                text += " WHERE " + t
-
-        limit_clause = self.update_limit_clause(update_stmt)
-        if limit_clause:
-            text += " " + limit_clause
-
-        if (
-            self.implicit_returning or update_stmt._returning
-        ) and not self.returning_precedes_values:
-            text += " " + self.returning_clause(
-                update_stmt,
-                self.implicit_returning or update_stmt._returning,
-                populate_result_map=toplevel,
-            )
-
-        if self.ctes:
-            nesting_level = len(self.stack) if not toplevel else None
-            text = self._render_cte_clause(nesting_level=nesting_level) + text
-
-        self.stack.pop(-1)
-
-        return text
-
-
-def _get_crud_params(
-    compiler: SQLCompiler,
-    stmt: ValuesBase,
-    compile_state: DMLState,
-    toplevel: bool,
-    **kw: Any,
-) -> _CrudParams:
-    """create a set of tuples representing column/string pairs for use
-    in an INSERT or UPDATE statement.
-
-    Also generates the Compiled object's postfetch, prefetch, and
-    returning column collections, used for default handling and ultimately
-    populating the CursorResult's prefetch_cols() and postfetch_cols()
-    collections.
-
-    """
-
-    # note: the _get_crud_params() system was written with the notion in mind
-    # that INSERT, UPDATE, DELETE are always the top level statement and
-    # that there is only one of them.  With the addition of CTEs that can
-    # make use of DML, this assumption is no longer accurate; the DML
-    # statement is not necessarily the top-level "row returning" thing
-    # and it is also theoretically possible (fortunately nobody has asked yet)
-    # to have a single statement with multiple DMLs inside of it via CTEs.
-
-    # the current _get_crud_params() design doesn't accommodate these cases
-    # right now.  It "just works" for a CTE that has a single DML inside of
-    # it, and for a CTE with multiple DML, it's not clear what would happen.
-
-    # overall, the "compiler.XYZ" collections here would need to be in a
-    # per-DML structure of some kind, and DefaultDialect would need to
-    # navigate these collections on a per-statement basis, with additional
-    # emphasis on the "toplevel returning data" statement.  However we
-    # still need to run through _get_crud_params() for all DML as we have
-    # Python / SQL generated column defaults that need to be rendered.
-
-    # if there is user need for this kind of thing, it's likely a post 2.0
-    # kind of change as it would require deep changes to DefaultDialect
-    # as well as here.
-
-    compiler.postfetch = []
-    compiler.insert_prefetch = []
-    compiler.update_prefetch = []
-    compiler.implicit_returning = []
-
-    # getters - these are normally just column.key,
-    # but in the case of mysql multi-table update, the rules for
-    # .key must conditionally take tablename into account
-    (
-        _column_as_key,
-        _getattr_col_key,
-        _col_bind_name,
-    ) = _key_getters_for_crud_column(compiler, stmt, compile_state)
-
-    compiler._get_bind_name_for_col = _col_bind_name
-
-    if stmt._returning and stmt._return_defaults:
-        raise exc.CompileError(
-            "Can't compile statement that includes returning() and "
-            "return_defaults() simultaneously"
-        )
-
-    if compile_state.isdelete:
-        _setup_delete_return_defaults(
-            compiler,
-            stmt,
-            compile_state,
-            (),
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            (),
-            (),
-            toplevel,
-            kw,
-        )
-        return _CrudParams([], [])
-
-    # no parameters in the statement, no parameters in the
-    # compiled params - return binds for all columns
-    if compiler.column_keys is None and compile_state._no_parameters:
-        return _CrudParams(
-            [
-                (
-                    c,
-                    compiler.preparer.format_column(c),
-                    _create_bind_param(compiler, c, None, required=True),
-                    (c.key,),
-                )
-                for c in stmt.table.columns
-            ],
-            [],
-        )
-
-    stmt_parameter_tuples: Optional[
-        List[Tuple[Union[str, ColumnClause[Any]], Any]]
-    ]
-    spd: Optional[MutableMapping[_DMLColumnElement, Any]]
-
-    if (
-        _compile_state_isinsert(compile_state)
-        and compile_state._has_multi_parameters
-    ):
-        mp = compile_state._multi_parameters
-        assert mp is not None
-        spd = mp[0]
-        stmt_parameter_tuples = list(spd.items())
-    elif compile_state._ordered_values:
-        spd = compile_state._dict_parameters
-        stmt_parameter_tuples = compile_state._ordered_values
-    elif compile_state._dict_parameters:
-        spd = compile_state._dict_parameters
-        stmt_parameter_tuples = list(spd.items())
-    else:
-        stmt_parameter_tuples = spd = None
-
-    # if we have statement parameters - set defaults in the
-    # compiled params
-    if compiler.column_keys is None:
-        parameters = {}
-    elif stmt_parameter_tuples:
-        assert spd is not None
-        parameters = {
-            _column_as_key(key): REQUIRED
-            for key in compiler.column_keys
-            if key not in spd
-        }
-    else:
-        parameters = {
-            _column_as_key(key): REQUIRED for key in compiler.column_keys
-        }
-
-    # create a list of column assignment clauses as tuples
-    values: List[_CrudParamElement] = []
-
-    if stmt_parameter_tuples is not None:
-        _get_stmt_parameter_tuples_params(
-            compiler,
-            compile_state,
-            parameters,
-            stmt_parameter_tuples,
-            _column_as_key,
-            values,
-            kw,
-        )
-
-    check_columns: Dict[str, ColumnClause[Any]] = {}
-
-    # special logic that only occurs for multi-table UPDATE
-    # statements
-    if dml.isupdate(compile_state) and compile_state.is_multitable:
-        _get_update_multitable_params(
-            compiler,
-            stmt,
-            compile_state,
-            stmt_parameter_tuples,
-            check_columns,
-            _col_bind_name,
-            _getattr_col_key,
-            values,
-            kw,
-        )
-
-    if _compile_state_isinsert(compile_state) and stmt._select_names:
-        # is an insert from select, is not a multiparams
-
-        assert not compile_state._has_multi_parameters
-
-        _scan_insert_from_select_cols(
-            compiler,
-            stmt,
-            compile_state,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            toplevel,
-            kw,
-        )
-    else:
-        _scan_cols(
-            compiler,
-            stmt,
-            compile_state,
-            parameters,
-            _getattr_col_key,
-            _column_as_key,
-            _col_bind_name,
-            check_columns,
-            values,
-            toplevel,
-            kw,
-        )
-
-    # [20] CrateDB patch.
-    #
-    # This sanity check performed by SQLAlchemy currently needs to be
-    # deactivated in order to satisfy the rewriting logic of the CrateDB
-    # dialect in `rewrite_update` and `visit_update`.
-    #
-    # It can be quickly reproduced by activating this section and running the
-    # test cases::
-    #
-    #   ./bin/test -vvvv -t dict_test
-    #
-    # That croaks like::
-    #
-    #   sqlalchemy.exc.CompileError: Unconsumed column names: characters_name
-    #
-    # TODO: Investigate why this is actually happening and eventually mitigate
-    #       the root cause.
-    """
-    if parameters and stmt_parameter_tuples:
-        check = (
-            set(parameters)
-            .intersection(_column_as_key(k) for k, v in stmt_parameter_tuples)
-            .difference(check_columns)
-        )
-        if check:
-            raise exc.CompileError(
-                "Unconsumed column names: %s"
-                % (", ".join("%s" % (c,) for c in check))
-            )
-    """
-
-    if (
-        _compile_state_isinsert(compile_state)
-        and compile_state._has_multi_parameters
-    ):
-        # is a multiparams, is not an insert from a select
-        assert not stmt._select_names
-        multi_extended_values = _extend_values_for_multiparams(
-            compiler,
-            stmt,
-            compile_state,
-            cast(
-                "Sequence[_CrudParamElementStr]",
-                values,
-            ),
-            cast("Callable[..., str]", _column_as_key),
-            kw,
-        )
-        return _CrudParams(values, multi_extended_values)
-    elif (
-        not values
-        and compiler.for_executemany
-        and compiler.dialect.supports_default_metavalue
-    ):
-        # convert an "INSERT DEFAULT VALUES"
-        # into INSERT (firstcol) VALUES (DEFAULT) which can be turned
-        # into an in-place multi values.  This supports
-        # insert_executemany_returning mode :)
-        values = [
-            (
-                _as_dml_column(stmt.table.columns[0]),
-                compiler.preparer.format_column(stmt.table.columns[0]),
-                compiler.dialect.default_metavalue_token,
-                (),
-            )
-        ]
-
-    return _CrudParams(values, [])
diff --git a/src/crate/client/sqlalchemy/compiler.py b/src/crate/client/sqlalchemy/compiler.py
deleted file mode 100644
index 7e6dad7d8..000000000
--- a/src/crate/client/sqlalchemy/compiler.py
+++ /dev/null
@@ -1,228 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import string
-from collections import defaultdict
-
-import sqlalchemy as sa
-from sqlalchemy.dialects.postgresql.base import PGCompiler
-from sqlalchemy.sql import compiler
-from .types import MutableDict, _Craty, Geopoint, Geoshape
-from .sa_version import SA_VERSION, SA_1_4
-
-
-def rewrite_update(clauseelement, multiparams, params):
-    """ change the params to enable partial updates
-
-    sqlalchemy by default only supports updates of complex types in the form of
-
-        "col = ?", ({"x": 1, "y": 2}
-
-    but crate supports
-
-        "col['x'] = ?, col['y'] = ?", (1, 2)
-
-    by using the `Craty` (`MutableDict`) type.
-    The update statement is only rewritten if an item of the MutableDict was
-    changed.
-    """
-    newmultiparams = []
-    _multiparams = multiparams[0]
-    if len(_multiparams) == 0:
-        return clauseelement, multiparams, params
-    for _params in _multiparams:
-        newparams = {}
-        for key, val in _params.items():
-            if (
-                not isinstance(val, MutableDict) or
-                (not any(val._changed_keys) and not any(val._deleted_keys))
-            ):
-                newparams[key] = val
-                continue
-
-            for subkey, subval in val.items():
-                if subkey in val._changed_keys:
-                    newparams["{0}['{1}']".format(key, subkey)] = subval
-            for subkey in val._deleted_keys:
-                newparams["{0}['{1}']".format(key, subkey)] = None
-        newmultiparams.append(newparams)
-    _multiparams = (newmultiparams, )
-    clause = clauseelement.values(newmultiparams[0])
-    clause._crate_specific = True
-    return clause, _multiparams, params
-
-
-@sa.event.listens_for(sa.engine.Engine, "before_execute", retval=True)
-def crate_before_execute(conn, clauseelement, multiparams, params, *args, **kwargs):
-    is_crate = type(conn.dialect).__name__ == 'CrateDialect'
-    if is_crate and isinstance(clauseelement, sa.sql.expression.Update):
-        if SA_VERSION >= SA_1_4:
-            if params is None:
-                multiparams = ([],)
-            else:
-                multiparams = ([params],)
-            params = {}
-
-        clauseelement, multiparams, params = rewrite_update(clauseelement, multiparams, params)
-
-        if SA_VERSION >= SA_1_4:
-            if multiparams[0]:
-                params = multiparams[0][0]
-            else:
-                params = multiparams[0]
-            multiparams = []
-
-    return clauseelement, multiparams, params
-
-
-class CrateDDLCompiler(compiler.DDLCompiler):
-
-    __special_opts_tmpl = {
-        'PARTITIONED_BY': ' PARTITIONED BY ({0})'
-    }
-    __clustered_opts_tmpl = {
-        'NUMBER_OF_SHARDS': ' INTO {0} SHARDS',
-        'CLUSTERED_BY': ' BY ({0})',
-    }
-    __clustered_opt_tmpl = ' CLUSTERED{CLUSTERED_BY}{NUMBER_OF_SHARDS}'
-
-    def get_column_specification(self, column, **kwargs):
-        colspec = self.preparer.format_column(column) + " " + \
-            self.dialect.type_compiler.process(column.type)
-        # TODO: once supported add default here
-
-        if column.computed is not None:
-            colspec += " " + self.process(column.computed)
-
-        if column.nullable is False:
-            colspec += " NOT NULL"
-        elif column.nullable and column.primary_key:
-            raise sa.exc.CompileError(
-                "Primary key columns cannot be nullable"
-            )
-
-        if column.dialect_options['crate'].get('index') is False:
-            if isinstance(column.type, (Geopoint, Geoshape, _Craty)):
-                raise sa.exc.CompileError(
-                    "Disabling indexing is not supported for column "
-                    "types OBJECT, GEO_POINT, and GEO_SHAPE"
-                )
-
-            colspec += " INDEX OFF"
-
-        return colspec
-
-    def visit_computed_column(self, generated):
-        if generated.persisted is False:
-            raise sa.exc.CompileError(
-                "Virtual computed columns are not supported, set "
-                "'persisted' to None or True"
-            )
-
-        return "GENERATED ALWAYS AS (%s)" % self.sql_compiler.process(
-            generated.sqltext, include_table=False, literal_binds=True
-        )
-
-    def post_create_table(self, table):
-        special_options = ''
-        clustered_options = defaultdict(str)
-        table_opts = []
-
-        opts = dict(
-            (k[len(self.dialect.name) + 1:].upper(), v)
-            for k, v, in table.kwargs.items()
-            if k.startswith('%s_' % self.dialect.name)
-        )
-        for k, v in opts.items():
-            if k in self.__special_opts_tmpl:
-                special_options += self.__special_opts_tmpl[k].format(v)
-            elif k in self.__clustered_opts_tmpl:
-                clustered_options[k] = self.__clustered_opts_tmpl[k].format(v)
-            else:
-                table_opts.append('{0} = {1}'.format(k, v))
-        if clustered_options:
-            special_options += string.Formatter().vformat(
-                self.__clustered_opt_tmpl, (), clustered_options)
-        if table_opts:
-            return special_options + ' WITH ({0})'.format(
-                ', '.join(sorted(table_opts)))
-        return special_options
-
-
-class CrateTypeCompiler(compiler.GenericTypeCompiler):
-
-    def visit_string(self, type_, **kw):
-        return 'STRING'
-
-    def visit_unicode(self, type_, **kw):
-        return 'STRING'
-
-    def visit_TEXT(self, type_, **kw):
-        return 'STRING'
-
-    def visit_DECIMAL(self, type_, **kw):
-        return 'DOUBLE'
-
-    def visit_BIGINT(self, type_, **kw):
-        return 'LONG'
-
-    def visit_NUMERIC(self, type_, **kw):
-        return 'LONG'
-
-    def visit_INTEGER(self, type_, **kw):
-        return 'INT'
-
-    def visit_SMALLINT(self, type_, **kw):
-        return 'SHORT'
-
-    def visit_datetime(self, type_, **kw):
-        return 'TIMESTAMP'
-
-    def visit_date(self, type_, **kw):
-        return 'TIMESTAMP'
-
-    def visit_ARRAY(self, type_, **kw):
-        if type_.dimensions is not None and type_.dimensions > 1:
-            raise NotImplementedError(
-                "CrateDB doesn't support multidimensional arrays")
-        return 'ARRAY({0})'.format(self.process(type_.item_type))
-
-
-class CrateCompiler(compiler.SQLCompiler):
-
-    def visit_getitem_binary(self, binary, operator, **kw):
-        return "{0}['{1}']".format(
-            self.process(binary.left, **kw),
-            binary.right.value
-        )
-
-    def visit_any(self, element, **kw):
-        return "%s%sANY (%s)" % (
-            self.process(element.left, **kw),
-            compiler.OPERATORS[element.operator],
-            self.process(element.right, **kw)
-        )
-
-    def limit_clause(self, select, **kw):
-        """
-        Generate OFFSET / LIMIT clause, PostgreSQL-compatible.
-        """
-        return PGCompiler.limit_clause(self, select, **kw)
diff --git a/src/crate/client/sqlalchemy/dialect.py b/src/crate/client/sqlalchemy/dialect.py
deleted file mode 100644
index 9bb16e1e8..000000000
--- a/src/crate/client/sqlalchemy/dialect.py
+++ /dev/null
@@ -1,349 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import logging
-from datetime import datetime, date
-
-from sqlalchemy import types as sqltypes
-from sqlalchemy.engine import default, reflection
-from sqlalchemy.sql import functions
-from sqlalchemy.util import asbool, to_list
-
-from .compiler import (
-    CrateTypeCompiler,
-    CrateDDLCompiler
-)
-from crate.client.exceptions import TimezoneUnawareException
-from .sa_version import SA_VERSION, SA_1_4, SA_2_0
-from .types import Object, ObjectArray
-
-TYPES_MAP = {
-    "boolean": sqltypes.Boolean,
-    "short": sqltypes.SmallInteger,
-    "smallint": sqltypes.SmallInteger,
-    "timestamp": sqltypes.TIMESTAMP,
-    "timestamp with time zone": sqltypes.TIMESTAMP,
-    "object": Object,
-    "integer": sqltypes.Integer,
-    "long": sqltypes.NUMERIC,
-    "bigint": sqltypes.NUMERIC,
-    "double": sqltypes.DECIMAL,
-    "double precision": sqltypes.DECIMAL,
-    "object_array": ObjectArray,
-    "float": sqltypes.Float,
-    "real": sqltypes.Float,
-    "string": sqltypes.String,
-    "text": sqltypes.String
-}
-try:
-    # SQLAlchemy >= 1.1
-    from sqlalchemy.types import ARRAY
-    TYPES_MAP["integer_array"] = ARRAY(sqltypes.Integer)
-    TYPES_MAP["boolean_array"] = ARRAY(sqltypes.Boolean)
-    TYPES_MAP["short_array"] = ARRAY(sqltypes.SmallInteger)
-    TYPES_MAP["smallint_array"] = ARRAY(sqltypes.SmallInteger)
-    TYPES_MAP["timestamp_array"] = ARRAY(sqltypes.TIMESTAMP)
-    TYPES_MAP["timestamp with time zone_array"] = ARRAY(sqltypes.TIMESTAMP)
-    TYPES_MAP["long_array"] = ARRAY(sqltypes.NUMERIC)
-    TYPES_MAP["bigint_array"] = ARRAY(sqltypes.NUMERIC)
-    TYPES_MAP["double_array"] = ARRAY(sqltypes.DECIMAL)
-    TYPES_MAP["double precision_array"] = ARRAY(sqltypes.DECIMAL)
-    TYPES_MAP["float_array"] = ARRAY(sqltypes.Float)
-    TYPES_MAP["real_array"] = ARRAY(sqltypes.Float)
-    TYPES_MAP["string_array"] = ARRAY(sqltypes.String)
-    TYPES_MAP["text_array"] = ARRAY(sqltypes.String)
-except Exception:
-    pass
-
-
-log = logging.getLogger(__name__)
-
-
-class Date(sqltypes.Date):
-    def bind_processor(self, dialect):
-        def process(value):
-            if value is not None:
-                assert isinstance(value, date)
-                return value.strftime('%Y-%m-%d')
-        return process
-
-    def result_processor(self, dialect, coltype):
-        def process(value):
-            if not value:
-                return
-            try:
-                return datetime.utcfromtimestamp(value / 1e3).date()
-            except TypeError:
-                pass
-
-            # Crate doesn't really have datetime or date types but a
-            # timestamp type. The "date" mapping (conversion to long)
-            # is only applied if the schema definition for the column exists
-            # and if the sql insert statement was used.
-            # In case of dynamic mapping or using the rest indexing endpoint
-            # the date will be returned in the format it was inserted.
-            log.warning(
-                "Received timestamp isn't a long value."
-                "Trying to parse as date string and then as datetime string")
-            try:
-                return datetime.strptime(value, '%Y-%m-%d').date()
-            except ValueError:
-                return datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%fZ').date()
-        return process
-
-
-class DateTime(sqltypes.DateTime):
-
-    TZ_ERROR_MSG = "Timezone aware datetime objects are not supported"
-
-    def bind_processor(self, dialect):
-        def process(value):
-            if value is not None:
-                assert isinstance(value, datetime)
-                if value.tzinfo is not None:
-                    raise TimezoneUnawareException(DateTime.TZ_ERROR_MSG)
-                return value.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
-            return value
-        return process
-
-    def result_processor(self, dialect, coltype):
-        def process(value):
-            if not value:
-                return
-            try:
-                return datetime.utcfromtimestamp(value / 1e3)
-            except TypeError:
-                pass
-
-            # Crate doesn't really have datetime or date types but a
-            # timestamp type. The "date" mapping (conversion to long)
-            # is only applied if the schema definition for the column exists
-            # and if the sql insert statement was used.
-            # In case of dynamic mapping or using the rest indexing endpoint
-            # the date will be returned in the format it was inserted.
-            log.warning(
-                "Received timestamp isn't a long value."
-                "Trying to parse as datetime string and then as date string")
-            try:
-                return datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%fZ')
-            except ValueError:
-                return datetime.strptime(value, '%Y-%m-%d')
-        return process
-
-
-colspecs = {
-    sqltypes.DateTime: DateTime,
-    sqltypes.Date: Date
-}
-
-
-if SA_VERSION >= SA_2_0:
-    from .compat.core20 import CrateCompilerSA20
-    statement_compiler = CrateCompilerSA20
-elif SA_VERSION >= SA_1_4:
-    from .compat.core14 import CrateCompilerSA14
-    statement_compiler = CrateCompilerSA14
-else:
-    from .compat.core10 import CrateCompilerSA10
-    statement_compiler = CrateCompilerSA10
-
-
-class CrateDialect(default.DefaultDialect):
-    name = 'crate'
-    driver = 'crate-python'
-    statement_compiler = statement_compiler
-    ddl_compiler = CrateDDLCompiler
-    type_compiler = CrateTypeCompiler
-    supports_native_boolean = True
-    supports_statement_cache = True
-    colspecs = colspecs
-    implicit_returning = True
-
-    def __init__(self, *args, **kwargs):
-        super(CrateDialect, self).__init__(*args, **kwargs)
-        # currently our sql parser doesn't support unquoted column names that
-        # start with _. Adding it here causes sqlalchemy to quote such columns
-        self.identifier_preparer.illegal_initial_characters.add('_')
-
-    def initialize(self, connection):
-        # get lowest server version
-        self.server_version_info = \
-            self._get_server_version_info(connection)
-        # get default schema name
-        self.default_schema_name = \
-            self._get_default_schema_name(connection)
-
-    def do_rollback(self, connection):
-        # if any exception is raised by the dbapi, sqlalchemy by default
-        # attempts to do a rollback crate doesn't support rollbacks.
-        # implementing this as noop seems to cause sqlalchemy to propagate the
-        # original exception to the user
-        pass
-
-    def connect(self, host=None, port=None, *args, **kwargs):
-        server = None
-        if host:
-            server = '{0}:{1}'.format(host, port or '4200')
-        if 'servers' in kwargs:
-            server = kwargs.pop('servers')
-        servers = to_list(server)
-        if servers:
-            use_ssl = asbool(kwargs.pop("ssl", False))
-            if use_ssl:
-                servers = ["https://" + server for server in servers]
-            return self.dbapi.connect(servers=servers, **kwargs)
-        return self.dbapi.connect(**kwargs)
-
-    def _get_default_schema_name(self, connection):
-        return 'doc'
-
-    def _get_server_version_info(self, connection):
-        return tuple(connection.connection.lowest_server_version.version)
-
-    @classmethod
-    def import_dbapi(cls):
-        from crate import client
-        return client
-
-    @classmethod
-    def dbapi(cls):
-        return cls.import_dbapi()
-
-    def has_schema(self, connection, schema):
-        return schema in self.get_schema_names(connection)
-
-    def has_table(self, connection, table_name, schema=None):
-        return table_name in self.get_table_names(connection, schema=schema)
-
-    @reflection.cache
-    def get_schema_names(self, connection, **kw):
-        cursor = connection.exec_driver_sql(
-            "select schema_name "
-            "from information_schema.schemata "
-            "order by schema_name asc"
-        )
-        return [row[0] for row in cursor.fetchall()]
-
-    @reflection.cache
-    def get_table_names(self, connection, schema=None, **kw):
-        cursor = connection.exec_driver_sql(
-            "SELECT table_name FROM information_schema.tables "
-            "WHERE {0} = ? "
-            "AND table_type = 'BASE TABLE' "
-            "ORDER BY table_name ASC, {0} ASC".format(self.schema_column),
-            (schema or self.default_schema_name, )
-        )
-        return [row[0] for row in cursor.fetchall()]
-
-    @reflection.cache
-    def get_view_names(self, connection, schema=None, **kw):
-        cursor = connection.exec_driver_sql(
-            "SELECT table_name FROM information_schema.views "
-            "ORDER BY table_name ASC, {0} ASC".format(self.schema_column),
-            (schema or self.default_schema_name, )
-        )
-        return [row[0] for row in cursor.fetchall()]
-
-    @reflection.cache
-    def get_columns(self, connection, table_name, schema=None, **kw):
-        query = "SELECT column_name, data_type " \
-                "FROM information_schema.columns " \
-                "WHERE table_name = ? AND {0} = ? " \
-                "AND column_name !~ ?" \
-                .format(self.schema_column)
-        cursor = connection.exec_driver_sql(
-            query,
-            (table_name,
-             schema or self.default_schema_name,
-             r"(.*)\[\'(.*)\'\]")  # regex to filter subscript
-        )
-        return [self._create_column_info(row) for row in cursor.fetchall()]
-
-    @reflection.cache
-    def get_pk_constraint(self, engine, table_name, schema=None, **kw):
-        if self.server_version_info >= (3, 0, 0):
-            query = """SELECT column_name
-                    FROM information_schema.key_column_usage
-                    WHERE table_name = ? AND table_schema = ?"""
-
-            def result_fun(result):
-                rows = result.fetchall()
-                return set(map(lambda el: el[0], rows))
-
-        elif self.server_version_info >= (2, 3, 0):
-            query = """SELECT column_name
-                    FROM information_schema.key_column_usage
-                    WHERE table_name = ? AND table_catalog = ?"""
-
-            def result_fun(result):
-                rows = result.fetchall()
-                return set(map(lambda el: el[0], rows))
-
-        else:
-            query = """SELECT constraint_name
-                   FROM information_schema.table_constraints
-                   WHERE table_name = ? AND {schema_col} = ?
-                   AND constraint_type='PRIMARY_KEY'
-                   """.format(schema_col=self.schema_column)
-
-            def result_fun(result):
-                rows = result.fetchone()
-                return set(rows[0] if rows else [])
-
-        pk_result = engine.exec_driver_sql(
-            query,
-            (table_name, schema or self.default_schema_name)
-        )
-        pks = result_fun(pk_result)
-        return {'constrained_columns': pks,
-                'name': 'PRIMARY KEY'}
-
-    @reflection.cache
-    def get_foreign_keys(self, connection, table_name, schema=None,
-                         postgresql_ignore_search_path=False, **kw):
-        # Crate doesn't support Foreign Keys, so this stays empty
-        return []
-
-    @reflection.cache
-    def get_indexes(self, connection, table_name, schema, **kw):
-        return []
-
-    @property
-    def schema_column(self):
-        return "table_schema"
-
-    def _create_column_info(self, row):
-        return {
-            'name': row[0],
-            'type': self._resolve_type(row[1]),
-            # In Crate every column is nullable except PK
-            # Primary Key Constraints are not nullable anyway, no matter what
-            # we return here, so it's fine to return always `True`
-            'nullable': True
-        }
-
-    def _resolve_type(self, type_):
-        return TYPES_MAP.get(type_, sqltypes.UserDefinedType)
-
-
-class DateTrunc(functions.GenericFunction):
-    name = "date_trunc"
-    type = sqltypes.TIMESTAMP
diff --git a/src/crate/client/sqlalchemy/predicates/__init__.py b/src/crate/client/sqlalchemy/predicates/__init__.py
deleted file mode 100644
index 4f974f928..000000000
--- a/src/crate/client/sqlalchemy/predicates/__init__.py
+++ /dev/null
@@ -1,99 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from sqlalchemy.sql.expression import ColumnElement, literal
-from sqlalchemy.ext.compiler import compiles
-
-
-class Match(ColumnElement):
-    inherit_cache = True
-
-    def __init__(self, column, term, match_type=None, options=None):
-        super(Match, self).__init__()
-        self.column = column
-        self.term = term
-        self.match_type = match_type
-        self.options = options
-
-    def compile_column(self, compiler):
-        if isinstance(self.column, dict):
-            column = ', '.join(
-                sorted(["{0} {1}".format(compiler.process(k), v)
-                       for k, v in self.column.items()])
-            )
-            return "({0})".format(column)
-        else:
-            return "{0}".format(compiler.process(self.column))
-
-    def compile_term(self, compiler):
-        return compiler.process(literal(self.term))
-
-    def compile_using(self, compiler):
-        if self.match_type:
-            using = "using {0}".format(self.match_type)
-            with_clause = self.with_clause()
-            if with_clause:
-                using = ' '.join([using, with_clause])
-            return using
-        if self.options:
-            raise ValueError("missing match_type. " +
-                             "It's not allowed to specify options " +
-                             "without match_type")
-
-    def with_clause(self):
-        if self.options:
-            options = ', '.join(
-                sorted(["{0}={1}".format(k, v)
-                       for k, v in self.options.items()])
-            )
-
-            return "with ({0})".format(options)
-
-
-def match(column, term, match_type=None, options=None):
-    """Generates match predicate for fulltext search
-
-    :param column: A reference to a column or an index, or a subcolumn, or a
-     dictionary of subcolumns with boost values.
-
-    :param term: The term to match against. This string is analyzed and the
-     resulting tokens are compared to the index.
-
-    :param match_type (optional): The match type. Determine how the term is
-     applied and the score calculated.
-
-    :param options (optional): The match options. Specify match type behaviour.
-     (Not possible without a specified match type.) Match options must be
-     supplied as a dictionary.
-    """
-    return Match(column, term, match_type, options)
-
-
-@compiles(Match)
-def compile_match(match, compiler, **kwargs):
-    func = "match(%s, %s)" % (
-        match.compile_column(compiler),
-        match.compile_term(compiler)
-    )
-    using = match.compile_using(compiler)
-    if using:
-        func = ' '.join([func, using])
-    return func
diff --git a/src/crate/client/sqlalchemy/sa_version.py b/src/crate/client/sqlalchemy/sa_version.py
deleted file mode 100644
index 972b568c4..000000000
--- a/src/crate/client/sqlalchemy/sa_version.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import sqlalchemy as sa
-from crate.client._pep440 import Version
-
-SA_VERSION = Version(sa.__version__)
-
-SA_1_4 = Version('1.4.0b1')
-SA_2_0 = Version('2.0.0')
diff --git a/src/crate/client/sqlalchemy/tests/__init__.py b/src/crate/client/sqlalchemy/tests/__init__.py
deleted file mode 100644
index acca5db03..000000000
--- a/src/crate/client/sqlalchemy/tests/__init__.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from ..compat.api13 import monkeypatch_amend_select_sa14, monkeypatch_add_connectionfairy_driver_connection
-from ..sa_version import SA_1_4, SA_VERSION
-
-# `sql.select()` of SQLAlchemy 1.3 uses old calling semantics,
-# but the test cases already need the modern ones.
-if SA_VERSION < SA_1_4:
-    monkeypatch_amend_select_sa14()
-    monkeypatch_add_connectionfairy_driver_connection()
-
-from unittest import TestSuite, makeSuite
-from .connection_test import SqlAlchemyConnectionTest
-from .dict_test import SqlAlchemyDictTypeTest
-from .datetime_test import SqlAlchemyDateAndDateTimeTest
-from .compiler_test import SqlAlchemyCompilerTest
-from .update_test import SqlAlchemyUpdateTest
-from .match_test import SqlAlchemyMatchTest
-from .bulk_test import SqlAlchemyBulkTest
-from .insert_from_select_test import SqlAlchemyInsertFromSelectTest
-from .create_table_test import SqlAlchemyCreateTableTest
-from .array_test import SqlAlchemyArrayTypeTest
-from .dialect_test import SqlAlchemyDialectTest
-from .function_test import SqlAlchemyFunctionTest
-from .warnings_test import SqlAlchemyWarningsTest
-
-
-def test_suite():
-    tests = TestSuite()
-    tests.addTest(makeSuite(SqlAlchemyConnectionTest))
-    tests.addTest(makeSuite(SqlAlchemyDictTypeTest))
-    tests.addTest(makeSuite(SqlAlchemyDateAndDateTimeTest))
-    tests.addTest(makeSuite(SqlAlchemyCompilerTest))
-    tests.addTest(makeSuite(SqlAlchemyUpdateTest))
-    tests.addTest(makeSuite(SqlAlchemyMatchTest))
-    tests.addTest(makeSuite(SqlAlchemyCreateTableTest))
-    tests.addTest(makeSuite(SqlAlchemyBulkTest))
-    tests.addTest(makeSuite(SqlAlchemyInsertFromSelectTest))
-    tests.addTest(makeSuite(SqlAlchemyInsertFromSelectTest))
-    tests.addTest(makeSuite(SqlAlchemyDialectTest))
-    tests.addTest(makeSuite(SqlAlchemyFunctionTest))
-    tests.addTest(makeSuite(SqlAlchemyArrayTypeTest))
-    tests.addTest(makeSuite(SqlAlchemyWarningsTest))
-    return tests
diff --git a/src/crate/client/sqlalchemy/tests/array_test.py b/src/crate/client/sqlalchemy/tests/array_test.py
deleted file mode 100644
index 6d6633274..000000000
--- a/src/crate/client/sqlalchemy/tests/array_test.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy.sql import operators
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.cursor import Cursor
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-@patch('crate.client.connection.Cursor', FakeCursor)
-class SqlAlchemyArrayTypeTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        Base = declarative_base()
-        self.metadata = sa.MetaData()
-
-        class User(Base):
-            __tablename__ = 'users'
-
-            name = sa.Column(sa.String, primary_key=True)
-            friends = sa.Column(sa.ARRAY(sa.String))
-            scores = sa.Column(sa.ARRAY(sa.Integer))
-
-        self.User = User
-        self.session = Session(bind=self.engine)
-
-    def assertSQL(self, expected_str, actual_expr):
-        self.assertEqual(expected_str, str(actual_expr).replace('\n', ''))
-
-    def test_create_with_array(self):
-        t1 = sa.Table('t', self.metadata,
-                      sa.Column('int_array', sa.ARRAY(sa.Integer)),
-                      sa.Column('str_array', sa.ARRAY(sa.String))
-                      )
-        t1.create(self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'int_array ARRAY(INT), \n\t'
-             'str_array ARRAY(STRING)\n)\n\n'),
-            ())
-
-    def test_array_insert(self):
-        trillian = self.User(name='Trillian', friends=['Arthur', 'Ford'])
-        self.session.add(trillian)
-        self.session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("INSERT INTO users (name, friends, scores) VALUES (?, ?, ?)"),
-            ('Trillian', ['Arthur', 'Ford'], None))
-
-    def test_any(self):
-        s = self.session.query(self.User.name) \
-                .filter(self.User.friends.any("arthur"))
-        self.assertSQL(
-            "SELECT users.name AS users_name FROM users "
-            "WHERE ? = ANY (users.friends)",
-            s
-        )
-
-    def test_any_with_operator(self):
-        s = self.session.query(self.User.name) \
-                .filter(self.User.scores.any(6, operator=operators.lt))
-        self.assertSQL(
-            "SELECT users.name AS users_name FROM users "
-            "WHERE ? < ANY (users.scores)",
-            s
-        )
-
-    def test_multidimensional_arrays(self):
-        t1 = sa.Table('t', self.metadata,
-                      sa.Column('unsupported_array',
-                                sa.ARRAY(sa.Integer, dimensions=2)),
-                      )
-        err = None
-        try:
-            t1.create(self.engine)
-        except NotImplementedError as e:
-            err = e
-        self.assertEqual(str(err),
-                         "CrateDB doesn't support multidimensional arrays")
diff --git a/src/crate/client/sqlalchemy/tests/bulk_test.py b/src/crate/client/sqlalchemy/tests/bulk_test.py
deleted file mode 100644
index ee4099cfc..000000000
--- a/src/crate/client/sqlalchemy/tests/bulk_test.py
+++ /dev/null
@@ -1,81 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class SqlAlchemyBulkTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer)
-
-        self.character = Character
-        self.session = Session(bind=self.engine)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_bulk_save(self):
-        chars = [
-            self.character(name='Arthur', age=35),
-            self.character(name='Banshee', age=26),
-            self.character(name='Callisto', age=37),
-        ]
-
-        fake_cursor.description = ()
-        fake_cursor.rowcount = len(chars)
-        fake_cursor.executemany.return_value = [
-            {'rowcount': 1},
-            {'rowcount': 1},
-            {'rowcount': 1},
-        ]
-        self.session.bulk_save_objects(chars)
-        (stmt, bulk_args), _ = fake_cursor.executemany.call_args
-
-        expected_stmt = "INSERT INTO characters (name, age) VALUES (?, ?)"
-        self.assertEqual(expected_stmt, stmt)
-
-        expected_bulk_args = (
-            ('Arthur', 35),
-            ('Banshee', 26),
-            ('Callisto', 37)
-        )
-        self.assertSequenceEqual(expected_bulk_args, bulk_args)
diff --git a/src/crate/client/sqlalchemy/tests/compiler_test.py b/src/crate/client/sqlalchemy/tests/compiler_test.py
deleted file mode 100644
index 47317db7b..000000000
--- a/src/crate/client/sqlalchemy/tests/compiler_test.py
+++ /dev/null
@@ -1,99 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from unittest import TestCase
-
-from crate.client.sqlalchemy.compiler import crate_before_execute
-
-import sqlalchemy as sa
-from sqlalchemy.sql import text, Update
-
-from crate.client.sqlalchemy.sa_version import SA_VERSION, SA_1_4
-from crate.client.sqlalchemy.types import Craty
-
-
-class SqlAlchemyCompilerTest(TestCase):
-
-    def setUp(self):
-        self.crate_engine = sa.create_engine('crate://')
-        self.sqlite_engine = sa.create_engine('sqlite://')
-        self.metadata = sa.MetaData()
-        self.mytable = sa.Table('mytable', self.metadata,
-                                sa.Column('name', sa.String),
-                                sa.Column('data', Craty))
-
-        self.update = Update(self.mytable).where(text('name=:name'))
-        self.values = [{'name': 'crate'}]
-        self.values = (self.values, )
-
-    def test_sqlite_update_not_rewritten(self):
-        clauseelement, multiparams, params = crate_before_execute(
-            self.sqlite_engine, self.update, self.values, {}
-        )
-
-        self.assertFalse(hasattr(clauseelement, '_crate_specific'))
-
-    def test_crate_update_rewritten(self):
-        clauseelement, multiparams, params = crate_before_execute(
-            self.crate_engine, self.update, self.values, {}
-        )
-
-        self.assertTrue(hasattr(clauseelement, '_crate_specific'))
-
-    def test_bulk_update_on_builtin_type(self):
-        """
-        The "before_execute" hook in the compiler doesn't get
-        access to the parameters in case of a bulk update. It
-        should not try to optimize any parameters.
-        """
-        data = ({},)
-        clauseelement, multiparams, params = crate_before_execute(
-            self.crate_engine, self.update, data, None
-        )
-
-        self.assertFalse(hasattr(clauseelement, '_crate_specific'))
-
-    def test_select_with_offset(self):
-        """
-        Verify the `CrateCompiler.limit_clause` method, with offset.
-        """
-        selectable = self.mytable.select().offset(5)
-        statement = str(selectable.compile(bind=self.crate_engine))
-        if SA_VERSION >= SA_1_4:
-            self.assertEqual(statement, "SELECT mytable.name, mytable.data \nFROM mytable\n LIMIT ALL OFFSET ?")
-        else:
-            self.assertEqual(statement, "SELECT mytable.name, mytable.data \nFROM mytable \n LIMIT ALL OFFSET ?")
-
-    def test_select_with_limit(self):
-        """
-        Verify the `CrateCompiler.limit_clause` method, with limit.
-        """
-        selectable = self.mytable.select().limit(42)
-        statement = str(selectable.compile(bind=self.crate_engine))
-        self.assertEqual(statement, "SELECT mytable.name, mytable.data \nFROM mytable \n LIMIT ?")
-
-    def test_select_with_offset_and_limit(self):
-        """
-        Verify the `CrateCompiler.limit_clause` method, with offset and limit.
-        """
-        selectable = self.mytable.select().offset(5).limit(42)
-        statement = str(selectable.compile(bind=self.crate_engine))
-        self.assertEqual(statement, "SELECT mytable.name, mytable.data \nFROM mytable \n LIMIT ? OFFSET ?")
diff --git a/src/crate/client/sqlalchemy/tests/connection_test.py b/src/crate/client/sqlalchemy/tests/connection_test.py
deleted file mode 100644
index 4e22489b0..000000000
--- a/src/crate/client/sqlalchemy/tests/connection_test.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from unittest import TestCase
-import sqlalchemy as sa
-from sqlalchemy.exc import NoSuchModuleError
-
-
-class SqlAlchemyConnectionTest(TestCase):
-
-    def test_connection_server_uri_unknown_sa_plugin(self):
-        with self.assertRaises(NoSuchModuleError):
-            sa.create_engine("foobar://otherhost:19201")
-
-    def test_default_connection(self):
-        engine = sa.create_engine('crate://')
-        conn = engine.raw_connection()
-        self.assertEqual(">",
-                         repr(conn.driver_connection))
-        conn.close()
-        engine.dispose()
-
-    def test_connection_server_uri_http(self):
-        engine = sa.create_engine(
-            "crate://otherhost:19201")
-        conn = engine.raw_connection()
-        self.assertEqual(">",
-                         repr(conn.driver_connection))
-        conn.close()
-        engine.dispose()
-
-    def test_connection_server_uri_https(self):
-        engine = sa.create_engine(
-            "crate://otherhost:19201/?ssl=true")
-        conn = engine.raw_connection()
-        self.assertEqual(">",
-                         repr(conn.driver_connection))
-        conn.close()
-        engine.dispose()
-
-    def test_connection_server_uri_invalid_port(self):
-        with self.assertRaises(ValueError) as context:
-            sa.create_engine("crate://foo:bar")
-        self.assertIn("invalid literal for int() with base 10: 'bar'", str(context.exception))
-
-    def test_connection_server_uri_https_with_trusted_user(self):
-        engine = sa.create_engine(
-            "crate://foo@otherhost:19201/?ssl=true")
-        conn = engine.raw_connection()
-        self.assertEqual(">",
-                         repr(conn.driver_connection))
-        self.assertEqual(conn.driver_connection.client.username, "foo")
-        self.assertEqual(conn.driver_connection.client.password, None)
-        conn.close()
-        engine.dispose()
-
-    def test_connection_server_uri_https_with_credentials(self):
-        engine = sa.create_engine(
-            "crate://foo:bar@otherhost:19201/?ssl=true")
-        conn = engine.raw_connection()
-        self.assertEqual(">",
-                         repr(conn.driver_connection))
-        self.assertEqual(conn.driver_connection.client.username, "foo")
-        self.assertEqual(conn.driver_connection.client.password, "bar")
-        conn.close()
-        engine.dispose()
-
-    def test_connection_multiple_server_http(self):
-        engine = sa.create_engine(
-            "crate://", connect_args={
-                'servers': ['localhost:4201', 'localhost:4202']
-            }
-        )
-        conn = engine.raw_connection()
-        self.assertEqual(
-            ">",
-            repr(conn.driver_connection))
-        conn.close()
-        engine.dispose()
-
-    def test_connection_multiple_server_https(self):
-        engine = sa.create_engine(
-            "crate://", connect_args={
-                'servers': ['localhost:4201', 'localhost:4202'],
-                'ssl': True,
-            }
-        )
-        conn = engine.raw_connection()
-        self.assertEqual(
-            ">",
-            repr(conn.driver_connection))
-        conn.close()
-        engine.dispose()
diff --git a/src/crate/client/sqlalchemy/tests/create_table_test.py b/src/crate/client/sqlalchemy/tests/create_table_test.py
deleted file mode 100644
index 7eca2628d..000000000
--- a/src/crate/client/sqlalchemy/tests/create_table_test.py
+++ /dev/null
@@ -1,234 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import sqlalchemy as sa
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.sqlalchemy.types import Object, ObjectArray, Geopoint
-from crate.client.cursor import Cursor
-
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-@patch('crate.client.connection.Cursor', FakeCursor)
-class SqlAlchemyCreateTableTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        self.Base = declarative_base()
-
-    def test_table_basic_types(self):
-        class User(self.Base):
-            __tablename__ = 'users'
-            string_col = sa.Column(sa.String, primary_key=True)
-            unicode_col = sa.Column(sa.Unicode)
-            text_col = sa.Column(sa.Text)
-            int_col = sa.Column(sa.Integer)
-            long_col1 = sa.Column(sa.BigInteger)
-            long_col2 = sa.Column(sa.NUMERIC)
-            bool_col = sa.Column(sa.Boolean)
-            short_col = sa.Column(sa.SmallInteger)
-            datetime_col = sa.Column(sa.DateTime)
-            date_col = sa.Column(sa.Date)
-            float_col = sa.Column(sa.Float)
-            double_col = sa.Column(sa.DECIMAL)
-
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE users (\n\tstring_col STRING NOT NULL, '
-             '\n\tunicode_col STRING, \n\ttext_col STRING, \n\tint_col INT, '
-             '\n\tlong_col1 LONG, \n\tlong_col2 LONG, '
-             '\n\tbool_col BOOLEAN, '
-             '\n\tshort_col SHORT, '
-             '\n\tdatetime_col TIMESTAMP, \n\tdate_col TIMESTAMP, '
-             '\n\tfloat_col FLOAT, \n\tdouble_col DOUBLE, '
-             '\n\tPRIMARY KEY (string_col)\n)\n\n'),
-            ())
-
-    def test_column_obj(self):
-        class DummyTable(self.Base):
-            __tablename__ = 'dummy'
-            pk = sa.Column(sa.String, primary_key=True)
-            obj_col = sa.Column(Object)
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE dummy (\n\tpk STRING NOT NULL, \n\tobj_col OBJECT, '
-             '\n\tPRIMARY KEY (pk)\n)\n\n'),
-            ())
-
-    def test_table_clustered_by(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            __table_args__ = {
-                'crate_clustered_by': 'p'
-            }
-            pk = sa.Column(sa.String, primary_key=True)
-            p = sa.Column(sa.String)
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'p STRING, \n\t'
-             'PRIMARY KEY (pk)\n'
-             ') CLUSTERED BY (p)\n\n'),
-            ())
-
-    def test_column_computed(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            ts = sa.Column(sa.BigInteger, primary_key=True)
-            p = sa.Column(sa.BigInteger, sa.Computed("date_trunc('day', ts)"))
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'ts LONG NOT NULL, \n\t'
-             'p LONG GENERATED ALWAYS AS (date_trunc(\'day\', ts)), \n\t'
-             'PRIMARY KEY (ts)\n'
-             ')\n\n'),
-            ())
-
-    def test_column_computed_virtual(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            ts = sa.Column(sa.BigInteger, primary_key=True)
-            p = sa.Column(sa.BigInteger, sa.Computed("date_trunc('day', ts)", persisted=False))
-        with self.assertRaises(sa.exc.CompileError):
-            self.Base.metadata.create_all(bind=self.engine)
-
-    def test_table_partitioned_by(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            __table_args__ = {
-                'crate_partitioned_by': 'p',
-                'invalid_option': 1
-            }
-            pk = sa.Column(sa.String, primary_key=True)
-            p = sa.Column(sa.String)
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'p STRING, \n\t'
-             'PRIMARY KEY (pk)\n'
-             ') PARTITIONED BY (p)\n\n'),
-            ())
-
-    def test_table_number_of_shards_and_replicas(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            __table_args__ = {
-                'crate_number_of_replicas': '2',
-                'crate_number_of_shards': 3
-            }
-            pk = sa.Column(sa.String, primary_key=True)
-
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'PRIMARY KEY (pk)\n'
-             ') CLUSTERED INTO 3 SHARDS WITH (NUMBER_OF_REPLICAS = 2)\n\n'),
-            ())
-
-    def test_table_clustered_by_and_number_of_shards(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            __table_args__ = {
-                'crate_clustered_by': 'p',
-                'crate_number_of_shards': 3
-            }
-            pk = sa.Column(sa.String, primary_key=True)
-            p = sa.Column(sa.String, primary_key=True)
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'p STRING NOT NULL, \n\t'
-             'PRIMARY KEY (pk, p)\n'
-             ') CLUSTERED BY (p) INTO 3 SHARDS\n\n'),
-            ())
-
-    def test_column_object_array(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            pk = sa.Column(sa.String, primary_key=True)
-            tags = sa.Column(ObjectArray)
-
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'tags ARRAY(OBJECT), \n\t'
-             'PRIMARY KEY (pk)\n)\n\n'), ())
-
-    def test_column_nullable(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            pk = sa.Column(sa.String, primary_key=True)
-            a = sa.Column(sa.Integer, nullable=True)
-            b = sa.Column(sa.Integer, nullable=False)
-
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'a INT, \n\t'
-             'b INT NOT NULL, \n\t'
-             'PRIMARY KEY (pk)\n)\n\n'), ())
-
-    def test_column_pk_nullable(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            pk = sa.Column(sa.String, primary_key=True, nullable=True)
-        with self.assertRaises(sa.exc.CompileError):
-            self.Base.metadata.create_all(bind=self.engine)
-
-    def test_column_crate_index(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            pk = sa.Column(sa.String, primary_key=True)
-            a = sa.Column(sa.Integer, crate_index=False)
-            b = sa.Column(sa.Integer, crate_index=True)
-
-        self.Base.metadata.create_all(bind=self.engine)
-        fake_cursor.execute.assert_called_with(
-            ('\nCREATE TABLE t (\n\t'
-             'pk STRING NOT NULL, \n\t'
-             'a INT INDEX OFF, \n\t'
-             'b INT, \n\t'
-             'PRIMARY KEY (pk)\n)\n\n'), ())
-
-    def test_column_geopoint_without_index(self):
-        class DummyTable(self.Base):
-            __tablename__ = 't'
-            pk = sa.Column(sa.String, primary_key=True)
-            a = sa.Column(Geopoint, crate_index=False)
-        with self.assertRaises(sa.exc.CompileError):
-            self.Base.metadata.create_all(bind=self.engine)
diff --git a/src/crate/client/sqlalchemy/tests/datetime_test.py b/src/crate/client/sqlalchemy/tests/datetime_test.py
deleted file mode 100644
index 07e98ede9..000000000
--- a/src/crate/client/sqlalchemy/tests/datetime_test.py
+++ /dev/null
@@ -1,90 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from __future__ import absolute_import
-from datetime import datetime, tzinfo, timedelta
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy.exc import DBAPIError
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class CST(tzinfo):
-    """
-    Timezone object for CST
-    """
-
-    def utcoffset(self, date_time):
-        return timedelta(seconds=-3600)
-
-    def dst(self, date_time):
-        return timedelta(seconds=-7200)
-
-
-@patch('crate.client.connection.Cursor', FakeCursor)
-class SqlAlchemyDateAndDateTimeTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-            name = sa.Column(sa.String, primary_key=True)
-            date = sa.Column(sa.Date)
-            timestamp = sa.Column(sa.DateTime)
-
-        fake_cursor.description = (
-            ('characters_name', None, None, None, None, None, None),
-            ('characters_date', None, None, None, None, None, None)
-        )
-        self.session = Session(bind=self.engine)
-        self.Character = Character
-
-    def test_date_can_handle_datetime(self):
-        """ date type should also be able to handle iso datetime strings.
-
-        this verifies that the fallback in the Date result_processor works.
-        """
-        fake_cursor.fetchall.return_value = [
-            ('Trillian', '2013-07-16T00:00:00.000Z')
-        ]
-        self.session.query(self.Character).first()
-
-    def test_date_cannot_handle_tz_aware_datetime(self):
-        character = self.Character()
-        character.name = "Athur"
-        character.timestamp = datetime(2009, 5, 13, 19, 19, 30, tzinfo=CST())
-        self.session.add(character)
-        self.assertRaises(DBAPIError, self.session.commit)
diff --git a/src/crate/client/sqlalchemy/tests/dialect_test.py b/src/crate/client/sqlalchemy/tests/dialect_test.py
deleted file mode 100644
index a6669df44..000000000
--- a/src/crate/client/sqlalchemy/tests/dialect_test.py
+++ /dev/null
@@ -1,128 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from datetime import datetime
-from unittest import TestCase
-from unittest.mock import MagicMock, patch
-
-import sqlalchemy as sa
-
-from crate.client.cursor import Cursor
-from crate.client.sqlalchemy.types import Object
-from sqlalchemy import inspect
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.testing import eq_, in_
-
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-
-
-@patch('crate.client.connection.Cursor', FakeCursor)
-class SqlAlchemyDialectTest(TestCase):
-
-    def execute_wrapper(self, query, *args, **kwargs):
-        self.executed_statement = query
-        return self.fake_cursor
-
-    def setUp(self):
-
-        self.fake_cursor = MagicMock(name='fake_cursor')
-        FakeCursor.return_value = self.fake_cursor
-
-        self.engine = sa.create_engine('crate://')
-
-        self.executed_statement = None
-
-        self.connection = self.engine.connect()
-
-        self.fake_cursor.execute = self.execute_wrapper
-
-        self.base = declarative_base()
-
-        class Character(self.base):
-            __tablename__ = 'characters'
-
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer, primary_key=True)
-            obj = sa.Column(Object)
-            ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
-
-        self.session = Session(bind=self.engine)
-
-    def test_primary_keys_2_3_0(self):
-        insp = inspect(self.session.bind)
-        self.engine.dialect.server_version_info = (2, 3, 0)
-
-        self.fake_cursor.rowcount = 3
-        self.fake_cursor.description = (
-            ('foo', None, None, None, None, None, None),
-        )
-        self.fake_cursor.fetchall = MagicMock(return_value=[["id"], ["id2"], ["id3"]])
-
-        eq_(insp.get_pk_constraint("characters")['constrained_columns'], {"id", "id2", "id3"})
-        self.fake_cursor.fetchall.assert_called_once_with()
-        in_("information_schema.key_column_usage", self.executed_statement)
-        in_("table_catalog = ?", self.executed_statement)
-
-    def test_primary_keys_3_0_0(self):
-        insp = inspect(self.session.bind)
-        self.engine.dialect.server_version_info = (3, 0, 0)
-
-        self.fake_cursor.rowcount = 3
-        self.fake_cursor.description = (
-            ('foo', None, None, None, None, None, None),
-        )
-        self.fake_cursor.fetchall = MagicMock(return_value=[["id"], ["id2"], ["id3"]])
-
-        eq_(insp.get_pk_constraint("characters")['constrained_columns'], {"id", "id2", "id3"})
-        self.fake_cursor.fetchall.assert_called_once_with()
-        in_("information_schema.key_column_usage", self.executed_statement)
-        in_("table_schema = ?", self.executed_statement)
-
-    def test_get_table_names(self):
-        self.fake_cursor.rowcount = 1
-        self.fake_cursor.description = (
-            ('foo', None, None, None, None, None, None),
-        )
-        self.fake_cursor.fetchall = MagicMock(return_value=[["t1"], ["t2"]])
-
-        insp = inspect(self.session.bind)
-        self.engine.dialect.server_version_info = (2, 0, 0)
-        eq_(insp.get_table_names(schema="doc"),
-            ['t1', 't2'])
-        in_("WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY", self.executed_statement)
-
-    def test_get_view_names(self):
-        self.fake_cursor.rowcount = 1
-        self.fake_cursor.description = (
-            ('foo', None, None, None, None, None, None),
-        )
-        self.fake_cursor.fetchall = MagicMock(return_value=[["v1"], ["v2"]])
-
-        insp = inspect(self.session.bind)
-        self.engine.dialect.server_version_info = (2, 0, 0)
-        eq_(insp.get_view_names(schema="doc"),
-            ['v1', 'v2'])
-        eq_(self.executed_statement, "SELECT table_name FROM information_schema.views "
-                                     "ORDER BY table_name ASC, table_schema ASC")
diff --git a/src/crate/client/sqlalchemy/tests/dict_test.py b/src/crate/client/sqlalchemy/tests/dict_test.py
deleted file mode 100644
index 2324591e9..000000000
--- a/src/crate/client/sqlalchemy/tests/dict_test.py
+++ /dev/null
@@ -1,460 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from __future__ import absolute_import
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy.sql import select
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.sqlalchemy.types import Craty, ObjectArray
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class SqlAlchemyDictTypeTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        metadata = sa.MetaData()
-        self.mytable = sa.Table('mytable', metadata,
-                                sa.Column('name', sa.String),
-                                sa.Column('data', Craty))
-
-    def assertSQL(self, expected_str, selectable):
-        actual_expr = selectable.compile(bind=self.engine)
-        self.assertEqual(expected_str, str(actual_expr).replace('\n', ''))
-
-    def test_select_with_dict_column(self):
-        mytable = self.mytable
-        self.assertSQL(
-            "SELECT mytable.data['x'] AS anon_1 FROM mytable",
-            select(mytable.c.data['x'])
-        )
-
-    def test_select_with_dict_column_where_clause(self):
-        mytable = self.mytable
-        s = select(mytable.c.data).\
-            where(mytable.c.data['x'] == 1)
-        self.assertSQL(
-            "SELECT mytable.data FROM mytable WHERE mytable.data['x'] = ?",
-            s
-        )
-
-    def test_select_with_dict_column_nested_where(self):
-        mytable = self.mytable
-        s = select(mytable.c.name)
-        s = s.where(mytable.c.data['x']['y'] == 1)
-        self.assertSQL(
-            "SELECT mytable.name FROM mytable " +
-            "WHERE mytable.data['x']['y'] = ?",
-            s
-        )
-
-    def test_select_with_dict_column_where_clause_gt(self):
-        mytable = self.mytable
-        s = select(mytable.c.data).\
-            where(mytable.c.data['x'] > 1)
-        self.assertSQL(
-            "SELECT mytable.data FROM mytable WHERE mytable.data['x'] > ?",
-            s
-        )
-
-    def test_select_with_dict_column_where_clause_other_col(self):
-        mytable = self.mytable
-        s = select(mytable.c.name)
-        s = s.where(mytable.c.data['x'] == mytable.c.name)
-        self.assertSQL(
-            "SELECT mytable.name FROM mytable " +
-            "WHERE mytable.data['x'] = mytable.name",
-            s
-        )
-
-    def test_update_with_dict_column(self):
-        mytable = self.mytable
-        stmt = mytable.update().\
-            where(mytable.c.name == 'Arthur Dent').\
-            values({
-                "data['x']": "Trillian"
-            })
-        self.assertSQL(
-            "UPDATE mytable SET data['x'] = ? WHERE mytable.name = ?",
-            stmt
-        )
-
-    def set_up_character_and_cursor(self, return_value=None):
-        return_value = return_value or [('Trillian', {})]
-        fake_cursor.fetchall.return_value = return_value
-        fake_cursor.description = (
-            ('characters_name', None, None, None, None, None, None),
-            ('characters_data', None, None, None, None, None, None)
-        )
-        fake_cursor.rowcount = 1
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer)
-            data = sa.Column(Craty)
-            data_list = sa.Column(ObjectArray)
-
-        session = Session(bind=self.engine)
-        return session, Character
-
-    def test_assign_null_to_object_array(self):
-        session, Character = self.set_up_character_and_cursor()
-        char_1 = Character(name='Trillian', data_list=None)
-        self.assertIsNone(char_1.data_list)
-        char_2 = Character(name='Trillian', data_list=1)
-        self.assertEqual(char_2.data_list, [1])
-        char_3 = Character(name='Trillian', data_list=[None])
-        self.assertEqual(char_3.data_list, [None])
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_assign_to_craty_type_after_commit(self):
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', None)]
-        )
-        char = Character(name='Trillian')
-        session.add(char)
-        session.commit()
-        char.data = {'x': 1}
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            "UPDATE characters SET data = ? WHERE characters.name = ?",
-            ({'x': 1}, 'Trillian',)
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_change_tracking(self):
-        session, Character = self.set_up_character_and_cursor()
-        char = Character(name='Trillian')
-        session.add(char)
-        session.commit()
-
-        try:
-            char.data['x'] = 1
-        except Exception:
-            print(fake_cursor.fetchall.called)
-            print(fake_cursor.mock_calls)
-            raise
-
-        self.assertIn(char, session.dirty)
-        try:
-            session.commit()
-        except Exception:
-            print(fake_cursor.mock_calls)
-            raise
-        self.assertNotIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update(self):
-        session, Character = self.set_up_character_and_cursor()
-        char = Character(name='Trillian')
-        session.add(char)
-        session.commit()
-        char.data['x'] = 1
-        char.data['y'] = 2
-        session.commit()
-
-        # on python 3 dicts aren't sorted so the order if x or y is updated
-        # first isn't deterministic
-        try:
-            fake_cursor.execute.assert_called_with(
-                ("UPDATE characters SET data['y'] = ?, data['x'] = ? "
-                    "WHERE characters.name = ?"),
-                (2, 1, 'Trillian')
-            )
-        except AssertionError:
-            fake_cursor.execute.assert_called_with(
-                ("UPDATE characters SET data['x'] = ?, data['y'] = ? "
-                    "WHERE characters.name = ?"),
-                (1, 2, 'Trillian')
-            )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_only_one_key_changed(self):
-        """
-        If only one attribute of Crate is changed
-        the update should only update that attribute
-        not all attributes of Crate.
-        """
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', dict(x=1, y=2))]
-        )
-
-        char = Character(name='Trillian')
-        char.data = dict(x=1, y=2)
-        session.add(char)
-        session.commit()
-        char.data['y'] = 3
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['y'] = ? "
-             "WHERE characters.name = ?"),
-            (3, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_with_regular_column(self):
-        session, Character = self.set_up_character_and_cursor()
-
-        char = Character(name='Trillian')
-        session.add(char)
-        session.commit()
-        char.data['x'] = 1
-        char.age = 20
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET age = ?, data['x'] = ? "
-             "WHERE characters.name = ?"),
-            (20, 1, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_with_delitem(self):
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', {'x': 1})]
-        )
-
-        char = Character(name='Trillian')
-        char.data = {'x': 1}
-        session.add(char)
-        session.commit()
-        del char.data['x']
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['x'] = ? "
-             "WHERE characters.name = ?"),
-            (None, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_with_delitem_setitem(self):
-        """ test that the change tracking doesn't get messed up
-
-        delitem -> setitem
-        """
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', {'x': 1})]
-        )
-
-        session = Session(bind=self.engine)
-        char = Character(name='Trillian')
-        char.data = {'x': 1}
-        session.add(char)
-        session.commit()
-        del char.data['x']
-        char.data['x'] = 4
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['x'] = ? "
-             "WHERE characters.name = ?"),
-            (4, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_with_setitem_delitem(self):
-        """ test that the change tracking doesn't get messed up
-
-        setitem -> delitem
-        """
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', {'x': 1})]
-        )
-
-        char = Character(name='Trillian')
-        char.data = {'x': 1}
-        session.add(char)
-        session.commit()
-        char.data['x'] = 4
-        del char.data['x']
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['x'] = ? "
-             "WHERE characters.name = ?"),
-            (None, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_partial_dict_update_with_setitem_delitem_setitem(self):
-        """ test that the change tracking doesn't get messed up
-
-        setitem -> delitem -> setitem
-        """
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', {'x': 1})]
-        )
-
-        char = Character(name='Trillian')
-        char.data = {'x': 1}
-        session.add(char)
-        session.commit()
-        char.data['x'] = 4
-        del char.data['x']
-        char.data['x'] = 3
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['x'] = ? "
-             "WHERE characters.name = ?"),
-            (3, 'Trillian')
-        )
-
-    def set_up_character_and_cursor_data_list(self, return_value=None):
-        return_value = return_value or [('Trillian', {})]
-        fake_cursor.fetchall.return_value = return_value
-        fake_cursor.description = (
-            ('characters_name', None, None, None, None, None, None),
-            ('characters_data_list', None, None, None, None, None, None)
-
-        )
-        fake_cursor.rowcount = 1
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-            name = sa.Column(sa.String, primary_key=True)
-            data_list = sa.Column(ObjectArray)
-
-        session = Session(bind=self.engine)
-        return session, Character
-
-    def _setup_object_array_char(self):
-        session, Character = self.set_up_character_and_cursor_data_list(
-            return_value=[('Trillian', [{'1': 1}, {'2': 2}])]
-        )
-        char = Character(name='Trillian', data_list=[{'1': 1}, {'2': 2}])
-        session.add(char)
-        session.commit()
-        return session, char
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_setitem_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list[1] = {'3': 3}
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data_list = ? "
-             "WHERE characters.name = ?"),
-            ([{'1': 1}, {'3': 3}], 'Trillian')
-        )
-
-    def _setup_nested_object_char(self):
-        session, Character = self.set_up_character_and_cursor(
-            return_value=[('Trillian', {'nested': {'x': 1, 'y': {'z': 2}}})]
-        )
-        char = Character(name='Trillian')
-        char.data = {'nested': {'x': 1, 'y': {'z': 2}}}
-        session.add(char)
-        session.commit()
-        return session, char
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_nested_object_change_tracking(self):
-        session, char = self._setup_nested_object_char()
-        char.data["nested"]["x"] = 3
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['nested'] = ? "
-             "WHERE characters.name = ?"),
-            ({'y': {'z': 2}, 'x': 3}, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_deep_nested_object_change_tracking(self):
-        session, char = self._setup_nested_object_char()
-        # change deep nested object
-        char.data["nested"]["y"]["z"] = 5
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['nested'] = ? "
-             "WHERE characters.name = ?"),
-            ({'y': {'z': 5}, 'x': 1}, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_delete_nested_object_tracking(self):
-        session, char = self._setup_nested_object_char()
-        # delete nested object
-        del char.data["nested"]["y"]["z"]
-        self.assertIn(char, session.dirty)
-        session.commit()
-        fake_cursor.execute.assert_called_with(
-            ("UPDATE characters SET data['nested'] = ? "
-             "WHERE characters.name = ?"),
-            ({'y': {}, 'x': 1}, 'Trillian')
-        )
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_append_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list.append({'3': 3})
-        self.assertIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_insert_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list.insert(0, {'3': 3})
-        self.assertIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_slice_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list[:] = [{'3': 3}]
-        self.assertIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_extend_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list.extend([{'3': 3}])
-        self.assertIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_pop_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        char.data_list.pop()
-        self.assertIn(char, session.dirty)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_object_array_remove_change_tracking(self):
-        session, char = self._setup_object_array_char()
-        item = char.data_list[0]
-        char.data_list.remove(item)
-        self.assertIn(char, session.dirty)
diff --git a/src/crate/client/sqlalchemy/tests/function_test.py b/src/crate/client/sqlalchemy/tests/function_test.py
deleted file mode 100644
index 072ab43ad..000000000
--- a/src/crate/client/sqlalchemy/tests/function_test.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from unittest import TestCase
-
-import sqlalchemy as sa
-from sqlalchemy.sql.sqltypes import TIMESTAMP
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-
-class SqlAlchemyFunctionTest(TestCase):
-    def setUp(self):
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = "characters"
-            name = sa.Column(sa.String, primary_key=True)
-            timestamp = sa.Column(sa.DateTime)
-
-        self.Character = Character
-
-    def test_date_trunc_type_is_timestamp(self):
-        f = sa.func.date_trunc("minute", self.Character.timestamp)
-        self.assertEqual(len(f.base_columns), 1)
-        for col in f.base_columns:
-            self.assertIsInstance(col.type, TIMESTAMP)
diff --git a/src/crate/client/sqlalchemy/tests/insert_from_select_test.py b/src/crate/client/sqlalchemy/tests/insert_from_select_test.py
deleted file mode 100644
index 692dfa55a..000000000
--- a/src/crate/client/sqlalchemy/tests/insert_from_select_test.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from datetime import datetime
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy import select, insert
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-fake_cursor.rowcount = 1
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class SqlAlchemyInsertFromSelectTest(TestCase):
-
-    def assertSQL(self, expected_str, actual_expr):
-        self.assertEqual(expected_str, str(actual_expr).replace('\n', ''))
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer)
-            ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
-            status = sa.Column(sa.String)
-
-        class CharacterArchive(Base):
-            __tablename__ = 'characters_archive'
-
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer)
-            ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
-            status = sa.Column(sa.String)
-
-        self.character = Character
-        self.character_archived = CharacterArchive
-        self.session = Session(bind=self.engine)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_insert_from_select_triggered(self):
-        char = self.character(name='Arthur', status='Archived')
-        self.session.add(char)
-        self.session.commit()
-
-        sel = select(self.character.name, self.character.age).where(self.character.status == "Archived")
-        ins = insert(self.character_archived).from_select(['name', 'age'], sel)
-        self.session.execute(ins)
-        self.session.commit()
-        self.assertSQL(
-            "INSERT INTO characters_archive (name, age) SELECT characters.name, characters.age FROM characters WHERE characters.status = ?",
-            ins.compile(bind=self.engine)
-        )
diff --git a/src/crate/client/sqlalchemy/tests/match_test.py b/src/crate/client/sqlalchemy/tests/match_test.py
deleted file mode 100644
index fdd5b7d07..000000000
--- a/src/crate/client/sqlalchemy/tests/match_test.py
+++ /dev/null
@@ -1,137 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-
-from unittest import TestCase
-from unittest.mock import MagicMock
-
-import sqlalchemy as sa
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.sqlalchemy.types import Craty
-from crate.client.sqlalchemy.predicates import match
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class SqlAlchemyMatchTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        metadata = sa.MetaData()
-        self.quotes = sa.Table('quotes', metadata,
-                               sa.Column('author', sa.String),
-                               sa.Column('quote', sa.String))
-        self.session, self.Character = self.set_up_character_and_session()
-        self.maxDiff = None
-
-    def assertSQL(self, expected_str, actual_expr):
-        self.assertEqual(expected_str, str(actual_expr).replace('\n', ''))
-
-    def set_up_character_and_session(self):
-        Base = declarative_base()
-
-        class Character(Base):
-            __tablename__ = 'characters'
-            name = sa.Column(sa.String, primary_key=True)
-            info = sa.Column(Craty)
-
-        session = Session(bind=self.engine)
-        return session, Character
-
-    def test_simple_match(self):
-        query = self.session.query(self.Character.name) \
-                    .filter(match(self.Character.name, 'Trillian'))
-        self.assertSQL(
-            "SELECT characters.name AS characters_name FROM characters " +
-            "WHERE match(characters.name, ?)",
-            query
-        )
-
-    def test_match_boost(self):
-        query = self.session.query(self.Character.name) \
-            .filter(match({self.Character.name: 0.5}, 'Trillian'))
-        self.assertSQL(
-            "SELECT characters.name AS characters_name FROM characters " +
-            "WHERE match((characters.name 0.5), ?)",
-            query
-        )
-
-    def test_muli_match(self):
-        query = self.session.query(self.Character.name) \
-            .filter(match({self.Character.name: 0.5,
-                           self.Character.info['race']: 0.9},
-                          'Trillian'))
-        self.assertSQL(
-            "SELECT characters.name AS characters_name FROM characters " +
-            "WHERE match(" +
-            "(characters.info['race'] 0.9, characters.name 0.5), ?" +
-            ")",
-            query
-        )
-
-    def test_match_type_options(self):
-        query = self.session.query(self.Character.name) \
-            .filter(match({self.Character.name: 0.5,
-                           self.Character.info['race']: 0.9},
-                          'Trillian',
-                          match_type='phrase',
-                          options={'fuzziness': 3, 'analyzer': 'english'}))
-        self.assertSQL(
-            "SELECT characters.name AS characters_name FROM characters " +
-            "WHERE match(" +
-            "(characters.info['race'] 0.9, characters.name 0.5), ?" +
-            ") using phrase with (analyzer=english, fuzziness=3)",
-            query
-        )
-
-    def test_score(self):
-        query = self.session.query(self.Character.name,
-                                   sa.literal_column('_score')) \
-                    .filter(match(self.Character.name, 'Trillian'))
-        self.assertSQL(
-            "SELECT characters.name AS characters_name, _score " +
-            "FROM characters WHERE match(characters.name, ?)",
-            query
-        )
-
-    def test_options_without_type(self):
-        query = self.session.query(self.Character.name).filter(
-            match({self.Character.name: 0.5, self.Character.info['race']: 0.9},
-                  'Trillian',
-                  options={'boost': 10.0})
-        )
-        err = None
-        try:
-            str(query)
-        except ValueError as e:
-            err = e
-        msg = "missing match_type. " + \
-              "It's not allowed to specify options without match_type"
-        self.assertEqual(str(err), msg)
diff --git a/src/crate/client/sqlalchemy/tests/update_test.py b/src/crate/client/sqlalchemy/tests/update_test.py
deleted file mode 100644
index 00aeef0ab..000000000
--- a/src/crate/client/sqlalchemy/tests/update_test.py
+++ /dev/null
@@ -1,115 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from datetime import datetime
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-
-from crate.client.sqlalchemy.types import Object
-
-import sqlalchemy as sa
-from sqlalchemy.orm import Session
-try:
-    from sqlalchemy.orm import declarative_base
-except ImportError:
-    from sqlalchemy.ext.declarative import declarative_base
-
-from crate.client.cursor import Cursor
-
-
-fake_cursor = MagicMock(name='fake_cursor')
-fake_cursor.rowcount = 1
-FakeCursor = MagicMock(name='FakeCursor', spec=Cursor)
-FakeCursor.return_value = fake_cursor
-
-
-class SqlAlchemyUpdateTest(TestCase):
-
-    def setUp(self):
-        self.engine = sa.create_engine('crate://')
-        self.base = declarative_base()
-
-        class Character(self.base):
-            __tablename__ = 'characters'
-
-            name = sa.Column(sa.String, primary_key=True)
-            age = sa.Column(sa.Integer)
-            obj = sa.Column(Object)
-            ts = sa.Column(sa.DateTime, onupdate=datetime.utcnow)
-
-        self.character = Character
-        self.session = Session(bind=self.engine)
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_onupdate_is_triggered(self):
-        char = self.character(name='Arthur')
-        self.session.add(char)
-        self.session.commit()
-        now = datetime.utcnow()
-
-        fake_cursor.fetchall.return_value = [('Arthur', None)]
-        fake_cursor.description = (
-            ('characters_name', None, None, None, None, None, None),
-            ('characters_ts', None, None, None, None, None, None),
-        )
-
-        char.age = 40
-        self.session.commit()
-
-        expected_stmt = ("UPDATE characters SET age = ?, "
-                         "ts = ? WHERE characters.name = ?")
-        args, kwargs = fake_cursor.execute.call_args
-        stmt = args[0]
-        args = args[1]
-        self.assertEqual(expected_stmt, stmt)
-        self.assertEqual(40, args[0])
-        dt = datetime.strptime(args[1], '%Y-%m-%dT%H:%M:%S.%fZ')
-        self.assertIsInstance(dt, datetime)
-        self.assertGreater(dt, now)
-        self.assertEqual('Arthur', args[2])
-
-    @patch('crate.client.connection.Cursor', FakeCursor)
-    def test_bulk_update(self):
-        """
-            Checks whether bulk updates work correctly
-            on native types and Crate types.
-        """
-        before_update_time = datetime.utcnow()
-
-        self.session.query(self.character).update({
-            # change everyone's name to Julia
-            self.character.name: 'Julia',
-            self.character.obj: {'favorite_book': 'Romeo & Juliet'}
-        })
-
-        self.session.commit()
-
-        expected_stmt = ("UPDATE characters SET "
-                         "name = ?, obj = ?, ts = ?")
-        args, kwargs = fake_cursor.execute.call_args
-        stmt = args[0]
-        args = args[1]
-        self.assertEqual(expected_stmt, stmt)
-        self.assertEqual('Julia', args[0])
-        self.assertEqual({'favorite_book': 'Romeo & Juliet'}, args[1])
-        dt = datetime.strptime(args[2], '%Y-%m-%dT%H:%M:%S.%fZ')
-        self.assertIsInstance(dt, datetime)
-        self.assertGreater(dt, before_update_time)
diff --git a/src/crate/client/sqlalchemy/tests/warnings_test.py b/src/crate/client/sqlalchemy/tests/warnings_test.py
deleted file mode 100644
index c300ad8c9..000000000
--- a/src/crate/client/sqlalchemy/tests/warnings_test.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# -*- coding: utf-8; -*-
-import sys
-import warnings
-from unittest import TestCase, skipIf
-
-from crate.client.sqlalchemy import SA_1_4, SA_VERSION
-from crate.testing.util import ExtraAssertions
-
-
-class SqlAlchemyWarningsTest(TestCase, ExtraAssertions):
-
-    @skipIf(SA_VERSION >= SA_1_4, "There is no deprecation warning for "
-                                  "SQLAlchemy 1.3 on higher versions")
-    def test_sa13_deprecation_warning(self):
-        """
-        Verify that a `DeprecationWarning` is issued when running SQLAlchemy 1.3.
-
-        https://docs.python.org/3/library/warnings.html#testing-warnings
-        """
-        with warnings.catch_warnings(record=True) as w:
-
-            # Cause all warnings to always be triggered.
-            warnings.simplefilter("always")
-
-            # Trigger a warning by importing the SQLAlchemy dialect module.
-            # Because it already has been loaded, unload it beforehand.
-            del sys.modules["crate.client.sqlalchemy"]
-            import crate.client.sqlalchemy  # noqa: F401
-
-            # Verify details of the SA13 EOL/deprecation warning.
-            self.assertEqual(len(w), 1)
-            self.assertIsSubclass(w[-1].category, DeprecationWarning)
-            self.assertIn("SQLAlchemy 1.3 is effectively EOL.", str(w[-1].message))
diff --git a/src/crate/client/sqlalchemy/types.py b/src/crate/client/sqlalchemy/types.py
deleted file mode 100644
index 1a3d7a065..000000000
--- a/src/crate/client/sqlalchemy/types.py
+++ /dev/null
@@ -1,269 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import sqlalchemy.types as sqltypes
-from sqlalchemy.sql import operators, expression
-from sqlalchemy.sql import default_comparator
-from sqlalchemy.ext.mutable import Mutable
-
-import geojson
-
-
-class MutableList(Mutable, list):
-
-    @classmethod
-    def coerce(cls, key, value):
-        """ Convert plain list to MutableList """
-        if not isinstance(value, MutableList):
-            if isinstance(value, list):
-                return MutableList(value)
-            elif value is None:
-                return value
-            else:
-                return MutableList([value])
-        else:
-            return value
-
-    def __init__(self, initval=None):
-        list.__init__(self, initval or [])
-
-    def __setitem__(self, key, value):
-        list.__setitem__(self, key, value)
-        self.changed()
-
-    def __eq__(self, other):
-        return list.__eq__(self, other)
-
-    def append(self, item):
-        list.append(self, item)
-        self.changed()
-
-    def insert(self, idx, item):
-        list.insert(self, idx, item)
-        self.changed()
-
-    def extend(self, iterable):
-        list.extend(self, iterable)
-        self.changed()
-
-    def pop(self, index=-1):
-        list.pop(self, index)
-        self.changed()
-
-    def remove(self, item):
-        list.remove(self, item)
-        self.changed()
-
-
-class MutableDict(Mutable, dict):
-
-    @classmethod
-    def coerce(cls, key, value):
-        "Convert plain dictionaries to MutableDict."
-
-        if not isinstance(value, MutableDict):
-            if isinstance(value, dict):
-                return MutableDict(value)
-
-            # this call will raise ValueError
-            return Mutable.coerce(key, value)
-        else:
-            return value
-
-    def __init__(self, initval=None, to_update=None, root_change_key=None):
-        initval = initval or {}
-        self._changed_keys = set()
-        self._deleted_keys = set()
-        self._overwrite_key = root_change_key
-        self.to_update = self if to_update is None else to_update
-        for k in initval:
-            initval[k] = self._convert_dict(initval[k],
-                                            overwrite_key=k if self._overwrite_key is None else self._overwrite_key
-                                            )
-        dict.__init__(self, initval)
-
-    def __setitem__(self, key, value):
-        value = self._convert_dict(value, key if self._overwrite_key is None else self._overwrite_key)
-        dict.__setitem__(self, key, value)
-        self.to_update.on_key_changed(
-            key if self._overwrite_key is None else self._overwrite_key
-        )
-
-    def __delitem__(self, key):
-        dict.__delitem__(self, key)
-        # add the key to the deleted keys if this is the root object
-        # otherwise update on root object
-        if self._overwrite_key is None:
-            self._deleted_keys.add(key)
-            self.changed()
-        else:
-            self.to_update.on_key_changed(self._overwrite_key)
-
-    def on_key_changed(self, key):
-        self._deleted_keys.discard(key)
-        self._changed_keys.add(key)
-        self.changed()
-
-    def _convert_dict(self, value, overwrite_key):
-        if isinstance(value, dict) and not isinstance(value, MutableDict):
-            return MutableDict(value, self.to_update, overwrite_key)
-        return value
-
-    def __eq__(self, other):
-        return dict.__eq__(self, other)
-
-
-class _Craty(sqltypes.UserDefinedType):
-    cache_ok = True
-
-    class Comparator(sqltypes.TypeEngine.Comparator):
-
-        def __getitem__(self, key):
-            return default_comparator._binary_operate(self.expr,
-                                                      operators.getitem,
-                                                      key)
-
-    def get_col_spec(self):
-        return 'OBJECT'
-
-    type = MutableDict
-    comparator_factory = Comparator
-
-
-Object = Craty = MutableDict.as_mutable(_Craty)
-
-
-class Any(expression.ColumnElement):
-    """Represent the clause ``left operator ANY (right)``.  ``right`` must be
-    an array expression.
-
-    copied from postgresql dialect
-
-    .. seealso::
-
-        :class:`sqlalchemy.dialects.postgresql.ARRAY`
-
-        :meth:`sqlalchemy.dialects.postgresql.ARRAY.Comparator.any`
-            ARRAY-bound method
-
-    """
-    __visit_name__ = 'any'
-    inherit_cache = True
-
-    def __init__(self, left, right, operator=operators.eq):
-        self.type = sqltypes.Boolean()
-        self.left = expression.literal(left)
-        self.right = right
-        self.operator = operator
-
-
-class _ObjectArray(sqltypes.UserDefinedType):
-    cache_ok = True
-
-    class Comparator(sqltypes.TypeEngine.Comparator):
-        def __getitem__(self, key):
-            return default_comparator._binary_operate(self.expr,
-                                                      operators.getitem,
-                                                      key)
-
-        def any(self, other, operator=operators.eq):
-            """Return ``other operator ANY (array)`` clause.
-
-            Argument places are switched, because ANY requires array
-            expression to be on the right hand-side.
-
-            E.g.::
-
-                from sqlalchemy.sql import operators
-
-                conn.execute(
-                    select([table.c.data]).where(
-                            table.c.data.any(7, operator=operators.lt)
-                        )
-                )
-
-            :param other: expression to be compared
-            :param operator: an operator object from the
-             :mod:`sqlalchemy.sql.operators`
-             package, defaults to :func:`.operators.eq`.
-
-            .. seealso::
-
-                :class:`.postgresql.Any`
-
-                :meth:`.postgresql.ARRAY.Comparator.all`
-
-            """
-            return Any(other, self.expr, operator=operator)
-
-    type = MutableList
-    comparator_factory = Comparator
-
-    def get_col_spec(self, **kws):
-        return "ARRAY(OBJECT)"
-
-
-ObjectArray = MutableList.as_mutable(_ObjectArray)
-
-
-class Geopoint(sqltypes.UserDefinedType):
-    cache_ok = True
-
-    class Comparator(sqltypes.TypeEngine.Comparator):
-
-        def __getitem__(self, key):
-            return default_comparator._binary_operate(self.expr,
-                                                      operators.getitem,
-                                                      key)
-
-    def get_col_spec(self):
-        return 'GEO_POINT'
-
-    def bind_processor(self, dialect):
-        def process(value):
-            if isinstance(value, geojson.Point):
-                return value.coordinates
-            return value
-        return process
-
-    def result_processor(self, dialect, coltype):
-        return tuple
-
-    comparator_factory = Comparator
-
-
-class Geoshape(sqltypes.UserDefinedType):
-    cache_ok = True
-
-    class Comparator(sqltypes.TypeEngine.Comparator):
-
-        def __getitem__(self, key):
-            return default_comparator._binary_operate(self.expr,
-                                                      operators.getitem,
-                                                      key)
-
-    def get_col_spec(self):
-        return 'GEO_SHAPE'
-
-    def result_processor(self, dialect, coltype):
-        return geojson.GeoJSON.to_instance
-
-    comparator_factory = Comparator
diff --git a/src/crate/client/test_connection.py b/src/crate/client/test_connection.py
deleted file mode 100644
index 3b5c294cf..000000000
--- a/src/crate/client/test_connection.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import datetime
-
-from .connection import Connection
-from .http import Client
-from crate.client import connect
-from unittest import TestCase
-
-from ..testing.settings import crate_host
-
-
-class ConnectionTest(TestCase):
-
-    def test_connection_mock(self):
-        """
-        For testing purposes it is often useful to replace the client used for
-        communication with the CrateDB server with a stub or mock.
-
-        This can be done by passing an object of the Client class when calling the
-        ``connect`` method.
-        """
-
-        class MyConnectionClient:
-            active_servers = ["localhost:4200"]
-
-            def __init__(self):
-                pass
-
-            def server_infos(self, server):
-                return ("localhost:4200", "my server", "0.42.0")
-
-        connection = connect([crate_host], client=MyConnectionClient())
-        self.assertIsInstance(connection, Connection)
-        self.assertEqual(connection.client.server_infos("foo"), ('localhost:4200', 'my server', '0.42.0'))
-
-    def test_lowest_server_version(self):
-        infos = [(None, None, '0.42.3'),
-                 (None, None, '0.41.8'),
-                 (None, None, 'not a version')]
-
-        client = Client(servers="localhost:4200 localhost:4201 localhost:4202")
-        client.server_infos = lambda server: infos.pop()
-        connection = connect(client=client)
-        self.assertEqual((0, 41, 8), connection.lowest_server_version.version)
-        connection.close()
-
-    def test_invalid_server_version(self):
-        client = Client(servers="localhost:4200")
-        client.server_infos = lambda server: (None, None, "No version")
-        connection = connect(client=client)
-        self.assertEqual((0, 0, 0), connection.lowest_server_version.version)
-        connection.close()
-
-    def test_context_manager(self):
-        with connect('localhost:4200') as conn:
-            pass
-        self.assertEqual(conn._closed, True)
-
-    def test_with_timezone(self):
-        """
-        Verify the cursor objects will return timezone-aware `datetime` objects when requested to.
-        When switching the time zone at runtime on the connection object, only new cursor objects
-        will inherit the new time zone.
-        """
-
-        tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
-        connection = connect('localhost:4200', time_zone=tz_mst)
-        cursor = connection.cursor()
-        self.assertEqual(cursor.time_zone.tzname(None), "MST")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=25200))
-
-        connection.time_zone = datetime.timezone.utc
-        cursor = connection.cursor()
-        self.assertEqual(cursor.time_zone.tzname(None), "UTC")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(0))
diff --git a/src/crate/client/test_cursor.py b/src/crate/client/test_cursor.py
deleted file mode 100644
index 79e7ddd6c..000000000
--- a/src/crate/client/test_cursor.py
+++ /dev/null
@@ -1,341 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import datetime
-from ipaddress import IPv4Address
-from unittest import TestCase
-from unittest.mock import MagicMock
-try:
-    import zoneinfo
-except ImportError:
-    from backports import zoneinfo
-
-import pytz
-
-from crate.client import connect
-from crate.client.converter import DataType, DefaultTypeConverter
-from crate.client.http import Client
-from crate.client.test_util import ClientMocked
-
-
-class CursorTest(TestCase):
-
-    @staticmethod
-    def get_mocked_connection():
-        client = MagicMock(spec=Client)
-        return connect(client=client)
-
-    def test_create_with_timezone_as_datetime_object(self):
-        """
-        Verify the cursor returns timezone-aware `datetime` objects when requested to.
-        Switching the time zone at runtime on the cursor object is possible.
-        Here: Use a `datetime.timezone` instance.
-        """
-
-        connection = self.get_mocked_connection()
-
-        tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
-        cursor = connection.cursor(time_zone=tz_mst)
-
-        self.assertEqual(cursor.time_zone.tzname(None), "MST")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=25200))
-
-        cursor.time_zone = datetime.timezone.utc
-        self.assertEqual(cursor.time_zone.tzname(None), "UTC")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(0))
-
-    def test_create_with_timezone_as_pytz_object(self):
-        """
-        Verify the cursor returns timezone-aware `datetime` objects when requested to.
-        Here: Use a `pytz.timezone` instance.
-        """
-        connection = self.get_mocked_connection()
-        cursor = connection.cursor(time_zone=pytz.timezone('Australia/Sydney'))
-        self.assertEqual(cursor.time_zone.tzname(None), "Australia/Sydney")
-
-        # Apparently, when using `pytz`, the timezone object does not return an offset.
-        # Nevertheless, it works, as demonstrated per doctest in `cursor.txt`.
-        self.assertEqual(cursor.time_zone.utcoffset(None), None)
-
-    def test_create_with_timezone_as_zoneinfo_object(self):
-        """
-        Verify the cursor returns timezone-aware `datetime` objects when requested to.
-        Here: Use a `zoneinfo.ZoneInfo` instance.
-        """
-        connection = self.get_mocked_connection()
-        cursor = connection.cursor(time_zone=zoneinfo.ZoneInfo('Australia/Sydney'))
-        self.assertEqual(cursor.time_zone.key, 'Australia/Sydney')
-
-    def test_create_with_timezone_as_utc_offset_success(self):
-        """
-        Verify the cursor returns timezone-aware `datetime` objects when requested to.
-        Here: Use a UTC offset in string format.
-        """
-        connection = self.get_mocked_connection()
-        cursor = connection.cursor(time_zone="+0530")
-        self.assertEqual(cursor.time_zone.tzname(None), "+0530")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=19800))
-
-        connection = self.get_mocked_connection()
-        cursor = connection.cursor(time_zone="-1145")
-        self.assertEqual(cursor.time_zone.tzname(None), "-1145")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(days=-1, seconds=44100))
-
-    def test_create_with_timezone_as_utc_offset_failure(self):
-        """
-        Verify the cursor croaks when trying to create it with invalid UTC offset strings.
-        """
-        connection = self.get_mocked_connection()
-        with self.assertRaises(AssertionError) as ex:
-            connection.cursor(time_zone="foobar")
-        self.assertEqual(str(ex.exception), "Time zone 'foobar' is given in invalid UTC offset format")
-
-        connection = self.get_mocked_connection()
-        with self.assertRaises(ValueError) as ex:
-            connection.cursor(time_zone="+abcd")
-        self.assertEqual(str(ex.exception), "Time zone '+abcd' is given in invalid UTC offset format: "
-                                            "invalid literal for int() with base 10: '+ab'")
-
-    def test_create_with_timezone_connection_cursor_precedence(self):
-        """
-        Verify that the time zone specified on the cursor object instance
-        takes precedence over the one specified on the connection instance.
-        """
-        client = MagicMock(spec=Client)
-        connection = connect(client=client, time_zone=pytz.timezone('Australia/Sydney'))
-        cursor = connection.cursor(time_zone="+0530")
-        self.assertEqual(cursor.time_zone.tzname(None), "+0530")
-        self.assertEqual(cursor.time_zone.utcoffset(None), datetime.timedelta(seconds=19800))
-
-    def test_execute_with_args(self):
-        client = MagicMock(spec=Client)
-        conn = connect(client=client)
-        c = conn.cursor()
-        statement = 'select * from locations where position = ?'
-        c.execute(statement, 1)
-        client.sql.assert_called_once_with(statement, 1, None)
-        conn.close()
-
-    def test_execute_with_bulk_args(self):
-        client = MagicMock(spec=Client)
-        conn = connect(client=client)
-        c = conn.cursor()
-        statement = 'select * from locations where position = ?'
-        c.execute(statement, bulk_parameters=[[1]])
-        client.sql.assert_called_once_with(statement, None, [[1]])
-        conn.close()
-
-    def test_execute_with_converter(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-
-        # Use the set of data type converters from `DefaultTypeConverter`
-        # and add another custom converter.
-        converter = DefaultTypeConverter(
-            {DataType.BIT: lambda value: value is not None and int(value[2:-1], 2) or None})
-
-        # Create a `Cursor` object with converter.
-        c = conn.cursor(converter=converter)
-
-        # Make up a response using CrateDB data types `TEXT`, `IP`,
-        # `TIMESTAMP`, `BIT`.
-        conn.client.set_next_response({
-            "col_types": [4, 5, 11, 25],
-            "cols": ["name", "address", "timestamp", "bitmask"],
-            "rows": [
-                ["foo", "10.10.10.1", 1658167836758, "B'0110'"],
-                [None, None, None, None],
-            ],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        c.execute("")
-        result = c.fetchall()
-        self.assertEqual(result, [
-            ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000), 6],
-            [None, None, None, None],
-        ])
-
-        conn.close()
-
-    def test_execute_with_converter_and_invalid_data_type(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-        converter = DefaultTypeConverter()
-
-        # Create a `Cursor` object with converter.
-        c = conn.cursor(converter=converter)
-
-        # Make up a response using CrateDB data types `TEXT`, `IP`,
-        # `TIMESTAMP`, `BIT`.
-        conn.client.set_next_response({
-            "col_types": [999],
-            "cols": ["foo"],
-            "rows": [
-                ["n/a"],
-            ],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        c.execute("")
-        with self.assertRaises(ValueError) as ex:
-            c.fetchone()
-        self.assertEqual(ex.exception.args, ("999 is not a valid DataType",))
-
-    def test_execute_array_with_converter(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-        converter = DefaultTypeConverter()
-        cursor = conn.cursor(converter=converter)
-
-        conn.client.set_next_response({
-            "col_types": [4, [100, 5]],
-            "cols": ["name", "address"],
-            "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        cursor.execute("")
-        result = cursor.fetchone()
-        self.assertEqual(result, [
-            'foo',
-            [IPv4Address('10.10.10.1'), IPv4Address('10.10.10.2')],
-        ])
-
-    def test_execute_array_with_converter_and_invalid_collection_type(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-        converter = DefaultTypeConverter()
-        cursor = conn.cursor(converter=converter)
-
-        # Converting collections only works for `ARRAY`s. (ID=100).
-        # When using `DOUBLE` (ID=6), it should croak.
-        conn.client.set_next_response({
-            "col_types": [4, [6, 5]],
-            "cols": ["name", "address"],
-            "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        cursor.execute("")
-
-        with self.assertRaises(ValueError) as ex:
-            cursor.fetchone()
-        self.assertEqual(ex.exception.args, ("Data type 6 is not implemented as collection type",))
-
-    def test_execute_nested_array_with_converter(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-        converter = DefaultTypeConverter()
-        cursor = conn.cursor(converter=converter)
-
-        conn.client.set_next_response({
-            "col_types": [4, [100, [100, 5]]],
-            "cols": ["name", "address_buckets"],
-            "rows": [["foo", [["10.10.10.1", "10.10.10.2"], ["10.10.10.3"], [], None]]],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        cursor.execute("")
-        result = cursor.fetchone()
-        self.assertEqual(result, [
-            'foo',
-            [[IPv4Address('10.10.10.1'), IPv4Address('10.10.10.2')], [IPv4Address('10.10.10.3')], [], None],
-        ])
-
-    def test_executemany_with_converter(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-        converter = DefaultTypeConverter()
-        cursor = conn.cursor(converter=converter)
-
-        conn.client.set_next_response({
-            "col_types": [4, 5],
-            "cols": ["name", "address"],
-            "rows": [["foo", "10.10.10.1"]],
-            "rowcount": 1,
-            "duration": 123
-        })
-
-        cursor.executemany("", [])
-        result = cursor.fetchall()
-
-        # ``executemany()`` is not intended to be used with statements returning result
-        # sets. The result will always be empty.
-        self.assertEqual(result, [])
-
-    def test_execute_with_timezone(self):
-        client = ClientMocked()
-        conn = connect(client=client)
-
-        # Create a `Cursor` object with `time_zone`.
-        tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
-        c = conn.cursor(time_zone=tz_mst)
-
-        # Make up a response using CrateDB data type `TIMESTAMP`.
-        conn.client.set_next_response({
-            "col_types": [4, 11],
-            "cols": ["name", "timestamp"],
-            "rows": [
-                ["foo", 1658167836758],
-                [None, None],
-            ],
-        })
-
-        # Run execution and verify the returned `datetime` object is timezone-aware,
-        # using the designated timezone object.
-        c.execute("")
-        result = c.fetchall()
-        self.assertEqual(result, [
-            [
-                'foo',
-                datetime.datetime(2022, 7, 19, 1, 10, 36, 758000,
-                                  tzinfo=datetime.timezone(datetime.timedelta(seconds=25200), 'MST')),
-            ],
-            [
-                None,
-                None,
-            ],
-        ])
-        self.assertEqual(result[0][1].tzname(), "MST")
-
-        # Change timezone and verify the returned `datetime` object is using it.
-        c.time_zone = datetime.timezone.utc
-        c.execute("")
-        result = c.fetchall()
-        self.assertEqual(result, [
-            [
-                'foo',
-                datetime.datetime(2022, 7, 18, 18, 10, 36, 758000, tzinfo=datetime.timezone.utc),
-            ],
-            [
-                None,
-                None,
-            ],
-        ])
-        self.assertEqual(result[0][1].tzname(), "UTC")
-
-        conn.close()
diff --git a/src/crate/client/test_http.py b/src/crate/client/test_http.py
deleted file mode 100644
index ee32778b6..000000000
--- a/src/crate/client/test_http.py
+++ /dev/null
@@ -1,628 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import json
-import time
-import socket
-import multiprocessing
-import sys
-import os
-import queue
-import random
-import traceback
-from http.server import BaseHTTPRequestHandler, HTTPServer
-from multiprocessing.context import ForkProcess
-from unittest import TestCase
-from unittest.mock import patch, MagicMock
-from threading import Thread, Event
-from decimal import Decimal
-import datetime as dt
-import urllib3.exceptions
-from base64 import b64decode
-from urllib.parse import urlparse, parse_qs
-from setuptools.ssl_support import find_ca_bundle
-
-from .http import Client, _get_socket_opts, _remove_certs_for_non_https
-from .exceptions import ConnectionError, ProgrammingError
-
-
-REQUEST = 'crate.client.http.Server.request'
-CA_CERT_PATH = find_ca_bundle()
-
-
-def fake_request(response=None):
-    def request(*args, **kwargs):
-        if isinstance(response, list):
-            resp = response.pop(0)
-            response.append(resp)
-            return resp
-        elif response:
-            return response
-        else:
-            return MagicMock(spec=urllib3.response.HTTPResponse)
-    return request
-
-
-def fake_response(status, reason=None, content_type='application/json'):
-    m = MagicMock(spec=urllib3.response.HTTPResponse)
-    m.status = status
-    m.reason = reason or ''
-    m.headers = {'content-type': content_type}
-    return m
-
-
-def fake_redirect(location):
-    m = fake_response(307)
-    m.get_redirect_location.return_value = location
-    return m
-
-
-def bad_bulk_response():
-    r = fake_response(400, 'Bad Request')
-    r.data = json.dumps({
-        "results": [
-            {"rowcount": 1},
-            {"error_message": "an error occured"},
-            {"error_message": "another error"},
-            {"error_message": ""},
-            {"error_message": None}
-        ]}).encode()
-    return r
-
-
-def fail_sometimes(*args, **kwargs):
-    if random.randint(1, 100) % 10 == 0:
-        raise urllib3.exceptions.MaxRetryError(None, '/_sql', '')
-    return fake_response(200)
-
-
-class HttpClientTest(TestCase):
-
-    @patch(REQUEST, fake_request([fake_response(200),
-                                  fake_response(104, 'Connection reset by peer'),
-                                  fake_response(503, 'Service Unavailable')]))
-    def test_connection_reset_exception(self):
-        client = Client(servers="localhost:4200")
-        client.sql('select 1')
-        client.sql('select 2')
-        self.assertEqual(['http://localhost:4200'], list(client._active_servers))
-        try:
-            client.sql('select 3')
-        except ProgrammingError:
-            self.assertEqual([], list(client._active_servers))
-        else:
-            self.assertTrue(False)
-        finally:
-            client.close()
-
-    def test_no_connection_exception(self):
-        client = Client()
-        self.assertRaises(ConnectionError, client.sql, 'select foo')
-        client.close()
-
-    @patch(REQUEST)
-    def test_http_error_is_re_raised(self, request):
-        request.side_effect = Exception
-
-        client = Client()
-        self.assertRaises(ProgrammingError, client.sql, 'select foo')
-        client.close()
-
-    @patch(REQUEST)
-    def test_programming_error_contains_http_error_response_content(self, request):
-        request.side_effect = Exception("this shouldn't be raised")
-
-        client = Client()
-        try:
-            client.sql('select 1')
-        except ProgrammingError as e:
-            self.assertEqual("this shouldn't be raised", e.message)
-        else:
-            self.assertTrue(False)
-        finally:
-            client.close()
-
-    @patch(REQUEST, fake_request([fake_response(200),
-                                  fake_response(503, 'Service Unavailable')]))
-    def test_server_error_50x(self):
-        client = Client(servers="localhost:4200 localhost:4201")
-        client.sql('select 1')
-        client.sql('select 2')
-        try:
-            client.sql('select 3')
-        except ProgrammingError as e:
-            self.assertEqual("No more Servers available, " +
-                             "exception from last server: Service Unavailable",
-                             e.message)
-            self.assertEqual([], list(client._active_servers))
-        else:
-            self.assertTrue(False)
-        finally:
-            client.close()
-
-    def test_connect(self):
-        client = Client(servers="localhost:4200 localhost:4201")
-        self.assertEqual(client._active_servers,
-                         ["http://localhost:4200", "http://localhost:4201"])
-        client.close()
-
-        client = Client(servers="localhost:4200")
-        self.assertEqual(client._active_servers, ["http://localhost:4200"])
-        client.close()
-
-        client = Client(servers=["localhost:4200"])
-        self.assertEqual(client._active_servers, ["http://localhost:4200"])
-        client.close()
-
-        client = Client(servers=["localhost:4200", "127.0.0.1:4201"])
-        self.assertEqual(client._active_servers,
-                         ["http://localhost:4200", "http://127.0.0.1:4201"])
-        client.close()
-
-    @patch(REQUEST, fake_request(fake_redirect('http://localhost:4201')))
-    def test_redirect_handling(self):
-        client = Client(servers='localhost:4200')
-        try:
-            client.blob_get('blobs', 'fake_digest')
-        except ProgrammingError:
-            # 4201 gets added to serverpool but isn't available
-            # that's why we run into an infinite recursion
-            # exception message is: maximum recursion depth exceeded
-            pass
-        self.assertEqual(
-            ['http://localhost:4200', 'http://localhost:4201'],
-            sorted(list(client.server_pool.keys()))
-        )
-        # the new non-https server must not contain any SSL only arguments
-        # regression test for github issue #179/#180
-        self.assertEqual(
-            {'socket_options': _get_socket_opts(keepalive=True)},
-            client.server_pool['http://localhost:4201'].pool.conn_kw
-        )
-        client.close()
-
-    @patch(REQUEST)
-    def test_server_infos(self, request):
-        request.side_effect = urllib3.exceptions.MaxRetryError(
-            None, '/', "this shouldn't be raised")
-        client = Client(servers="localhost:4200 localhost:4201")
-        self.assertRaises(
-            ConnectionError, client.server_infos, 'http://localhost:4200')
-        client.close()
-
-    @patch(REQUEST, fake_request(fake_response(503)))
-    def test_server_infos_503(self):
-        client = Client(servers="localhost:4200")
-        self.assertRaises(
-            ConnectionError, client.server_infos, 'http://localhost:4200')
-        client.close()
-
-    @patch(REQUEST, fake_request(
-        fake_response(401, 'Unauthorized', 'text/html')))
-    def test_server_infos_401(self):
-        client = Client(servers="localhost:4200")
-        try:
-            client.server_infos('http://localhost:4200')
-        except ProgrammingError as e:
-            self.assertEqual("401 Client Error: Unauthorized", e.message)
-        else:
-            self.assertTrue(False, msg="Exception should have been raised")
-        finally:
-            client.close()
-
-    @patch(REQUEST, fake_request(bad_bulk_response()))
-    def test_bad_bulk_400(self):
-        client = Client(servers="localhost:4200")
-        try:
-            client.sql("Insert into users (name) values(?)",
-                       bulk_parameters=[["douglas"], ["monthy"]])
-        except ProgrammingError as e:
-            self.assertEqual("an error occured\nanother error", e.message)
-        else:
-            self.assertTrue(False, msg="Exception should have been raised")
-        finally:
-            client.close()
-
-    @patch(REQUEST, autospec=True)
-    def test_decimal_serialization(self, request):
-        client = Client(servers="localhost:4200")
-        request.return_value = fake_response(200)
-
-        dec = Decimal(0.12)
-        client.sql('insert into users (float_col) values (?)', (dec,))
-
-        data = json.loads(request.call_args[1]['data'])
-        self.assertEqual(data['args'], [str(dec)])
-        client.close()
-
-    @patch(REQUEST, autospec=True)
-    def test_datetime_is_converted_to_ts(self, request):
-        client = Client(servers="localhost:4200")
-        request.return_value = fake_response(200)
-
-        datetime = dt.datetime(2015, 2, 28, 7, 31, 40)
-        client.sql('insert into users (dt) values (?)', (datetime,))
-
-        # convert string to dict
-        # because the order of the keys isn't deterministic
-        data = json.loads(request.call_args[1]['data'])
-        self.assertEqual(data['args'], [1425108700000])
-        client.close()
-
-    @patch(REQUEST, autospec=True)
-    def test_date_is_converted_to_ts(self, request):
-        client = Client(servers="localhost:4200")
-        request.return_value = fake_response(200)
-
-        day = dt.date(2016, 4, 21)
-        client.sql('insert into users (dt) values (?)', (day,))
-        data = json.loads(request.call_args[1]['data'])
-        self.assertEqual(data['args'], [1461196800000])
-        client.close()
-
-    def test_socket_options_contain_keepalive(self):
-        server = 'http://localhost:4200'
-        client = Client(servers=server)
-        conn_kw = client.server_pool[server].pool.conn_kw
-        self.assertIn(
-            (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), conn_kw['socket_options']
-        )
-        client.close()
-
-
-@patch(REQUEST, fail_sometimes)
-class ThreadSafeHttpClientTest(TestCase):
-    """
-    Using a pool of 5 Threads to emit commands to the multiple servers through
-    one Client-instance
-
-    check if number of servers in _inactive_servers and _active_servers always
-    equals the number of servers initially given.
-    """
-    servers = [
-        "127.0.0.1:44209",
-        "127.0.0.2:44209",
-        "127.0.0.3:44209",
-    ]
-    num_threads = 5
-    num_commands = 1000
-    thread_timeout = 5.0  # seconds
-
-    def __init__(self, *args, **kwargs):
-        self.event = Event()
-        self.err_queue = queue.Queue()
-        super(ThreadSafeHttpClientTest, self).__init__(*args, **kwargs)
-
-    def setUp(self):
-        self.client = Client(self.servers)
-        self.client.retry_interval = 0.2  # faster retry
-
-    def tearDown(self):
-        self.client.close()
-
-    def _run(self):
-        self.event.wait()  # wait for the others
-        expected_num_servers = len(self.servers)
-        for x in range(self.num_commands):
-            try:
-                self.client.sql('select name from sys.cluster')
-            except ConnectionError:
-                pass
-            try:
-                with self.client._lock:
-                    num_servers = len(self.client._active_servers) + \
-                        len(self.client._inactive_servers)
-                self.assertEqual(
-                    expected_num_servers,
-                    num_servers,
-                    "expected %d but got %d" % (expected_num_servers,
-                                                num_servers)
-                )
-            except AssertionError:
-                self.err_queue.put(sys.exc_info())
-
-    def test_client_threaded(self):
-        """
-        Testing if lists of servers is handled correctly when client is used
-        from multiple threads with some requests failing.
-
-        **ATTENTION:** this test is probabilistic and does not ensure that the
-        client is indeed thread-safe in all cases, it can only show that it
-        withstands this scenario.
-        """
-        threads = [
-            Thread(target=self._run, name=str(x))
-            for x in range(self.num_threads)
-        ]
-        for thread in threads:
-            thread.start()
-
-        self.event.set()
-        for t in threads:
-            t.join(self.thread_timeout)
-
-        if not self.err_queue.empty():
-            self.assertTrue(False, "".join(
-                traceback.format_exception(*self.err_queue.get(block=False))))
-
-
-class ClientAddressRequestHandler(BaseHTTPRequestHandler):
-    """
-    http handler for use with HTTPServer
-
-    returns client host and port in crate-conform-responses
-    """
-    protocol_version = 'HTTP/1.1'
-
-    def do_GET(self):
-        content_length = self.headers.get("content-length")
-        if content_length:
-            self.rfile.read(int(content_length))
-        response = json.dumps({
-            "cols": ["host", "port"],
-            "rows": [
-                self.client_address[0],
-                self.client_address[1]
-            ],
-            "rowCount": 1,
-        })
-        self.send_response(200)
-        self.send_header("Content-Length", len(response))
-        self.send_header("Content-Type", "application/json; charset=UTF-8")
-        self.end_headers()
-        self.wfile.write(response.encode('UTF-8'))
-
-    do_POST = do_PUT = do_DELETE = do_HEAD = do_GET
-
-
-class KeepAliveClientTest(TestCase):
-
-    server_address = ("127.0.0.1", 65535)
-
-    def __init__(self, *args, **kwargs):
-        super(KeepAliveClientTest, self).__init__(*args, **kwargs)
-        self.server_process = ForkProcess(target=self._run_server)
-
-    def setUp(self):
-        super(KeepAliveClientTest, self).setUp()
-        self.client = Client(["%s:%d" % self.server_address])
-        self.server_process.start()
-        time.sleep(.10)
-
-    def tearDown(self):
-        self.server_process.terminate()
-        self.client.close()
-        super(KeepAliveClientTest, self).tearDown()
-
-    def _run_server(self):
-        self.server = HTTPServer(self.server_address,
-                                 ClientAddressRequestHandler)
-        self.server.handle_request()
-
-    def test_client_keepalive(self):
-        for x in range(10):
-            result = self.client.sql("select * from fake")
-
-            another_result = self.client.sql("select again from fake")
-            self.assertEqual(result, another_result)
-
-
-class ParamsTest(TestCase):
-
-    def test_params(self):
-        client = Client(['127.0.0.1:4200'], error_trace=True)
-        parsed = urlparse(client.path)
-        params = parse_qs(parsed.query)
-        self.assertEqual(params["error_trace"], ["true"])
-        client.close()
-
-    def test_no_params(self):
-        client = Client()
-        self.assertEqual(client.path, "/_sql?types=true")
-        client.close()
-
-
-class RequestsCaBundleTest(TestCase):
-
-    def test_open_client(self):
-        os.environ["REQUESTS_CA_BUNDLE"] = CA_CERT_PATH
-        try:
-            Client('http://127.0.0.1:4200')
-        except ProgrammingError:
-            self.fail("HTTP not working with REQUESTS_CA_BUNDLE")
-        finally:
-            os.unsetenv('REQUESTS_CA_BUNDLE')
-            os.environ["REQUESTS_CA_BUNDLE"] = ''
-
-    def test_remove_certs_for_non_https(self):
-        d = _remove_certs_for_non_https('https', {"ca_certs": 1})
-        self.assertIn('ca_certs', d)
-
-        kwargs = {'ca_certs': 1, 'foobar': 2, 'cert_file': 3}
-        d = _remove_certs_for_non_https('http', kwargs)
-        self.assertNotIn('ca_certs', d)
-        self.assertNotIn('cert_file', d)
-        self.assertIn('foobar', d)
-
-
-class TimeoutRequestHandler(BaseHTTPRequestHandler):
-    """
-    HTTP handler for use with TestingHTTPServer
-    updates the shared counter and waits so that the client times out
-    """
-
-    def do_POST(self):
-        self.server.SHARED['count'] += 1
-        time.sleep(5)
-
-
-class SharedStateRequestHandler(BaseHTTPRequestHandler):
-    """
-    HTTP handler for use with TestingHTTPServer
-    sets the shared state of the server and returns an empty response
-    """
-
-    def do_POST(self):
-        self.server.SHARED['count'] += 1
-        self.server.SHARED['schema'] = self.headers.get('Default-Schema')
-
-        if self.headers.get('Authorization') is not None:
-            auth_header = self.headers['Authorization'].replace('Basic ', '')
-            credentials = b64decode(auth_header).decode('utf-8').split(":", 1)
-            self.server.SHARED['username'] = credentials[0]
-            if len(credentials) > 1 and credentials[1]:
-                self.server.SHARED['password'] = credentials[1]
-            else:
-                self.server.SHARED['password'] = None
-        else:
-            self.server.SHARED['username'] = None
-
-        if self.headers.get('X-User') is not None:
-            self.server.SHARED['usernameFromXUser'] = self.headers['X-User']
-        else:
-            self.server.SHARED['usernameFromXUser'] = None
-
-        # send empty response
-        response = '{}'
-        self.send_response(200)
-        self.send_header("Content-Length", len(response))
-        self.send_header("Content-Type", "application/json; charset=UTF-8")
-        self.end_headers()
-        self.wfile.write(response.encode('utf-8'))
-
-
-class TestingHTTPServer(HTTPServer):
-    """
-    http server providing a shared dict
-    """
-    manager = multiprocessing.Manager()
-    SHARED = manager.dict()
-    SHARED['count'] = 0
-    SHARED['usernameFromXUser'] = None
-    SHARED['username'] = None
-    SHARED['password'] = None
-    SHARED['schema'] = None
-
-    @classmethod
-    def run_server(cls, server_address, request_handler_cls):
-        cls(server_address, request_handler_cls).serve_forever()
-
-
-class TestingHttpServerTestCase(TestCase):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.assertIsNotNone(self.request_handler)
-        self.server_address = ('127.0.0.1', random.randint(65000, 65535))
-        self.server_process = ForkProcess(target=TestingHTTPServer.run_server,
-                                          args=(self.server_address, self.request_handler))
-
-    def setUp(self):
-        self.server_process.start()
-        self.wait_for_server()
-
-    def wait_for_server(self):
-        while True:
-            try:
-                with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
-                    s.connect(self.server_address)
-            except Exception:
-                time.sleep(.25)
-            else:
-                break
-
-    def tearDown(self):
-        self.server_process.terminate()
-
-    def clientWithKwargs(self, **kwargs):
-        return Client(["%s:%d" % self.server_address], timeout=1, **kwargs)
-
-
-class RetryOnTimeoutServerTest(TestingHttpServerTestCase):
-
-    request_handler = TimeoutRequestHandler
-
-    def setUp(self):
-        super().setUp()
-        self.client = self.clientWithKwargs()
-
-    def tearDown(self):
-        super().tearDown()
-        self.client.close()
-
-    def test_no_retry_on_read_timeout(self):
-        try:
-            self.client.sql("select * from fake")
-        except ConnectionError as e:
-            self.assertIn('Read timed out', e.message,
-                          msg='Error message must contain: Read timed out')
-        self.assertEqual(TestingHTTPServer.SHARED['count'], 1)
-
-
-class TestDefaultSchemaHeader(TestingHttpServerTestCase):
-
-    request_handler = SharedStateRequestHandler
-
-    def setUp(self):
-        super().setUp()
-        self.client = self.clientWithKwargs(schema='my_custom_schema')
-
-    def tearDown(self):
-        self.client.close()
-        super().tearDown()
-
-    def test_default_schema(self):
-        self.client.sql('SELECT 1')
-        self.assertEqual(TestingHTTPServer.SHARED['schema'], 'my_custom_schema')
-
-
-class TestUsernameSentAsHeader(TestingHttpServerTestCase):
-
-    request_handler = SharedStateRequestHandler
-
-    def setUp(self):
-        super().setUp()
-        self.clientWithoutUsername = self.clientWithKwargs()
-        self.clientWithUsername = self.clientWithKwargs(username='testDBUser')
-        self.clientWithUsernameAndPassword = self.clientWithKwargs(username='testDBUser',
-                                                                   password='test:password')
-
-    def tearDown(self):
-        self.clientWithoutUsername.close()
-        self.clientWithUsername.close()
-        self.clientWithUsernameAndPassword.close()
-        super().tearDown()
-
-    def test_username(self):
-        self.clientWithoutUsername.sql("select * from fake")
-        self.assertEqual(TestingHTTPServer.SHARED['usernameFromXUser'], None)
-        self.assertEqual(TestingHTTPServer.SHARED['username'], None)
-        self.assertEqual(TestingHTTPServer.SHARED['password'], None)
-
-        self.clientWithUsername.sql("select * from fake")
-        self.assertEqual(TestingHTTPServer.SHARED['usernameFromXUser'], 'testDBUser')
-        self.assertEqual(TestingHTTPServer.SHARED['username'], 'testDBUser')
-        self.assertEqual(TestingHTTPServer.SHARED['password'], None)
-
-        self.clientWithUsernameAndPassword.sql("select * from fake")
-        self.assertEqual(TestingHTTPServer.SHARED['usernameFromXUser'], 'testDBUser')
-        self.assertEqual(TestingHTTPServer.SHARED['username'], 'testDBUser')
-        self.assertEqual(TestingHTTPServer.SHARED['password'], 'test:password')
diff --git a/src/crate/client/test_util.py b/src/crate/client/test_util.py
deleted file mode 100644
index 90379a796..000000000
--- a/src/crate/client/test_util.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-
-class ClientMocked(object):
-
-    active_servers = ["http://localhost:4200"]
-
-    def __init__(self):
-        self.response = {}
-        self._server_infos = ("http://localhost:4200", "my server", "2.0.0")
-
-    def sql(self, stmt=None, parameters=None, bulk_parameters=None):
-        return self.response
-
-    def server_infos(self, server):
-        return self._server_infos
-
-    def set_next_response(self, response):
-        self.response = response
-
-    def set_next_server_infos(self, server, server_name, version):
-        self._server_infos = (server, server_name, version)
-
-    def close(self):
-        pass
diff --git a/src/crate/client/tests.py b/src/crate/client/tests.py
deleted file mode 100644
index 7bf1487da..000000000
--- a/src/crate/client/tests.py
+++ /dev/null
@@ -1,397 +0,0 @@
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-from __future__ import absolute_import
-
-import json
-import os
-import socket
-import unittest
-import doctest
-from pprint import pprint
-from http.server import HTTPServer, BaseHTTPRequestHandler
-import ssl
-import time
-import threading
-import logging
-
-import stopit
-
-from crate.testing.layer import CrateLayer
-from crate.testing.settings import \
-    crate_host, crate_path, crate_port, \
-    crate_transport_port, docs_path, localhost
-from crate.client import connect
-
-from .test_cursor import CursorTest
-from .test_connection import ConnectionTest
-from .test_http import (
-    HttpClientTest,
-    ThreadSafeHttpClientTest,
-    KeepAliveClientTest,
-    ParamsTest,
-    RetryOnTimeoutServerTest,
-    RequestsCaBundleTest,
-    TestUsernameSentAsHeader,
-    TestDefaultSchemaHeader,
-)
-from .sqlalchemy.tests import test_suite as sqlalchemy_test_suite
-
-log = logging.getLogger('crate.testing.layer')
-ch = logging.StreamHandler()
-ch.setLevel(logging.ERROR)
-log.addHandler(ch)
-
-
-def cprint(s):
-    if isinstance(s, bytes):
-        s = s.decode('utf-8')
-    print(s)
-
-
-settings = {
-    'udc.enabled': 'false',
-    'lang.js.enabled': 'true',
-    'auth.host_based.enabled': 'true',
-    'auth.host_based.config.0.user': 'crate',
-    'auth.host_based.config.0.method': 'trust',
-    'auth.host_based.config.98.user': 'trusted_me',
-    'auth.host_based.config.98.method': 'trust',
-    'auth.host_based.config.99.user': 'me',
-    'auth.host_based.config.99.method': 'password',
-}
-crate_layer = None
-
-
-def ensure_cratedb_layer():
-    """
-    In order to skip individual tests by manually disabling them within
-    `def test_suite()`, it is crucial make the test layer not run on each
-    and every occasion. So, things like this will be possible::
-
-        ./bin/test -vvvv --ignore_dir=testing
-
-    TODO: Through a subsequent patch, the possibility to individually
-          unselect specific tests might be added to `def test_suite()`
-          on behalf of environment variables.
-          A blueprint for this kind of logic can be found at
-          https://github.com/crate/crate/commit/414cd833.
-    """
-    global crate_layer
-
-    if crate_layer is None:
-        crate_layer = CrateLayer('crate',
-                                 crate_home=crate_path(),
-                                 port=crate_port,
-                                 host=localhost,
-                                 transport_port=crate_transport_port,
-                                 settings=settings)
-    return crate_layer
-
-
-def setUpCrateLayerBaseline(test):
-    test.globs['crate_host'] = crate_host
-    test.globs['pprint'] = pprint
-    test.globs['print'] = cprint
-
-    with connect(crate_host) as conn:
-        cursor = conn.cursor()
-
-        with open(docs_path('testing/testdata/mappings/locations.sql')) as s:
-            stmt = s.read()
-            cursor.execute(stmt)
-            stmt = ("select count(*) from information_schema.tables "
-                    "where table_name = 'locations'")
-            cursor.execute(stmt)
-            assert cursor.fetchall()[0][0] == 1
-
-        data_path = docs_path('testing/testdata/data/test_a.json')
-        # load testing data into crate
-        cursor.execute("copy locations from ?", (data_path,))
-        # refresh location table so imported data is visible immediately
-        cursor.execute("refresh table locations")
-        # create blob table
-        cursor.execute("create blob table myfiles clustered into 1 shards " +
-                       "with (number_of_replicas=0)")
-
-        # create users
-        cursor.execute("CREATE USER me WITH (password = 'my_secret_pw')")
-        cursor.execute("CREATE USER trusted_me")
-
-        cursor.close()
-
-
-def setUpCrateLayerSqlAlchemy(test):
-    """
-    Setup tables and views needed for SQLAlchemy tests.
-    """
-    setUpCrateLayerBaseline(test)
-
-    ddl_statements = [
-        """
-        CREATE TABLE characters (
-            id STRING PRIMARY KEY,
-            name STRING,
-            quote STRING,
-            details OBJECT,
-            more_details ARRAY(OBJECT),
-            INDEX name_ft USING fulltext(name) WITH (analyzer = 'english'),
-            INDEX quote_ft USING fulltext(quote) WITH (analyzer = 'english')
-            )""",
-        """
-        CREATE VIEW characters_view
-            AS SELECT * FROM characters
-        """,
-        """
-        CREATE TABLE cities (
-            name STRING PRIMARY KEY,
-            coordinate GEO_POINT,
-            area GEO_SHAPE
-        )"""
-    ]
-    _execute_statements(ddl_statements, on_error="raise")
-
-
-def tearDownDropEntitiesBaseline(test):
-    """
-    Drop all tables, views, and users created by `setUpWithCrateLayer*`.
-    """
-    ddl_statements = [
-        "DROP TABLE locations",
-        "DROP BLOB TABLE myfiles",
-        "DROP USER me",
-        "DROP USER trusted_me",
-    ]
-    _execute_statements(ddl_statements)
-
-
-def tearDownDropEntitiesSqlAlchemy(test):
-    """
-    Drop all tables, views, and users created by `setUpWithCrateLayer*`.
-    """
-    tearDownDropEntitiesBaseline(test)
-    ddl_statements = [
-        "DROP TABLE characters",
-        "DROP VIEW characters_view",
-        "DROP TABLE cities",
-    ]
-    _execute_statements(ddl_statements)
-
-
-class HttpsTestServerLayer:
-    PORT = 65534
-    HOST = "localhost"
-    CERT_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__),
-                                "pki/server_valid.pem"))
-    CACERT_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__),
-                                  "pki/cacert_valid.pem"))
-
-    __name__ = "httpsserver"
-    __bases__ = tuple()
-
-    class HttpsServer(HTTPServer):
-        def get_request(self):
-
-            # Prepare SSL context.
-            context = ssl._create_unverified_context(
-                protocol=ssl.PROTOCOL_TLS_SERVER,
-                cert_reqs=ssl.CERT_OPTIONAL,
-                check_hostname=False,
-                purpose=ssl.Purpose.CLIENT_AUTH,
-                certfile=HttpsTestServerLayer.CERT_FILE,
-                keyfile=HttpsTestServerLayer.CERT_FILE,
-                cafile=HttpsTestServerLayer.CACERT_FILE)
-
-            # Set minimum protocol version, TLSv1 and TLSv1.1 are unsafe.
-            context.minimum_version = ssl.TLSVersion.TLSv1_2
-
-            # Wrap TLS encryption around socket.
-            socket, client_address = HTTPServer.get_request(self)
-            socket = context.wrap_socket(socket, server_side=True)
-
-            return socket, client_address
-
-    class HttpsHandler(BaseHTTPRequestHandler):
-
-        payload = json.dumps({"name": "test", "status": 200, })
-
-        def do_GET(self):
-            self.send_response(200)
-            payload = self.payload.encode('UTF-8')
-            self.send_header("Content-Length", len(payload))
-            self.send_header("Content-Type", "application/json; charset=UTF-8")
-            self.end_headers()
-            self.wfile.write(payload)
-
-    def setUp(self):
-        self.server = self.HttpsServer(
-            (self.HOST, self.PORT),
-            self.HttpsHandler
-        )
-        thread = threading.Thread(target=self.serve_forever)
-        thread.daemon = True  # quit interpreter when only thread exists
-        thread.start()
-        self.waitForServer()
-
-    def serve_forever(self):
-        print("listening on", self.HOST, self.PORT)
-        self.server.serve_forever()
-        print("server stopped.")
-
-    def tearDown(self):
-        self.server.shutdown()
-        self.server.server_close()
-
-    def isUp(self):
-        """
-        Test if a host is up.
-        """
-        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        ex = s.connect_ex((self.HOST, self.PORT))
-        s.close()
-        return ex == 0
-
-    def waitForServer(self, timeout=5):
-        """
-        Wait for the host to be available.
-        """
-        with stopit.ThreadingTimeout(timeout) as to_ctx_mgr:
-            while True:
-                if self.isUp():
-                    break
-                time.sleep(0.001)
-
-        if not to_ctx_mgr:
-            raise TimeoutError("Could not properly start embedded webserver "
-                               "within {} seconds".format(timeout))
-
-
-def setUpWithHttps(test):
-    test.globs['crate_host'] = "https://{0}:{1}".format(
-        HttpsTestServerLayer.HOST, HttpsTestServerLayer.PORT
-    )
-    test.globs['pprint'] = pprint
-    test.globs['print'] = cprint
-
-    test.globs['cacert_valid'] = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), "pki/cacert_valid.pem")
-    )
-    test.globs['cacert_invalid'] = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), "pki/cacert_invalid.pem")
-    )
-    test.globs['clientcert_valid'] = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), "pki/client_valid.pem")
-    )
-    test.globs['clientcert_invalid'] = os.path.abspath(
-        os.path.join(os.path.dirname(__file__), "pki/client_invalid.pem")
-    )
-
-
-def _execute_statements(statements, on_error="ignore"):
-    with connect(crate_host) as conn:
-        cursor = conn.cursor()
-        for stmt in statements:
-            _execute_statement(cursor, stmt, on_error=on_error)
-        cursor.close()
-
-
-def _execute_statement(cursor, stmt, on_error="ignore"):
-    try:
-        cursor.execute(stmt)
-    except Exception:  # pragma: no cover
-        # FIXME: Why does this croak on statements like ``DROP TABLE cities``?
-        # Note: When needing to debug the test environment, you may want to
-        #       enable this logger statement.
-        # log.exception("Executing SQL statement failed")
-        if on_error == "ignore":
-            pass
-        elif on_error == "raise":
-            raise
-
-
-def test_suite():
-    suite = unittest.TestSuite()
-    flags = (doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS)
-
-    # Unit tests.
-    suite.addTest(unittest.makeSuite(CursorTest))
-    suite.addTest(unittest.makeSuite(HttpClientTest))
-    suite.addTest(unittest.makeSuite(KeepAliveClientTest))
-    suite.addTest(unittest.makeSuite(ThreadSafeHttpClientTest))
-    suite.addTest(unittest.makeSuite(ParamsTest))
-    suite.addTest(unittest.makeSuite(ConnectionTest))
-    suite.addTest(unittest.makeSuite(RetryOnTimeoutServerTest))
-    suite.addTest(unittest.makeSuite(RequestsCaBundleTest))
-    suite.addTest(unittest.makeSuite(TestUsernameSentAsHeader))
-    suite.addTest(unittest.makeSuite(TestDefaultSchemaHeader))
-    suite.addTest(sqlalchemy_test_suite())
-    suite.addTest(doctest.DocTestSuite('crate.client.connection'))
-    suite.addTest(doctest.DocTestSuite('crate.client.http'))
-
-    s = doctest.DocFileSuite(
-        'docs/by-example/connection.rst',
-        'docs/by-example/cursor.rst',
-        module_relative=False,
-        optionflags=flags,
-        encoding='utf-8'
-    )
-    suite.addTest(s)
-
-    s = doctest.DocFileSuite(
-        'docs/by-example/https.rst',
-        module_relative=False,
-        setUp=setUpWithHttps,
-        optionflags=flags,
-        encoding='utf-8'
-    )
-    s.layer = HttpsTestServerLayer()
-    suite.addTest(s)
-
-    # Integration tests.
-    s = doctest.DocFileSuite(
-        'docs/by-example/http.rst',
-        'docs/by-example/client.rst',
-        'docs/by-example/blob.rst',
-        module_relative=False,
-        setUp=setUpCrateLayerBaseline,
-        tearDown=tearDownDropEntitiesBaseline,
-        optionflags=flags,
-        encoding='utf-8'
-    )
-    s.layer = ensure_cratedb_layer()
-    suite.addTest(s)
-
-    s = doctest.DocFileSuite(
-        'docs/by-example/sqlalchemy/getting-started.rst',
-        'docs/by-example/sqlalchemy/crud.rst',
-        'docs/by-example/sqlalchemy/working-with-types.rst',
-        'docs/by-example/sqlalchemy/advanced-querying.rst',
-        'docs/by-example/sqlalchemy/inspection-reflection.rst',
-        module_relative=False,
-        setUp=setUpCrateLayerSqlAlchemy,
-        tearDown=tearDownDropEntitiesSqlAlchemy,
-        optionflags=flags,
-        encoding='utf-8'
-    )
-    s.layer = ensure_cratedb_layer()
-    suite.addTest(s)
-
-    return suite
diff --git a/src/crate/testing/__init__.py b/src/crate/testing/__init__.py
index 5bb534f79..e69de29bb 100644
--- a/src/crate/testing/__init__.py
+++ b/src/crate/testing/__init__.py
@@ -1 +0,0 @@
-# package
diff --git a/src/crate/testing/layer.py b/src/crate/testing/layer.py
index 5fd6d8fdc..960a4889e 100644
--- a/src/crate/testing/layer.py
+++ b/src/crate/testing/layer.py
@@ -19,38 +19,45 @@
 # with Crate these terms will supersede the license and you may use the
 # software solely pursuant to the terms of the relevant commercial agreement.
 
+# ruff: noqa: S603  # `subprocess` call: check for execution of untrusted input
+# ruff: noqa: S202  # Uses of `tarfile.extractall()`
+
+import io
+import json
+import logging
 import os
 import re
-import sys
-import time
-import json
-import urllib3
-import tempfile
 import shutil
 import subprocess
+import sys
 import tarfile
-import io
+import tempfile
 import threading
-import logging
+import time
+from typing import Optional
+
+import urllib3
 
 try:
     from urllib.request import urlopen
 except ImportError:
-    from urllib import urlopen
+    from urllib import urlopen  # type: ignore[attr-defined,no-redef]
 
 
 log = logging.getLogger(__name__)
 
 
-CRATE_CONFIG_ERROR = 'crate_config must point to a folder or to a file named "crate.yml"'
+CRATE_CONFIG_ERROR = (
+    'crate_config must point to a folder or to a file named "crate.yml"'
+)
 HTTP_ADDRESS_RE = re.compile(
-    r'.*\[(http|.*HttpServer.*)\s*] \[.*\] .*'
-    'publish_address {'
-    r'(?:inet\[[\w\d\.-]*/|\[)?'
-    r'(?:[\w\d\.-]+/)?'
-    r'(?P[\d\.:]+)'
-    r'(?:\])?'
-    '}'
+    r".*\[(http|.*HttpServer.*)\s*] \[.*\] .*"
+    "publish_address {"
+    r"(?:inet\[[\w\d\.-]*/|\[)?"
+    r"(?:[\w\d\.-]+/)?"
+    r"(?P[\d\.:]+)"
+    r"(?:\])?"
+    "}"
 )
 
 
@@ -61,18 +68,22 @@ def http_url_from_host_port(host, port):
                 port = int(port)
             except ValueError:
                 return None
-        return '{}:{}'.format(prepend_http(host), port)
+        return "{}:{}".format(prepend_http(host), port)
     return None
 
 
 def prepend_http(host):
-    if not re.match(r'^https?\:\/\/.*', host):
-        return 'http://{}'.format(host)
+    if not re.match(r"^https?\:\/\/.*", host):
+        return "http://{}".format(host)
     return host
 
 
 def _download_and_extract(uri, directory):
-    sys.stderr.write("\nINFO:    Downloading CrateDB archive from {} into {}".format(uri, directory))
+    sys.stderr.write(
+        "\nINFO:    Downloading CrateDB archive from {} into {}".format(
+            uri, directory
+        )
+    )
     sys.stderr.flush()
     with io.BytesIO(urlopen(uri).read()) as tmpfile:
         with tarfile.open(fileobj=tmpfile) as t:
@@ -82,19 +93,18 @@ def _download_and_extract(uri, directory):
 def wait_for_http_url(log, timeout=30, verbose=False):
     start = time.monotonic()
     while True:
-        line = log.readline().decode('utf-8').strip()
+        line = log.readline().decode("utf-8").strip()
         elapsed = time.monotonic() - start
         if verbose:
-            sys.stderr.write('[{:>4.1f}s]{}\n'.format(elapsed, line))
+            sys.stderr.write("[{:>4.1f}s]{}\n".format(elapsed, line))
         m = HTTP_ADDRESS_RE.match(line)
         if m:
-            return prepend_http(m.group('addr'))
+            return prepend_http(m.group("addr"))
         elif elapsed > timeout:
             return None
 
 
 class OutputMonitor:
-
     def __init__(self):
         self.consumers = []
 
@@ -105,7 +115,9 @@ def consume(self, iterable):
 
     def start(self, proc):
         self._stop_out_thread = threading.Event()
-        self._out_thread = threading.Thread(target=self.consume, args=(proc.stdout,))
+        self._out_thread = threading.Thread(
+            target=self.consume, args=(proc.stdout,)
+        )
         self._out_thread.daemon = True
         self._out_thread.start()
 
@@ -116,7 +128,6 @@ def stop(self):
 
 
 class LineBuffer:
-
     def __init__(self):
         self.lines = []
 
@@ -124,7 +135,7 @@ def send(self, line):
         self.lines.append(line.strip())
 
 
-class CrateLayer(object):
+class CrateLayer:
     """
     This layer starts a Crate server.
     """
@@ -135,14 +146,16 @@ class CrateLayer(object):
     wait_interval = 0.2
 
     @staticmethod
-    def from_uri(uri,
-                 name,
-                 http_port='4200-4299',
-                 transport_port='4300-4399',
-                 settings=None,
-                 directory=None,
-                 cleanup=True,
-                 verbose=False):
+    def from_uri(
+        uri,
+        name,
+        http_port="4200-4299",
+        transport_port="4300-4399",
+        settings=None,
+        directory=None,
+        cleanup=True,
+        verbose=False,
+    ):
         """Download the Crate tarball from a URI and create a CrateLayer
 
         :param uri: The uri that points to the Crate tarball
@@ -158,11 +171,14 @@ def from_uri(uri,
         """
         directory = directory or tempfile.mkdtemp()
         filename = os.path.basename(uri)
-        crate_dir = re.sub(r'\.tar(\.gz)?$', '', filename)
+        crate_dir = re.sub(r"\.tar(\.gz)?$", "", filename)
         crate_home = os.path.join(directory, crate_dir)
 
         if os.path.exists(crate_home):
-            sys.stderr.write("\nWARNING: Not extracting Crate tarball because folder already exists")
+            sys.stderr.write(
+                "\nWARNING: Not extracting CrateDB tarball"
+                " because folder already exists"
+            )
             sys.stderr.flush()
         else:
             _download_and_extract(uri, directory)
@@ -173,29 +189,33 @@ def from_uri(uri,
             port=http_port,
             transport_port=transport_port,
             settings=settings,
-            verbose=verbose)
+            verbose=verbose,
+        )
         if cleanup:
             tearDown = layer.tearDown
 
             def new_teardown(*args, **kws):
                 shutil.rmtree(directory)
                 tearDown(*args, **kws)
-            layer.tearDown = new_teardown
+
+            layer.tearDown = new_teardown  # type: ignore[method-assign]
         return layer
 
-    def __init__(self,
-                 name,
-                 crate_home,
-                 crate_config=None,
-                 port=None,
-                 keepRunning=False,
-                 transport_port=None,
-                 crate_exec=None,
-                 cluster_name=None,
-                 host="127.0.0.1",
-                 settings=None,
-                 verbose=False,
-                 env=None):
+    def __init__(
+        self,
+        name,
+        crate_home,
+        crate_config=None,
+        port=None,
+        keepRunning=False,
+        transport_port=None,
+        crate_exec=None,
+        cluster_name=None,
+        host="127.0.0.1",
+        settings=None,
+        verbose=False,
+        env=None,
+    ):
         """
         :param name: layer name, is also used as the cluser name
         :param crate_home: path to home directory of the crate installation
@@ -216,52 +236,69 @@ def __init__(self,
         self.__name__ = name
         if settings and isinstance(settings, dict):
             # extra settings may override host/port specification!
-            self.http_url = http_url_from_host_port(settings.get('network.host', host),
-                                                    settings.get('http.port', port))
+            self.http_url = http_url_from_host_port(
+                settings.get("network.host", host),
+                settings.get("http.port", port),
+            )
         else:
             self.http_url = http_url_from_host_port(host, port)
 
-        self.process = None
+        self.process: Optional[subprocess.Popen] = None
         self.verbose = verbose
         self.env = env or {}
-        self.env.setdefault('CRATE_USE_IPV4', 'true')
-        self.env.setdefault('JAVA_HOME', os.environ.get('JAVA_HOME', ''))
+        self.env.setdefault("CRATE_USE_IPV4", "true")
+        self.env.setdefault("JAVA_HOME", os.environ.get("JAVA_HOME", ""))
         self._stdout_consumers = []
         self.conn_pool = urllib3.PoolManager(num_pools=1)
 
         crate_home = os.path.abspath(crate_home)
         if crate_exec is None:
-            start_script = 'crate.bat' if sys.platform == 'win32' else 'crate'
-            crate_exec = os.path.join(crate_home, 'bin', start_script)
+            start_script = "crate.bat" if sys.platform == "win32" else "crate"
+            crate_exec = os.path.join(crate_home, "bin", start_script)
         if crate_config is None:
-            crate_config = os.path.join(crate_home, 'config', 'crate.yml')
-        elif (os.path.isfile(crate_config) and
-              os.path.basename(crate_config) != 'crate.yml'):
+            crate_config = os.path.join(crate_home, "config", "crate.yml")
+        elif (
+            os.path.isfile(crate_config)
+            and os.path.basename(crate_config) != "crate.yml"
+        ):
             raise ValueError(CRATE_CONFIG_ERROR)
         if cluster_name is None:
-            cluster_name = "Testing{0}".format(port or 'Dynamic')
-        settings = self.create_settings(crate_config,
-                                        cluster_name,
-                                        name,
-                                        host,
-                                        port or '4200-4299',
-                                        transport_port or '4300-4399',
-                                        settings)
+            cluster_name = "Testing{0}".format(port or "Dynamic")
+        settings = self.create_settings(
+            crate_config,
+            cluster_name,
+            name,
+            host,
+            port or "4200-4299",
+            transport_port or "4300-4399",
+            settings,
+        )
         # ES 5 cannot parse 'True'/'False' as booleans so convert to lowercase
-        start_cmd = (crate_exec, ) + tuple(["-C%s=%s" % ((key, str(value).lower()) if type(value) == bool else (key, value))
-                                            for key, value in settings.items()])
-
-        self._wd = wd = os.path.join(CrateLayer.tmpdir, 'crate_layer', name)
-        self.start_cmd = start_cmd + ('-Cpath.data=%s' % wd,)
-
-    def create_settings(self,
-                        crate_config,
-                        cluster_name,
-                        node_name,
-                        host,
-                        http_port,
-                        transport_port,
-                        further_settings=None):
+        start_cmd = (crate_exec,) + tuple(
+            [
+                "-C%s=%s"
+                % (
+                    (key, str(value).lower())
+                    if isinstance(value, bool)
+                    else (key, value)
+                )
+                for key, value in settings.items()
+            ]
+        )
+
+        self._wd = wd = os.path.join(CrateLayer.tmpdir, "crate_layer", name)
+        self.start_cmd = start_cmd + ("-Cpath.data=%s" % wd,)
+
+    def create_settings(
+        self,
+        crate_config,
+        cluster_name,
+        node_name,
+        host,
+        http_port,
+        transport_port,
+        further_settings=None,
+    ):
         settings = {
             "discovery.type": "zen",
             "discovery.initial_state_timeout": 0,
@@ -294,20 +331,23 @@ def _clean(self):
 
     def start(self):
         self._clean()
-        self.process = subprocess.Popen(self.start_cmd,
-                                        env=self.env,
-                                        stdout=subprocess.PIPE)
+        self.process = subprocess.Popen(
+            self.start_cmd, env=self.env, stdout=subprocess.PIPE
+        )
         returncode = self.process.poll()
         if returncode is not None:
             raise SystemError(
-                'Failed to start server rc={0} cmd={1}'.format(returncode,
-                                                               self.start_cmd)
+                "Failed to start server rc={0} cmd={1}".format(
+                    returncode, self.start_cmd
+                )
             )
 
         if not self.http_url:
             # try to read http_url from startup logs
             # this is necessary if no static port is assigned
-            self.http_url = wait_for_http_url(self.process.stdout, verbose=self.verbose)
+            self.http_url = wait_for_http_url(
+                self.process.stdout, verbose=self.verbose
+            )
 
         self.monitor = OutputMonitor()
         self.monitor.start(self.process)
@@ -315,17 +355,19 @@ def start(self):
         if not self.http_url:
             self.stop()
         else:
-            sys.stderr.write('HTTP: {}\n'.format(self.http_url))
+            sys.stderr.write("HTTP: {}\n".format(self.http_url))
             self._wait_for_start()
             self._wait_for_master()
-            sys.stderr.write('\nCrate instance ready.\n')
+            sys.stderr.write("\nCrate instance ready.\n")
 
     def stop(self):
         self.conn_pool.clear()
         if self.process:
             self.process.terminate()
             self.process.communicate(timeout=10)
-            self.process.stdout.close()
+            stdout = self.process.stdout
+            if stdout:
+                stdout.close()
             self.process = None
         self.monitor.stop()
         self._clean()
@@ -348,14 +390,13 @@ def _wait_for(self, validator):
                 self.stop()
                 raise e
 
-            if wait_time > 30:
+            if wait_time > 60:
                 for line in line_buf.lines:
                     log.error(line)
                 self.stop()
-                raise SystemError('Failed to start Crate instance in time.')
-            else:
-                sys.stderr.write('.')
-                time.sleep(self.wait_interval)
+                raise SystemError("Failed to start Crate instance in time.")
+            sys.stderr.write(".")
+            time.sleep(self.wait_interval)
 
         self.monitor.consumers.remove(line_buf)
 
@@ -367,7 +408,7 @@ def _wait_for_start(self):
         # after the layer starts don't result in 503
         def validator():
             try:
-                resp = self.conn_pool.request('HEAD', self.http_url)
+                resp = self.conn_pool.request("HEAD", self.http_url)
                 return resp.status == 200
             except Exception:
                 return False
@@ -379,12 +420,12 @@ def _wait_for_master(self):
 
         def validator():
             resp = self.conn_pool.urlopen(
-                'POST',
-                '{server}/_sql'.format(server=self.http_url),
-                headers={'Content-Type': 'application/json'},
-                body='{"stmt": "select master_node from sys.cluster"}'
+                "POST",
+                "{server}/_sql".format(server=self.http_url),
+                headers={"Content-Type": "application/json"},
+                body='{"stmt": "select master_node from sys.cluster"}',
             )
-            data = json.loads(resp.data.decode('utf-8'))
-            return resp.status == 200 and data['rows'][0][0]
+            data = json.loads(resp.data.decode("utf-8"))
+            return resp.status == 200 and data["rows"][0][0]
 
         self._wait_for(validator)
diff --git a/src/crate/testing/tests.py b/src/crate/testing/tests.py
deleted file mode 100644
index fb08f7ab2..000000000
--- a/src/crate/testing/tests.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# vi: set encoding=utf-8
-# -*- coding: utf-8; -*-
-#
-# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
-# license agreements.  See the NOTICE file distributed with this work for
-# additional information regarding copyright ownership.  Crate licenses
-# this file to you under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.  You may
-# obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License 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.
-#
-# However, if you have executed another commercial license agreement
-# with Crate these terms will supersede the license and you may use the
-# software solely pursuant to the terms of the relevant commercial agreement.
-
-import unittest
-from .test_layer import LayerUtilsTest, LayerTest
-
-
-def test_suite():
-    suite = unittest.TestSuite()
-    suite.addTest(unittest.makeSuite(LayerUtilsTest))
-    suite.addTest(unittest.makeSuite(LayerTest))
-    return suite
diff --git a/src/crate/testing/util.py b/src/crate/testing/util.py
index 3e9885d60..6f25b276b 100644
--- a/src/crate/testing/util.py
+++ b/src/crate/testing/util.py
@@ -1,4 +1,75 @@
-class ExtraAssertions:
+# -*- coding: utf-8; -*-
+#
+# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
+# license agreements.  See the NOTICE file distributed with this work for
+# additional information regarding copyright ownership.  Crate licenses
+# this file to you under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.  You may
+# obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License 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.
+#
+# However, if you have executed another commercial license agreement
+# with Crate these terms will supersede the license and you may use the
+# software solely pursuant to the terms of the relevant commercial agreement.
+import unittest
+
+
+class ClientMocked:
+    active_servers = ["http://localhost:4200"]
+
+    def __init__(self):
+        self.response = {}
+        self._server_infos = ("http://localhost:4200", "my server", "2.0.0")
+
+    def sql(self, stmt=None, parameters=None, bulk_parameters=None):
+        return self.response
+
+    def server_infos(self, server):
+        return self._server_infos
+
+    def set_next_response(self, response):
+        self.response = response
+
+    def set_next_server_infos(self, server, server_name, version):
+        self._server_infos = (server, server_name, version)
+
+    def close(self):
+        pass
+
+
+class ParametrizedTestCase(unittest.TestCase):
+    """
+    TestCase classes that want to be parametrized should
+    inherit from this class.
+
+    https://eli.thegreenplace.net/2011/08/02/python-unit-testing-parametrized-test-cases
+    """
+
+    def __init__(self, methodName="runTest", param=None):
+        super(ParametrizedTestCase, self).__init__(methodName)
+        self.param = param
+
+    @staticmethod
+    def parametrize(testcase_klass, param=None):
+        """Create a suite containing all tests taken from the given
+        subclass, passing them the parameter 'param'.
+        """
+        testloader = unittest.TestLoader()
+        testnames = testloader.getTestCaseNames(testcase_klass)
+        suite = unittest.TestSuite()
+        for name in testnames:
+            suite.addTest(testcase_klass(name, param=param))
+        return suite
+
+
+class ExtraAssertions(unittest.TestCase):
     """
     Additional assert methods for unittest.
 
@@ -12,9 +83,13 @@ def assertIsSubclass(self, cls, superclass, msg=None):
             r = issubclass(cls, superclass)
         except TypeError:
             if not isinstance(cls, type):
-                self.fail(self._formatMessage(msg,
-                          '%r is not a class' % (cls,)))
+                self.fail(
+                    self._formatMessage(msg, "%r is not a class" % (cls,))
+                )
             raise
         if not r:
-            self.fail(self._formatMessage(msg,
-                      '%r is not a subclass of %r' % (cls, superclass)))
+            self.fail(
+                self._formatMessage(
+                    msg, "%r is not a subclass of %r" % (cls, superclass)
+                )
+            )
diff --git a/src/crate/client/sqlalchemy/compat/__init__.py b/tests/__init__.py
similarity index 100%
rename from src/crate/client/sqlalchemy/compat/__init__.py
rename to tests/__init__.py
diff --git a/src/crate/testing/testdata/data/test_a.json b/tests/assets/import/test_a.json
similarity index 100%
rename from src/crate/testing/testdata/data/test_a.json
rename to tests/assets/import/test_a.json
diff --git a/src/crate/testing/testdata/mappings/locations.sql b/tests/assets/mappings/locations.sql
similarity index 100%
rename from src/crate/testing/testdata/mappings/locations.sql
rename to tests/assets/mappings/locations.sql
diff --git a/src/crate/client/pki/cacert_invalid.pem b/tests/assets/pki/cacert_invalid.pem
similarity index 100%
rename from src/crate/client/pki/cacert_invalid.pem
rename to tests/assets/pki/cacert_invalid.pem
diff --git a/tests/assets/pki/cacert_valid.pem b/tests/assets/pki/cacert_valid.pem
new file mode 100644
index 000000000..7c74ebea6
--- /dev/null
+++ b/tests/assets/pki/cacert_valid.pem
@@ -0,0 +1,48 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDZQ39/x7oQ8OpO
+8+uT2rA2zxmXT7/5EP7bQuhTXRlZdBSR3UmTm5YMGvCSFGMkNMxoD30it/9i8Ind
+54egJuoVgqi8/XEi048hX4aT3QFuJjsHmslkrXhKuvsBFwhkJomVE0OHeLPPA4EY
+LAB1F0SWmiQrujpwqcJtVqX0NaVr0O40T7yfswmWwdCG9hCXOTfbXToCVzBnsiRf
+l1zsTt5ZyC2Cc5e2po5o203jecwMO0NrFFDbkCyhJuJ11m7oTyuu1FaSwRIoR2aN
+jLTLBXhbh7nWsLtcIEDQbX765SJFmRdscOyM3pkuaTFPM6e2MbD7zFzqVXNe8kfB
+BPID/jYTAgMBAAECggEBAIPsGlUKf46d2ohKT/cdSPZe1lksUZL4oOeePtevph7M
+/1h6l5yLUpbTHNR2kKSBcsSU8xTLyMTY7b3SQmYaevTIu5DWC4ZsE3LeQCc0gRPT
+HvDFKhh5xJsL4jGNPMkjJVNhuRVfUz8WD6MPxM9Ua49O1Tu//pA+ZBJkFKyujl1i
+B2uiLhAP2jjOiJ2ViS+ygbJn0YvQbNBLTJOOCiRKwST+TmHHPz+mgCf4QT2Qe9IN
+zBlSPGvanKZ8b6uiMR04zvcnS2IcaPR5frCHOGp7GLCwGTTCMLlkZzuRHOqWuadv
+fQcONZRUCh5f5fioDPs+vrsHX+oEr3eDPpfajTbKLEECgYEA8IryaXlWZ7yz1zS6
+Gemwl+6WKbF7whKWF1sIgyx3OASBGuSSLCsYgzwHtDerX6xwW1CVAYpFDOzFhwrN
+SxZMTlzP/gdzyZkEhiBECUyhgBwb9fJLwYv5YZk6ge9DNupjlCaA33zEkZyb75Yv
+mKI7Z2ZwEl/iteDv5DLy96ZBoJECgYEA5zmZEJdUApAvPKZNKSuFqBA6LbEfaYql
+NmPknnjuPxvVU3zk0rN3tYV48x5X9Gsn7cHpM2j5PV39PmQxFbkDNfGEsj0aIhi/
+STJbYl0iZU211kJ59srOGoQzlw53IzHgEKGdXkwJAS9p0vQY+1UyHZ7x+KAPRkJh
+65HsOLUaPmMCgYEAusapREz7UsGrw0cl2kki3lZcMzOlLSTRdoExMumsCMs2lHM3
+LSSpe/143e78GuYCuGTxzqhWxZp0Jk+06MD/rzlSJ6YRulAeuycCrVXbcWZiL69c
+M4VEtwwL6iimwFVvzPG4z9BJsecxDfBINPSDu95G132sxB1NKJyHHNTz0IECgYEA
+3WqtZVLT8sRpR3Wka+kuSvqHC5wSDXnjrQrJMOtykWIwzIrLf+y1lFBkusJyzS4T
+hTxr+HvsN9SaHbXJvxh0t6I4RIn4TXBPNGORWMcAmetpVexAaJBZ/tBbqqoCceRf
+wO1SCatb8BOcDZMLnRk/LVg4M7HqDoigxfjtMHxTzisCgYAob3Uj8b4Gq3OlNwt0
+FEx4aS32LiRNVsHsOi4CuO71GtLJoY3vs+wAxDEGjx+TNO2vS6nMZ+ey4Ov0s3oP
+MSswtSQmJd/txUn2MAdixmLOgXQRLNxRFixOtyi6Zk4URQUbIDKL9oXPh9w+HKMG
+P28ts0ZcnBZuSNiKFPBqsgbcEw==
+-----END PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+MIIDSzCCAjOgAwIBAgIIBovQCzdcoSQwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgMDY4YmQwMCAXDTI1MTIxODAwMTYwM1oYDzIxMjUx
+MjE4MDAxNjAzWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAwNjhiZDAwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZQ39/x7oQ8OpO8+uT2rA2zxmX
+T7/5EP7bQuhTXRlZdBSR3UmTm5YMGvCSFGMkNMxoD30it/9i8Ind54egJuoVgqi8
+/XEi048hX4aT3QFuJjsHmslkrXhKuvsBFwhkJomVE0OHeLPPA4EYLAB1F0SWmiQr
+ujpwqcJtVqX0NaVr0O40T7yfswmWwdCG9hCXOTfbXToCVzBnsiRfl1zsTt5ZyC2C
+c5e2po5o203jecwMO0NrFFDbkCyhJuJ11m7oTyuu1FaSwRIoR2aNjLTLBXhbh7nW
+sLtcIEDQbX765SJFmRdscOyM3pkuaTFPM6e2MbD7zFzqVXNe8kfBBPID/jYTAgMB
+AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
+BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRwwy1w6mD+t8Kn
+ZqLlhr8GwasSuzAfBgNVHSMEGDAWgBRwwy1w6mD+t8KnZqLlhr8GwasSuzANBgkq
+hkiG9w0BAQsFAAOCAQEA0C4W43FwgWN3G9H1W4INPQNmVAs34hrwyMGcLXeP4XiS
+9IsMNJMJwmV5IpKN8XkSmPY7iVfj/sGALpAx6OXcgE1BLbOQc7GpDENNdwfAPFEE
+jqdUJQpHPcsFoI2sXCPUfBHv6M5bHdBnD9QkTMUpf01NA41rImGvyBZJOPCdQ/BL
+OqwAzZKqEghu83y4W8Oc6BDDXY32bC6Uyq+VXUisS2MLsqilIn0Yck0uxZ60DlIt
+9+ZjlYti7TCAvLgfFHX7mAaGsufs2yy6u4cagYE6avNQ2MaGRlV5Q/52D1Ium3TQ
+xHvhqeUhkdR5gQ2qYPvF4m+Enrrw8/vbssKNryFXtQ==
+-----END CERTIFICATE-----
diff --git a/tests/assets/pki/client_invalid.pem b/tests/assets/pki/client_invalid.pem
new file mode 100644
index 000000000..8a71f1cfb
--- /dev/null
+++ b/tests/assets/pki/client_invalid.pem
@@ -0,0 +1,48 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDRr/6ySMYRxcx2
+ntVoj6ZWZP5E+aBrtrJATltiqfC//q4/XOXKUdhEsXdj5jAzff7WNE0PSLV6106I
+pvSBjEqnzBFwTfIZJ+U7ORs4z2G00r5d4wrUYdO6KkIXD5OUNNytr7i29H+oQndm
+B78WgWXZoTO3c0aqexFsPCGTQtY3KI9MNmHOL7MCARFoKZDVlTS96eGEoQ8LMmS6
+HasMd38lELKH7Me81bad7vkozWD4Ditc3Ab4Czc5BlmGc2qofhSH+w5hhAftVaZP
+D/8LAQa9ki6Fn4Ag+gDVEZJfPs4itBaZtXvVubZ/p24LUOpW89eaNeQuvroZX9NJ
+MEJjDiXzAgMBAAECggEAJx0+VIFwuSX9gu8T/gI11A3YxIMQIqeczpQPRfNPXnEi
+jd1FXB0yS3YTQEZTHXfWTpzI9x3oBHcfRixmlbfWwUA2zI0tqPWhNA/t/QXqQvHA
+4wCUJ5PsC+EQHP8h11aZB6ksuqwXGAr1/MlbItaWaidA32qYdvqoAFmjJbYlAXqv
+7XHlbrjEZYuYVa6n+Bf8JJWQj5/7uIuerGuZaHfc11fXl+Eb8Ni9RCqPUQASPp8O
+AkHqVraSg4yNbtbIgSzxVAtTWKwQfKagUHpwe/4TD9D4MDmqgdpEX2u1NVd5YznI
+34TaUi8UNV4V2UwIrYNZ/q7FLYJpDoR3qP8We/V52QKBgQDvBZ55ISU3dCyIM4df
+0C5gJzTQ1E8F4udPawu1FZXt2voglv+RQjhjbUlHQOQ0ZP8S04ocrEXBguruGCGD
+1dzcoGGiQTEQ2PLxn1zQ6/ZRhKGTWsyZvWC8EvyW5C9qqFrzxPK4ewH4lLRaFowr
+2QYecrHZGyJtSuWnpi47VBilxQKBgQDglPUI5AbrlIgKx+ODSAkzGmN0zV/u4v5P
+YGdBfv1u4Sk1aPcETlTo4EfOxXnpPFD7pxQLPlRKokECMhxkF0OU/IFf0Czpnufp
+CS9Gh3DAMpgcU04kPKy3wAHUUhCb20dwaPa9wy63+3HT0+Tje4/LKaK+yKbHip5P
+dOF/IaOQVwKBgQCWehghtA9FERt0B9TDKkH4ANbMXcz+LLJpGkA9mcBRBVoRKF7u
+6Rys48GNXToODkNTa9/4WzBzOmOCz6hR4lLwhvvQdXBZZevU1/pMATGKcpByP2it
+pT8ASIfCyxda83CHJUeVgsNj5uKAEM/gTvn+M0DqKJ4M5mkcmY9jhHcb7QKBgQC8
+UArbJqEq3oPoTk3Wogucvy7QHPmpDMtjcZmDUOFfHQv5zgb6pFgMYSu1FN8xlDLv
+oNxsxRnW7xCFr9EgmGVBwy0UUzGlv2oEfIAYqq7/ai0j6zJlrj7BYQ3no9xrY8YW
+Wt0ypzxAZ2W90jpmCRC8sS/Wu9clEHXIeGAwwtssxwKBgQCy5hhNX/5GCo8v4XkA
+Na1V/U2+o9NWHCf7eewH5up3TAiN1C3fMHDZnIA/1z4JdXebiURsXKJfheteDmku
+gmA1edWrbDklcUKaw2BOKEuRHVt/5jy3VoikNciGaETgOg+ABF1FCNwMMD7Zouig
+V0gva4sjUnr5uXeKLfD8Sq4csw==
+-----END PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+MIIDUDCCArmgAwIBAgIUKv3ztxeHaA+A/oV3nSyMwqfpulEwDQYJKoZIhvcNAQEL
+BQAwgYgxCzAJBgNVBAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJl
+cmxpbjEiMCAGA1UECgwZQ3LDg8KkdGUgVGVjaG5vbG9naWUgR21iSDETMBEGA1UE
+AwwKbG9jYWxob3JzdDEeMBwGCSqGSIb3DQEJARYPbm9ib2R5QGNyYXRlLmlvMCAX
+DTI1MTIxODAwNTQxMFoYDzMwMDYwMjE5MDA1NDEwWjB4MQswCQYDVQQGEwJBVDET
+MBEGA1UECAwKVm9yYXJsYmVyZzERMA8GA1UEBwwIRG9ybmJpcm4xETAPBgNVBAoM
+CENyYXRlLmlvMQ4wDAYDVQQDDAVob3JzdDEeMBwGCSqGSIb3DQEJARYPbm9ib2R5
+QGNyYXRlLmlvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0a/+skjG
+EcXMdp7VaI+mVmT+RPmga7ayQE5bYqnwv/6uP1zlylHYRLF3Y+YwM33+1jRND0i1
+etdOiKb0gYxKp8wRcE3yGSflOzkbOM9htNK+XeMK1GHTuipCFw+TlDTcra+4tvR/
+qEJ3Zge/FoFl2aEzt3NGqnsRbDwhk0LWNyiPTDZhzi+zAgERaCmQ1ZU0venhhKEP
+CzJkuh2rDHd/JRCyh+zHvNW2ne75KM1g+A4rXNwG+As3OQZZhnNqqH4Uh/sOYYQH
+7VWmTw//CwEGvZIuhZ+AIPoA1RGSXz7OIrQWmbV71bm2f6duC1DqVvPXmjXkLr66
+GV/TSTBCYw4l8wIDAQABo0AwPjAfBgNVHSMEGDAWgBQhaSXE45HsPHiBcQ6kG8BZ
+rfKxkTAJBgNVHRMEAjAAMBAGA1UdEQQJMAeCBWhvcnN0MA0GCSqGSIb3DQEBCwUA
+A4GBAKKdX4G3ColM07yxVUQPTdXI/WFkHMRwJhRhHpV01xedo83fxJVi5DjZbg4u
++JxBJKhfNsBVQxflgWiZ7kQlhRpjCIf65LZGApD86PLjwKaQzriTQGFsnduoGBrt
+oLGCQMYXjC3ApZoDuA+/BgBgY6OBKdXuDelaUN+T/I7cHkYy
+-----END CERTIFICATE-----
diff --git a/tests/assets/pki/client_valid.pem b/tests/assets/pki/client_valid.pem
new file mode 100644
index 000000000..446332a9a
--- /dev/null
+++ b/tests/assets/pki/client_valid.pem
@@ -0,0 +1,47 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCYmZUgstwotlwu
+BMBRgZkCWyCsHNokOxeXgIet3cysYbShGdAlYSvF/3y7in0+9A7ayOkYpkL71G5H
+e5SOlF3wl0CO2b3LLOMb6VBaXgx4xdwjlsNOu8Be83oq2Mc6X8oTg5kHscXSwRqk
+gmXONJjeSDxr5S0erLpuJmY2M7n4ISTmZ2XGHzrWPPnpXxtuK6ihxFLdG6VhIlXE
+ukiYCEQ+yaX3h4Z2tsbrx4F8rvFvviLCvZgd2eXM8iXpxqq5K/5wsQ8CoNCS35cc
+lnukR78mrMgs9otKXqWPb3+IjNjBt79tjsr/LpNvlHtmDNytQF42q2PC5QIi6u54
+NybhJyddAgMBAAECggEAL12MckfSGjjwR8BlyWbGjXqnm4ShGRnWasoUUx1DSEZT
+gKW4e8G7iouQtc14rq+mZLZz05jXtb7sM/fh1peqyODJww1G4eee3qz+qe4DRQF1
+IP7MxIV2YoKEawQ6Yp4U5RW1zDObyZzynhlzbBvbTzNwvbS22oyWmVYF59EKaj+0
+qpiQpeK+SSNHVHo2hPDzYzQ+CMLNPQsJF/LL3t0KXToTcAZYgcuWuMFaSJBwUBA7
+78TDfF2OVv226LWxRM7uxJmkjCyAri75u7ms/ZdXno5e1l349Woms9cn3dRhQOb4
+DaUl91gu6XjBjjMHZccVMM9YybZ0eN4PS94GwouWQQKBgQDAEkk0qjHHv6aYztqq
+wzm68zZZcKuQNeymhGKtGdwEjvCaPxf3clsDVVN1Ek34KcWPVWfNa72pZgeebJ47
+kFZjKujaSjhEallZrd5BbLoWX4aojAFriVo/vreSG3kf6lVWDGiwsJFN27Ty92bx
+Z8jdoMYwdkWFK8X+U490/dzCZQKBgQDLZBKSr5rxA2N0kBCsLei8xn7bYjVcrXV8
+3+TJNIPftjxBiCNWoqYbxxR92yEoYm5RvAGowL7y0wtGsm82Hm/TdP676JhPA0Cl
+pqdntSuvGMYo1N9JCjJHyb+OucQFfedYbsAe1NeJQ1WqhmafX/bfmiRoBCtT/3GN
+YOtrGo8FmQKBgQCJfI1qZWm80jMuruNX2Oc6K158J2qnj/IWQC1iO2CC4s7XmtJN
+Dk6X1DwpUTD+suf38eqb4MOivqY1gQrDGE6+LbkAiKA+WOIcj5jIvEt4OGjrvUxG
+7crsVliQ/Zl2fwcW0UsfL0TTtrGQOgg2RVRDURKNHhwld3AC3U2TMSUVAQKBgDvj
+8dwjvXoux0Bxk5c/xYKg6IXtO01Y/nmQ7CubPSHI5aFeVxmRt1wCmTQeLHtZ0KZc
+uBha+V9DadSdgkXrXH5luwTWDtNlX8nncvAE1yIuJ1uqoz08tWWSMMf0R5ZeZpsR
+sZzii70tiLI39UwE1AsvtnyyjZekZml9GpVlMqwxAoGAcT7F/MP8o4C/U7Rx8viK
+9Xg+OLC8V12NSABZpHXL1fJZBi88yqjlol8RTlhfjhMRxaOBEr7lixtYnYgEAehc
+1Vd/04W7Zj7M3iyPFkcPqzGxgBoX1lcBefSMMrWiPQ2h5FRWF1M/77HG94WqUbrN
+dgR1BU4l90CAn3ZBbSsomdY=
+-----END PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+MIIDLDCCAhSgAwIBAgIIaO9BWgPy+NcwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgMDY4YmQwMB4XDTI1MTIxODAwMzE0NFoXDTI4MDEx
+NzAwMzE0NFowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAmJmVILLcKLZcLgTAUYGZAlsgrBzaJDsXl4CHrd3MrGG0
+oRnQJWErxf98u4p9PvQO2sjpGKZC+9RuR3uUjpRd8JdAjtm9yyzjG+lQWl4MeMXc
+I5bDTrvAXvN6KtjHOl/KE4OZB7HF0sEapIJlzjSY3kg8a+UtHqy6biZmNjO5+CEk
+5mdlxh861jz56V8bbiuoocRS3RulYSJVxLpImAhEPsml94eGdrbG68eBfK7xb74i
+wr2YHdnlzPIl6caquSv+cLEPAqDQkt+XHJZ7pEe/JqzILPaLSl6lj29/iIzYwbe/
+bY7K/y6Tb5R7ZgzcrUBeNqtjwuUCIurueDcm4ScnXQIDAQABo3YwdDAOBgNVHQ8B
+Af8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB
+/wQCMAAwHwYDVR0jBBgwFoAUcMMtcOpg/rfCp2ai5Ya/BsGrErswFAYDVR0RBA0w
+C4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQCnjPvjIH4azFQWf2kXflYj
+bO0l0N+OFUDRdzp8NuaEM1HGesizh8SRrGfFUlWxBus1l5OR2T7uYEcH5KdsuezL
+wDDGZtUAv92PAttM9i+YHIh/Ri4zAaj5T1t5ZvPfeTzLt6NgvKw2kwEHiE+7ne+A
+ywPjv5tZ0LPTM/wLMuhdCYCXzztCPrA5TfbjYXnFC5N+WXxrpGflhoPWUq3bZCyM
+GwtW/SqWkjZI4lvd/gavtmQyGsuUyxXcsHBTvDX3/EcNe4OJ8KfLRyiUb/Qrkg4J
+0xXVSNNWA9zioOrKrDERyISquvLDFFwwKFJTd+4zLZJBtkn2ixCgq2t9I1Vdvrqw
+-----END CERTIFICATE-----
diff --git a/tests/assets/pki/readme.rst b/tests/assets/pki/readme.rst
new file mode 100644
index 000000000..0bcc73183
--- /dev/null
+++ b/tests/assets/pki/readme.rst
@@ -0,0 +1,78 @@
+#########################
+Generate new certificates
+#########################
+
+
+*****
+About
+*****
+
+For conducting TLS connectivity tests, there are a few X.509 certificates at
+`tests/assets/pki/*.pem`_. The instructions here outline how to renew them.
+
+In order to invoke the corresponding test cases, run::
+
+    ./bin/test -t https.rst
+
+
+*******
+Details
+*******
+
+
+``*_valid.pem``
+===============
+
+By example, this will renew the ``server_valid.pem`` and ``client_valid.pem``
+X.509 certificates.
+
+Create keys and certificates for certificate authority and one application/user::
+
+    brew install minica
+    minica -ca-alg rsa -domains localhost
+
+Combine private key and certificate into single PEM file::
+
+    cat minica-key.pem > cacert_valid.pem; cat minica.pem >> cacert_valid.pem
+    cat localhost/key.pem > server_valid.pem; cat localhost/cert.pem >> server_valid.pem
+    cp server_valid.pem client_valid.pem
+
+Display the certificates::
+
+    openssl x509 -in cacert_valid.pem -text
+    openssl x509 -in server_valid.pem -text
+    openssl x509 -in client_valid.pem -text
+
+
+``*_invalid.pem``
+=================
+
+This will renew the ``client_invalid.pem`` X.509 certificate.
+In order to create an invalid certificate, it is using a wrong hostname.
+
+- ``CN=horst`` and ``subjectAltName=DNS:horst`` do not match ``localhost``.
+
+Create RSA private key and certificate request::
+
+    openssl req -nodes \
+        -newkey rsa:2048 -keyout invalid-key.pem -out invalid.csr \
+        -subj "/C=AT/ST=Vorarlberg/L=Dornbirn/O=Crate.io/CN=horst/emailAddress=nobody@crate.io"
+
+Create certificate::
+
+    openssl x509 -req -in invalid.csr \
+        -CA cacert_invalid.pem -CAkey cacert_invalid.pem -CAcreateserial -sha256 \
+        -days 358000 \
+        -out invalid.pem \
+        -extfile - < client_invalid.pem; cat invalid.pem >> client_invalid.pem
+
+
+.. _tests/assets/pki/*.pem: https://github.com/crate/crate-python/tree/main/tests/assets/pki
diff --git a/tests/assets/pki/server_valid.pem b/tests/assets/pki/server_valid.pem
new file mode 100644
index 000000000..446332a9a
--- /dev/null
+++ b/tests/assets/pki/server_valid.pem
@@ -0,0 +1,47 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCYmZUgstwotlwu
+BMBRgZkCWyCsHNokOxeXgIet3cysYbShGdAlYSvF/3y7in0+9A7ayOkYpkL71G5H
+e5SOlF3wl0CO2b3LLOMb6VBaXgx4xdwjlsNOu8Be83oq2Mc6X8oTg5kHscXSwRqk
+gmXONJjeSDxr5S0erLpuJmY2M7n4ISTmZ2XGHzrWPPnpXxtuK6ihxFLdG6VhIlXE
+ukiYCEQ+yaX3h4Z2tsbrx4F8rvFvviLCvZgd2eXM8iXpxqq5K/5wsQ8CoNCS35cc
+lnukR78mrMgs9otKXqWPb3+IjNjBt79tjsr/LpNvlHtmDNytQF42q2PC5QIi6u54
+NybhJyddAgMBAAECggEAL12MckfSGjjwR8BlyWbGjXqnm4ShGRnWasoUUx1DSEZT
+gKW4e8G7iouQtc14rq+mZLZz05jXtb7sM/fh1peqyODJww1G4eee3qz+qe4DRQF1
+IP7MxIV2YoKEawQ6Yp4U5RW1zDObyZzynhlzbBvbTzNwvbS22oyWmVYF59EKaj+0
+qpiQpeK+SSNHVHo2hPDzYzQ+CMLNPQsJF/LL3t0KXToTcAZYgcuWuMFaSJBwUBA7
+78TDfF2OVv226LWxRM7uxJmkjCyAri75u7ms/ZdXno5e1l349Woms9cn3dRhQOb4
+DaUl91gu6XjBjjMHZccVMM9YybZ0eN4PS94GwouWQQKBgQDAEkk0qjHHv6aYztqq
+wzm68zZZcKuQNeymhGKtGdwEjvCaPxf3clsDVVN1Ek34KcWPVWfNa72pZgeebJ47
+kFZjKujaSjhEallZrd5BbLoWX4aojAFriVo/vreSG3kf6lVWDGiwsJFN27Ty92bx
+Z8jdoMYwdkWFK8X+U490/dzCZQKBgQDLZBKSr5rxA2N0kBCsLei8xn7bYjVcrXV8
+3+TJNIPftjxBiCNWoqYbxxR92yEoYm5RvAGowL7y0wtGsm82Hm/TdP676JhPA0Cl
+pqdntSuvGMYo1N9JCjJHyb+OucQFfedYbsAe1NeJQ1WqhmafX/bfmiRoBCtT/3GN
+YOtrGo8FmQKBgQCJfI1qZWm80jMuruNX2Oc6K158J2qnj/IWQC1iO2CC4s7XmtJN
+Dk6X1DwpUTD+suf38eqb4MOivqY1gQrDGE6+LbkAiKA+WOIcj5jIvEt4OGjrvUxG
+7crsVliQ/Zl2fwcW0UsfL0TTtrGQOgg2RVRDURKNHhwld3AC3U2TMSUVAQKBgDvj
+8dwjvXoux0Bxk5c/xYKg6IXtO01Y/nmQ7CubPSHI5aFeVxmRt1wCmTQeLHtZ0KZc
+uBha+V9DadSdgkXrXH5luwTWDtNlX8nncvAE1yIuJ1uqoz08tWWSMMf0R5ZeZpsR
+sZzii70tiLI39UwE1AsvtnyyjZekZml9GpVlMqwxAoGAcT7F/MP8o4C/U7Rx8viK
+9Xg+OLC8V12NSABZpHXL1fJZBi88yqjlol8RTlhfjhMRxaOBEr7lixtYnYgEAehc
+1Vd/04W7Zj7M3iyPFkcPqzGxgBoX1lcBefSMMrWiPQ2h5FRWF1M/77HG94WqUbrN
+dgR1BU4l90CAn3ZBbSsomdY=
+-----END PRIVATE KEY-----
+-----BEGIN CERTIFICATE-----
+MIIDLDCCAhSgAwIBAgIIaO9BWgPy+NcwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
+AxMVbWluaWNhIHJvb3QgY2EgMDY4YmQwMB4XDTI1MTIxODAwMzE0NFoXDTI4MDEx
+NzAwMzE0NFowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAmJmVILLcKLZcLgTAUYGZAlsgrBzaJDsXl4CHrd3MrGG0
+oRnQJWErxf98u4p9PvQO2sjpGKZC+9RuR3uUjpRd8JdAjtm9yyzjG+lQWl4MeMXc
+I5bDTrvAXvN6KtjHOl/KE4OZB7HF0sEapIJlzjSY3kg8a+UtHqy6biZmNjO5+CEk
+5mdlxh861jz56V8bbiuoocRS3RulYSJVxLpImAhEPsml94eGdrbG68eBfK7xb74i
+wr2YHdnlzPIl6caquSv+cLEPAqDQkt+XHJZ7pEe/JqzILPaLSl6lj29/iIzYwbe/
+bY7K/y6Tb5R7ZgzcrUBeNqtjwuUCIurueDcm4ScnXQIDAQABo3YwdDAOBgNVHQ8B
+Af8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB
+/wQCMAAwHwYDVR0jBBgwFoAUcMMtcOpg/rfCp2ai5Ya/BsGrErswFAYDVR0RBA0w
+C4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQCnjPvjIH4azFQWf2kXflYj
+bO0l0N+OFUDRdzp8NuaEM1HGesizh8SRrGfFUlWxBus1l5OR2T7uYEcH5KdsuezL
+wDDGZtUAv92PAttM9i+YHIh/Ri4zAaj5T1t5ZvPfeTzLt6NgvKw2kwEHiE+7ne+A
+ywPjv5tZ0LPTM/wLMuhdCYCXzztCPrA5TfbjYXnFC5N+WXxrpGflhoPWUq3bZCyM
+GwtW/SqWkjZI4lvd/gavtmQyGsuUyxXcsHBTvDX3/EcNe4OJ8KfLRyiUb/Qrkg4J
+0xXVSNNWA9zioOrKrDERyISquvLDFFwwKFJTd+4zLZJBtkn2ixCgq2t9I1Vdvrqw
+-----END CERTIFICATE-----
diff --git a/src/crate/testing/testdata/settings/test_a.json b/tests/assets/settings/test_a.json
similarity index 100%
rename from src/crate/testing/testdata/settings/test_a.json
rename to tests/assets/settings/test_a.json
diff --git a/tests/client/__init__.py b/tests/client/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/crate/testing/settings.py b/tests/client/settings.py
similarity index 73%
rename from src/crate/testing/settings.py
rename to tests/client/settings.py
index 34793cc61..53c56b54a 100644
--- a/src/crate/testing/settings.py
+++ b/tests/client/settings.py
@@ -19,33 +19,27 @@
 # However, if you have executed another commercial license agreement
 # with Crate these terms will supersede the license and you may use the
 # software solely pursuant to the terms of the relevant commercial agreement.
-from __future__ import absolute_import
 
-import os
 
+from pathlib import Path
 
-def docs_path(*parts):
-    return os.path.abspath(
-        os.path.join(
-            os.path.dirname(os.path.dirname(__file__)), *parts
-        )
+
+def assets_path(*parts) -> str:
+    return str(
+        (project_root() / "tests" / "assets").joinpath(*parts).absolute()
     )
 
 
-def project_root(*parts):
-    return os.path.abspath(
-        os.path.join(docs_path("..", ".."), *parts)
-    )
+def crate_path() -> str:
+    return str(project_root() / "parts" / "crate")
 
 
-def crate_path(*parts):
-    return os.path.abspath(
-        project_root("parts", "crate", *parts)
-    )
+def project_root() -> Path:
+    return Path(__file__).parent.parent.parent
 
 
 crate_port = 44209
 crate_transport_port = 44309
-localhost = '127.0.0.1'
+localhost = "127.0.0.1"
 crate_host = "{host}:{port}".format(host=localhost, port=crate_port)
 crate_uri = "http://%s" % crate_host
diff --git a/tests/client/test_blob.py b/tests/client/test_blob.py
new file mode 100644
index 000000000..6afc93b56
--- /dev/null
+++ b/tests/client/test_blob.py
@@ -0,0 +1,54 @@
+from io import BytesIO
+from unittest.mock import MagicMock
+
+import pytest
+
+from crate.client.blob import BlobContainer
+
+
+def test_container():
+    """Verify a container can be instantiated."""
+    expected_name = "somename"
+    container = BlobContainer(expected_name, MagicMock())
+    assert container.container_name == expected_name
+
+
+def test_container_digest():
+    digester = BlobContainer("", MagicMock())._compute_digest
+
+    # sha1 of some_data.
+    some_data, expected_digest = (
+        b"some_data_123456",
+        "51bea75c0f26998083ef3717a489f2dc05818e8d",
+    )
+    result = digester(BytesIO(some_data))
+    assert result == expected_digest
+
+    with pytest.raises(AttributeError):
+        digester("someundigestabledata")
+
+
+def test_container_put():
+    """Test the logic of container put method"""
+    some_data, expected_digest = (
+        b"some_data_123456",
+        "51bea75c0f26998083ef3717a489f2dc05818e8d",
+    )
+    expected_container_name = "somename"
+    m = MagicMock()
+    m.client.blob_put = MagicMock()
+    container = BlobContainer(expected_container_name, m)
+
+    result = container.put(BytesIO(some_data))
+    assert result == expected_digest
+
+    new_digest = "asdfn"
+    data = BytesIO(some_data)
+    result = container.put(data, digest=new_digest)
+    assert isinstance(result, MagicMock)
+    assert m.client.blob_put.call_count == 2
+    assert m.client.blob_put.call_args.args == (
+        expected_container_name,
+        new_digest,
+        data,
+    )
diff --git a/tests/client/test_connection.py b/tests/client/test_connection.py
new file mode 100644
index 000000000..90b121f2d
--- /dev/null
+++ b/tests/client/test_connection.py
@@ -0,0 +1,169 @@
+import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from urllib3 import Timeout
+
+from crate.client import connect
+from crate.client.connection import Connection
+from crate.client.exceptions import ProgrammingError
+from crate.client.http import Client
+
+from .settings import crate_host
+
+
+def test_lowest_server_version():
+    """
+    Verify the lowest server version is correctly set.
+    """
+    servers = "localhost:4200 localhost:4201 localhost:4202 localhost:4207"
+    infos = [
+        (None, None, "1.0.3"),
+        (None, None, "5.5.2"),
+        (None, None, "6.0.0"),
+        (None, None, "not a version"),
+    ]
+
+    client = Client(servers=servers)
+    client.server_infos = lambda server: infos.pop()
+    connection = connect(client=client)
+    assert (1, 0, 3) == connection.lowest_server_version.version
+
+
+def test_connection_closes_access():
+    """
+    Verify that a connection closes on exit and that it also closes
+    the client.
+    """
+    with patch(
+        "crate.client.connection.Client", spec=Client, return_value=MagicMock()
+    ) as client:
+        conn = connect()
+        conn.close()
+
+        assert conn._closed
+        client.assert_called_once()
+
+        # Should raise an exception if
+        # we try to access a cursor now.
+        with pytest.raises(ProgrammingError):
+            conn.cursor()
+
+        with pytest.raises(ProgrammingError):
+            conn.commit()
+
+
+def test_connection_closes_context_manager():
+    """Verify that the context manager of the client closes the connection"""
+    with patch.object(connect, "close", autospec=True) as close_fn:
+        with connect():
+            pass
+        close_fn.assert_called_once()
+
+
+def test_invalid_server_version():
+    """
+    Verify that when no correct version is set,
+    the default (0, 0, 0) is returned.
+    """
+    client = Client(servers="localhost:4200")
+    client.server_infos = lambda server: (None, None, "No version")
+    connection = connect(client=client)
+    assert (0, 0, 0) == connection.lowest_server_version.version
+
+
+def test_context_manager():
+    """
+    Verify the context manager implementation of `Connection`.
+    """
+    close_method = "crate.client.http.Client.close"
+    with patch(close_method, return_value=MagicMock()) as close_func:
+        with connect("localhost:4200") as conn:
+            assert not conn._closed
+
+        assert conn._closed
+        # Checks that the close method of the client
+        # is called when the connection is closed.
+        close_func.assert_called_once()
+
+
+def test_connection_mock():
+    """
+    Verify that a custom client can be passed.
+
+
+    For testing purposes, it is often useful to replace the client used for
+    communication with the CrateDB server with a stub or mock.
+
+    This can be done by passing an object of the Client class when calling
+    the `connect` method.
+    """
+
+    mock = MagicMock(spec=Client)
+    mock.server_infos.return_value = "localhost:4200", "my server", "0.42.0"
+    connection = connect(crate_host, client=mock)
+
+    assert isinstance(connection, Connection)
+    assert connection.client.server_infos("foo") == (
+        "localhost:4200",
+        "my server",
+        "0.42.0",
+    )
+
+
+def test_default_repr():
+    """
+    Verify default repr dunder method.
+    """
+    conn = connect()
+    assert repr(conn) == ">"
+
+
+def test_with_timezone():
+    """
+    Verify the logic of passing timezone objects to the client.
+
+    The cursor can return timezone-aware `datetime` objects when requested.
+
+    When switching the time zone at runtime on the connection object, only
+    new cursor objects will inherit the new time zone.
+
+    These tests are complementary to timezone `test_cursor`
+    """
+
+    tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
+    connection = connect("localhost:4200", time_zone=tz_mst)
+    cursor = connection.cursor()
+
+    assert cursor.time_zone.tzname(None) == "MST"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=25200)
+
+    connection.time_zone = datetime.timezone.utc
+    cursor = connection.cursor()
+    assert cursor.time_zone.tzname(None) == "UTC"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(0)
+
+
+def test_timeout_float():
+    """
+    Verify setting the timeout value as a scalar (float) works.
+    """
+    with connect("localhost:4200", timeout=2.42) as conn:
+        assert conn.client._pool_kw["timeout"] == 2.42
+
+
+def test_timeout_string():
+    """
+    Verify setting the timeout value as a scalar (string) works.
+    """
+    with connect("localhost:4200", timeout="2.42") as conn:
+        assert conn.client._pool_kw["timeout"] == 2.42
+
+
+def test_timeout_object():
+    """
+    Verify setting the timeout value as a Timeout object works.
+    """
+    timeout = Timeout(connect=2.42, read=0.01)
+    with connect("localhost:4200", timeout=timeout) as conn:
+        assert conn.client._pool_kw["timeout"] == timeout
diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py
new file mode 100644
index 000000000..6fb49c204
--- /dev/null
+++ b/tests/client/test_cursor.py
@@ -0,0 +1,767 @@
+# -*- coding: utf-8; -*-
+#
+# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
+# license agreements.  See the NOTICE file distributed with this work for
+# additional information regarding copyright ownership.  Crate licenses
+# this file to you under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.  You may
+# obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License 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.
+#
+# However, if you have executed another commercial license agreement
+# with Crate these terms will supersede the license and you may use the
+# software solely pursuant to the terms of the relevant commercial agreement.
+
+import datetime
+import zoneinfo
+from ipaddress import IPv4Address
+from unittest import mock
+
+import pytest
+import pytz
+
+from crate.client import connect
+from crate.client.converter import DataType, DefaultTypeConverter
+from crate.client.exceptions import ProgrammingError
+
+
+def test_cursor_fetch(mocked_connection):
+    """Verify fetchone/fetchmany behaviour"""
+    cursor = mocked_connection.cursor()
+    response = {
+        "col_types": [4, 5],
+        "cols": ["name", "address"],
+        "rows": [["foo", "10.10.10.1"], ["bar", "10.10.10.2"]],
+        "rowcount": 2,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        assert cursor.fetchone() == ["foo", "10.10.10.1"]
+        assert cursor.fetchmany() == [
+            ["bar", "10.10.10.2"],
+        ]
+
+
+def test_cursor_description(mocked_connection):
+    cursor = mocked_connection.cursor()
+    response = {
+        "col_types": [4, 5],
+        "cols": ["name", "address"],
+        "rows": [["foo", "10.10.10.1"], ["bar", "10.10.10.2"]],
+        "rowcount": 2,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        assert len(cursor.description) == len(response["cols"])
+        assert len(cursor.description[0]) == 7  # It's 7 by convention.
+        for expected_name, name in zip(
+            response["cols"], cursor.description, strict=False
+        ):
+            assert expected_name == name[0]
+
+        cursor.close()
+
+        assert cursor.description is None
+
+
+def test_cursor_rowcount(mocked_connection):
+    """Verify the logic of cursor.rowcount"""
+    cursor = mocked_connection.cursor()
+    response = {
+        "col_types": [4, 5],
+        "cols": ["name", "address"],
+        "rows": [["foo", "10.10.10.1"], ["bar", "10.10.10.2"]],
+        "rowcount": 2,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        assert cursor.rowcount == len(response["rows"])
+
+        cursor._result = None
+        assert cursor.rowcount == -1
+
+        cursor.execute("")
+        cursor._result = {}
+        assert cursor.rowcount == -1
+
+        cursor.execute("")
+        cursor.close()
+        assert cursor.rowcount == -1
+
+
+def test_cursor_executemany(mocked_connection):
+    """
+    Verify executemany.
+    """
+    response = {
+        "col_types": [],
+        "cols": [],
+        "duration": 123,
+        "results": [{"rowcount": 1, "rowcount:": 1}],
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor = mocked_connection.cursor()
+        result = cursor.executemany("some sql", ())
+
+        assert isinstance(result, list)
+        assert response["results"] == result
+
+
+def test_executemany_with_named_params(mocked_connection):
+    """
+    Verify that executemany() translates pyformat %(name)s placeholders to
+    positional $N markers and converts each dict row to a positional list.
+
+    """
+    response = {
+        "col_types": [],
+        "cols": [],
+        "duration": 123,
+        "results": [{"rowcount": 1}, {"rowcount": 1}],
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor = mocked_connection.cursor()
+        cursor.executemany(
+            "INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
+            [
+                {"name": "Arthur", "age": 42},
+                {"name": "Bill", "age": 35},
+            ],
+        )
+        sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
+        assert sql == "INSERT INTO characters (name, age) VALUES ($1, $2)"
+        assert bulk_args == [["Arthur", 42], ["Bill", 35]]
+
+
+def test_executemany_with_named_params_missing_key(mocked_connection):
+    """
+    Verify that executemany() raises ProgrammingError when a row is missing a
+    key that appears as a placeholder in the SQL.
+    """
+    cursor = mocked_connection.cursor()
+    with pytest.raises(
+        ProgrammingError, match="Named parameter 'age' not found"
+    ):
+        cursor.executemany(
+            "INSERT INTO characters (name, age) VALUES (%(name)s, %(age)s)",
+            [
+                {"name": "Arthur", "age": 42},
+                {"name": "Bill"},  # missing 'age'
+            ],
+        )
+    mocked_connection.client.sql.assert_not_called()
+
+
+def test_executemany_with_named_params_repeated(mocked_connection):
+    """
+    Verify that a placeholder name used multiple times in the SQL maps to the
+    same $N position in every occurrence, and the value appears only once in
+    each row's positional list.
+    """
+    response = {
+        "col_types": [],
+        "cols": [],
+        "duration": 123,
+        "results": [{"rowcount": 1}, {"rowcount": 1}],
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor = mocked_connection.cursor()
+        cursor.executemany(
+            "INSERT INTO t (a, b) VALUES (%(x)s, %(x)s)",
+            [{"x": 1}, {"x": 2}],
+        )
+        sql, _params, bulk_args = mocked_connection.client.sql.call_args[0]
+        assert sql == "INSERT INTO t (a, b) VALUES ($1, $1)"
+        assert bulk_args == [[1], [2]]
+
+
+def test_executemany_with_mixed_param_types(mocked_connection):
+    """
+    Verify that executemany() raises a clear ProgrammingError when the
+    parameter sequence mixes dicts and non-dicts while the SQL uses pyformat.
+    """
+    cursor = mocked_connection.cursor()
+    with pytest.raises(
+        ProgrammingError, match="All bulk parameter rows must be dicts"
+    ):
+        cursor.executemany(
+            "INSERT INTO characters (name) VALUES (%(name)s)",
+            [{"name": "Arthur"}, ["Trillian"]],  # second row is a list
+        )
+    mocked_connection.client.sql.assert_not_called()
+
+
+def test_create_with_timezone_as_datetime_object(mocked_connection):
+    """
+    The cursor can return timezone-aware `datetime` objects when requested.
+    Switching the time zone at runtime on the cursor object is possible.
+    Here: Use a `datetime.timezone` instance.
+    """
+    tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
+    cursor = mocked_connection.cursor(time_zone=tz_mst)
+
+    assert cursor.time_zone.tzname(None) == "MST"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=25200)
+
+    cursor.time_zone = datetime.timezone.utc
+
+    assert cursor.time_zone.tzname(None) == "UTC"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(0)
+
+
+def test_create_with_timezone_as_pytz_object(mocked_connection):
+    """
+    The cursor can return timezone-aware `datetime` objects when requested.
+    Here: Use a `pytz.timezone` instance.
+    """
+
+    cursor = mocked_connection.cursor(
+        time_zone=pytz.timezone("Australia/Sydney")
+    )
+    assert cursor.time_zone.tzname(None) == "Australia/Sydney"
+
+    # Apparently, when using `pytz`, the timezone object does not return
+    # an offset. Nevertheless, it works, as demonstrated per doctest in
+    # `cursor.txt`.
+    assert cursor.time_zone.utcoffset(None) is None
+
+
+def test_create_with_timezone_as_zoneinfo_object(mocked_connection):
+    """
+    The cursor can return timezone-aware `datetime` objects when requested.
+    Here: Use a `zoneinfo.ZoneInfo` instance.
+    """
+    cursor = mocked_connection.cursor(
+        time_zone=zoneinfo.ZoneInfo("Australia/Sydney")
+    )
+    assert cursor.time_zone.key == "Australia/Sydney"
+
+
+def test_create_with_timezone_as_utc_offset_success(mocked_connection):
+    """
+    Verify the cursor can return timezone-aware `datetime` objects when
+    requested.
+
+    Here: Use a UTC offset in string format.
+    """
+
+    cursor = mocked_connection.cursor(time_zone="+0530")
+    assert cursor.time_zone.tzname(None) == "+0530"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=19800)
+
+    cursor = mocked_connection.cursor(time_zone="-1145")
+    assert cursor.time_zone.tzname(None) == "-1145"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(
+        days=-1, seconds=44100
+    )
+
+
+def test_create_with_timezone_as_utc_offset_failure(mocked_connection):
+    """
+    Verify the cursor trips when trying to use invalid UTC offset strings.
+    """
+
+    with pytest.raises(ValueError) as err:
+        mocked_connection.cursor(time_zone="foobar")
+        assert err == "Time zone 'foobar' is given in invalid UTC offset format"
+
+    with pytest.raises(ValueError) as err:
+        mocked_connection.cursor(time_zone="+abcd")
+        assert (
+            err
+            == "Time zone '+abcd' is given in invalid UTC offset format: "
+            + "invalid literal for int() with base 10: '+ab'"
+        )
+
+
+def test_create_with_timezone_connection_cursor_precedence(mocked_connection):
+    """
+    Verify that the time zone specified on the cursor object instance
+    takes precedence over the one specified on the connection instance.
+    """
+    connection = connect(
+        client=mocked_connection.client,
+        time_zone=pytz.timezone("Australia/Sydney"),
+    )
+    cursor = connection.cursor(time_zone="+0530")
+    assert cursor.time_zone.tzname(None) == "+0530"
+    assert cursor.time_zone.utcoffset(None) == datetime.timedelta(seconds=19800)
+
+
+def test_execute_with_args(mocked_connection):
+    """
+    Verify that `cursor.execute` is called with the right parameters.
+    """
+    cursor = mocked_connection.cursor()
+    statement = "select * from locations where position = ?"
+    cursor.execute(statement, 1)
+    mocked_connection.client.sql.assert_called_once_with(statement, 1, None)
+
+
+def test_execute_with_bulk_args(mocked_connection):
+    """
+    Verify that `cursor.execute` is called with the right parameters
+    when passing `bulk_parameters`.
+    """
+    cursor = mocked_connection.cursor()
+    statement = "select * from locations where position = ?"
+    cursor.execute(statement, bulk_parameters=[[1]])
+    mocked_connection.client.sql.assert_called_once_with(statement, None, [[1]])
+
+
+def test_execute_with_pyformat_sql_and_bulk_parameters(mocked_connection):
+    """
+    cursor.execute() converts %(name)s SQL to $N when bulk_parameters is
+    provided. Rows are already positional; only the SQL needs conversion.
+    """
+    cursor = mocked_connection.cursor()
+    sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
+    bulk = [[1, "hello"], [2, "world"]]
+    cursor.execute(sql, bulk_parameters=bulk)
+    mocked_connection.client.sql.assert_called_once_with(
+        "INSERT INTO t (id, val) VALUES ($1, $2)", None, bulk
+    )
+
+
+def test_execute_with_pyformat_sql_and_dict_bulk_parameters(mocked_connection):
+    """
+    cursor.execute() with pyformat SQL and dict-format bulk_parameters converts
+    both the SQL template (%(x)s → $N) and the rows (dicts → positional lists).
+    """
+    cursor = mocked_connection.cursor()
+    sql = "INSERT INTO t (id, val) VALUES (%(id)s, %(val)s)"
+    bulk = [{"id": 1, "val": "hello"}, {"id": 2, "val": "world"}]
+    cursor.execute(sql, bulk_parameters=bulk)
+    mocked_connection.client.sql.assert_called_once_with(
+        "INSERT INTO t (id, val) VALUES ($1, $2)",
+        None,
+        [[1, "hello"], [2, "world"]],
+    )
+
+
+def test_execute_with_dict_bulk_parameters_mixed_types_raises(
+    mocked_connection,
+):
+    """
+    cursor.execute() raises ProgrammingError when bulk_parameters mixes
+    dict and non-dict rows with pyformat SQL.
+    """
+    cursor = mocked_connection.cursor()
+    with pytest.raises(
+        ProgrammingError, match="All bulk parameter rows must be dicts"
+    ):
+        cursor.execute(
+            "INSERT INTO t (id) VALUES (%(id)s)",
+            bulk_parameters=[{"id": 1}, [2]],
+        )
+    mocked_connection.client.sql.assert_not_called()
+
+
+def test_execute_with_pyformat_sql_and_bulk_parameters_no_placeholders(
+    mocked_connection,
+):
+    """
+    SQL without %(name)s placeholders is passed through unchanged
+    even when bulk_parameters is provided.
+    """
+    cursor = mocked_connection.cursor()
+    sql = "INSERT INTO t (id, val) VALUES (?, ?)"
+    bulk = [[1, "hello"], [2, "world"]]
+    cursor.execute(sql, bulk_parameters=bulk)
+    mocked_connection.client.sql.assert_called_once_with(sql, None, bulk)
+
+
+def test_execute_custom_converter(mocked_connection):
+    """
+    Verify that a custom converter is correctly applied when passed to a cursor.
+    """
+    # Extends the DefaultTypeConverter
+    converter = DefaultTypeConverter(
+        {
+            DataType.BIT: lambda value: (
+                value is not None and int(value[2:-1], 2) or None
+            )
+        }
+    )
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [4, 5, 11, 25],
+        "cols": ["name", "address", "timestamp", "bitmask"],
+        "rows": [
+            ["foo", "10.10.10.1", 1658167836758, "B'0110'"],
+            [None, None, None, None],
+        ],
+        "rowcount": 1,
+        "duration": 123,
+    }
+
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        result = cursor.fetchall()
+
+        assert result == [
+            [
+                "foo",
+                IPv4Address("10.10.10.1"),
+                datetime.datetime(
+                    2022,
+                    7,
+                    18,
+                    18,
+                    10,
+                    36,
+                    758000,
+                    tzinfo=datetime.timezone.utc,
+                ),
+                6,
+            ],
+            [None, None, None, None],
+        ]
+
+
+def test_execute_time_converter(mocked_connection):
+    """
+    Verify that CrateDB's TIMETZ wire format
+    ``[microseconds, tz_offset_seconds]`` is decoded to a ``datetime.time``
+    object by ``DefaultTypeConverter``.
+    """
+    converter = DefaultTypeConverter()
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [20],
+        "cols": ["t"],
+        "rows": [
+            [[45045000000, 0]],       # 12:30:45 UTC
+            [[45045123456, 7200]],    # 12:30:45.123456 +02:00
+            [None],
+        ],
+        "rowcount": 3,
+        "duration": 1,
+    }
+
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        result = cursor.fetchall()
+
+    assert result == [
+        [datetime.time(12, 30, 45, 0,
+                       tzinfo=datetime.timezone.utc)],
+        [datetime.time(12, 30, 45, 123456,
+                       tzinfo=datetime.timezone(datetime.timedelta(hours=2)))],
+        [None],
+    ]
+
+
+def test_execute_with_converter_and_invalid_data_type(mocked_connection):
+    converter = DefaultTypeConverter()
+
+    # Create a `Cursor` object with converter.
+    cursor = mocked_connection.cursor(converter=converter)
+
+    response = {
+        "col_types": [999],
+        "cols": ["foo"],
+        "rows": [
+            ["n/a"],
+        ],
+        "rowcount": 1,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        with pytest.raises(ValueError) as e:
+            cursor.fetchone()
+            assert e.exception.args == "999 is not a valid DataType"
+
+
+def test_execute_array_with_converter(mocked_connection):
+    converter = DefaultTypeConverter()
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [4, [100, 5]],
+        "cols": ["name", "address"],
+        "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]],
+        "rowcount": 1,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        result = cursor.fetchone()
+
+        assert result == [
+            "foo",
+            [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")],
+        ]
+
+
+def test_execute_array_with_converter_invalid(mocked_connection):
+    converter = DefaultTypeConverter()
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [4, [6, 5]],
+        "cols": ["name", "address"],
+        "rows": [["foo", ["10.10.10.1", "10.10.10.2"]]],
+        "rowcount": 1,
+        "duration": 123,
+    }
+    # Converting collections only works for `ARRAY`s. (ID=100).
+    # When using `DOUBLE` (ID=6), it should raise an Exception.
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        with pytest.raises(ValueError) as e:
+            cursor.fetchone()
+            assert e.exception.args == (
+                "Data type 6 is not implemented as collection type"
+            )
+
+
+def test_execute_nested_array_with_converter(mocked_connection):
+    converter = DefaultTypeConverter()
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [4, [100, [100, 5]]],
+        "cols": ["name", "address_buckets"],
+        "rows": [
+            [
+                "foo",
+                [
+                    ["10.10.10.1", "10.10.10.2"],
+                    ["10.10.10.3"],
+                    [],
+                    None,
+                ],
+            ]
+        ],
+        "rowcount": 1,
+        "duration": 123,
+    }
+
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.execute("")
+        result = cursor.fetchone()
+        assert result == [
+            "foo",
+            [
+                [IPv4Address("10.10.10.1"), IPv4Address("10.10.10.2")],
+                [IPv4Address("10.10.10.3")],
+                [],
+                None,
+            ],
+        ]
+
+
+def test_executemany_with_converter(mocked_connection):
+    converter = DefaultTypeConverter()
+    cursor = mocked_connection.cursor(converter=converter)
+    response = {
+        "col_types": [4, 5],
+        "cols": ["name", "address"],
+        "rows": [["foo", "10.10.10.1"]],
+        "rowcount": 1,
+        "duration": 123,
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        cursor.executemany("", [])
+        result = cursor.fetchall()
+
+        # ``executemany()`` is not intended to be used with statements
+        # returning result sets. The result will always be empty.
+        assert result == []
+
+
+def test_execute_with_timezone(mocked_connection):
+    # Create a `Cursor` object with `time_zone`.
+    tz_mst = datetime.timezone(datetime.timedelta(hours=7), name="MST")
+    cursor = mocked_connection.cursor(time_zone=tz_mst)
+
+    # Make up a response using CrateDB data type `TIMESTAMP`.
+    response = {
+        "col_types": [4, 11],
+        "cols": ["name", "timestamp"],
+        "rows": [
+            ["foo", 1658167836758],
+            [None, None],
+        ],
+    }
+    with mock.patch.object(
+        mocked_connection.client, "sql", return_value=response
+    ):
+        # Run execution and verify the returned `datetime` object is
+        # timezone-aware, using the designated timezone object.
+        cursor.execute("")
+        result = cursor.fetchall()
+        assert result == [
+            [
+                "foo",
+                datetime.datetime(
+                    2022,
+                    7,
+                    19,
+                    1,
+                    10,
+                    36,
+                    758000,
+                    tzinfo=datetime.timezone(
+                        datetime.timedelta(seconds=25200), "MST"
+                    ),
+                ),
+            ],
+            [
+                None,
+                None,
+            ],
+        ]
+
+        assert result[0][1].tzname() == "MST"
+
+        # Change timezone and verify the returned `datetime` object is using it.
+        cursor.time_zone = datetime.timezone.utc
+        cursor.execute("")
+        result = cursor.fetchall()
+        assert result == [
+            [
+                "foo",
+                datetime.datetime(
+                    2022,
+                    7,
+                    18,
+                    18,
+                    10,
+                    36,
+                    758000,
+                    tzinfo=datetime.timezone.utc,
+                ),
+            ],
+            [
+                None,
+                None,
+            ],
+        ]
+
+        assert result[0][1].tzname() == "UTC"
+
+
+def test_execute_with_named_params(mocked_connection):
+    """
+    Verify that named %(name)s parameters are converted to positional ? markers
+    and the values are passed as an ordered list.
+    """
+    cursor = mocked_connection.cursor()
+    cursor.execute(
+        "SELECT * FROM t WHERE a = %(a)s AND b = %(b)s",
+        {"a": 1, "b": 2},
+    )
+    mocked_connection.client.sql.assert_called_once_with(
+        "SELECT * FROM t WHERE a = $1 AND b = $2", [1, 2], None
+    )
+
+
+def test_execute_with_named_params_repeated(mocked_connection):
+    """
+    Verify that a parameter name used multiple times in the SQL is resolved
+    correctly each time it appears.
+    """
+    cursor = mocked_connection.cursor()
+    cursor.execute("SELECT %(x)s, %(x)s", {"x": 42})
+    mocked_connection.client.sql.assert_called_once_with(
+        "SELECT $1, $1", [42], None
+    )
+
+
+def test_execute_with_named_params_missing(mocked_connection):
+    """
+    Verify that a ProgrammingError is raised when a placeholder name is absent
+    from the parameters dict, and that the client is never called.
+    """
+    cursor = mocked_connection.cursor()
+    with pytest.raises(ProgrammingError, match="Named parameter 'z' not found"):
+        cursor.execute("SELECT %(z)s", {"a": 1})
+    mocked_connection.client.sql.assert_not_called()
+
+
+def test_execute_with_named_params_non_identifier_keys(mocked_connection):
+    """
+    Verify that %(name)s placeholders whose name contains characters outside
+    [a-zA-Z0-9_] are still converted to positional $N markers.
+
+    """
+    cursor = mocked_connection.cursor()
+
+    cursor.execute(
+        "UPDATE characters SET data['x'] = %(data['x'])s WHERE name = %(name)s",
+        {"data['x']": 42, "name": "Berlin"},
+    )
+    sql, args, _ = mocked_connection.client.sql.call_args[0]
+    assert "%" not in sql
+    assert sql == "UPDATE characters SET data['x'] = $1 WHERE name = $2"
+    assert args == [42, "Berlin"]
+
+
+def test_cursor_close(mocked_connection):
+    """
+    Verify that a cursor is not closed if not specifically closed.
+    """
+
+    cursor = mocked_connection.cursor()
+    cursor.execute("")
+    assert cursor._closed is False
+
+    cursor.close()
+
+    assert cursor._closed is True
+    assert not cursor._result
+    assert cursor.duration == -1
+
+    with pytest.raises(ProgrammingError, match="Connection closed"):
+        mocked_connection.close()
+        cursor.execute("")
+
+
+def test_cursor_closes_access(mocked_connection):
+    """
+    Verify that a cursor cannot be used once it is closed.
+    """
+
+    cursor = mocked_connection.cursor()
+    cursor.execute("")
+
+    cursor.close()
+
+    with pytest.raises(ProgrammingError):
+        cursor.execute("s")
diff --git a/tests/client/test_exceptions.py b/tests/client/test_exceptions.py
new file mode 100644
index 000000000..8efd8c8da
--- /dev/null
+++ b/tests/client/test_exceptions.py
@@ -0,0 +1,17 @@
+from crate.client import Error
+from crate.client.exceptions import BlobException
+
+
+def test_error_with_msg():
+    err = Error("foo")
+    assert str(err) == "foo"
+
+
+def test_error_with_error_trace():
+    err = Error("foo", error_trace="### TRACE ###")
+    assert str(err), "foo\n### TRACE ###"
+
+
+def test_blob_exception():
+    err = BlobException(table="sometable", digest="somedigest")
+    assert str(err) == "BlobException('sometable/somedigest')"
diff --git a/tests/client/test_http.py b/tests/client/test_http.py
new file mode 100644
index 000000000..573fca682
--- /dev/null
+++ b/tests/client/test_http.py
@@ -0,0 +1,860 @@
+# -*- coding: utf-8; -*-
+#
+# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
+# license agreements.  See the NOTICE file distributed with this work for
+# additional information regarding copyright ownership.  Crate licenses
+# this file to you under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.  You may
+# obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License 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.
+#
+# However, if you have executed another commercial license agreement
+# with Crate these terms will supersede the license and you may use the
+# software solely pursuant to the terms of the relevant commercial agreement.
+
+import gzip
+import json
+import os
+import queue
+import random
+import socket
+import time
+from base64 import b64decode
+from http.server import BaseHTTPRequestHandler
+from threading import Event, Thread
+from unittest.mock import MagicMock, patch
+from urllib.parse import parse_qs, urlparse
+
+import certifi
+import pytest
+import urllib3.exceptions
+
+from crate.client.connection import connect
+from crate.client.exceptions import (
+    BlobLocationNotFoundException,
+    ConnectionError,
+    DigestNotFoundException,
+    IntegrityError,
+    ProgrammingError,
+)
+from crate.client.http import (
+    Client,
+    _get_socket_opts,
+    _remove_certs_for_non_https,
+)
+from tests.conftest import REQUEST_PATH, fake_response
+
+mocked_request = MagicMock(spec=urllib3.response.HTTPResponse)
+
+
+def fake_redirect(location: str) -> MagicMock:
+    m = fake_response(307)
+    m.get_redirect_location.return_value = location
+    return m
+
+
+def duplicate_key_exception():
+    r = fake_response(409, "Conflict")
+    r.data = json.dumps(
+        {
+            "error": {
+                "code": 4091,
+                "message": "DuplicateKeyException[A document with the "
+                "same primary key exists already]",
+            }
+        }
+    ).encode()
+    return r
+
+
+def fail_sometimes(*args, **kwargs) -> MagicMock:
+    """
+    Function that fails with a 50% chance. It either returns a successful mocked
+    response or raises an urllib3 exception.
+    """
+    if random.randint(1, 10) % 2:
+        raise urllib3.exceptions.MaxRetryError(None, "/_sql", "")
+    return fake_response(200)
+
+
+def test_connection_reset_exception():
+    """
+    Verify that a HTTP 503 status code response raises an exception.
+    """
+
+    expected_exception_msg = (
+        "No more Servers available, exception"
+        " from last server: Service Unavailable"
+    )
+    with patch(
+        REQUEST_PATH,
+        side_effect=[
+            fake_response(200),
+            fake_response(104, "Connection reset by peer"),
+            fake_response(503, "Service Unavailable"),
+        ],
+    ):
+        client = Client(servers="localhost:4200")
+        client.sql("select 1")  # 200 response
+        client.sql("select 2")  # 104 response
+        assert list(client._active_servers) == ["http://localhost:4200"]
+
+        with pytest.raises(ProgrammingError, match=expected_exception_msg):
+            client.sql("select 3")  # 503 response
+        assert not client._active_servers
+
+
+def test_no_connection_exception():
+    """
+    Verify that when no connection can be made to the server,
+    a `ConnectionError` is raised.
+    """
+    client = Client(servers="localhost:9999")
+    with pytest.raises(ConnectionError):
+        client.sql("")
+
+
+def test_http_error_is_re_raised():
+    """
+    Verify that when calling `REQUEST` if any error occurs,
+    a `ProgrammingError` exception is raised _from_ that exception.
+    """
+    client = Client()
+
+    exception_msg = "some exception did happen"
+    with patch(REQUEST_PATH, side_effect=Exception(exception_msg)):
+        with pytest.raises(ProgrammingError, match=exception_msg):
+            client.sql("select foo")
+
+
+def test_programming_error_contains_http_error_response_content():
+    """
+    Verify that when calling `REQUEST` if any error occurs,
+    the raised `ProgrammingError` exception
+    contains the error message from the original error.
+    """
+    expected_msg = "this message should appear"
+    with patch(REQUEST_PATH, side_effect=Exception(expected_msg)):
+        client = Client()
+        with pytest.raises(ProgrammingError, match=expected_msg):
+            client.sql("select 1")
+
+
+def test_connect():
+    """
+    Verify the correctness of `server` parameter when `Client` is instantiated.
+    """
+    client = Client(servers="localhost:4200 localhost:4201")
+    assert client._active_servers == [
+        "http://localhost:4200",
+        "http://localhost:4201",
+    ]
+
+    # By default, it's http://127.0.0.1:4200
+    client = Client(servers=None)
+    assert client._active_servers == ["http://127.0.0.1:4200"]
+
+    with pytest.raises(TypeError, match="expected string or bytes"):
+        Client(servers=[123, "127.0.0.1:4201", False])
+
+
+def test_redirect_handling():
+    """
+    Verify that when a redirect happens, that redirect uri
+    gets added to the server pool.
+    """
+    with patch(
+        REQUEST_PATH, return_value=fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
+    ):
+        client = Client(servers="localhost:4200")
+
+        # Don't try to print the exception or use `match`, otherwise
+        # the recursion will not be short-circuited and it will hang.
+        with pytest.raises(ProgrammingError):
+            # 4201 gets added to serverpool but isn't available
+            # that's why we run into an infinite recursion
+            # exception message is: maximum recursion depth exceeded
+            client.blob_get("blobs", "fake_digest")
+
+        assert sorted(client.server_pool.keys()) == [
+            "http://localhost:4200",
+            "http://localhost:4201",
+        ]
+
+    # the new non-https server must not contain any SSL only arguments
+    # regression test for:
+    # - https://github.com/crate/crate-python/issues/179
+    # - https://github.com/crate/crate-python/issues/180
+
+    # Remove some optional server pool parameters added by `urllib3-future`.
+    conn_kw = client.server_pool["http://localhost:4201"].pool.conn_kw
+    conn_kw.pop("keepalive_delay", None)
+    conn_kw.pop("resolver", None)
+
+    assert conn_kw == {"socket_options": _get_socket_opts(keepalive=True)}
+
+
+@pytest.mark.parametrize("method,args,success_status", [
+    ("blob_exists", ("blobs", "fake_digest"), 200),
+    ("blob_put",    ("blobs", "fake_digest", b"data"), 201),
+    ("blob_del",    ("blobs", "fake_digest"), 204),
+    ("blob_get",    ("blobs", "fake_digest"), 200),
+])
+def test_redirect_blob_preserves_basic_auth(method, args, success_status):
+    """
+    Verify Basic HTTP auth credentials are forwarded when following blob
+    endpoint redirects.
+    """
+    redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
+    success = fake_response(success_status)
+
+    with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
+        client = Client(
+            servers="localhost:4200", username="admin", password="secret"
+        )
+        getattr(client, method)(*args)
+
+    assert mock_req.call_count == 2
+    for call in mock_req.call_args_list:
+        assert call.kwargs.get("username") == "admin"
+        assert call.kwargs.get("password") == "secret"
+
+
+def test_redirect_blob_preserves_jwt_auth():
+    """
+    Verify JWT bearer token is forwarded when following blob endpoint redirects.
+    """
+    redirect = fake_redirect("http://localhost:4201/_blobs/blobs/fake_digest")
+    success = fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=[redirect, success]) as mock_req:
+        client = Client(servers="localhost:4200", jwt_token="my.jwt.token")
+        client.blob_exists("blobs", "fake_digest")
+
+    assert mock_req.call_count == 2
+    for call in mock_req.call_args_list:
+        assert call.kwargs.get("jwt_token") == "my.jwt.token"
+
+
+def test_server_infos():
+    """
+    Verify that when a `MaxRetryError` is raised, a `ConnectionError` is raised.
+    """
+    error = urllib3.exceptions.MaxRetryError(None, "/")
+    with patch(REQUEST_PATH, side_effect=error):
+        client = Client(servers="localhost:4200 localhost:4201")
+        with pytest.raises(ConnectionError):
+            client.server_infos("http://localhost:4200")
+
+
+def test_server_infos_401():
+    """
+    Verify that when a 401 status code is returned, a `ProgrammingError`
+    is raised.
+    """
+    response = fake_response(401, "Unauthorized", "text/html")
+    with patch(REQUEST_PATH, return_value=response):
+        client = Client(servers="localhost:4200")
+        with pytest.raises(
+            ProgrammingError, match="401 Client Error: Unauthorized"
+        ):
+            client.server_infos("http://localhost:4200")
+
+
+def test_credentials_derived():
+    """
+    Tests that Client correctly derives username and password from the url.
+    """
+    expected_user = "someuser"
+    expected_password = "somepassword"
+    client = Client(
+        f"http://{expected_user}:{expected_password}@localhost:4200"
+    )
+
+    assert client.username == expected_user
+    assert client.password == expected_password
+
+    with patch("crate.client.http.urlparse", side_effect=Exception):
+        Client("")
+
+    actual_username = "actual_username"
+    client = Client(
+        username=actual_username,
+        servers=[f"http://{expected_user}:{expected_password}@localhost:4200"],
+    )
+    assert client.username == actual_username
+    assert client.password is None
+
+    actual_password = "actual_password"
+    client = Client(
+        password=actual_password,
+        servers=[f"http://{expected_user}:{expected_password}@localhost:4200"],
+    )
+    assert client.username == expected_user
+    assert client.password == expected_password
+
+
+def test_bad_bulk_400():
+    """
+    Verify that a 400 response when doing a bulk request raises
+    a `ProgrammingException` with the error message of the response object's
+    key `error_message`, several error messages can be returned by the database.
+    """
+    response = fake_response(400, "Bad Request")
+    response.data = json.dumps(
+        {
+            "results": [
+                {"rowcount": 1},
+                {"error_message": "an error occurred"},
+                {"error_message": "another error"},
+                {"error_message": ""},
+                {"error_message": None},
+            ]
+        }
+    ).encode()
+
+    client = Client(servers="localhost:4200")
+    with patch(REQUEST_PATH, return_value=response):
+        with pytest.raises(
+            ProgrammingError, match="an error occurred\nanother error"
+        ):
+            client.sql(
+                "Insert into users (name) values(?)",
+                bulk_parameters=[["douglas"], ["monthy"]],
+            )
+
+
+def test_socket_options_contain_keepalive():
+    """
+    Verify that KEEPALIVE options are present at `socket_options`
+    """
+    server = "http://localhost:4200"
+    client = Client(servers=server)
+    conn_kw = client.server_pool[server].pool.conn_kw
+    assert (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) in conn_kw[
+        "socket_options"
+    ]
+
+
+def test_duplicate_key_error():
+    """
+    Verify that an `IntegrityError` is raised on duplicate key errors,
+    instead of the more general `ProgrammingError`.
+    """
+    expected_error_msg = (
+        r"DuplicateKeyException\[A document with "
+        r"the same primary key exists already\]"
+    )
+    with patch(REQUEST_PATH, return_value=duplicate_key_exception()):
+        client = Client(servers="localhost:4200")
+        with pytest.raises(IntegrityError, match=expected_error_msg):
+            client.sql("INSERT INTO testdrive (foo) VALUES (42)")
+
+
+@patch(REQUEST_PATH, fail_sometimes)
+def test_client_multithreaded():
+    """
+    Verify client multithreading using a pool of 5 Threads to emit commands to
+     the multiple servers through one Client-instance.
+
+    Checks if the number of servers in _inactive_servers and _active_servers
+    always equals the number of servers initially given.
+
+    Note:
+        This test is probabilistic and does not ensure that the
+        client is indeed thread-safe in all cases, it can only show that it
+        withstands this scenario.
+
+    """
+    servers = [
+        "127.0.0.1:44209",
+        "127.0.0.2:44209",
+        "127.0.0.3:44209",
+    ]
+    num_threads = 5
+    num_commands = 1000
+    thread_timeout = 10.0  # seconds
+
+    gate = Event()
+    error_queue = queue.Queue()
+
+    client = Client(servers)
+    client.retry_interval = 0.2  # faster retry
+
+    def worker():
+        """
+        Worker that sends many requests, if the `num_server` is not the
+        expected value at some point, an assertion will be added to the shared
+        error queue.
+        """
+        gate.wait()  # wait for the others
+        expected_num_servers = len(servers)
+        for _ in range(num_commands):
+            try:
+                client.sql("select name from sys.cluster")
+            except ConnectionError:
+                # Sometimes it will fail.
+                pass
+            try:
+                with client._lock:
+                    num_servers = len(client._active_servers) + len(
+                        client._inactive_servers
+                    )
+                    assert num_servers == expected_num_servers, (
+                        f"expected {expected_num_servers} but got {num_servers}"
+                    )
+            except AssertionError as e:
+                error_queue.put(e)
+
+    threads = [Thread(target=worker, name=str(i)) for i in range(num_threads)]
+
+    for thread in threads:
+        thread.start()
+
+    gate.set()
+
+    for t in threads:
+        t.join(timeout=thread_timeout)
+
+    # If any thread is still alive after the timeout, consider it a failure.
+    alive = [t.name for t in threads if t.is_alive()]
+    if alive:
+        pytest.fail(f"Threads did not finish within {thread_timeout}s: {alive}")
+
+    if not error_queue.empty():
+        # If an error happened, consider it a failure as well.
+        first_error_trace = error_queue.get(block=False)
+        pytest.fail(first_error_trace)
+
+
+def test_client_params():
+    """
+    Verify client parameters translate correctly to query parameters.
+    """
+    client = Client(["127.0.0.1:4200"], error_trace=True)
+    parsed = urlparse(client.path)
+    params = parse_qs(parsed.query)
+
+    assert params["error_trace"] == ["true"]
+    assert params["types"] == ["true"]
+
+    client = Client(["127.0.0.1:4200"])
+    parsed = urlparse(client.path)
+    params = parse_qs(parsed.query)
+
+    # Default is False
+    assert "error_trace" not in params
+    assert params["types"] == ["true"]
+
+    assert "/_sql?" in client.path
+
+
+def test_client_ca():
+    """
+    Verify that if env variable `REQUESTS_CA_BUNDLE` is set,  certs are
+    loaded into the pool.
+    """
+    with patch.dict(os.environ, {"REQUEST_PATH": certifi.where()}, clear=True):
+        client = Client("http://127.0.0.1:4200")
+        assert "ca_certs" in client._pool_kw
+
+
+def test_client_blob_put():
+    """Verifies the handling of put requests to CrateDB"""
+    expected_table = "sometable"
+    expected_digest = "somedigest"
+    expected_data = b"data"
+    with patch(REQUEST_PATH, return_value=fake_response(201)) as f:
+        created = Client("").blob_put(
+            expected_table, expected_digest, expected_data
+        )
+        assert f.call_args[0][0] == "PUT"
+        assert (
+            f.call_args[0][1] == f"/_blobs/{expected_table}/{expected_digest}"
+        )
+        assert created is True
+
+    with patch(REQUEST_PATH, return_value=fake_response(409)):
+        created = Client("").blob_put(
+            expected_table, expected_digest, expected_data
+        )
+        assert created is False
+
+    with patch(REQUEST_PATH, return_value=fake_response(400)):
+        with pytest.raises(BlobLocationNotFoundException):
+            Client("").blob_put(expected_table, expected_digest, expected_data)
+
+    response = fake_response(402)
+    expected_error_message = "someerrormsg"
+    response.data = json.dumps({"error": expected_error_message})
+
+    with patch(REQUEST_PATH, return_value=response):
+        with pytest.raises(ProgrammingError, match=expected_error_message):
+            Client("").blob_put(expected_table, expected_digest, expected_data)
+
+
+def test_client_blob_del():
+    """Verifies the handling of del requests to CrateDB"""
+    expected_table = "sometable"
+    expected_digest = "somedigest"
+    with patch(REQUEST_PATH, return_value=fake_response(204)) as f:
+        deleted = Client("").blob_del(expected_table, expected_digest)
+        assert f.call_args[0][0] == "DELETE"
+        assert (
+            f.call_args[0][1] == f"/_blobs/{expected_table}/{expected_digest}"
+        )
+        assert deleted is True
+
+    with patch(REQUEST_PATH, return_value=fake_response(404)):
+        deleted = Client("").blob_del(expected_table, expected_digest)
+        assert deleted is False
+
+    response = fake_response(500)
+    expected_error_message = "someerrormsg"
+    response.data = json.dumps({"error": expected_error_message})
+
+    with patch(REQUEST_PATH, return_value=response):
+        with pytest.raises(ProgrammingError, match=expected_error_message):
+            Client("").blob_del(expected_table, expected_digest)
+
+
+def test_client_blob_exists():
+    """Verifies the handling of exists requests to CrateDB"""
+    expected_table = "sometable"
+    expected_digest = "somedigest"
+    with patch(REQUEST_PATH, return_value=fake_response(200)) as f:
+        exists = Client("").blob_exists(expected_table, expected_digest)
+        assert f.call_args[0][0] == "HEAD"
+        assert (
+            f.call_args[0][1] == f"/_blobs/{expected_table}/{expected_digest}"
+        )
+        assert exists is True
+
+    with patch(REQUEST_PATH, return_value=fake_response(404)):
+        exists = Client("").blob_exists(expected_table, expected_digest)
+        assert exists is False
+
+    response = fake_response(500)
+    expected_error_message = "someerrormsg"
+    response.data = json.dumps({"error": expected_error_message})
+
+    with patch(REQUEST_PATH, return_value=response):
+        with pytest.raises(ProgrammingError, match=expected_error_message):
+            Client("").blob_exists(expected_table, expected_digest)
+
+
+def test_client_blob_get():
+    """Verifies the handling of getting a blob from CrateDB"""
+    expected_table = "sometable"
+    expected_digest = "somedigest"
+    expected_chunksize = 10
+
+    with patch(REQUEST_PATH, return_value=fake_response(200)) as f:
+        f.return_value.stream = MagicMock()
+        Client("").blob_get(expected_table, expected_digest, expected_chunksize)
+        assert f.call_args[0][0] == "GET"
+        assert (
+            f.call_args[0][1] == f"/_blobs/{expected_table}/{expected_digest}"
+        )
+        assert f.return_value.stream.call_count == 1
+        assert f.return_value.stream.call_args[1] == {"amt": expected_chunksize}
+
+    with pytest.raises(DigestNotFoundException):
+        with patch(REQUEST_PATH, return_value=fake_response(404)):
+            Client("").blob_get(expected_table, expected_digest)
+
+    response = fake_response(500)
+    expected_error_message = "someerrormsg"
+    response.data = json.dumps({"error": expected_error_message})
+
+    with patch(REQUEST_PATH, return_value=response):
+        with pytest.raises(ProgrammingError, match=expected_error_message):
+            Client("").blob_get(expected_table, expected_digest)
+
+
+def test_remove_certs_for_non_https():
+    """
+    Verify that `_remove_certs_for_non_https` correctly removes ca_certs.
+    """
+    d = _remove_certs_for_non_https("https", {"ca_certs": 1})
+    assert "ca_certs" in d
+
+    kwargs = {"ca_certs": 1, "foobar": 2, "cert_file": 3}
+    d = _remove_certs_for_non_https("http", kwargs)
+    assert "ca_certs" not in d
+    assert "cert_file" not in d
+    assert "foobar" in d
+
+
+def test_keep_alive(serve_http):
+    """
+    Verify that when launching several requests, the connection is kept
+    alive and successfully terminates.
+
+    This uses a real http sever that mocks CrateDB-like responses.
+    """
+
+    class ClientAddressRequestHandler(BaseHTTPRequestHandler):
+        """
+        http handler for use with HTTPServer
+
+        returns client host and port in crate-conform-responses
+        """
+
+        protocol_version = "HTTP/1.1"
+
+        def do_GET(self):
+            content_length = self.headers.get("content-length")
+            if content_length:
+                self.rfile.read(int(content_length))
+
+            response = json.dumps(
+                {
+                    "cols": ["host", "port"],
+                    "rows": [self.client_address[0], self.client_address[1]],
+                    "rowCount": 1,
+                }
+            )
+
+            self.send_response(200)
+            self.send_header("Content-Length", str(len(response)))
+            self.send_header("Content-Type", "application/json; charset=UTF-8")
+            self.end_headers()
+            self.wfile.write(response.encode("UTF-8"))
+
+        do_POST = do_GET
+
+    with serve_http(ClientAddressRequestHandler) as (_, url):
+        with connect(url) as conn:
+            client = conn.client
+            for _ in range(25):
+                result = client.sql("select * from fake")
+
+                another_result = client.sql("select again from fake")
+                assert result == another_result
+
+
+def test_no_retry_on_read_timeout(serve_http):
+    timeout = 1
+
+    class TimeoutRequestHandler(BaseHTTPRequestHandler):
+        """
+        HTTP handler for use with TestingHTTPServer
+        updates the shared counter and waits so that the client times out
+        """
+
+        def do_POST(self):
+            self.server.SHARED["count"] += 1
+            time.sleep(timeout + 0.1)
+
+        def do_GET(self):
+            pass
+
+    # Start the http server.
+    with serve_http(TimeoutRequestHandler) as (server, url):
+        # Connect to the server.
+        with connect(url, timeout=timeout) as conn:
+            # We expect it to raise a `ConnectionError`
+            with pytest.raises(ConnectionError, match="Read timed out"):
+                conn.client.sql("select * from fake")
+            assert server.SHARED.get("count") == 1
+
+
+class SharedStateRequestHandler(BaseHTTPRequestHandler):
+    """
+    HTTP handler for use with TestingHTTPServer
+    sets the shared state of the server and returns an empty response
+    """
+
+    def do_POST(self):
+        self.server.SHARED["count"] += 1
+        self.server.SHARED["schema"] = self.headers.get("Default-Schema")
+
+        if self.headers.get("Authorization") is not None:
+            auth_header = self.headers["Authorization"]
+            if "Basic" in auth_header:
+                auth_header = auth_header.replace("Basic ", "")
+                credentials = (
+                    b64decode(auth_header).decode("utf-8").split(":", 1)
+                )
+                self.server.SHARED["username"] = credentials[0]
+                if len(credentials) > 1 and credentials[1]:
+                    self.server.SHARED["password"] = credentials[1]
+                else:
+                    self.server.SHARED["password"] = None
+            elif "Bearer" in auth_header:
+                jwt_token = auth_header.replace("Bearer ", "")
+                self.server.SHARED["jwt_token"] = jwt_token
+        else:
+            self.server.SHARED["jwt_token"] = None
+            self.server.SHARED["username"] = None
+
+        if self.headers.get("X-User") is not None:
+            self.server.SHARED["usernameFromXUser"] = self.headers["X-User"]
+        else:
+            self.server.SHARED["usernameFromXUser"] = None
+
+        # send empty response
+        response = "{}"
+        self.send_response(200)
+        self.send_header("Content-Length", len(response))
+        self.send_header("Content-Type", "application/json; charset=UTF-8")
+        self.end_headers()
+        self.wfile.write(response.encode("utf-8"))
+
+    def do_GET(self):
+        pass
+
+
+def test_default_schema(serve_http):
+    """
+    Verify that the schema is correctly sent.
+    """
+    test_schema = "some_schema"
+    with serve_http(SharedStateRequestHandler) as (server, url):
+        with connect(url, schema=test_schema) as conn:
+            conn.client.sql("select 1;")
+        assert server.SHARED.get("schema") == test_schema
+
+
+def test_credentials(serve_http):
+    """
+    Verify credentials are correctly set in the connection and client.
+    """
+    with serve_http(SharedStateRequestHandler) as (server, url):
+        # Nothing default
+        with connect(url) as conn:
+            assert not conn.client.username
+            assert not conn.client.password
+
+            conn.client.sql("select 1;")
+            assert not server.SHARED["usernameFromXUser"]
+            assert not server.SHARED["username"]
+            assert not server.SHARED["password"]
+
+        # Just the username
+        username = "some_username"
+        with connect(url, username=username) as conn:
+            assert conn.client.username == username
+            assert not conn.client.password
+
+            conn.client.sql("select 2;")
+            assert server.SHARED["usernameFromXUser"] == username
+            assert server.SHARED["username"] == username
+            assert not server.SHARED["password"]
+
+        # Both username and password
+        password = "some_password"
+        with connect(url, username=username, password=password) as conn:
+            assert conn.client.username == username
+            assert conn.client.password == password
+            conn.client.sql("select 3;")
+            assert server.SHARED["usernameFromXUser"] == username
+            assert server.SHARED["username"] == username
+            assert server.SHARED["password"] == password
+
+        # Just a single token, most convenient.
+        jwt_token = "testJwtToken"
+        with connect(url, jwt_token=jwt_token) as conn:
+            assert conn.client.jwt_token == jwt_token
+            conn.client.sql("select 3;")
+            assert server.SHARED["jwt_token"] == jwt_token
+
+
+def test_credentials_and_token(serve_http):
+    """
+    Verify exception when user provides both credentials and token.
+    """
+    with serve_http(SharedStateRequestHandler) as (server, url):
+        with pytest.raises(ProgrammingError) as excinfo:
+            connect(url, username="foo", jwt_token="bar")
+        assert excinfo.match(
+            "Either JWT tokens are accepted, or user credentials, but not both"
+        )
+
+def test_compress_accept_encoding_always_sent():
+    """Accept-Encoding is sent even when compression is disabled."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200", compress=False).sql("SELECT 1")
+    assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
+
+
+def test_compress_false_no_content_encoding():
+    """No Content-Encoding header when compress=False."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200", compress=False).sql("SELECT 1")
+    assert "Content-Encoding" not in captured["headers"]
+
+
+def test_compress_true_always_compresses():
+    """compress=True compresses regardless of payload size."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["data"] = kwargs.get("data", b"")
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200", compress=True).sql("SELECT 1")
+    assert captured["headers"].get("Content-Encoding") == "gzip"
+    assert b'"stmt"' in gzip.decompress(captured["data"])
+
+
+def test_compress_threshold_above():
+    """Payload above threshold is compressed."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200", compress=0).sql("SELECT 1")
+    assert captured["headers"].get("Content-Encoding") == "gzip"
+
+
+def test_compress_threshold_below():
+    """Payload below threshold is not compressed."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200", compress=999_999).sql("SELECT 1")
+    assert "Content-Encoding" not in captured["headers"]
+
+
+def test_compress_default():
+    """Default args: Accept-Encoding sent, small payload not compressed."""
+    captured = {}
+
+    def capturing(*_, **kwargs):
+        captured["headers"] = kwargs.get("headers") or {}
+        return fake_response(200)
+
+    with patch(REQUEST_PATH, side_effect=capturing):
+        Client(servers="localhost:4200").sql("SELECT 1")
+    assert captured["headers"].get("Accept-Encoding") == "gzip, deflate"
+    assert "Content-Encoding" not in captured["headers"]
diff --git a/tests/client/test_serialization.py b/tests/client/test_serialization.py
new file mode 100644
index 000000000..ed917c4c9
--- /dev/null
+++ b/tests/client/test_serialization.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8; -*-
+#
+# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor
+# license agreements.  See the NOTICE file distributed with this work for
+# additional information regarding copyright ownership.  Crate licenses
+# this file to you under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.  You may
+# obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License 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.
+#
+# However, if you have executed another commercial license agreement
+# with Crate these terms will supersede the license and you may use the
+# software solely pursuant to the terms of the relevant commercial agreement.
+
+"""
+Tests for serializing data, typically python objects
+ into CrateDB-sql compatible structures.
+"""
+
+import datetime
+import datetime as dt
+import uuid
+from decimal import Decimal
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from crate.client.http import Client, json_dumps
+from tests.conftest import REQUEST_PATH, fake_response
+
+
+def test_data_is_serialized():
+    """
+    Verify that when a request is issued, `json_dumps` is called with
+    the right parameters and that a requests gets the output from json_dumps,
+    this verifies the entire serialization call chain, so in the following
+    tests we can just test `json_dumps` and ignore
+    `Client` altogether.
+    """
+    mock = MagicMock(spec=bytes)
+
+    with patch("crate.client.http.json_dumps", return_value=mock) as f:
+        with patch(REQUEST_PATH, return_value=fake_response(200)) as request:
+            client = Client(servers="localhost:4200")
+            client.sql(
+                "insert into t (a, b) values (?, ?)",
+                (
+                    datetime.datetime(
+                        2025,
+                        10,
+                        23,
+                        11,
+                    ),
+                    "ss",
+                ),
+            )
+
+            # Verify json_dumps is called with the right parameters.
+            f.assert_called_once_with(
+                {
+                    "stmt": "insert into t (a, b) values (?, ?)",
+                    "args": (datetime.datetime(2025, 10, 23, 11, 0), "ss"),
+                }
+            )
+
+            # Verify that the output of json_dumps is used as
+            # call argument for a request.
+            assert request.call_args[1]["data"] is mock
+
+
+def test_serialization_unsupported():
+    """Tests that when an object that is not serializable is given
+    a type error is raised."""
+    with pytest.raises(TypeError):
+        json_dumps(type("d", (), {}))
+
+
+def test_naive_datetime_serialization():
+    """
+    Verify that a `datetime.datetime` can be serialized.
+    """
+    data = dt.datetime(2015, 2, 28, 7, 31, 40)
+    result = json_dumps(data)
+    assert isinstance(result, bytes)
+    assert result == b"1425108700000"
+
+
+def test_aware_datetime_serialization():
+    """
+    Verify that a `datetime` that is tz aware type can be serialized.
+    """
+    data = dt.datetime.fromisoformat("2023-06-26T09:24:00.123+02:00")
+    result = json_dumps(data)
+    assert isinstance(result, bytes)
+    assert result == b"1687764240123"
+
+
+def test_decimal_serialization():
+    """
+    Verify that a `Decimal` type can be serialized.
+    """
+
+    data = Decimal(0.12)
+    expected = b'"0.11999999999999999555910790149937383830547332763671875"'
+    result = json_dumps(data)
+    assert isinstance(result, bytes)
+
+    # Question: Is this deterministic in every Python release?
+    assert result == expected
+
+
+def test_date_serialization():
+    """
+    Verify that a `datetime.date` can be serialized.
+    """
+    data = dt.date(2016, 4, 21)
+    result = json_dumps(data)
+    assert result == b"1461196800000"
+
+
+def test_naive_time_serialization():
+    """
+    Verify that a naive `datetime.time` serializes to an ISO 8601 string.
+    """
+    data = dt.time(12, 30, 45)
+    result = json_dumps(data)
+    assert result == b'"12:30:45"'
+
+
+def test_time_with_microseconds_serialization():
+    """
+    Verify that `datetime.time` with microseconds serializes correctly.
+    """
+    data = dt.time(12, 30, 45, 123456)
+    result = json_dumps(data)
+    assert result == b'"12:30:45.123456"'
+
+
+def test_aware_time_serialization():
+    """
+    Verify that a timezone-aware `datetime.time` serializes to ISO 8601 format,
+    including the UTC offset.
+    """
+    data = dt.time(12, 30, 45, tzinfo=dt.timezone.utc)
+    result = json_dumps(data)
+    assert result == b'"12:30:45+00:00"'
+
+
+def test_aware_time_with_offset_serialization():
+    """
+    Verify that a `datetime.time` with a non-UTC offset serializes correctly.
+    """
+    tz = dt.timezone(dt.timedelta(hours=2))
+    data = dt.time(12, 30, 45, tzinfo=tz)
+    result = json_dumps(data)
+    assert result == b'"12:30:45+02:00"'
+
+
+def test_uuid_serialization():
+    """
+    Verify that a `uuid.UUID` can be serialized.
+
+    We do not care about specific uuid versions, just the object that is
+    re-used across all versions of the uuid module.
+    """
+    uuid_int = 50583033507982468033520929066863110751
+    data = uuid.UUID(bytes=uuid_int.to_bytes(16, byteorder="big"), version=4)
+    result = json_dumps(data)
+    assert result == b'"260df019-a183-431f-ad46-115ccdf12a5f"'
diff --git a/tests/client/test_utils.py b/tests/client/test_utils.py
new file mode 100644
index 000000000..00378c6b4
--- /dev/null
+++ b/tests/client/test_utils.py
@@ -0,0 +1,85 @@
+import io
+import ssl
+import tempfile
+from unittest.mock import patch
+
+import urllib3
+
+from crate.client.http import (
+    _update_pool_kwargs_for_ssl_minimum_version,
+    super_len,
+)
+
+
+def test_super_len_all():
+    assert super_len([1, 2]) == 2
+    assert super_len("abc") == 3
+    assert super_len((1, 2, 3)) == 3
+
+    class len_obj:
+        def __init__(self, len_f):
+            self.len = len_f
+
+    assert super_len(len_obj(5)) == 5
+
+    data = b"somedata"
+    with tempfile.TemporaryFile() as f:
+        f.write(data)
+        f.flush()
+        assert super_len(f) == len(data)
+
+    class bad_fileno:
+        def fileno(self):
+            raise io.UnsupportedOperation
+
+    assert super_len(bad_fileno()) is None
+
+    class bad_fileno_with_getvalue:
+        def fileno(self):
+            raise io.UnsupportedOperation
+
+        def getvalue(self):
+            return b"abcde"
+
+    assert super_len(bad_fileno_with_getvalue()) == 5
+
+    buf = io.BytesIO(b"123456")
+    assert super_len(buf) == 6
+
+    class getvalue_obj:
+        def getvalue(self):
+            return "abcdef"
+
+    assert super_len(getvalue_obj()) == 6
+
+    class Empty:
+        pass
+
+    assert super_len(Empty()) is None
+
+
+def test_update_pool_kwargs_for_ssl_minimum_version():
+    """Test that the ssl_minimum_version is set correctly in the kwargs"""
+    with patch.object(urllib3, "__version__", "2.0.0"):
+        kwargs = {}
+        _update_pool_kwargs_for_ssl_minimum_version(
+            "https://example.com", kwargs
+        )
+        assert (
+            kwargs.get("ssl_minimum_version")
+            == ssl.TLSVersion.MINIMUM_SUPPORTED
+        )
+
+        # not https
+        kwargs = {}
+        _update_pool_kwargs_for_ssl_minimum_version(
+            "http://example.com", kwargs
+        )
+        assert "ssl_minimum_version" not in kwargs
+
+    with patch.object(urllib3, "__version__", "1.26.0"):
+        kwargs = {}
+        _update_pool_kwargs_for_ssl_minimum_version(
+            "https://example.com", kwargs
+        )
+        assert "ssl_minimum_version" not in kwargs
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..f6325b2cf
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,308 @@
+import json
+import logging
+import multiprocessing
+import os
+import platform
+import socket
+import ssl
+import sys
+import tarfile
+import threading
+import time
+import zipfile
+from contextlib import contextmanager
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from unittest.mock import MagicMock
+from urllib.request import urlretrieve
+
+import pytest
+import urllib3
+
+import crate
+from crate.client import connect
+from crate.testing.layer import CrateLayer
+from tests.client.settings import assets_path
+
+log = logging.getLogger("tests.conftest")
+
+
+REQUEST_PATH = "crate.client.http.Server.request"
+URL_TMPL = "https://cdn.crate.io/downloads/releases/cratedb/{arch}_{os}/crate-6.1.2.{ext}"
+
+project_root = Path(__file__).parent.parent
+cratedb_path = project_root / "parts/crate"
+
+
+crate_port = 44209
+crate_transport_port = 44309
+localhost = "127.0.0.1"
+crate_host = f"http://{localhost}:{crate_port}"
+
+
+def fake_response(
+    status: int, reason: str = None, content_type: str = "application/json"
+) -> MagicMock:
+    """
+    Returns a mocked `urllib3.response.HTTPResponse` HTTP response.
+    """
+    m = MagicMock(spec=urllib3.response.HTTPResponse)
+    m.status = status
+    m.reason = reason or ""
+    m.headers = {"content-type": content_type}
+    return m
+
+
+@pytest.fixture
+def mocked_connection():
+    """
+    Returns a crate `Connection` with a mocked `Client`
+
+    Example:
+        def test_conn(mocked_connection):
+            cursor = mocked_connection.cursor()
+            statement = "select * from locations where position = ?"
+            cursor.execute(statement, 1)
+            mocked_connection.client.sql.called_with(statement, 1, None)
+    """
+    yield crate.client.connect(client=MagicMock(spec=crate.client.http.Client))
+
+
+@pytest.fixture
+def serve_http():
+    """
+    Returns a context manager that start an http server running
+    in another thread that returns CrateDB successful responses.
+
+    It accepts an optional parameter, the handler class, it has to be an
+    instance of `BaseHTTPRequestHandler`
+
+    The port will be an unused random port.
+
+    Example:
+        def test_http(serve_http):
+            with serve_http() as url:
+                urllib3.urlopen(url)
+
+    See `test_http.test_keep_alive` for more advance example.
+    """
+
+    @contextmanager
+    def _serve(handler_cls=BaseHTTPRequestHandler):
+        assert issubclass(handler_cls, BaseHTTPRequestHandler)  # noqa: S101
+        sock = socket.socket()
+        sock.bind(("127.0.0.1", 0))
+        host, port = sock.getsockname()
+        sock.close()
+
+        manager = multiprocessing.Manager()
+        SHARED = manager.dict()
+        SHARED["count"] = 0
+        SHARED["usernameFromXUser"] = None
+        SHARED["username"] = None
+        SHARED["password"] = None
+        SHARED["schema"] = None
+
+        server = HTTPServer((host, port), handler_cls)
+
+        server.SHARED = SHARED
+
+        thread = threading.Thread(target=server.serve_forever, daemon=False)
+        thread.start()
+        try:
+            yield server, f"http://{host}:{port}"
+
+        finally:
+            server.shutdown()
+            thread.join()
+
+    return _serve
+
+
+def get_crate_url() -> str:
+    extension = "tar.gz"
+
+    machine = platform.machine()
+    if machine.startswith("arm") or machine == "aarch64":
+        arch = "aarch64"
+    else:
+        arch = "x64"
+
+    if sys.platform.startswith("linux"):
+        os = "linux"
+    elif sys.platform.startswith("win32"):
+        os = "windows"
+        extension = "zip"
+    elif sys.platform.startswith("darwin"):
+        os = "mac"
+
+        # there are no aarch64/arm64 distributions available
+        # x64 should work via emulation layer
+        arch = "x64"
+    else:
+        raise ValueError(f"Unsupported platform: {sys.platform}")
+
+    return URL_TMPL.format(arch=arch, os=os, ext=extension)
+
+
+def download_cratedb(path: Path):
+    url = get_crate_url()
+    if path.exists():
+        return
+    if not url.startswith("https:"):
+        raise ValueError("Invalid url")
+    filename, _msg = urlretrieve(url)
+    if sys.platform.startswith("win32"):
+        with zipfile.ZipFile(filename) as z:
+            first_file = z.namelist()[0]
+            folder_name = os.path.dirname(first_file)
+            z.extractall(path.parent)
+            (path.parent / folder_name).rename(path)
+    else:
+        with tarfile.open(filename) as t:
+            first_file = t.getnames()[0]
+            folder_name = os.path.dirname(first_file)
+            t.extractall(path.parent, filter="data")
+            (path.parent / folder_name).rename(path)
+
+
+def create_test_data(cursor):
+    with open(project_root / "tests/assets/mappings/locations.sql") as s:
+        stmt = s.read()
+        cursor.execute(stmt)
+        stmt = (
+            "select count(*) from information_schema.tables "
+            "where table_name = 'locations'"
+        )
+        cursor.execute(stmt)
+        assert cursor.fetchall()[0][0] == 1  # noqa: S101
+
+    data_path = str(project_root / "tests/assets/import/test_a.json")
+    # load testing data into crate
+    cursor.execute("copy locations from ?", (data_path,))
+    # refresh location table so imported data is visible immediately
+    cursor.execute("refresh table locations")
+    # create blob table
+    cursor.execute(
+        "create blob table myfiles clustered into 1 shards "
+        + "with (number_of_replicas=0)"
+    )
+
+    # create users
+    cursor.execute("CREATE USER me WITH (password = 'my_secret_pw')")
+    cursor.execute("CREATE USER trusted_me")
+
+
+@pytest.fixture()
+def doctest_node():
+    download_cratedb(cratedb_path)
+    settings = {
+        "udc.enabled": "false",
+        "lang.js.enabled": "true",
+        "auth.host_based.enabled": "true",
+        "auth.host_based.config.0.user": "crate",
+        "auth.host_based.config.0.method": "trust",
+        "auth.host_based.config.98.user": "trusted_me",
+        "auth.host_based.config.98.method": "trust",
+        "auth.host_based.config.99.user": "me",
+        "auth.host_based.config.99.method": "password",
+        "discovery.type": "single-node",
+    }
+    crate_layer = CrateLayer(
+        "crate",
+        crate_home=cratedb_path,
+        port=crate_port,
+        host=localhost,
+        transport_port=crate_transport_port,
+        settings=settings,
+    )
+    crate_layer.start()
+    with connect(crate_host) as conn:
+        cursor = conn.cursor()
+        create_test_data(cursor)
+        cursor.close()
+
+    yield crate_layer
+    crate_layer.stop()
+
+
+class HttpsServer(HTTPServer):
+    PORT = 65534
+    HOST = "localhost"
+    CERT_FILE = assets_path("pki/server_valid.pem")
+    CACERT_FILE = assets_path("pki/cacert_valid.pem")
+
+    def get_request(self):
+        # Prepare SSL context.
+        context = ssl._create_unverified_context(  # noqa: S323
+            protocol=ssl.PROTOCOL_TLS_SERVER,
+            cert_reqs=ssl.CERT_OPTIONAL,
+            check_hostname=False,
+            purpose=ssl.Purpose.CLIENT_AUTH,
+            certfile=HttpsServer.CERT_FILE,
+            keyfile=HttpsServer.CERT_FILE,
+            cafile=HttpsServer.CACERT_FILE,
+        )  # noqa: S323
+
+        # Set minimum protocol version, TLSv1 and TLSv1.1 are unsafe.
+        context.minimum_version = ssl.TLSVersion.TLSv1_2
+
+        # Wrap TLS encryption around socket.
+        socket, client_address = HTTPServer.get_request(self)
+        socket = context.wrap_socket(socket, server_side=True)
+
+        return socket, client_address
+
+
+class HttpsHandler(BaseHTTPRequestHandler):
+    payload = json.dumps(
+        {
+            "name": "test",
+            "status": 200,
+        }
+    )
+
+    def do_GET(self):
+        self.send_response(200)
+        payload = self.payload.encode("UTF-8")
+        self.send_header("Content-Length", f"{len(payload)}")
+        self.send_header("Content-Type", "application/json; charset=UTF-8")
+        self.end_headers()
+        self.wfile.write(payload)
+
+
+def is_up(host: str, port: int) -> bool:
+    """
+    Test if a host is up.
+    """
+    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    ex = s.connect_ex((host, port))
+    s.close()
+    return ex == 0
+
+
+@pytest.fixture
+def https_server():
+    port = HttpsServer.PORT
+    host = HttpsServer.HOST
+    server_address = (host, port)
+    server = HttpsServer(server_address, HttpsHandler)
+    thread = threading.Thread(target=server.serve_forever)
+    thread.daemon = True
+    thread.start()
+
+    start = time.monotonic()
+    timeout = 5
+    while True:
+        if is_up(host, port):
+            break
+        now = time.monotonic()
+        if now - start > timeout:
+            raise TimeoutError(
+                "Could not properly start embedded webserver "
+                "within {} seconds".format(timeout)
+            )
+
+    yield server
+    server.shutdown()
+    server.server_close()
diff --git a/tests/test_docs.py b/tests/test_docs.py
new file mode 100644
index 000000000..5fe52d809
--- /dev/null
+++ b/tests/test_docs.py
@@ -0,0 +1,37 @@
+import doctest
+from pprint import pprint
+
+from tests.client.settings import assets_path
+
+from .conftest import HttpsServer, crate_host
+
+
+def cprint(s):
+    if isinstance(s, bytes):
+        s = s.decode("utf-8")
+    print(s)  # noqa: T201
+
+
+def test_docs(doctest_node, https_server):
+    flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
+    globs = {
+        "pprint": pprint,
+        "print": cprint,
+        "crate_host": crate_host,
+        "https_host": f"https://{HttpsServer.HOST}:{HttpsServer.PORT}",
+        "cacert_valid": assets_path("pki/cacert_valid.pem"),
+        "cacert_invalid": assets_path("pki/cacert_invalid.pem"),
+        "clientcert_valid": assets_path("pki/client_valid.pem"),
+        "clientcert_invalid": assets_path("pki/client_invalid.pem"),
+    }
+
+    def test(path):
+        failures, tests = doctest.testfile(path, optionflags=flags, globs=globs)
+        assert not failures
+
+    test("../docs/by-example/connection.rst")
+    test("../docs/by-example/cursor.rst")
+    test("../docs/by-example/http.rst")
+    test("../docs/by-example/client.rst")
+    test("../docs/by-example/blob.rst")
+    test("../docs/by-example/https.rst")
diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/crate/testing/test_layer.py b/tests/testing/test_layer.py
similarity index 53%
rename from src/crate/testing/test_layer.py
rename to tests/testing/test_layer.py
index f028e021d..250bfde18 100644
--- a/src/crate/testing/test_layer.py
+++ b/tests/testing/test_layer.py
@@ -18,96 +18,110 @@
 # However, if you have executed another commercial license agreement
 # with Crate these terms will supersede the license and you may use the
 # software solely pursuant to the terms of the relevant commercial agreement.
+
 import json
 import os
 import tempfile
-import urllib
-from crate.client._pep440 import Version
-from unittest import TestCase, mock
 from io import BytesIO
+from pathlib import Path
+from unittest import TestCase, mock
 
 import urllib3
 
 import crate
-from .layer import CrateLayer, prepend_http, http_url_from_host_port, wait_for_http_url
-from .settings import crate_path
+from crate.testing.layer import (
+    CrateLayer,
+    http_url_from_host_port,
+    prepend_http,
+    wait_for_http_url,
+)
+from tests.client.settings import crate_path
+from tests.conftest import download_cratedb, get_crate_url
 
 
 class LayerUtilsTest(TestCase):
-
     def test_prepend_http(self):
-        host = prepend_http('localhost')
-        self.assertEqual('http://localhost', host)
-        host = prepend_http('http://localhost')
-        self.assertEqual('http://localhost', host)
-        host = prepend_http('https://localhost')
-        self.assertEqual('https://localhost', host)
-        host = prepend_http('http')
-        self.assertEqual('http://http', host)
+        host = prepend_http("localhost")
+        self.assertEqual("http://localhost", host)
+        host = prepend_http("http://localhost")
+        self.assertEqual("http://localhost", host)
+        host = prepend_http("https://localhost")
+        self.assertEqual("https://localhost", host)
+        host = prepend_http("http")
+        self.assertEqual("http://http", host)
 
     def test_http_url(self):
         url = http_url_from_host_port(None, None)
         self.assertEqual(None, url)
-        url = http_url_from_host_port('localhost', None)
+        url = http_url_from_host_port("localhost", None)
         self.assertEqual(None, url)
         url = http_url_from_host_port(None, 4200)
         self.assertEqual(None, url)
-        url = http_url_from_host_port('localhost', 4200)
-        self.assertEqual('http://localhost:4200', url)
-        url = http_url_from_host_port('https://crate', 4200)
-        self.assertEqual('https://crate:4200', url)
+        url = http_url_from_host_port("localhost", 4200)
+        self.assertEqual("http://localhost:4200", url)
+        url = http_url_from_host_port("https://crate", 4200)
+        self.assertEqual("https://crate:4200", url)
 
     def test_wait_for_http(self):
-        log = BytesIO(b'[i.c.p.h.CrateNettyHttpServerTransport] [crate] publish_address {127.0.0.1:4200}')
+        log = BytesIO(
+            b"[i.c.p.h.CrateNettyHttpServerTransport] [crate] publish_address {127.0.0.1:4200}"  # noqa: E501
+        )
         addr = wait_for_http_url(log)
-        self.assertEqual('http://127.0.0.1:4200', addr)
-        log = BytesIO(b'[i.c.p.h.CrateNettyHttpServerTransport] [crate] publish_address {}')
+        self.assertEqual("http://127.0.0.1:4200", addr)
+        log = BytesIO(
+            b"[i.c.p.h.CrateNettyHttpServerTransport] [crate] publish_address {}"  # noqa: E501
+        )
         addr = wait_for_http_url(log=log, timeout=1)
         self.assertEqual(None, addr)
 
-    @mock.patch.object(crate.testing.layer, "_download_and_extract", lambda uri, directory: None)
+    @mock.patch.object(
+        crate.testing.layer,
+        "_download_and_extract",
+        lambda uri, directory: None,
+    )
     def test_layer_from_uri(self):
         """
         The CrateLayer can also be created by providing an URI that points to
         a CrateDB tarball.
         """
-        with urllib.request.urlopen("https://crate.io/versions.json") as response:
-            versions = json.loads(response.read().decode())
-            version = versions["crate_testing"]
-
-        self.assertGreaterEqual(Version(version), Version("4.5.0"))
-
-        uri = "https://cdn.crate.io/downloads/releases/crate-{}.tar.gz".format(version)
-        layer = CrateLayer.from_uri(uri, name="crate-by-uri", http_port=42203)
+        layer = CrateLayer.from_uri(get_crate_url(),
+                                    name="crate-by-uri",
+                                    http_port=42203
+                                    )
         self.assertIsInstance(layer, CrateLayer)
 
-    @mock.patch.dict('os.environ', {}, clear=True)
+    @mock.patch.dict("os.environ", {}, clear=True)
     def test_java_home_env_not_set(self):
         with tempfile.TemporaryDirectory() as tmpdir:
-            layer = CrateLayer('java-home-test', tmpdir)
-            # JAVA_HOME must not be set to `None`, since it would be interpreted as a
-            # string 'None', and therefore intepreted as a path
-            self.assertEqual(layer.env['JAVA_HOME'], '')
+            layer = CrateLayer("java-home-test", tmpdir)
+            # JAVA_HOME must not be set to `None`: It would be literally
+            # interpreted as a string 'None', which is an invalid path.
+            self.assertEqual(layer.env["JAVA_HOME"], "")
 
-    @mock.patch.dict('os.environ', {}, clear=True)
+    @mock.patch.dict("os.environ", {}, clear=True)
     def test_java_home_env_set(self):
-        java_home = '/usr/lib/jvm/java-11-openjdk-amd64'
+        java_home = "/usr/lib/jvm/java-11-openjdk-amd64"
         with tempfile.TemporaryDirectory() as tmpdir:
-            os.environ['JAVA_HOME'] = java_home
-            layer = CrateLayer('java-home-test', tmpdir)
-            self.assertEqual(layer.env['JAVA_HOME'], java_home)
+            os.environ["JAVA_HOME"] = java_home
+            layer = CrateLayer("java-home-test", tmpdir)
+            self.assertEqual(layer.env["JAVA_HOME"], java_home)
 
-    @mock.patch.dict('os.environ', {}, clear=True)
+    @mock.patch.dict("os.environ", {}, clear=True)
     def test_java_home_env_override(self):
-        java_11_home = '/usr/lib/jvm/java-11-openjdk-amd64'
-        java_12_home = '/usr/lib/jvm/java-12-openjdk-amd64'
+        java_11_home = "/usr/lib/jvm/java-11-openjdk-amd64"
+        java_12_home = "/usr/lib/jvm/java-12-openjdk-amd64"
         with tempfile.TemporaryDirectory() as tmpdir:
-            os.environ['JAVA_HOME'] = java_11_home
-            layer = CrateLayer('java-home-test', tmpdir, env={'JAVA_HOME': java_12_home})
-            self.assertEqual(layer.env['JAVA_HOME'], java_12_home)
+            os.environ["JAVA_HOME"] = java_11_home
+            layer = CrateLayer(
+                "java-home-test", tmpdir, env={"JAVA_HOME": java_12_home}
+            )
+            self.assertEqual(layer.env["JAVA_HOME"], java_12_home)
 
 
 class LayerTest(TestCase):
+    @classmethod
+    def setup_class(cls):
+        download_cratedb(Path(crate_path()))
 
     def test_basic(self):
         """
@@ -118,13 +132,14 @@ def test_basic(self):
         port = 44219
         transport_port = 44319
 
-        layer = CrateLayer('crate',
-                           crate_home=crate_path(),
-                           host='127.0.0.1',
-                           port=port,
-                           transport_port=transport_port,
-                           cluster_name='my_cluster'
-                           )
+        layer = CrateLayer(
+            "crate",
+            crate_home=crate_path(),
+            host="127.0.0.1",
+            port=port,
+            transport_port=transport_port,
+            cluster_name="my_cluster",
+        )
 
         # The working directory is defined on layer instantiation.
         # It is sometimes required to know it before starting the layer.
@@ -142,7 +157,7 @@ def test_basic(self):
         http = urllib3.PoolManager()
 
         stats_uri = "http://127.0.0.1:{0}/".format(port)
-        response = http.request('GET', stats_uri)
+        response = http.request("GET", stats_uri)
         self.assertEqual(response.status, 200)
 
         # The layer can be shutdown using its `stop()` method.
@@ -150,91 +165,98 @@ def test_basic(self):
 
     def test_dynamic_http_port(self):
         """
-        It is also possible to define a port range instead of a static HTTP port for the layer.
+        Verify defining a port range instead of a static HTTP port.
+
+        CrateDB will start with the first available port in the given range and
+        the test layer obtains the chosen port from the startup logs of the
+        CrateDB process.
 
-        Crate will start with the first available port in the given range and the test
-        layer obtains the chosen port from the startup logs of the Crate process.
-        Note, that this feature requires a logging configuration with at least loglevel
-        ``INFO`` on ``http``.
+        Note that this feature requires a logging configuration with at least
+        loglevel ``INFO`` on ``http``.
         """
-        port = '44200-44299'
-        layer = CrateLayer('crate', crate_home=crate_path(), port=port)
+        port = "44200-44299"
+        layer = CrateLayer("crate", crate_home=crate_path(), port=port)
         layer.start()
         self.assertRegex(layer.crate_servers[0], r"http://127.0.0.1:442\d\d")
         layer.stop()
 
     def test_default_settings(self):
         """
-        Starting a CrateDB layer leaving out optional parameters will apply the following
-        defaults.
+        Starting a CrateDB layer leaving out optional parameters will apply
+        the following defaults.
 
-        The default http port is the first free port in the range of ``4200-4299``,
-        the default transport port is the first free port in the range of ``4300-4399``,
-        the host defaults to ``127.0.0.1``.
+        The default http port is the first free port in the range of
+        ``4200-4299``, the default transport port is the first free port in
+        the range of ``4300-4399``, the host defaults to ``127.0.0.1``.
 
         The command to call is ``bin/crate`` inside the ``crate_home`` path.
         The default config file is ``config/crate.yml`` inside ``crate_home``.
         The default cluster name will be auto generated using the HTTP port.
         """
-        layer = CrateLayer('crate_defaults', crate_home=crate_path())
+        layer = CrateLayer("crate_defaults", crate_home=crate_path())
         layer.start()
         self.assertEqual(layer.crate_servers[0], "http://127.0.0.1:4200")
         layer.stop()
 
     def test_additional_settings(self):
         """
-        The ``Crate`` layer can be started with additional settings as well.
-        Add a dictionary for keyword argument ``settings`` which contains your settings.
-        Those additional setting will override settings given as keyword argument.
+        The CrateDB test layer can be started with additional settings as well.
+
+        Add a dictionary for keyword argument ``settings`` which contains your
+        settings. Those additional setting will override settings given as
+        keyword argument.
 
-        The settings will be handed over to the ``Crate`` process with the ``-C`` flag.
-        So the setting ``threadpool.bulk.queue_size: 100`` becomes
-        the command line flag: ``-Cthreadpool.bulk.queue_size=100``::
+        The settings will be handed over to the ``Crate`` process with the
+        ``-C`` flag. So, the setting ``threadpool.bulk.queue_size: 100``
+        becomes the command line flag: ``-Cthreadpool.bulk.queue_size=100``::
         """
         layer = CrateLayer(
-            'custom',
+            "custom",
             crate_path(),
             port=44401,
             settings={
                 "cluster.graceful_stop.min_availability": "none",
-                "http.port": 44402
-            }
+                "http.port": 44402,
+            },
         )
         layer.start()
         self.assertEqual(layer.crate_servers[0], "http://127.0.0.1:44402")
-        self.assertIn("-Ccluster.graceful_stop.min_availability=none", layer.start_cmd)
+        self.assertIn(
+            "-Ccluster.graceful_stop.min_availability=none", layer.start_cmd
+        )
         layer.stop()
 
     def test_verbosity(self):
         """
-        The test layer hides the standard output of Crate per default. To increase the
-        verbosity level the additional keyword argument ``verbose`` needs to be set
-        to ``True``::
+        The test layer hides the standard output of Crate per default.
+
+        To increase the verbosity level, the additional keyword argument
+        ``verbose`` needs to be set to ``True``::
         """
-        layer = CrateLayer('crate',
-                           crate_home=crate_path(),
-                           verbose=True)
+        layer = CrateLayer("crate", crate_home=crate_path(), verbose=True)
         layer.start()
         self.assertTrue(layer.verbose)
         layer.stop()
 
     def test_environment_variables(self):
         """
-        It is possible to provide environment variables for the ``Crate`` testing
-        layer.
+        Verify providing environment variables for the CrateDB testing layer.
         """
-        layer = CrateLayer('crate',
-                           crate_home=crate_path(),
-                           env={"CRATE_HEAP_SIZE": "300m"})
+        layer = CrateLayer(
+            "crate", crate_home=crate_path(), env={"CRATE_HEAP_SIZE": "300m"}
+        )
 
         layer.start()
 
         sql_uri = layer.crate_servers[0] + "/_sql"
 
         http = urllib3.PoolManager()
-        response = http.urlopen('POST', sql_uri,
-                                body='{"stmt": "select heap[\'max\'] from sys.nodes"}')
-        json_response = json.loads(response.data.decode('utf-8'))
+        response = http.urlopen(
+            "POST",
+            sql_uri,
+            body='{"stmt": "select heap[\'max\'] from sys.nodes"}',
+        )
+        json_response = json.loads(response.data.decode("utf-8"))
 
         self.assertEqual(json_response["rows"][0][0], 314572800)
 
@@ -243,25 +265,25 @@ def test_environment_variables(self):
     def test_cluster(self):
         """
         To start a cluster of ``Crate`` instances, give each instance the same
-        ``cluster_name``. If you want to start instances on the same machine then
+        ``cluster_name``. If you want to start instances on the same machine,
         use value ``_local_`` for ``host`` and give every node different ports::
         """
         cluster_layer1 = CrateLayer(
-            'crate1',
+            "crate1",
             crate_path(),
-            host='_local_',
-            cluster_name='my_cluster',
+            host="_local_",
+            cluster_name="my_cluster",
         )
         cluster_layer2 = CrateLayer(
-            'crate2',
+            "crate2",
             crate_path(),
-            host='_local_',
-            cluster_name='my_cluster',
-            settings={"discovery.initial_state_timeout": "10s"}
+            host="_local_",
+            cluster_name="my_cluster",
+            settings={"discovery.initial_state_timeout": "10s"},
         )
 
-        # If we start both layers, they will, after a small amount of time, find each other
-        # and form a cluster.
+        # If we start both layers, they will, after a small amount of time,
+        # find each other, and form a cluster.
         cluster_layer1.start()
         cluster_layer2.start()
 
@@ -270,13 +292,18 @@ def test_cluster(self):
 
         def num_cluster_nodes(crate_layer):
             sql_uri = crate_layer.crate_servers[0] + "/_sql"
-            response = http.urlopen('POST', sql_uri, body='{"stmt":"select count(*) from sys.nodes"}')
-            json_response = json.loads(response.data.decode('utf-8'))
+            response = http.urlopen(
+                "POST",
+                sql_uri,
+                body='{"stmt":"select count(*) from sys.nodes"}',
+            )
+            json_response = json.loads(response.data.decode("utf-8"))
             return json_response["rows"][0][0]
 
         # We might have to wait a moment before the cluster is finally created.
         num_nodes = num_cluster_nodes(cluster_layer1)
         import time
+
         retries = 0
         while num_nodes < 2:  # pragma: no cover
             time.sleep(1)
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index fa7995bca..000000000
--- a/tox.ini
+++ /dev/null
@@ -1,19 +0,0 @@
-[tox]
-envlist = py{py3,35,36,37,38,39}-sa_{1_0,1_1,1_2,1_3,1_4}
-
-[testenv]
-usedevelop = True
-passenv = JAVA_HOME
-deps =
-    zope.testrunner
-    zope.testing
-    zc.customdoctests
-    sa_1_0: sqlalchemy>=1.0,<1.1
-    sa_1_1: sqlalchemy>=1.1,<1.2
-    sa_1_2: sqlalchemy>=1.2,<1.3
-    sa_1_3: sqlalchemy>=1.3,<1.4
-    sa_1_4: sqlalchemy>=1.4,<1.5
-    mock
-    urllib3
-commands =
-    zope-testrunner -c --test-path=src
diff --git a/versions.cfg b/versions.cfg
deleted file mode 100644
index 62f7d9f34..000000000
--- a/versions.cfg
+++ /dev/null
@@ -1,4 +0,0 @@
-[versions]
-crate_server = 5.1.1
-
-hexagonit.recipe.download = 1.7.1