e6data-python-connector 2.3.15__py3-none-any.whl → 3.0.1__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.
Files changed (67) hide show
  1. e6data_python_connector/aio.py +26 -0
  2. e6data_python_connector/async_cluster_manager.py +272 -0
  3. e6data_python_connector/async_connection.py +620 -0
  4. e6data_python_connector/async_connection_pool.py +429 -0
  5. e6data_python_connector/async_cursor.py +923 -0
  6. e6data_python_connector/async_dialect.py +285 -0
  7. e6data_python_connector/async_oauth.py +248 -0
  8. e6data_python_connector/async_work.py +105 -0
  9. e6data_python_connector/cluster_manager.py +167 -11
  10. e6data_python_connector/common.py +2 -1
  11. e6data_python_connector/connection_pool.py +11 -9
  12. e6data_python_connector/datainputstream.py +43 -9
  13. e6data_python_connector/date_time_utils.py +4 -8
  14. e6data_python_connector/dialect.py +7 -2
  15. e6data_python_connector/e6data_grpc.py +1224 -155
  16. e6data_python_connector/exceptions.py +29 -1
  17. e6data_python_connector/oauth.py +185 -0
  18. e6data_python_connector/oauth_cache.py +238 -0
  19. e6data_python_connector/oauth_common.py +66 -0
  20. e6data_python_connector/result_batch.py +49 -0
  21. e6data_python_connector/result_decode.py +559 -0
  22. e6data_python_connector/result_decode_worker.py +50 -0
  23. e6data_python_connector/result_prefetch.py +421 -0
  24. e6data_python_connector/server/e6x_engine_pb2.py +181 -179
  25. e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
  26. {e6data_python_connector-2.3.15.dist-info → e6data_python_connector-3.0.1.dist-info}/METADATA +249 -7
  27. e6data_python_connector-3.0.1.dist-info/RECORD +118 -0
  28. {e6data_python_connector-2.3.15.dist-info → e6data_python_connector-3.0.1.dist-info}/WHEEL +1 -1
  29. {e6data_python_connector-2.3.15.dist-info → e6data_python_connector-3.0.1.dist-info}/entry_points.txt +1 -0
  30. {e6data_python_connector-2.3.15.dist-info → e6data_python_connector-3.0.1.dist-info}/top_level.txt +0 -1
  31. test/conftest.py +18 -0
  32. test/integration/__init__.py +1 -0
  33. test/integration/benchmark_preflight.py +30 -0
  34. test/integration/benchmark_result_batch_v2.py +149 -0
  35. test/integration/benchmark_sync_async.py +177 -0
  36. test/integration/expiry_preflight.py +37 -0
  37. test/integration/result_batch_benchmark_support.py +196 -0
  38. test/integration/support.py +176 -0
  39. test/integration/test_async_connection.py +24 -0
  40. test/integration/test_async_pool.py +51 -0
  41. test/integration/test_async_queries.py +62 -0
  42. test/integration/test_async_resume.py +26 -0
  43. test/integration/test_async_sqlalchemy.py +45 -0
  44. test/integration/test_async_token_lifecycle.py +35 -0
  45. test/integration/test_e2e_auth_boundary.py +34 -0
  46. test/integration/test_e2e_oauth_continuation.py +57 -0
  47. test/integration/test_e2e_readonly_matrix.py +173 -0
  48. test/integration/test_result_batch_v2.py +120 -0
  49. test/integration/test_shared_token_issuance.py +97 -0
  50. test/integration/test_sync_oauth_lifecycle.py +20 -0
  51. test/integration/token_observation.py +55 -0
  52. test/test_cluster_manager_efficiency.py +8 -101
  53. test/test_cluster_manager_oauth.py +193 -0
  54. test/test_cluster_manager_strategy.py +1 -1
  55. test/test_connection_pool.py +36 -20
  56. test/test_oauth_authentication.py +592 -0
  57. test/test_oauth_auto_resume.py +414 -0
  58. test/test_oauth_resume_integration.py +244 -0
  59. e6data_python_connector-2.3.15.dist-info/RECORD +0 -85
  60. gen-py/__init__.py +0 -0
  61. gen-py/e6x_vector/__init__.py +0 -1
  62. gen-py/e6x_vector/constants.py +0 -15
  63. gen-py/e6x_vector/ttypes.py +0 -1875
  64. test/benchmark_fastbinary_performance.py +0 -303
  65. test/test_optimizations_integration.py +0 -443
  66. test/test_ssl_cert.py +0 -548
  67. {e6data_python_connector-2.3.15.dist-info → e6data_python_connector-3.0.1.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,272 @@
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
+ for name in ('max_receive_message_length', 'max_send_message_length'):
127
+ limits = [options.pop(key) for key in (name, 'grpc.' + name) if key in options]
128
+ for limit in limits:
129
+ if (isinstance(limit, bool) or not isinstance(limit, int)
130
+ or (limit != -1 and limit <= 0)):
131
+ raise ValueError(name + ' must be -1 (unlimited) or a positive integer.')
132
+ if len(limits) == 2 and limits[0] != limits[1]:
133
+ raise ValueError('Conflicting ' + name + ' options.')
134
+ options['grpc.' + name] = limits[0] if limits else -1
135
+ self._host, self._port = host, port
136
+ self._user, self._password = user, password
137
+ self._secure_channel, self._ssl_cert = secure_channel, ssl_cert
138
+ self._grpc_options = tuple(options.items())
139
+ self.cluster_uuid = cluster_uuid
140
+ self._metadata_provider = metadata_provider
141
+ self.last_successful_strategy = initial_strategy
142
+ self._timeout = validate_positive_timeout(timeout if auto_resume_timeout is None else auto_resume_timeout)
143
+ self._cleanup_timeout = validate_positive_timeout(cleanup_timeout)
144
+ self._flight = _ResumeFlight(self._timeout)
145
+ self._mutation = _ResumeState()
146
+ self._owner = None
147
+ self._closed = False
148
+
149
+ def _check_owner(self):
150
+ owner = (os.getpid(), threading.get_ident(), asyncio.get_running_loop())
151
+ if self._owner is None:
152
+ self._owner = owner
153
+ elif self._owner != owner:
154
+ raise ProgrammingError('AsyncClusterManager cannot cross loops, threads or processes.')
155
+ if self._closed:
156
+ raise ProgrammingError('AsyncClusterManager is closed.')
157
+
158
+ async def resume(self, *, timeout=None, deadline=None):
159
+ self._check_owner()
160
+ if deadline is not None and (isinstance(deadline, bool) or not isinstance(deadline, (int, float)) or not math.isfinite(deadline)):
161
+ raise ValueError('deadline must be finite.')
162
+ if timeout is not None:
163
+ bound = asyncio.get_running_loop().time() + validate_positive_timeout(timeout)
164
+ deadline = bound if deadline is None else min(bound, deadline)
165
+ try:
166
+ if deadline is not None:
167
+ _remaining(deadline)
168
+ return await self._flight.wait(self._recover, deadline)
169
+ except (TimeoutError, grpc.RpcError) as error:
170
+ raise OperationalError('Cluster recovery failed; a dispatched resume may still complete.') from error
171
+
172
+ async def _metadata(self, strategy, deadline, rejected_token=None):
173
+ _remaining(deadline)
174
+ if self._metadata_provider is not None:
175
+ async with asyncio.timeout_at(deadline):
176
+ if rejected_token is None:
177
+ return await self._metadata_provider(strategy, deadline)
178
+ return await self._metadata_provider(strategy, deadline, rejected_token=rejected_token)
179
+ values = [('strategy', strategy)]
180
+ if self.cluster_uuid:
181
+ values.append(('cluster-name', self.cluster_uuid))
182
+ return values
183
+
184
+ async def _request(self, client, operation, deadline):
185
+ request_type = pb.ClusterStatusRequest if operation == 'status' else pb.ResumeRequest
186
+ request = request_type() if self._metadata_provider else request_type(user=self._user, password=self._password)
187
+ first = self.last_successful_strategy or 'blue'
188
+ refreshed = False
189
+ for strategy in (first, 'green' if first == 'blue' else 'blue'):
190
+ metadata = await self._metadata(strategy, deadline)
191
+ while True:
192
+ try:
193
+ remaining = _remaining(deadline)
194
+ if operation == 'resume':
195
+ self._mutation.dispatched()
196
+ response = await getattr(client, operation)(request, metadata=metadata, timeout=remaining)
197
+ if operation == 'resume':
198
+ self._mutation.acknowledged()
199
+ self.last_successful_strategy = strategy
200
+ return response
201
+ except grpc.RpcError as error:
202
+ if (error.code() == grpc.StatusCode.UNAUTHENTICATED and not refreshed
203
+ and self._metadata_provider is not None):
204
+ refreshed = True
205
+ rejected = dict(metadata).get('authorization', '')[len('Bearer '):]
206
+ replacement = await self._metadata(strategy, deadline, rejected)
207
+ if replacement is not None:
208
+ metadata = replacement
209
+ continue
210
+ if strategy == first and is_strategy_mismatch(error):
211
+ break
212
+ raise
213
+
214
+ async def _recover(self, deadline):
215
+ channel = None
216
+ try:
217
+ async with asyncio.timeout_at(deadline):
218
+ certificate = self._ssl_cert
219
+ if isinstance(certificate, str):
220
+ from .async_work import run_blocking
221
+ certificate = await run_blocking(_read_certificate, certificate, deadline=deadline)
222
+ target = '{}:{}'.format(self._host, self._port)
223
+ if self._secure_channel:
224
+ channel = grpc.aio.secure_channel(target, grpc.ssl_channel_credentials(root_certificates=certificate), options=self._grpc_options)
225
+ else:
226
+ channel = grpc.aio.insecure_channel(target, options=self._grpc_options)
227
+ client = bindings.ClusterServiceStub(channel)
228
+ while True:
229
+ response = await self._request(client, 'status', deadline)
230
+ should_resume = self._mutation.observe(response.status)
231
+ if response.status == 'active':
232
+ return True
233
+ if should_resume:
234
+ try:
235
+ result = await self._request(client, 'resume', deadline)
236
+ self._mutation.observe(result.status)
237
+ # Only a status read resolves readiness, even if resume says active.
238
+ self._mutation.acknowledged()
239
+ except grpc.RpcError as error:
240
+ if error.code() not in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED, grpc.StatusCode.CANCELLED):
241
+ raise
242
+ continue
243
+ await asyncio.sleep(min(1.0, _remaining(deadline)))
244
+ finally:
245
+ if channel is not None:
246
+ async with asyncio.timeout(self._cleanup_timeout):
247
+ await channel.close()
248
+
249
+ async def close(self, *, deadline=None):
250
+ if self._owner is not None:
251
+ owner = (os.getpid(), threading.get_ident(), asyncio.get_running_loop())
252
+ if self._owner != owner:
253
+ raise ProgrammingError('AsyncClusterManager cannot cross loops, threads or processes.')
254
+ if self._closed and (self._flight.task is None or self._flight.task.done()):
255
+ return
256
+ if not self._closed:
257
+ self._check_owner()
258
+ self._closed = True
259
+ bound = asyncio.get_running_loop().time() + self._cleanup_timeout
260
+ deadline = bound if deadline is None else min(bound, deadline)
261
+ task = self._flight.task
262
+ if task is not None:
263
+ task.cancel()
264
+ try:
265
+ async with asyncio.timeout_at(deadline):
266
+ await asyncio.shield(task)
267
+ except asyncio.CancelledError:
268
+ if asyncio.current_task().cancelling():
269
+ raise
270
+ except Exception:
271
+ if not task.done():
272
+ raise