diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f887898..0f1e78b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added +- Allow to require specific server protocol version and features (#267). + ## 1.0.0 - 2023-04-17 ### Changed diff --git a/tarantool/connection.py b/tarantool/connection.py index 9e0ec0f9..ea089c08 100644 --- a/tarantool/connection.py +++ b/tarantool/connection.py @@ -20,6 +20,7 @@ import ctypes.util from ctypes import c_ssize_t from typing import Optional, Union +from copy import copy import msgpack @@ -65,6 +66,9 @@ IPROTO_FEATURE_TRANSACTIONS, IPROTO_FEATURE_ERROR_EXTENSION, IPROTO_FEATURE_WATCHERS, + IPROTO_FEATURE_PAGINATION, + IPROTO_FEATURE_SPACE_AND_INDEX_NAMES, + IPROTO_FEATURE_WATCH_ONCE, IPROTO_CHUNK, AUTH_TYPE_CHAP_SHA1, AUTH_TYPE_PAP_SHA256, @@ -607,7 +611,9 @@ def __init__(self, host, port, packer_factory=default_packer_factory, unpacker_factory=default_unpacker_factory, auth_type=None, - fetch_schema=True): + fetch_schema=True, + required_protocol_version=None, + required_features=None): """ :param host: Server hostname or IP address. Use ``None`` for Unix sockets. @@ -776,6 +782,14 @@ def __init__(self, host, port, :meth:`~tarantool.Connection.space`. :type fetch_schema: :obj:`bool`, optional + :param required_protocol_version: Minimal protocol version that + should be supported by Tarantool server. + :type required_protocol_version: :obj:`int` or :obj:`None`, optional + + :param required_features: List of protocol features that + should be supported by Tarantool server. + :type required_features: :obj:`list` or :obj:`None`, optional + :raise: :exc:`~tarantool.error.ConfigurationError`, :meth:`~tarantool.Connection.connect` exceptions @@ -784,7 +798,7 @@ def __init__(self, host, port, .. _mp_bin: https://github.com/msgpack/msgpack/blob/master/spec.md#bin-format-family .. _mp_array: https://github.com/msgpack/msgpack/blob/master/spec.md#array-format-family """ - # pylint: disable=too-many-arguments,too-many-locals + # pylint: disable=too-many-arguments,too-many-locals,too-many-statements if msgpack.version >= (1, 0, 0) and encoding not in (None, 'utf-8'): raise ConfigurationError("msgpack>=1.0.0 only supports None and " @@ -830,6 +844,9 @@ def __init__(self, host, port, IPROTO_FEATURE_TRANSACTIONS: False, IPROTO_FEATURE_ERROR_EXTENSION: False, IPROTO_FEATURE_WATCHERS: False, + IPROTO_FEATURE_PAGINATION: False, + IPROTO_FEATURE_SPACE_AND_INDEX_NAMES: False, + IPROTO_FEATURE_WATCH_ONCE: False, } self._packer_factory_impl = packer_factory self._unpacker_factory_impl = unpacker_factory @@ -838,6 +855,12 @@ def __init__(self, host, port, self.version_id = None self.uuid = None self._salt = None + self._client_protocol_version = CONNECTOR_IPROTO_VERSION + self._client_features = copy(CONNECTOR_FEATURES) + self._server_protocol_version = None + self._server_features = None + self.required_protocol_version = required_protocol_version + self.required_features = copy(required_features) if connect_now: self.connect() @@ -1044,10 +1067,11 @@ def handshake(self): if greeting.protocol != "Binary": raise NetworkError("Unsupported protocol: " + greeting.protocol) self.version_id = greeting.version_id - if self.version_id >= version_id(2, 10, 0): - self._check_features() self.uuid = greeting.uuid self._salt = greeting.salt + + self._check_features() + if self.user: self.authenticate(self.user, self.password) @@ -2057,32 +2081,47 @@ def _check_features(self): :exc:`~tarantool.error.SslError` """ - try: - request = RequestProtocolVersion(self, - CONNECTOR_IPROTO_VERSION, - CONNECTOR_FEATURES) - response = self._send_request(request) - server_protocol_version = response.protocol_version - server_features = response.features - server_auth_type = response.auth_type - except DatabaseError as exc: - if exc.code == ER_UNKNOWN_REQUEST_TYPE: - server_protocol_version = None - server_features = [] - server_auth_type = None + if self.version_id >= version_id(2, 10, 0): + try: + request = RequestProtocolVersion(self, + self._client_protocol_version, + self._client_features) + response = self._send_request(request) + self._server_protocol_version = response.protocol_version + self._server_features = response.features + self._server_auth_type = response.auth_type + except DatabaseError as exc: + if exc.code != ER_UNKNOWN_REQUEST_TYPE: + raise exc + + if self.required_protocol_version is not None: + if self._server_protocol_version is None or \ + self._server_protocol_version < self.required_protocol_version: + raise ConfigurationError('Server protocol version is ' + f'{self._server_protocol_version}, ' + f'protocol version {self.required_protocol_version} ' + 'is required') + + if self.required_features is not None: + if self._server_features is None: + failed_features = self.required_features else: - raise exc + failed_features = [val for val in self.required_features + if val not in self._server_features] - if server_protocol_version is not None: - self._protocol_version = min(server_protocol_version, - CONNECTOR_IPROTO_VERSION) + if len(failed_features) > 0: + str_features = ', '.join([str(v) for v in failed_features]) + raise ConfigurationError(f'Server missing protocol features with id {str_features}') - # Intercept lists of features - features_list = [val for val in CONNECTOR_FEATURES if val in server_features] - for val in features_list: - self._features[val] = True + if self._server_protocol_version is not None: + self._protocol_version = min(self._server_protocol_version, + self._client_protocol_version) - self._server_auth_type = server_auth_type + # Intercept lists of features + if self._server_features is not None: + features_list = [val for val in self._client_features if val in self._server_features] + for val in features_list: + self._features[val] = True def _packer_factory(self): return self._packer_factory_impl(self) diff --git a/tarantool/const.py b/tarantool/const.py index 21a3c8a9..1e2b0895 100644 --- a/tarantool/const.py +++ b/tarantool/const.py @@ -99,6 +99,9 @@ IPROTO_FEATURE_TRANSACTIONS = 1 IPROTO_FEATURE_ERROR_EXTENSION = 2 IPROTO_FEATURE_WATCHERS = 3 +IPROTO_FEATURE_PAGINATION = 4 +IPROTO_FEATURE_SPACE_AND_INDEX_NAMES = 5 +IPROTO_FEATURE_WATCH_ONCE = 6 # Default value for connection timeout (seconds) CONNECTION_TIMEOUT = None @@ -133,8 +136,8 @@ # Default delay between attempts to reconnect (seconds) POOL_INSTANCE_RECONNECT_DELAY = 0 -# Tarantool 2.10 protocol version is 3 -CONNECTOR_IPROTO_VERSION = 3 +# Tarantool master 970ea48 protocol version is 6 +CONNECTOR_IPROTO_VERSION = 6 # List of connector-supported features CONNECTOR_FEATURES = [IPROTO_FEATURE_ERROR_EXTENSION] diff --git a/test/suites/lib/skip.py b/test/suites/lib/skip.py index 694d8d57..e370f7a1 100644 --- a/test/suites/lib/skip.py +++ b/test/suites/lib/skip.py @@ -277,3 +277,19 @@ def skip_or_run_constraints_test(func): return skip_or_run_test_tarantool(func, '2.10.0', 'does not support schema constraints') + + +def skip_or_run_iproto_basic_features_test(func): + """ + Decorator to skip or run tests related to iproto ID requests, + protocol version and features. + + Tarantool supports iproto ID requests only since 2.10.0 version. + Protocol version is 3 for Tarantool 2.10.0, + IPROTO_FEATURE_STREAMS, IPROTO_FEATURE_TRANSACTIONS + and IPROTO_FEATURE_ERROR_EXTENSION are supported in Tarantool 2.10.0. + See https://github.com/tarantool/tarantool/issues/6253 + """ + + return skip_or_run_test_tarantool(func, '2.10.0', + 'does not support iproto ID and iproto basic features') diff --git a/test/suites/test_protocol.py b/test/suites/test_protocol.py index 6f3025f6..004f0ea0 100644 --- a/test/suites/test_protocol.py +++ b/test/suites/test_protocol.py @@ -15,10 +15,15 @@ IPROTO_FEATURE_TRANSACTIONS, IPROTO_FEATURE_ERROR_EXTENSION, IPROTO_FEATURE_WATCHERS, + IPROTO_FEATURE_PAGINATION, + IPROTO_FEATURE_SPACE_AND_INDEX_NAMES, + IPROTO_FEATURE_WATCH_ONCE, ) +from tarantool.error import NetworkError from tarantool.utils import greeting_decode, version_id from .lib.tarantool_server import TarantoolServer +from .lib.skip import skip_or_run_iproto_basic_features_test class TestSuiteProtocol(unittest.TestCase): @@ -91,6 +96,35 @@ def test_04_protocol(self): self.assertEqual(self.con._features[IPROTO_FEATURE_STREAMS], False) self.assertEqual(self.con._features[IPROTO_FEATURE_TRANSACTIONS], False) self.assertEqual(self.con._features[IPROTO_FEATURE_WATCHERS], False) + self.assertEqual(self.con._features[IPROTO_FEATURE_PAGINATION], False) + self.assertEqual(self.con._features[IPROTO_FEATURE_SPACE_AND_INDEX_NAMES], False) + self.assertEqual(self.con._features[IPROTO_FEATURE_WATCH_ONCE], False) + + @skip_or_run_iproto_basic_features_test + def test_protocol_requirement(self): + try: + con = tarantool.Connection(self.srv.host, self.srv.args['primary'], + required_protocol_version=3, + required_features=[IPROTO_FEATURE_STREAMS, + IPROTO_FEATURE_TRANSACTIONS, + IPROTO_FEATURE_ERROR_EXTENSION]) + con.close() + except Exception as exc: # pylint: disable=bad-option-value,broad-exception-caught,broad-except + self.fail(f'Connection create have raised Exception: {repr(exc)}') + + def test_protocol_version_requirement_fail(self): + with self.assertRaisesRegex(NetworkError, # ConfigurationError is wrapped in NetworkError + 'protocol version 100500 is required'): + con = tarantool.Connection(self.srv.host, self.srv.args['primary'], + required_protocol_version=100500) + con.close() + + def test_protocol_features_requirement_fail(self): + with self.assertRaisesRegex(NetworkError, # ConfigurationError is wrapped in NetworkError + 'Server missing protocol features with id 100500, 500100'): + con = tarantool.Connection(self.srv.host, self.srv.args['primary'], + required_features=[100500, 500100]) + con.close() @classmethod def tearDownClass(cls):