diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83c7e0ff..afb5a5b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: matrix: os: ['ubuntu-22.04', 'macos-latest'] python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] - cratedb-version: ['5.8.3'] + cratedb-version: ['5.9.2'] # To save resources, only verify the most recent Python versions on macOS. exclude: diff --git a/CHANGES.txt b/CHANGES.txt index 4a0f0a48..aac5886b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -12,6 +12,10 @@ Unreleased "Threads may share the module, but not connections." - 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``. +- Added ``BulkResponse`` wrapper for improved decoding of CrateDB HTTP bulk + responses including ``rowcount=`` items. .. _Migrate from crate.client to sqlalchemy-cratedb: https://cratedb.com/docs/sqlalchemy-cratedb/migrate-from-crate-client.html .. _sqlalchemy-cratedb: https://pypi.org/project/sqlalchemy-cratedb/ diff --git a/DEVELOP.rst b/DEVELOP.rst index 41373f18..b523a4bf 100644 --- a/DEVELOP.rst +++ b/DEVELOP.rst @@ -26,34 +26,40 @@ see, for example, `useful command-line options for zope-testrunner`_. Run all tests:: - ./bin/test -vvvv + bin/test Run specific tests:: - ./bin/test -vvvv -t test_score + # Select modules. + bin/test -t test_cursor + bin/test -t client + bin/test -t testing + + # Select doctests. + bin/test -t http.rst Ignore specific test directories:: - ./bin/test -vvvv --ignore_dir=testing + bin/test --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' + bin/test -t '!LayerTest' -Invoke all tests without integration tests (~15 seconds runtime):: +Invoke all tests without integration tests (~10 seconds runtime):: - ./bin/test --layer '!crate.testing.layer.crate' --test '!LayerTest' + bin/test --layer '!crate.testing.layer.crate' --test '!LayerTest' -Yet ~130 test cases, but only ~5 seconds runtime:: +Yet ~60 test cases, but only ~1 second runtime:: - ./bin/test --layer '!crate.testing.layer.crate' --test '!LayerTest' \ + 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:: - ./bin/test --list-tests + bin/test --list-tests You can run the tests against multiple Python interpreters with `tox`_:: diff --git a/bin/test b/bin/test index 05407417..749ec64b 100755 --- a/bin/test +++ b/bin/test @@ -12,6 +12,6 @@ sys.argv[0] = os.path.abspath(sys.argv[0]) if __name__ == '__main__': zope.testrunner.run([ - '-vvv', '--auto-color', - '--test-path', join(base, 'src')], - ) + '-vvvv', '--auto-color', + '--path', join(base, 'tests'), + ]) diff --git a/bootstrap.sh b/bootstrap.sh index 9e011195..06c52f12 100644 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -17,7 +17,7 @@ # set -x # Default variables. -CRATEDB_VERSION=${CRATEDB_VERSION:-5.8.3} +CRATEDB_VERSION=${CRATEDB_VERSION:-5.9.2} function print_header() { diff --git a/docs/by-example/connection.rst b/docs/by-example/connection.rst index 4b89db7d..108166a3 100644 --- a/docs/by-example/connection.rst +++ b/docs/by-example/connection.rst @@ -21,7 +21,7 @@ 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 7fc7da7d..c649ee8c 100644 --- a/docs/by-example/cursor.rst +++ b/docs/by-example/cursor.rst @@ -23,7 +23,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() diff --git a/src/crate/client/result.py b/src/crate/client/result.py new file mode 100644 index 00000000..ed8f069d --- /dev/null +++ b/src/crate/client/result.py @@ -0,0 +1,68 @@ +import typing as t +from functools import cached_property + + +class BulkResultItem(t.TypedDict): + """ + Define the shape of a CrateDB bulk request response item. + """ + + rowcount: int + + +class BulkResponse: + """ + Manage a response to a CrateDB bulk request. + Accepts a list of bulk arguments (parameter list) and a list of bulk response items. + + https://cratedb.com/docs/crate/reference/en/latest/interfaces/http.html#bulk-operations + """ + + def __init__( + self, + records: t.List[t.Dict[str, t.Any]], + results: t.List[BulkResultItem]): + if records is None: + raise ValueError("Processing a bulk response without records is an invalid operation") + if results is None: + raise ValueError("Processing a bulk response without results is an invalid operation") + self.records = records + self.results = results + + @cached_property + def failed_records(self) -> t.List[t.Dict[str, t.Any]]: + """ + Compute list of failed records. + + CrateDB signals failed inserts using `rowcount=-2`. + + https://cratedb.com/docs/crate/reference/en/latest/interfaces/http.html#error-handling + """ + errors: t.List[t.Dict[str, t.Any]] = [] + for record, status in zip(self.records, self.results): + if status["rowcount"] == -2: + errors.append(record) + return errors + + @cached_property + def record_count(self) -> int: + """ + Compute bulk size / length of parameter list. + """ + if not self.records: + return 0 + return len(self.records) + + @cached_property + def success_count(self) -> int: + """ + Compute number of succeeding records within a batch. + """ + return self.record_count - self.failed_count + + @cached_property + def failed_count(self) -> int: + """ + Compute number of failed records within a batch. + """ + return len(self.failed_records) diff --git a/src/crate/client/test_util.py b/src/crate/client/test_util.py deleted file mode 100644 index 823a44e3..00000000 --- a/src/crate/client/test_util.py +++ /dev/null @@ -1,69 +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 unittest - - -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 - - -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 diff --git a/src/crate/testing/util.py b/src/crate/testing/util.py index 3e9885d6..54f9098c 100644 --- a/src/crate/testing/util.py +++ b/src/crate/testing/util.py @@ -1,3 +1,74 @@ +# -*- 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(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 + + +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: """ Additional assert methods for unittest. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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/src/crate/client/pki/cacert_valid.pem b/tests/assets/pki/cacert_valid.pem similarity index 100% rename from src/crate/client/pki/cacert_valid.pem rename to tests/assets/pki/cacert_valid.pem diff --git a/src/crate/client/pki/client_invalid.pem b/tests/assets/pki/client_invalid.pem similarity index 100% rename from src/crate/client/pki/client_invalid.pem rename to tests/assets/pki/client_invalid.pem diff --git a/src/crate/client/pki/client_valid.pem b/tests/assets/pki/client_valid.pem similarity index 100% rename from src/crate/client/pki/client_valid.pem rename to tests/assets/pki/client_valid.pem diff --git a/src/crate/client/pki/readme.rst b/tests/assets/pki/readme.rst similarity index 100% rename from src/crate/client/pki/readme.rst rename to tests/assets/pki/readme.rst diff --git a/src/crate/client/pki/server_valid.pem b/tests/assets/pki/server_valid.pem similarity index 100% rename from src/crate/client/pki/server_valid.pem rename to tests/assets/pki/server_valid.pem 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 00000000..e69de29b diff --git a/src/crate/client/tests.py b/tests/client/layer.py similarity index 71% rename from src/crate/client/tests.py rename to tests/client/layer.py index 2f6be428..b2d521e7 100644 --- a/src/crate/client/tests.py +++ b/tests/client/layer.py @@ -25,7 +25,6 @@ import os import socket import unittest -import doctest from pprint import pprint from http.server import HTTPServer, BaseHTTPRequestHandler import ssl @@ -35,25 +34,12 @@ 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 crate.testing.layer import CrateLayer +from .settings import \ + assets_path, crate_host, crate_path, crate_port, \ + crate_transport_port, localhost -from .test_cursor import CursorTest -from .test_connection import ConnectionTest -from .test_http import ( - HttpClientTest, - ThreadSafeHttpClientTest, - KeepAliveClientTest, - ParamsTest, - RetryOnTimeoutServerTest, - RequestsCaBundleTest, - TestUsernameSentAsHeader, - TestCrateJsonEncoder, - TestDefaultSchemaHeader, -) makeSuite = unittest.TestLoader().loadTestsFromTestCase @@ -110,14 +96,15 @@ def ensure_cratedb_layer(): def setUpCrateLayerBaseline(test): - test.globs['crate_host'] = crate_host - test.globs['pprint'] = pprint - test.globs['print'] = cprint + if hasattr(test, "globs"): + 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: + with open(assets_path('mappings/locations.sql')) as s: stmt = s.read() cursor.execute(stmt) stmt = ("select count(*) from information_schema.tables " @@ -125,7 +112,7 @@ def setUpCrateLayerBaseline(test): cursor.execute(stmt) assert cursor.fetchall()[0][0] == 1 - data_path = docs_path('testing/testdata/data/test_a.json') + data_path = assets_path('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 @@ -146,6 +133,7 @@ def tearDownDropEntitiesBaseline(test): Drop all tables, views, and users created by `setUpWithCrateLayer*`. """ ddl_statements = [ + "DROP TABLE foobar", "DROP TABLE locations", "DROP BLOB TABLE myfiles", "DROP USER me", @@ -157,10 +145,8 @@ def tearDownDropEntitiesBaseline(test): 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")) + CERT_FILE = assets_path("pki/server_valid.pem") + CACERT_FILE = assets_path("pki/cacert_valid.pem") __name__ = "httpsserver" __bases__ = tuple() @@ -249,18 +235,10 @@ def setUpWithHttps(test): 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") - ) + test.globs['cacert_valid'] = assets_path("pki/cacert_valid.pem") + test.globs['cacert_invalid'] = assets_path("pki/cacert_invalid.pem") + test.globs['clientcert_valid'] = assets_path("pki/client_valid.pem") + test.globs['clientcert_invalid'] = assets_path("pki/client_invalid.pem") def _execute_statements(statements, on_error="ignore"): @@ -283,58 +261,3 @@ def _execute_statement(cursor, stmt, 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(makeSuite(CursorTest)) - suite.addTest(makeSuite(HttpClientTest)) - suite.addTest(makeSuite(KeepAliveClientTest)) - suite.addTest(makeSuite(ThreadSafeHttpClientTest)) - suite.addTest(makeSuite(ParamsTest)) - suite.addTest(makeSuite(ConnectionTest)) - suite.addTest(makeSuite(RetryOnTimeoutServerTest)) - suite.addTest(makeSuite(RequestsCaBundleTest)) - suite.addTest(makeSuite(TestUsernameSentAsHeader)) - suite.addTest(makeSuite(TestCrateJsonEncoder)) - suite.addTest(makeSuite(TestDefaultSchemaHeader)) - 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) - - return suite diff --git a/src/crate/testing/settings.py b/tests/client/settings.py similarity index 77% rename from src/crate/testing/settings.py rename to tests/client/settings.py index 34793cc6..228222fd 100644 --- a/src/crate/testing/settings.py +++ b/tests/client/settings.py @@ -21,27 +21,20 @@ # 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 diff --git a/src/crate/client/test_connection.py b/tests/client/test_connection.py similarity index 96% rename from src/crate/client/test_connection.py rename to tests/client/test_connection.py index 93510864..5badfab2 100644 --- a/src/crate/client/test_connection.py +++ b/tests/client/test_connection.py @@ -2,12 +2,12 @@ from urllib3 import Timeout -from .connection import Connection -from .http import Client +from crate.client.connection import Connection +from crate.client.http import Client from crate.client import connect from unittest import TestCase -from ..testing.settings import crate_host +from .settings import crate_host class ConnectionTest(TestCase): diff --git a/src/crate/client/test_cursor.py b/tests/client/test_cursor.py similarity index 99% rename from src/crate/client/test_cursor.py rename to tests/client/test_cursor.py index 79e7ddd6..318c172b 100644 --- a/src/crate/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -33,7 +33,7 @@ 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 +from crate.testing.util import ClientMocked class CursorTest(TestCase): diff --git a/src/crate/client/test_exceptions.py b/tests/client/test_exceptions.py similarity index 100% rename from src/crate/client/test_exceptions.py rename to tests/client/test_exceptions.py diff --git a/src/crate/client/test_http.py b/tests/client/test_http.py similarity index 98% rename from src/crate/client/test_http.py rename to tests/client/test_http.py index 8e547963..fd538fc1 100644 --- a/src/crate/client/test_http.py +++ b/tests/client/test_http.py @@ -43,8 +43,8 @@ import uuid import certifi -from .http import Client, CrateJsonEncoder, _get_socket_opts, _remove_certs_for_non_https -from .exceptions import ConnectionError, ProgrammingError, IntegrityError +from crate.client.http import Client, CrateJsonEncoder, _get_socket_opts, _remove_certs_for_non_https +from crate.client.exceptions import ConnectionError, ProgrammingError, IntegrityError REQUEST = 'crate.client.http.Server.request' CA_CERT_PATH = certifi.where() @@ -127,7 +127,7 @@ def test_connection_reset_exception(self): client.close() def test_no_connection_exception(self): - client = Client() + client = Client(servers="localhost:9999") self.assertRaises(ConnectionError, client.sql, 'select foo') client.close() diff --git a/tests/client/test_result.py b/tests/client/test_result.py new file mode 100644 index 00000000..dfed504f --- /dev/null +++ b/tests/client/test_result.py @@ -0,0 +1,88 @@ +import sys +import unittest + +from crate import client +from crate.client.exceptions import ProgrammingError +from .layer import setUpCrateLayerBaseline, tearDownDropEntitiesBaseline +from .settings import crate_host + + +class BulkOperationTest(unittest.TestCase): + + def setUp(self): + setUpCrateLayerBaseline(self) + + def tearDown(self): + tearDownDropEntitiesBaseline(self) + + @unittest.skipIf(sys.version_info < (3, 8), "BulkResponse needs Python 3.8 or higher") + def test_executemany_with_bulk_response_partial(self): + + # Import at runtime is on purpose, to permit skipping the test case. + from crate.client.result import BulkResponse + + connection = client.connect(crate_host) + cursor = connection.cursor() + + # Run SQL DDL. + cursor.execute("CREATE TABLE foobar (id INTEGER PRIMARY KEY, name STRING);") + + # Run a batch insert that only partially succeeds. + invalid_records = [(1, "Hotzenplotz 1"), (1, "Hotzenplotz 2")] + result = cursor.executemany("INSERT INTO foobar (id, name) VALUES (?, ?)", invalid_records) + + # Verify CrateDB response. + self.assertEqual(result, [{"rowcount": 1}, {"rowcount": -2}]) + + # Verify decoded response. + bulk_response = BulkResponse(invalid_records, result) + self.assertEqual(bulk_response.failed_records, [(1, "Hotzenplotz 2")]) + self.assertEqual(bulk_response.record_count, 2) + self.assertEqual(bulk_response.success_count, 1) + self.assertEqual(bulk_response.failed_count, 1) + + cursor.execute("REFRESH TABLE foobar;") + cursor.execute("SELECT * FROM foobar;") + result = cursor.fetchall() + self.assertEqual(result, [[1, "Hotzenplotz 1"]]) + + cursor.close() + connection.close() + + @unittest.skipIf(sys.version_info < (3, 8), "BulkResponse needs Python 3.8 or higher") + def test_executemany_empty(self): + + connection = client.connect(crate_host) + cursor = connection.cursor() + + # Run SQL DDL. + cursor.execute("CREATE TABLE foobar (id INTEGER PRIMARY KEY, name STRING);") + + # Run a batch insert that is empty. + with self.assertRaises(ProgrammingError) as cm: + cursor.executemany("INSERT INTO foobar (id, name) VALUES (?, ?)", []) + self.assertEqual( + str(cm.exception), + "SQLParseException[The query contains a parameter placeholder $1, " + "but there are only 0 parameter values]") + + cursor.close() + connection.close() + + @unittest.skipIf(sys.version_info < (3, 8), "BulkResponse needs Python 3.8 or higher") + def test_bulk_response_empty_records_or_results(self): + + # Import at runtime is on purpose, to permit skipping the test case. + from crate.client.result import BulkResponse + + with self.assertRaises(ValueError) as cm: + BulkResponse(records=None, results=None) + self.assertEqual( + str(cm.exception), + "Processing a bulk response without records is an invalid operation") + + with self.assertRaises(ValueError) as cm: + BulkResponse(records=[], results=None) + self.assertEqual( + str(cm.exception), + "Processing a bulk response without results is an invalid operation") diff --git a/tests/client/tests.py b/tests/client/tests.py new file mode 100644 index 00000000..423a3206 --- /dev/null +++ b/tests/client/tests.py @@ -0,0 +1,72 @@ +import doctest +import unittest + +from .test_connection import ConnectionTest +from .test_cursor import CursorTest +from .test_http import HttpClientTest, KeepAliveClientTest, ThreadSafeHttpClientTest, ParamsTest, \ + RetryOnTimeoutServerTest, RequestsCaBundleTest, TestUsernameSentAsHeader, TestCrateJsonEncoder, \ + TestDefaultSchemaHeader +from .layer import makeSuite, setUpWithHttps, HttpsTestServerLayer, setUpCrateLayerBaseline, \ + tearDownDropEntitiesBaseline, ensure_cratedb_layer +from .test_result import BulkOperationTest + + +def test_suite(): + suite = unittest.TestSuite() + flags = (doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS) + + # Unit tests. + suite.addTest(makeSuite(CursorTest)) + suite.addTest(makeSuite(HttpClientTest)) + suite.addTest(makeSuite(KeepAliveClientTest)) + suite.addTest(makeSuite(ThreadSafeHttpClientTest)) + suite.addTest(makeSuite(ParamsTest)) + suite.addTest(makeSuite(ConnectionTest)) + suite.addTest(makeSuite(RetryOnTimeoutServerTest)) + suite.addTest(makeSuite(RequestsCaBundleTest)) + suite.addTest(makeSuite(TestUsernameSentAsHeader)) + suite.addTest(makeSuite(TestCrateJsonEncoder)) + suite.addTest(makeSuite(TestDefaultSchemaHeader)) + 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. + layer = ensure_cratedb_layer() + + s = makeSuite(BulkOperationTest) + s.layer = layer + suite.addTest(s) + + 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 = layer + suite.addTest(s) + + return suite diff --git a/tests/testing/__init__.py b/tests/testing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/testing/settings.py b/tests/testing/settings.py new file mode 100644 index 00000000..eb99a055 --- /dev/null +++ b/tests/testing/settings.py @@ -0,0 +1,9 @@ +from pathlib import Path + + +def crate_path() -> str: + return str(project_root() / "parts" / "crate") + + +def project_root() -> Path: + return Path(__file__).parent.parent.parent diff --git a/src/crate/testing/test_layer.py b/tests/testing/test_layer.py similarity index 99% rename from src/crate/testing/test_layer.py rename to tests/testing/test_layer.py index aaeca336..38d53922 100644 --- a/src/crate/testing/test_layer.py +++ b/tests/testing/test_layer.py @@ -29,7 +29,7 @@ import urllib3 import crate -from .layer import CrateLayer, prepend_http, http_url_from_host_port, wait_for_http_url +from crate.testing.layer import CrateLayer, prepend_http, http_url_from_host_port, wait_for_http_url from .settings import crate_path diff --git a/src/crate/testing/tests.py b/tests/testing/tests.py similarity index 100% rename from src/crate/testing/tests.py rename to tests/testing/tests.py diff --git a/tox.ini b/tox.ini index 978bd90c..1ea931fa 100644 --- a/tox.ini +++ b/tox.ini @@ -11,4 +11,4 @@ deps = mock urllib3 commands = - zope-testrunner -c --test-path=src + zope-testrunner -c --path=tests diff --git a/versions.cfg b/versions.cfg index 62f7d9f3..6dd217c8 100644 --- a/versions.cfg +++ b/versions.cfg @@ -1,4 +1,4 @@ [versions] -crate_server = 5.1.1 +crate_server = 5.9.2 hexagonit.recipe.download = 1.7.1