Skip to content

Commit a226518

Browse files
dkropachevabsurdfarcebschoening
authored
Sync with 3.29.0 (#406)
* PYTHON-1364 Fix ssl.wrap_socket errors (from eventlet) for Python 3.12 (datastax#1181) * PYTHON-1366 Handle removal of asyncore in Python 3.12 (datastax#1187) * Remove outdated Python pre-3.7 references (datastax#1186) * PYTHON-1371 Add explicit exception type for serialization failures (datastax#1193) * PYTHON-1331 ssl.match_hostname() is deprecated in 3.7 (datastax#1191) * Documentation (and other) updates for 3.29.0 (datastax#1194) * fix(test-requirements): remove python 3.13 restrictions Original commit c9b24b7 made it not install certain modules on python 3.13 because at the time python was broken and did not support these modules, now they work ok, roll it back. --------- Co-authored-by: Bret McGuire <[email protected]> Co-authored-by: Brad Schoening <[email protected]>
1 parent 297622f commit a226518

21 files changed

+304
-104
lines changed

CHANGELOG.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
3.29.0
2+
======
3+
December 19, 2023
4+
5+
Features
6+
--------
7+
* Add support for Python 3.9 through 3.12, drop support for 3.7 (PYTHON-1283)
8+
* Removal of dependency on six module (PR 1172)
9+
* Raise explicit exception when deserializing a vector with a subtype that isn’t a constant size (PYTHON-1371)
10+
11+
Others
12+
------
13+
* Remove outdated Python pre-3.7 references (PR 1186)
14+
* Remove backup(.bak) files (PR 1185)
15+
* Fix doc typo in add_callbacks (PR 1177)
16+
117
3.28.0
218
======
319
June 5, 2023

benchmarks/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
from cassandra.io.libevreactor import LibevConnection
5555
have_libev = True
5656
supported_reactors.append(LibevConnection)
57-
except ImportError as exc:
57+
except cassandra.DependencyException as exc:
5858
pass
5959

6060
have_asyncio = False

cassandra/__init__.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def emit(self, record):
2323

2424
logging.getLogger('cassandra').addHandler(NullHandler())
2525

26-
__version_info__ = (3, 28, 2)
26+
__version_info__ = (3, 29, 0)
2727
__version__ = '.'.join(map(str, __version_info__))
2828

2929

@@ -735,6 +735,7 @@ class OperationType(Enum):
735735
Read = 0
736736
Write = 1
737737

738+
738739
class RateLimitReached(ConfigurationException):
739740
'''
740741
Rate limit was exceeded for a partition affected by the request.
@@ -747,3 +748,26 @@ def __init__(self, op_type=None, rejected_by_coordinator=False):
747748
self.rejected_by_coordinator = rejected_by_coordinator
748749
message = f"[request_error_rate_limit_reached OpType={op_type.name} RejectedByCoordinator={rejected_by_coordinator}]"
749750
Exception.__init__(self, message)
751+
752+
753+
class DependencyException(Exception):
754+
"""
755+
Specific exception class for handling issues with driver dependencies
756+
"""
757+
758+
excs = []
759+
"""
760+
A sequence of child exceptions
761+
"""
762+
763+
def __init__(self, msg, excs=[]):
764+
complete_msg = msg
765+
if excs:
766+
complete_msg += ("\nThe following exceptions were observed: \n - " + '\n - '.join(str(e) for e in excs))
767+
Exception.__init__(self, complete_msg)
768+
769+
class VectorDeserializationFailure(DriverException):
770+
"""
771+
The driver was unable to deserialize a given vector
772+
"""
773+
pass

cassandra/cluster.py

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from collections.abc import Mapping
2626
from concurrent.futures import ThreadPoolExecutor, FIRST_COMPLETED, wait as wait_futures
2727
from copy import copy
28-
from functools import partial, wraps
28+
from functools import partial, reduce, wraps
2929
from itertools import groupby, count, chain
3030
import json
3131
import logging
@@ -45,7 +45,7 @@
4545
from cassandra import (ConsistencyLevel, AuthenticationFailed, InvalidRequest,
4646
OperationTimedOut, UnsupportedOperation,
4747
SchemaTargetType, DriverException, ProtocolVersion,
48-
UnresolvableContactPoints)
48+
UnresolvableContactPoints, DependencyException)
4949
from cassandra.auth import _proxy_execute_key, PlainTextAuthProvider
5050
from cassandra.connection import (ConnectionException, ConnectionShutdown,
5151
ConnectionHeartbeat, ProtocolVersionUnsupported,
@@ -113,6 +113,19 @@
113113
except ImportError:
114114
from cassandra.util import WeakSet # NOQA
115115

116+
def _is_gevent_monkey_patched():
117+
if 'gevent.monkey' not in sys.modules:
118+
return False
119+
import gevent.socket
120+
return socket.socket is gevent.socket.socket
121+
122+
def _try_gevent_import():
123+
if _is_gevent_monkey_patched():
124+
from cassandra.io.geventreactor import GeventConnection
125+
return (GeventConnection,None)
126+
else:
127+
return (None,None)
128+
116129
def _is_eventlet_monkey_patched():
117130
if 'eventlet.patcher' not in sys.modules:
118131
return False
@@ -124,28 +137,46 @@ def _is_eventlet_monkey_patched():
124137
# TODO: remove it when eventlet issue would be fixed
125138
return False
126139

127-
def _is_gevent_monkey_patched():
128-
if 'gevent.monkey' not in sys.modules:
129-
return False
130-
import gevent.socket
131-
return socket.socket is gevent.socket.socket
140+
def _try_eventlet_import():
141+
if _is_eventlet_monkey_patched():
142+
from cassandra.io.eventletreactor import EventletConnection
143+
return (EventletConnection,None)
144+
else:
145+
return (None,None)
132146

147+
def _try_libev_import():
148+
try:
149+
from cassandra.io.libevreactor import LibevConnection
150+
return (LibevConnection,None)
151+
except DependencyException as e:
152+
return (None, e)
133153

134-
# default to gevent when we are monkey patched with gevent, eventlet when
135-
# monkey patched with eventlet, otherwise if libev is available, use that as
136-
# the default because it's fastest. Otherwise, use asyncore.
137-
if _is_gevent_monkey_patched():
138-
from cassandra.io.geventreactor import GeventConnection as DefaultConnection
139-
elif _is_eventlet_monkey_patched():
140-
from cassandra.io.eventletreactor import EventletConnection as DefaultConnection
141-
else:
154+
def _try_asyncore_import():
142155
try:
143-
from cassandra.io.libevreactor import LibevConnection as DefaultConnection # NOQA
144-
except ImportError:
145-
try:
146-
from cassandra.io.asyncorereactor import AsyncoreConnection as DefaultConnection # NOQA
147-
except ImportError:
148-
from cassandra.io.asyncioreactor import AsyncioConnection as DefaultConnection # NOQA
156+
from cassandra.io.asyncorereactor import AsyncoreConnection
157+
return (AsyncoreConnection,None)
158+
except DependencyException as e:
159+
return (None, e)
160+
161+
def _try_asyncio_import():
162+
from cassandra.io.asyncioreactor import AsyncioConnection
163+
return (AsyncioConnection, None)
164+
165+
def _connection_reduce_fn(val,import_fn):
166+
(rv, excs) = val
167+
# If we've already found a workable Connection class return immediately
168+
if rv:
169+
return val
170+
(import_result, exc) = import_fn()
171+
if exc:
172+
excs.append(exc)
173+
return (rv or import_result, excs)
174+
175+
conn_fns = (_try_gevent_import, _try_eventlet_import, _try_libev_import, _try_asyncore_import, _try_asyncio_import)
176+
(conn_class, excs) = reduce(_connection_reduce_fn, conn_fns, (None,[]))
177+
if not conn_class:
178+
raise DependencyException("Exception loading connection class dependencies", excs)
179+
DefaultConnection = conn_class
149180

150181
# Forces load of utf8 encoding module to avoid deadlock that occurs
151182
# if code that is being imported tries to import the module in a seperate
@@ -802,9 +833,9 @@ def default_retry_policy(self, policy):
802833
Using ssl_options without ssl_context is deprecated and will be removed in the
803834
next major release.
804835
805-
An optional dict which will be used as kwargs for ``ssl.SSLContext.wrap_socket`` (or
806-
``ssl.wrap_socket()`` if used without ssl_context) when new sockets are created.
807-
This should be used when client encryption is enabled in Cassandra.
836+
An optional dict which will be used as kwargs for ``ssl.SSLContext.wrap_socket``
837+
when new sockets are created. This should be used when client encryption is enabled
838+
in Cassandra.
808839
809840
The following documentation only applies when ssl_options is used without ssl_context.
810841
@@ -820,6 +851,12 @@ def default_retry_policy(self, policy):
820851
should almost always require the option ``'cert_reqs': ssl.CERT_REQUIRED``. Note also that this functionality was not built into
821852
Python standard library until (2.7.9, 3.2). To enable this mechanism in earlier versions, patch ``ssl.match_hostname``
822853
with a custom or `back-ported function <https://pypi.org/project/backports.ssl_match_hostname/>`_.
854+
855+
.. versionchanged:: 3.29.0
856+
857+
``ssl.match_hostname`` has been deprecated since Python 3.7 (and removed in Python 3.12). This functionality is now implemented
858+
via ``ssl.SSLContext.check_hostname``. All options specified above (including ``check_hostname``) should continue to behave in a
859+
way that is consistent with prior implementations.
823860
"""
824861

825862
ssl_context = None

cassandra/connection.py

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,6 @@ class Connection(object):
752752
_socket = None
753753

754754
_socket_impl = socket
755-
_ssl_impl = ssl
756755

757756
_check_hostname = False
758757
_product_type = None
@@ -780,7 +779,7 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
780779
self.endpoint = host if isinstance(host, EndPoint) else DefaultEndPoint(host, port)
781780

782781
self.authenticator = authenticator
783-
self.ssl_options = ssl_options.copy() if ssl_options else None
782+
self.ssl_options = ssl_options.copy() if ssl_options else {}
784783
self.ssl_context = ssl_context
785784
self.sockopts = sockopts
786785
self.compression = compression
@@ -800,15 +799,20 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
800799
self._on_orphaned_stream_released = on_orphaned_stream_released
801800

802801
if ssl_options:
803-
self._check_hostname = bool(self.ssl_options.pop('check_hostname', False))
804-
if self._check_hostname:
805-
if not getattr(ssl, 'match_hostname', None):
806-
raise RuntimeError("ssl_options specify 'check_hostname', but ssl.match_hostname is not provided. "
807-
"Patch or upgrade Python to use this option.")
808802
self.ssl_options.update(self.endpoint.ssl_options or {})
809803
elif self.endpoint.ssl_options:
810804
self.ssl_options = self.endpoint.ssl_options
811805

806+
# PYTHON-1331
807+
#
808+
# We always use SSLContext.wrap_socket() now but legacy configs may have other params that were passed to ssl.wrap_socket()...
809+
# and either could have 'check_hostname'. Remove these params into a separate map and use them to build an SSLContext if
810+
# we need to do so.
811+
#
812+
# Note the use of pop() here; we are very deliberately removing these params from ssl_options if they're present. After this
813+
# operation ssl_options should contain only args needed for the ssl_context.wrap_socket() call.
814+
if not self.ssl_context and self.ssl_options:
815+
self.ssl_context = self._build_ssl_context_from_options()
812816

813817
if protocol_version >= 3:
814818
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
@@ -882,15 +886,48 @@ def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs):
882886
else:
883887
return conn
884888

889+
def _build_ssl_context_from_options(self):
890+
891+
# Extract a subset of names from self.ssl_options which apply to SSLContext creation
892+
ssl_context_opt_names = ['ssl_version', 'cert_reqs', 'check_hostname', 'keyfile', 'certfile', 'ca_certs', 'ciphers']
893+
opts = {k:self.ssl_options.get(k, None) for k in ssl_context_opt_names if k in self.ssl_options}
894+
895+
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
896+
# being explicit
897+
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
898+
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
899+
rv = ssl.SSLContext(protocol=int(ssl_version))
900+
rv.check_hostname = bool(opts.get('check_hostname', False))
901+
rv.options = int(cert_reqs)
902+
903+
certfile = opts.get('certfile', None)
904+
keyfile = opts.get('keyfile', None)
905+
if certfile:
906+
rv.load_cert_chain(certfile, keyfile)
907+
ca_certs = opts.get('ca_certs', None)
908+
if ca_certs:
909+
rv.load_verify_locations(ca_certs)
910+
ciphers = opts.get('ciphers', None)
911+
if ciphers:
912+
rv.set_ciphers(ciphers)
913+
914+
return rv
915+
885916
def _wrap_socket_from_context(self):
886-
ssl_options = self.ssl_options or {}
917+
918+
# Extract a subset of names from self.ssl_options which apply to SSLContext.wrap_socket (or at least the parts
919+
# of it that don't involve building an SSLContext under the covers)
920+
wrap_socket_opt_names = ['server_side', 'do_handshake_on_connect', 'suppress_ragged_eofs', 'server_hostname']
921+
opts = {k:self.ssl_options.get(k, None) for k in wrap_socket_opt_names if k in self.ssl_options}
922+
887923
# PYTHON-1186: set the server_hostname only if the SSLContext has
888924
# check_hostname enabled and it is not already provided by the EndPoint ssl options
889-
if (self.ssl_context.check_hostname and
890-
'server_hostname' not in ssl_options):
891-
ssl_options = ssl_options.copy()
892-
ssl_options['server_hostname'] = self.endpoint.address
893-
self._socket = self.ssl_context.wrap_socket(self._socket, **ssl_options)
925+
#opts['server_hostname'] = self.endpoint.address
926+
if (self.ssl_context.check_hostname and 'server_hostname' not in opts):
927+
server_hostname = self.endpoint.address
928+
opts['server_hostname'] = server_hostname
929+
930+
return self.ssl_context.wrap_socket(self._socket, **opts)
894931

895932
def _initiate_connection(self, sockaddr):
896933
if self.features.shard_id is not None:
@@ -904,8 +941,11 @@ def _initiate_connection(self, sockaddr):
904941

905942
self._socket.connect(sockaddr)
906943

907-
def _match_hostname(self):
908-
ssl.match_hostname(self._socket.getpeercert(), self.endpoint.address)
944+
# PYTHON-1331
945+
#
946+
# Allow implementations specific to an event loop to add additional behaviours
947+
def _validate_hostname(self):
948+
pass
909949

910950
def _get_socket_addresses(self):
911951
address, port = self.endpoint.resolve()
@@ -927,18 +967,21 @@ def _connect_socket(self):
927967
try:
928968
self._socket = self._socket_impl.socket(af, socktype, proto)
929969
if self.ssl_context:
930-
self._wrap_socket_from_context()
931-
elif self.ssl_options:
932-
if not self._ssl_impl:
933-
raise RuntimeError("This version of Python was not compiled with SSL support")
934-
self._socket = self._ssl_impl.wrap_socket(self._socket, **self.ssl_options)
970+
self._socket = self._wrap_socket_from_context()
935971
self._socket.settimeout(self.connect_timeout)
936972
self._initiate_connection(sockaddr)
937973
self._socket.settimeout(None)
974+
938975
local_addr = self._socket.getsockname()
939976
log.debug("Connection %s: '%s' -> '%s'", id(self), local_addr, sockaddr)
977+
978+
# PYTHON-1331
979+
#
980+
# Most checking is done via the check_hostname param on the SSLContext.
981+
# Subclasses can add additional behaviours via _validate_hostname() so
982+
# run that here.
940983
if self._check_hostname:
941-
self._match_hostname()
984+
self._validate_hostname()
942985
sockerr = None
943986
break
944987
except socket.error as err:

0 commit comments

Comments
 (0)