e6data-python-connector 2.3.14__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- e6data_python_connector/aio.py +26 -0
- e6data_python_connector/async_cluster_manager.py +267 -0
- e6data_python_connector/async_connection.py +547 -0
- e6data_python_connector/async_connection_pool.py +425 -0
- e6data_python_connector/async_cursor.py +562 -0
- e6data_python_connector/async_dialect.py +284 -0
- e6data_python_connector/async_oauth.py +248 -0
- e6data_python_connector/async_work.py +105 -0
- e6data_python_connector/cluster_manager.py +106 -1
- e6data_python_connector/common.py +2 -1
- e6data_python_connector/datainputstream.py +43 -9
- e6data_python_connector/date_time_utils.py +4 -8
- e6data_python_connector/dialect.py +18 -1
- e6data_python_connector/e6data_grpc.py +536 -79
- e6data_python_connector/exceptions.py +29 -1
- e6data_python_connector/oauth.py +185 -0
- e6data_python_connector/oauth_cache.py +238 -0
- e6data_python_connector/oauth_common.py +66 -0
- e6data_python_connector/server/e6x_engine_pb2.py +179 -179
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/METADATA +103 -6
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/RECORD +51 -25
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/WHEEL +1 -1
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/entry_points.txt +1 -0
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/top_level.txt +0 -1
- test/conftest.py +18 -0
- test/integration/__init__.py +1 -0
- test/integration/benchmark_preflight.py +30 -0
- test/integration/benchmark_sync_async.py +177 -0
- test/integration/expiry_preflight.py +37 -0
- test/integration/support.py +173 -0
- test/integration/test_async_connection.py +24 -0
- test/integration/test_async_pool.py +51 -0
- test/integration/test_async_queries.py +62 -0
- test/integration/test_async_resume.py +26 -0
- test/integration/test_async_sqlalchemy.py +45 -0
- test/integration/test_async_token_lifecycle.py +35 -0
- test/integration/test_e2e_auth_boundary.py +34 -0
- test/integration/test_e2e_oauth_continuation.py +57 -0
- test/integration/test_e2e_readonly_matrix.py +173 -0
- test/integration/test_shared_token_issuance.py +97 -0
- test/integration/test_sync_oauth_lifecycle.py +20 -0
- test/integration/token_observation.py +55 -0
- test/test_cluster_manager_efficiency.py +8 -101
- test/test_cluster_manager_oauth.py +193 -0
- test/test_cluster_manager_strategy.py +1 -1
- test/test_connection_pool.py +33 -20
- test/test_dialect_columns.py +32 -0
- test/test_oauth_authentication.py +592 -0
- test/test_oauth_auto_resume.py +409 -0
- test/test_oauth_resume_integration.py +244 -0
- gen-py/__init__.py +0 -0
- gen-py/e6x_vector/__init__.py +0 -1
- gen-py/e6x_vector/constants.py +0 -15
- gen-py/e6x_vector/ttypes.py +0 -1875
- test/benchmark_fastbinary_performance.py +0 -303
- test/test_optimizations_integration.py +0 -443
- test/test_ssl_cert.py +0 -548
- {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Explicit optional native asyncio entry point (Python 3.11+)."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
if sys.version_info < (3, 11):
|
|
5
|
+
raise RuntimeError('The async API requires Python 3.11 or newer.')
|
|
6
|
+
try:
|
|
7
|
+
import httpx as _httpx
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise ImportError('Install e6data-python-connector[async] for the async API.') from None
|
|
10
|
+
|
|
11
|
+
from .async_connection import AsyncConnection
|
|
12
|
+
|
|
13
|
+
__all__ = ['connect', 'AsyncConnection', 'AsyncCursor', 'AsyncConnectionPool', 'AsyncClusterManager']
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def connect(*args, **kwargs):
|
|
17
|
+
return await AsyncConnection(*args, **kwargs).open()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name):
|
|
21
|
+
modules = {'AsyncCursor': 'async_cursor', 'AsyncConnectionPool': 'async_connection_pool',
|
|
22
|
+
'AsyncClusterManager': 'async_cluster_manager'}
|
|
23
|
+
if name not in modules:
|
|
24
|
+
raise AttributeError(name)
|
|
25
|
+
from importlib import import_module
|
|
26
|
+
return getattr(import_module('.' + modules[name], __package__), name)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Native async cluster recovery with owner-scoped mutation tracking."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import grpc
|
|
8
|
+
|
|
9
|
+
from .cluster_server import cluster_pb2 as pb, cluster_pb2_grpc as bindings
|
|
10
|
+
from .exceptions import OperationalError, ProgrammingError
|
|
11
|
+
from .oauth_common import validate_positive_timeout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_strategy_mismatch(error):
|
|
15
|
+
return error.code() == grpc.StatusCode.UNKNOWN and error.details() == 'status: 456'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_suspended_error(error):
|
|
19
|
+
return (error.code() == grpc.StatusCode.UNAVAILABLE
|
|
20
|
+
and error.details() == 'status: 503, cluster is suspended')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _remaining(deadline):
|
|
24
|
+
result = deadline - asyncio.get_running_loop().time()
|
|
25
|
+
if result <= 0:
|
|
26
|
+
raise TimeoutError('Cluster recovery deadline exceeded; dispatched resume may still complete.')
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_certificate(path):
|
|
31
|
+
with open(path, 'rb') as source:
|
|
32
|
+
return source.read()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _ResumeState:
|
|
36
|
+
"""Mutation state deliberately outlives any individual recovery flight."""
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self.pending = None
|
|
39
|
+
|
|
40
|
+
def observe(self, status):
|
|
41
|
+
if status not in ('active', 'suspended', 'resuming'):
|
|
42
|
+
raise RuntimeError('Cluster returned failed or unsupported status: {!r}'.format(status))
|
|
43
|
+
if status == 'active':
|
|
44
|
+
self.pending = None
|
|
45
|
+
return status == 'suspended' and self.pending is None
|
|
46
|
+
|
|
47
|
+
def dispatched(self):
|
|
48
|
+
self.pending = 'unknown'
|
|
49
|
+
|
|
50
|
+
def acknowledged(self):
|
|
51
|
+
self.pending = 'acknowledged'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _ResumeFlight:
|
|
55
|
+
"""One phase deadline, independently bounded shielded waiters."""
|
|
56
|
+
def __init__(self, timeout):
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self.task = None
|
|
59
|
+
self.waiters = 0
|
|
60
|
+
self.retiring = False
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _consume_result(task):
|
|
64
|
+
if not task.cancelled():
|
|
65
|
+
task.exception()
|
|
66
|
+
|
|
67
|
+
async def wait(self, work, deadline):
|
|
68
|
+
# Last-waiter disposal must finish before a replacement can start.
|
|
69
|
+
while self.retiring and self.task is not None:
|
|
70
|
+
retiring_task = self.task
|
|
71
|
+
try:
|
|
72
|
+
async with asyncio.timeout_at(deadline):
|
|
73
|
+
await asyncio.shield(retiring_task)
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
if asyncio.current_task().cancelling():
|
|
76
|
+
raise
|
|
77
|
+
except Exception:
|
|
78
|
+
if not retiring_task.done():
|
|
79
|
+
raise
|
|
80
|
+
if self.task is retiring_task and retiring_task.done():
|
|
81
|
+
self.task = None
|
|
82
|
+
self.retiring = False
|
|
83
|
+
if deadline is not None:
|
|
84
|
+
_remaining(deadline)
|
|
85
|
+
if self.task is None or (self.task.done() and self.waiters == 0):
|
|
86
|
+
phase_deadline = asyncio.get_running_loop().time() + self.timeout
|
|
87
|
+
self.task = asyncio.create_task(work(phase_deadline))
|
|
88
|
+
self.task.add_done_callback(self._consume_result)
|
|
89
|
+
task = self.task
|
|
90
|
+
self.waiters += 1
|
|
91
|
+
try:
|
|
92
|
+
async with asyncio.timeout_at(deadline):
|
|
93
|
+
return await asyncio.shield(task)
|
|
94
|
+
finally:
|
|
95
|
+
self.waiters -= 1
|
|
96
|
+
if self.waiters == 0 and not task.done():
|
|
97
|
+
self.retiring = True
|
|
98
|
+
task.cancel()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AsyncClusterManager:
|
|
102
|
+
"""Local constructor; channels belong to the first invoking loop/thread/process.
|
|
103
|
+
|
|
104
|
+
Sharing this explicit object shares recovery only within its target and auth
|
|
105
|
+
owner. OAuth metadata providers must be async and own their refresh policy.
|
|
106
|
+
"""
|
|
107
|
+
def __init__(self, host, port, user='', password='', secure_channel=False,
|
|
108
|
+
timeout=300, cluster_uuid=None, grpc_options=None, debug=False,
|
|
109
|
+
ssl_cert=None, metadata_provider=None, initial_strategy=None,
|
|
110
|
+
auto_resume_timeout=None, cleanup_timeout=5):
|
|
111
|
+
if not isinstance(host, str) or not host or isinstance(port, bool) or not isinstance(port, int) or not 0 < port < 65536:
|
|
112
|
+
raise ValueError('host and a valid port are required.')
|
|
113
|
+
if metadata_provider is not None and not secure_channel:
|
|
114
|
+
raise ValueError('Async OAuth cluster recovery requires verified TLS.')
|
|
115
|
+
if metadata_provider is not None and (user or password):
|
|
116
|
+
raise ValueError('OAuth and password authentication cannot be combined.')
|
|
117
|
+
if metadata_provider is None and (not user or not password):
|
|
118
|
+
raise ValueError('user and password are required for legacy recovery.')
|
|
119
|
+
if initial_strategy not in (None, 'blue', 'green'):
|
|
120
|
+
raise ValueError('initial_strategy must be blue or green.')
|
|
121
|
+
if ssl_cert is not None and not isinstance(ssl_cert, (str, bytes)):
|
|
122
|
+
raise ValueError('ssl_cert must be a certificate path or PEM bytes.')
|
|
123
|
+
options = dict(grpc_options or {})
|
|
124
|
+
if any(key.removeprefix('grpc.') in ('ssl_target_name_override', 'default_authority') for key in options):
|
|
125
|
+
raise ValueError('TLS authority overrides are unsupported.')
|
|
126
|
+
limit = options.get('grpc.max_receive_message_length', 64 * 1024 * 1024)
|
|
127
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
|
128
|
+
raise ValueError('A finite positive receive limit is required.')
|
|
129
|
+
options['grpc.max_receive_message_length'] = limit
|
|
130
|
+
self._host, self._port = host, port
|
|
131
|
+
self._user, self._password = user, password
|
|
132
|
+
self._secure_channel, self._ssl_cert = secure_channel, ssl_cert
|
|
133
|
+
self._grpc_options = tuple(options.items())
|
|
134
|
+
self.cluster_uuid = cluster_uuid
|
|
135
|
+
self._metadata_provider = metadata_provider
|
|
136
|
+
self.last_successful_strategy = initial_strategy
|
|
137
|
+
self._timeout = validate_positive_timeout(timeout if auto_resume_timeout is None else auto_resume_timeout)
|
|
138
|
+
self._cleanup_timeout = validate_positive_timeout(cleanup_timeout)
|
|
139
|
+
self._flight = _ResumeFlight(self._timeout)
|
|
140
|
+
self._mutation = _ResumeState()
|
|
141
|
+
self._owner = None
|
|
142
|
+
self._closed = False
|
|
143
|
+
|
|
144
|
+
def _check_owner(self):
|
|
145
|
+
owner = (os.getpid(), threading.get_ident(), asyncio.get_running_loop())
|
|
146
|
+
if self._owner is None:
|
|
147
|
+
self._owner = owner
|
|
148
|
+
elif self._owner != owner:
|
|
149
|
+
raise ProgrammingError('AsyncClusterManager cannot cross loops, threads or processes.')
|
|
150
|
+
if self._closed:
|
|
151
|
+
raise ProgrammingError('AsyncClusterManager is closed.')
|
|
152
|
+
|
|
153
|
+
async def resume(self, *, timeout=None, deadline=None):
|
|
154
|
+
self._check_owner()
|
|
155
|
+
if deadline is not None and (isinstance(deadline, bool) or not isinstance(deadline, (int, float)) or not math.isfinite(deadline)):
|
|
156
|
+
raise ValueError('deadline must be finite.')
|
|
157
|
+
if timeout is not None:
|
|
158
|
+
bound = asyncio.get_running_loop().time() + validate_positive_timeout(timeout)
|
|
159
|
+
deadline = bound if deadline is None else min(bound, deadline)
|
|
160
|
+
try:
|
|
161
|
+
if deadline is not None:
|
|
162
|
+
_remaining(deadline)
|
|
163
|
+
return await self._flight.wait(self._recover, deadline)
|
|
164
|
+
except (TimeoutError, grpc.RpcError) as error:
|
|
165
|
+
raise OperationalError('Cluster recovery failed; a dispatched resume may still complete.') from error
|
|
166
|
+
|
|
167
|
+
async def _metadata(self, strategy, deadline, rejected_token=None):
|
|
168
|
+
_remaining(deadline)
|
|
169
|
+
if self._metadata_provider is not None:
|
|
170
|
+
async with asyncio.timeout_at(deadline):
|
|
171
|
+
if rejected_token is None:
|
|
172
|
+
return await self._metadata_provider(strategy, deadline)
|
|
173
|
+
return await self._metadata_provider(strategy, deadline, rejected_token=rejected_token)
|
|
174
|
+
values = [('strategy', strategy)]
|
|
175
|
+
if self.cluster_uuid:
|
|
176
|
+
values.append(('cluster-name', self.cluster_uuid))
|
|
177
|
+
return values
|
|
178
|
+
|
|
179
|
+
async def _request(self, client, operation, deadline):
|
|
180
|
+
request_type = pb.ClusterStatusRequest if operation == 'status' else pb.ResumeRequest
|
|
181
|
+
request = request_type() if self._metadata_provider else request_type(user=self._user, password=self._password)
|
|
182
|
+
first = self.last_successful_strategy or 'blue'
|
|
183
|
+
refreshed = False
|
|
184
|
+
for strategy in (first, 'green' if first == 'blue' else 'blue'):
|
|
185
|
+
metadata = await self._metadata(strategy, deadline)
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
remaining = _remaining(deadline)
|
|
189
|
+
if operation == 'resume':
|
|
190
|
+
self._mutation.dispatched()
|
|
191
|
+
response = await getattr(client, operation)(request, metadata=metadata, timeout=remaining)
|
|
192
|
+
if operation == 'resume':
|
|
193
|
+
self._mutation.acknowledged()
|
|
194
|
+
self.last_successful_strategy = strategy
|
|
195
|
+
return response
|
|
196
|
+
except grpc.RpcError as error:
|
|
197
|
+
if (error.code() == grpc.StatusCode.UNAUTHENTICATED and not refreshed
|
|
198
|
+
and self._metadata_provider is not None):
|
|
199
|
+
refreshed = True
|
|
200
|
+
rejected = dict(metadata).get('authorization', '')[len('Bearer '):]
|
|
201
|
+
replacement = await self._metadata(strategy, deadline, rejected)
|
|
202
|
+
if replacement is not None:
|
|
203
|
+
metadata = replacement
|
|
204
|
+
continue
|
|
205
|
+
if strategy == first and is_strategy_mismatch(error):
|
|
206
|
+
break
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
async def _recover(self, deadline):
|
|
210
|
+
channel = None
|
|
211
|
+
try:
|
|
212
|
+
async with asyncio.timeout_at(deadline):
|
|
213
|
+
certificate = self._ssl_cert
|
|
214
|
+
if isinstance(certificate, str):
|
|
215
|
+
from .async_work import run_blocking
|
|
216
|
+
certificate = await run_blocking(_read_certificate, certificate, deadline=deadline)
|
|
217
|
+
target = '{}:{}'.format(self._host, self._port)
|
|
218
|
+
if self._secure_channel:
|
|
219
|
+
channel = grpc.aio.secure_channel(target, grpc.ssl_channel_credentials(root_certificates=certificate), options=self._grpc_options)
|
|
220
|
+
else:
|
|
221
|
+
channel = grpc.aio.insecure_channel(target, options=self._grpc_options)
|
|
222
|
+
client = bindings.ClusterServiceStub(channel)
|
|
223
|
+
while True:
|
|
224
|
+
response = await self._request(client, 'status', deadline)
|
|
225
|
+
should_resume = self._mutation.observe(response.status)
|
|
226
|
+
if response.status == 'active':
|
|
227
|
+
return True
|
|
228
|
+
if should_resume:
|
|
229
|
+
try:
|
|
230
|
+
result = await self._request(client, 'resume', deadline)
|
|
231
|
+
self._mutation.observe(result.status)
|
|
232
|
+
# Only a status read resolves readiness, even if resume says active.
|
|
233
|
+
self._mutation.acknowledged()
|
|
234
|
+
except grpc.RpcError as error:
|
|
235
|
+
if error.code() not in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED, grpc.StatusCode.CANCELLED):
|
|
236
|
+
raise
|
|
237
|
+
continue
|
|
238
|
+
await asyncio.sleep(min(1.0, _remaining(deadline)))
|
|
239
|
+
finally:
|
|
240
|
+
if channel is not None:
|
|
241
|
+
async with asyncio.timeout(self._cleanup_timeout):
|
|
242
|
+
await channel.close()
|
|
243
|
+
|
|
244
|
+
async def close(self, *, deadline=None):
|
|
245
|
+
if self._owner is not None:
|
|
246
|
+
owner = (os.getpid(), threading.get_ident(), asyncio.get_running_loop())
|
|
247
|
+
if self._owner != owner:
|
|
248
|
+
raise ProgrammingError('AsyncClusterManager cannot cross loops, threads or processes.')
|
|
249
|
+
if self._closed and (self._flight.task is None or self._flight.task.done()):
|
|
250
|
+
return
|
|
251
|
+
if not self._closed:
|
|
252
|
+
self._check_owner()
|
|
253
|
+
self._closed = True
|
|
254
|
+
bound = asyncio.get_running_loop().time() + self._cleanup_timeout
|
|
255
|
+
deadline = bound if deadline is None else min(bound, deadline)
|
|
256
|
+
task = self._flight.task
|
|
257
|
+
if task is not None:
|
|
258
|
+
task.cancel()
|
|
259
|
+
try:
|
|
260
|
+
async with asyncio.timeout_at(deadline):
|
|
261
|
+
await asyncio.shield(task)
|
|
262
|
+
except asyncio.CancelledError:
|
|
263
|
+
if asyncio.current_task().cancelling():
|
|
264
|
+
raise
|
|
265
|
+
except Exception:
|
|
266
|
+
if not task.done():
|
|
267
|
+
raise
|