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.
Files changed (58) hide show
  1. e6data_python_connector/aio.py +26 -0
  2. e6data_python_connector/async_cluster_manager.py +267 -0
  3. e6data_python_connector/async_connection.py +547 -0
  4. e6data_python_connector/async_connection_pool.py +425 -0
  5. e6data_python_connector/async_cursor.py +562 -0
  6. e6data_python_connector/async_dialect.py +284 -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 +106 -1
  10. e6data_python_connector/common.py +2 -1
  11. e6data_python_connector/datainputstream.py +43 -9
  12. e6data_python_connector/date_time_utils.py +4 -8
  13. e6data_python_connector/dialect.py +18 -1
  14. e6data_python_connector/e6data_grpc.py +536 -79
  15. e6data_python_connector/exceptions.py +29 -1
  16. e6data_python_connector/oauth.py +185 -0
  17. e6data_python_connector/oauth_cache.py +238 -0
  18. e6data_python_connector/oauth_common.py +66 -0
  19. e6data_python_connector/server/e6x_engine_pb2.py +179 -179
  20. {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/METADATA +103 -6
  21. {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/RECORD +51 -25
  22. {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/WHEEL +1 -1
  23. {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/entry_points.txt +1 -0
  24. {e6data_python_connector-2.3.14.dist-info → e6data_python_connector-3.0.0.dist-info}/top_level.txt +0 -1
  25. test/conftest.py +18 -0
  26. test/integration/__init__.py +1 -0
  27. test/integration/benchmark_preflight.py +30 -0
  28. test/integration/benchmark_sync_async.py +177 -0
  29. test/integration/expiry_preflight.py +37 -0
  30. test/integration/support.py +173 -0
  31. test/integration/test_async_connection.py +24 -0
  32. test/integration/test_async_pool.py +51 -0
  33. test/integration/test_async_queries.py +62 -0
  34. test/integration/test_async_resume.py +26 -0
  35. test/integration/test_async_sqlalchemy.py +45 -0
  36. test/integration/test_async_token_lifecycle.py +35 -0
  37. test/integration/test_e2e_auth_boundary.py +34 -0
  38. test/integration/test_e2e_oauth_continuation.py +57 -0
  39. test/integration/test_e2e_readonly_matrix.py +173 -0
  40. test/integration/test_shared_token_issuance.py +97 -0
  41. test/integration/test_sync_oauth_lifecycle.py +20 -0
  42. test/integration/token_observation.py +55 -0
  43. test/test_cluster_manager_efficiency.py +8 -101
  44. test/test_cluster_manager_oauth.py +193 -0
  45. test/test_cluster_manager_strategy.py +1 -1
  46. test/test_connection_pool.py +33 -20
  47. test/test_dialect_columns.py +32 -0
  48. test/test_oauth_authentication.py +592 -0
  49. test/test_oauth_auto_resume.py +409 -0
  50. test/test_oauth_resume_integration.py +244 -0
  51. gen-py/__init__.py +0 -0
  52. gen-py/e6x_vector/__init__.py +0 -1
  53. gen-py/e6x_vector/constants.py +0 -15
  54. gen-py/e6x_vector/ttypes.py +0 -1875
  55. test/benchmark_fastbinary_performance.py +0 -303
  56. test/test_optimizations_integration.py +0 -443
  57. test/test_ssl_cert.py +0 -548
  58. {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