diff --git a/CHANGES.txt b/CHANGES.txt index b22f2a60..5e99d18c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,8 @@ Changes for crate Unreleased ========== +- Added a generic data type converter to the ``Cursor`` object, for converting + fetched data from CrateDB data types to Python data types. 2022/10/10 0.27.2 diff --git a/docs/query.rst b/docs/query.rst index 10c6b42a..b80bcb9c 100644 --- a/docs/query.rst +++ b/docs/query.rst @@ -199,7 +199,55 @@ You can turn this into something more manageable with a `list comprehension`_:: >>> [column[0] for column in cursor.description] ['date', 'datetime_tz', 'datetime_notz', ..., 'nullable_datetime', 'position'] + +Data type conversion +==================== + +The cursor object can optionally convert database types to native Python data +types. There is a default implementation for the CrateDB data types ``IP`` and +``TIMESTAMP`` on behalf of the ``DefaultTypeConverter``. + +:: + + >>> from crate.client.converter import DefaultTypeConverter + >>> from crate.client.cursor import Cursor + >>> cursor = connection.cursor(converter=DefaultTypeConverter()) + + >>> cursor.execute("SELECT datetime_tz, datetime_notz FROM locations ORDER BY name") + + >>> cursor.fetchone() + [datetime.datetime(2022, 7, 18, 18, 10, 36, 758000), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000)] + + +Custom data type conversion +=========================== + +By providing a custom converter instance, you can define your own data type +conversions. For investigating the list of available data types, please either +inspect the ``DataType`` enum, or the documentation about the list of available +`CrateDB data type identifiers for the HTTP interface`_. + +This example creates and applies a simple custom converter for converging +CrateDB's ``BOOLEAN`` type to Python's ``str`` type. It is using a simple +converter function defined as ``lambda``, which assigns ``yes`` for boolean +``True``, and ``no`` otherwise. + +:: + + >>> from crate.client.converter import Converter, DataType + + >>> converter = Converter() + >>> converter.set(DataType.BOOLEAN, lambda value: value is True and "yes" or "no") + >>> cursor = connection.cursor(converter=converter) + + >>> cursor.execute("SELECT flag FROM locations ORDER BY name") + + >>> cursor.fetchone() + ['no'] + + .. _Bulk inserts: https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#bulk-operations +.. _CrateDB data type identifiers for the HTTP interface: https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types .. _Database API: http://www.python.org/dev/peps/pep-0249/ .. _database cursor: https://en.wikipedia.org/wiki/Cursor_(databases) .. _DB API 2.0: http://www.python.org/dev/peps/pep-0249/ diff --git a/docs/sqlalchemy.rst b/docs/sqlalchemy.rst index 9ee38cf0..f53ea5ed 100644 --- a/docs/sqlalchemy.rst +++ b/docs/sqlalchemy.rst @@ -629,7 +629,7 @@ column on the ``Character`` class. .. _operator: https://docs.python.org/2/library/operator.html .. _any: http://docs.sqlalchemy.org/en/latest/core/type_basics.html#sqlalchemy.types.ARRAY.Comparator.any .. _tuple: https://docs.python.org/3/library/stdtypes.html#sequence-types-list-tuple-range -.. _count result rows: http://docs.sqlalchemy.org/en/latest/orm/tutorial.html#counting +.. _count result rows: http://docs.sqlalchemy.org/en/14/orm/tutorial.html#counting .. _MATCH predicate: https://crate.io/docs/crate/reference/en/latest/general/dql/fulltext.html#match-predicate .. _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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2f6fe486 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.mypy] + +# Needed until `mypy-0.990` for `ConverterDefinition` in `converter.py`. +# https://github.com/python/mypy/issues/731#issuecomment-1260976955 +enable_recursive_aliases = true diff --git a/src/crate/client/connection.py b/src/crate/client/connection.py index 58059063..84e4ec8e 100644 --- a/src/crate/client/connection.py +++ b/src/crate/client/connection.py @@ -46,6 +46,7 @@ def __init__(self, socket_tcp_keepidle=None, socket_tcp_keepintvl=None, socket_tcp_keepcnt=None, + converter=None, ): """ :param servers: @@ -99,7 +100,13 @@ def __init__(self, Set the ``TCP_KEEPCNT`` socket option, which overrides ``net.ipv4.tcp_keepalive_probes`` kernel setting if ``socket_keepalive`` is ``True``. + :param converter: + (optional, defaults to ``None``) + A `Converter` object to propagate to newly created `Cursor` objects. """ + + self._converter = converter + if client: self.client = client else: @@ -123,12 +130,16 @@ def __init__(self, self.lowest_server_version = self._lowest_server_version() self._closed = False - def cursor(self): + def cursor(self, **kwargs) -> Cursor: """ Return a new Cursor Object using the connection. """ + converter = kwargs.pop("converter", self._converter) if not self._closed: - return Cursor(self) + return Cursor( + connection=self, + converter=converter, + ) else: raise ProgrammingError("Connection closed") diff --git a/src/crate/client/converter.py b/src/crate/client/converter.py new file mode 100644 index 00000000..d0dad3b0 --- /dev/null +++ b/src/crate/client/converter.py @@ -0,0 +1,135 @@ +# -*- 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. +""" +Machinery for converting CrateDB database types to native Python data types. + +https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types +""" +import ipaddress +from copy import deepcopy +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Union + +ConverterFunction = Callable[[Optional[Any]], Optional[Any]] +ColTypesDefinition = Union[int, List[Union[int, "ColTypesDefinition"]]] + + +def _to_ipaddress(value: Optional[str]) -> Optional[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: + """ + https://docs.python.org/3/library/ipaddress.html + """ + if value is None: + return None + return ipaddress.ip_address(value) + + +def _to_datetime(value: Optional[float]) -> Optional[datetime]: + """ + https://docs.python.org/3/library/datetime.html + """ + if value is None: + return None + return datetime.utcfromtimestamp(value / 1e3) + + +def _to_default(value: Optional[Any]) -> Optional[Any]: + return value + + +# Symbolic aliases for the numeric 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 + NOT_SUPPORTED = 1 + CHAR = 2 + BOOLEAN = 3 + TEXT = 4 + IP = 5 + DOUBLE = 6 + REAL = 7 + SMALLINT = 8 + INTEGER = 9 + BIGINT = 10 + TIMESTAMP_WITH_TZ = 11 + OBJECT = 12 + GEOPOINT = 13 + GEOSHAPE = 14 + TIMESTAMP_WITHOUT_TZ = 15 + UNCHECKED_OBJECT = 16 + REGPROC = 19 + TIME = 20 + OIDVECTOR = 21 + NUMERIC = 22 + REGCLASS = 23 + DATE = 24 + BIT = 25 + JSON = 26 + CHARACTER = 27 + ARRAY = 100 + + +ConverterMapping = Dict[DataType, ConverterFunction] + + +# Map data type identifier to converter function. +_DEFAULT_CONVERTERS: ConverterMapping = { + DataType.IP: _to_ipaddress, + DataType.TIMESTAMP_WITH_TZ: _to_datetime, + DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime, +} + + +class Converter: + def __init__( + self, + mappings: Optional[ConverterMapping] = None, + default: ConverterFunction = _to_default, + ) -> None: + self._mappings = mappings or {} + self._default = default + + def get(self, type_: ColTypesDefinition) -> ConverterFunction: + if isinstance(type_, int): + 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") + + inner_convert = self.get(inner_type) + + def convert(value: Any) -> Optional[List[Any]]: + if value is None: + return None + return [inner_convert(x) for x in value] + + return convert + + +class DefaultTypeConverter(Converter): + 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 + ) diff --git a/src/crate/client/cursor.py b/src/crate/client/cursor.py index 59e936d7..25b88667 100644 --- a/src/crate/client/cursor.py +++ b/src/crate/client/cursor.py @@ -19,9 +19,11 @@ # 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 .exceptions import ProgrammingError import warnings +from .converter import Converter +from .exceptions import ProgrammingError + class Cursor(object): """ @@ -30,9 +32,10 @@ class Cursor(object): """ lastrowid = None # currently not supported - def __init__(self, connection): + def __init__(self, connection, converter: Converter): self.arraysize = 1 self.connection = connection + self._converter = converter self._closed = False self._result = None self.rows = None @@ -50,7 +53,10 @@ def execute(self, sql, parameters=None, bulk_parameters=None): self._result = self.connection.client.sql(sql, parameters, bulk_parameters) if "rows" in self._result: - self.rows = iter(self._result["rows"]) + if self._converter is None: + self.rows = iter(self._result["rows"]) + else: + self.rows = iter(self._convert_rows()) def executemany(self, sql, seq_of_parameters): """ @@ -73,9 +79,13 @@ def executemany(self, sql, seq_of_parameters): "duration": sum(durations) if durations else -1, "rows": [], "cols": self._result.get("cols", []), + "col_types": self._result.get("col_types", []), "results": self._result.get("results") } - self.rows = iter(self._result["rows"]) + if self._converter is None: + self.rows = iter(self._result["rows"]) + else: + self.rows = iter(self._convert_rows()) return self._result["results"] def fetchone(self): @@ -210,3 +220,24 @@ def duration(self): "duration" not in self._result: return -1 return self._result.get("duration", 0) + + 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" + + # 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 + ] + + # Process result rows with conversion. + for row in self._result["rows"]: + yield [ + convert(value) + for convert, value in zip(converters, row) + ] diff --git a/src/crate/client/doctests/cursor.txt b/src/crate/client/doctests/cursor.txt index 970465da..f1f2ee6b 100644 --- a/src/crate/client/doctests/cursor.txt +++ b/src/crate/client/doctests/cursor.txt @@ -2,9 +2,18 @@ Cursor ====== + +Setup +===== + +This section sets up a cursor object, inspects some of its attributes, and sets +up the response for subsequent cursor operations. + :: >>> from crate.client import connect + >>> from crate.client.converter import DefaultTypeConverter + >>> from crate.client.cursor import Cursor >>> connection = connect(client=connection_client_mocked) >>> cursor = connection.cursor() @@ -81,7 +90,7 @@ If the specified number of rows not being available, fewer rows may returned:: >>> cursor.execute('') -If no number of rows are specified it defaults to the current cursor.arraysize:: +If no number of rows are specified it defaults to the current ``cursor.arraysize``:: >>> cursor.arraysize 1 @@ -179,7 +188,7 @@ The attribute is -1 in case the cursor has been closed:: >>> cursor.rowcount -1 -If the last respsonse doesn't contain the rowcount attribute -1 is returned:: +If the last response does not contain the rowcount attribute, ``-1`` is returned:: >>> cursor = connection.cursor() >>> connection.client.set_next_response({ @@ -296,6 +305,68 @@ closed connection an ``ProgrammingError`` exception will be raised:: ... crate.client.exceptions.ProgrammingError: Cursor closed + +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``. + +:: + + >>> cursor = connection.cursor(converter=DefaultTypeConverter()) + + >>> connection.client.set_next_response({ + ... "col_types": [4, 5, 11], + ... "rows":[ [ "foo", "10.10.10.1", 1658167836758 ] ], + ... "cols":[ "name", "address", "timestamp" ], + ... "rowcount":1, + ... "duration":123 + ... }) + + >>> cursor.execute('') + + >>> cursor.fetchone() + ['foo', IPv4Address('10.10.10.1'), datetime.datetime(2022, 7, 18, 18, 10, 36, 758000)] + + +Custom data type conversion +=========================== + +By providing a custom converter instance, you can define your own data type +conversions. For investigating the list of available data types, please either +inspect the ``DataType`` enum, or the documentation about the list of available +`CrateDB data type identifiers for the HTTP interface`_. + +To create a simple converter for converging CrateDB's ``BIT`` type to Python's +``int`` type:: + + >>> from crate.client.converter import Converter, DataType + + >>> converter = Converter({DataType.BIT: lambda value: int(value[2:-1], 2)}) + >>> cursor = connection.cursor(converter=converter) + +Proof that the converter works correctly, ``B\'0110\'`` should be converted to +``6``. CrateDB's ``BIT`` data type has the numeric identifier ``25``:: + + >>> connection.client.set_next_response({ + ... "col_types": [25], + ... "rows":[ [ "B'0110'" ] ], + ... "cols":[ "value" ], + ... "rowcount":1, + ... "duration":123 + ... }) + + >>> cursor.execute('') + + >>> cursor.fetchone() + [6] + + .. Hidden: close connection >>> connection.close() + + +.. _CrateDB data type identifiers for the HTTP interface: https://crate.io/docs/crate/reference/en/latest/interfaces/http.html#column-types diff --git a/src/crate/client/doctests/http.txt b/src/crate/client/doctests/http.txt index fa9407c3..0c411f55 100644 --- a/src/crate/client/doctests/http.txt +++ b/src/crate/client/doctests/http.txt @@ -69,7 +69,8 @@ Issue a select statement against our with test data pre-filled crate instance:: >>> http_client = HttpClient(crate_host) >>> result = http_client.sql('select name from locations order by name') >>> pprint(result) - {'cols': ['name'], + {'col_types': [4], + 'cols': ['name'], 'duration': ..., 'rowcount': 13, 'rows': [['Aldebaran'], diff --git a/src/crate/client/http.py b/src/crate/client/http.py index 44643a36..e932f732 100644 --- a/src/crate/client/http.py +++ b/src/crate/client/http.py @@ -315,7 +315,7 @@ class Client(object): Crate connection client using CrateDB's HTTP API. """ - SQL_PATH = '/_sql' + SQL_PATH = '/_sql?types=true' """Crate URI path for issuing SQL statements.""" retry_interval = 30 @@ -385,7 +385,7 @@ def __init__(self, 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(): diff --git a/src/crate/client/test_cursor.py b/src/crate/client/test_cursor.py index a926ae4c..8a1f3e7f 100644 --- a/src/crate/client/test_cursor.py +++ b/src/crate/client/test_cursor.py @@ -19,11 +19,15 @@ # 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 ipaddress import IPv4Address from unittest import TestCase from unittest.mock import MagicMock 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): @@ -45,3 +49,147 @@ def test_execute_with_bulk_args(self): 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(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() + assert 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() + assert 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, []) diff --git a/src/crate/client/test_http.py b/src/crate/client/test_http.py index 4a073099..42e77e43 100644 --- a/src/crate/client/test_http.py +++ b/src/crate/client/test_http.py @@ -436,7 +436,7 @@ def test_params(self): def test_no_params(self): client = Client() - self.assertEqual(client.path, "/_sql") + self.assertEqual(client.path, "/_sql?types=true") client.close() diff --git a/src/crate/client/test_util.py b/src/crate/client/test_util.py new file mode 100644 index 00000000..90379a79 --- /dev/null +++ b/src/crate/client/test_util.py @@ -0,0 +1,44 @@ +# -*- 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 index e0abafd2..9abe1881 100644 --- a/src/crate/client/tests.py +++ b/src/crate/client/tests.py @@ -40,6 +40,7 @@ from crate.testing.tests import crate_path, docs_path from crate.client import connect from crate.client.sqlalchemy.dialect import CrateDialect +from crate.client.test_util import ClientMocked from . import http from .test_cursor import CursorTest @@ -69,30 +70,6 @@ def cprint(s): print(s) -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 - - def setUpMocked(test): test.globs['connection_client_mocked'] = ClientMocked() @@ -335,6 +312,10 @@ def _try_execute(cursor, stmt): try: cursor.execute(stmt) except Exception: + # 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") pass