e6data-python-connector 2.3.14__tar.gz → 3.0.0__tar.gz

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 (126) hide show
  1. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/PKG-INFO +103 -6
  2. e6data_python_connector-2.3.14/e6data_python_connector.egg-info/PKG-INFO → e6data_python_connector-3.0.0/README.md +94 -43
  3. e6data_python_connector-3.0.0/e6data_python_connector/aio.py +26 -0
  4. e6data_python_connector-3.0.0/e6data_python_connector/async_cluster_manager.py +267 -0
  5. e6data_python_connector-3.0.0/e6data_python_connector/async_connection.py +547 -0
  6. e6data_python_connector-3.0.0/e6data_python_connector/async_connection_pool.py +425 -0
  7. e6data_python_connector-3.0.0/e6data_python_connector/async_cursor.py +562 -0
  8. e6data_python_connector-3.0.0/e6data_python_connector/async_dialect.py +284 -0
  9. e6data_python_connector-3.0.0/e6data_python_connector/async_oauth.py +248 -0
  10. e6data_python_connector-3.0.0/e6data_python_connector/async_work.py +105 -0
  11. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/cluster_manager.py +106 -1
  12. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/common.py +2 -1
  13. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/datainputstream.py +43 -9
  14. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/date_time_utils.py +4 -8
  15. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/dialect.py +18 -1
  16. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/e6data_grpc.py +536 -79
  17. e6data_python_connector-3.0.0/e6data_python_connector/exceptions.py +43 -0
  18. e6data_python_connector-3.0.0/e6data_python_connector/oauth.py +185 -0
  19. e6data_python_connector-3.0.0/e6data_python_connector/oauth_cache.py +238 -0
  20. e6data_python_connector-3.0.0/e6data_python_connector/oauth_common.py +66 -0
  21. e6data_python_connector-3.0.0/e6data_python_connector/server/e6x_engine_pb2.py +207 -0
  22. e6data_python_connector-2.3.14/README.md → e6data_python_connector-3.0.0/e6data_python_connector.egg-info/PKG-INFO +140 -4
  23. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector.egg-info/SOURCES.txt +34 -5
  24. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector.egg-info/entry_points.txt +1 -0
  25. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector.egg-info/requires.txt +7 -0
  26. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector.egg-info/top_level.txt +0 -1
  27. e6data_python_connector-3.0.0/pyproject.toml +12 -0
  28. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/setup.py +7 -2
  29. e6data_python_connector-3.0.0/test/conftest.py +18 -0
  30. e6data_python_connector-3.0.0/test/integration/__init__.py +1 -0
  31. e6data_python_connector-3.0.0/test/integration/benchmark_preflight.py +30 -0
  32. e6data_python_connector-3.0.0/test/integration/benchmark_sync_async.py +177 -0
  33. e6data_python_connector-3.0.0/test/integration/expiry_preflight.py +37 -0
  34. e6data_python_connector-3.0.0/test/integration/support.py +173 -0
  35. e6data_python_connector-3.0.0/test/integration/test_async_connection.py +24 -0
  36. e6data_python_connector-3.0.0/test/integration/test_async_pool.py +51 -0
  37. e6data_python_connector-3.0.0/test/integration/test_async_queries.py +62 -0
  38. e6data_python_connector-3.0.0/test/integration/test_async_resume.py +26 -0
  39. e6data_python_connector-3.0.0/test/integration/test_async_sqlalchemy.py +45 -0
  40. e6data_python_connector-3.0.0/test/integration/test_async_token_lifecycle.py +35 -0
  41. e6data_python_connector-3.0.0/test/integration/test_e2e_auth_boundary.py +34 -0
  42. e6data_python_connector-3.0.0/test/integration/test_e2e_oauth_continuation.py +57 -0
  43. e6data_python_connector-3.0.0/test/integration/test_e2e_readonly_matrix.py +173 -0
  44. e6data_python_connector-3.0.0/test/integration/test_shared_token_issuance.py +97 -0
  45. e6data_python_connector-3.0.0/test/integration/test_sync_oauth_lifecycle.py +20 -0
  46. e6data_python_connector-3.0.0/test/integration/token_observation.py +55 -0
  47. e6data_python_connector-3.0.0/test/test_cluster_manager_efficiency.py +105 -0
  48. e6data_python_connector-3.0.0/test/test_cluster_manager_oauth.py +193 -0
  49. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_cluster_manager_strategy.py +1 -1
  50. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_connection_pool.py +33 -20
  51. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_dialect_columns.py +32 -0
  52. e6data_python_connector-3.0.0/test/test_oauth_authentication.py +592 -0
  53. e6data_python_connector-3.0.0/test/test_oauth_auto_resume.py +409 -0
  54. e6data_python_connector-3.0.0/test/test_oauth_resume_integration.py +244 -0
  55. e6data_python_connector-2.3.14/e6data_python_connector/exceptions.py +0 -15
  56. e6data_python_connector-2.3.14/e6data_python_connector/server/e6x_engine_pb2.py +0 -207
  57. e6data_python_connector-2.3.14/gen-py/__init__.py +0 -0
  58. e6data_python_connector-2.3.14/gen-py/e6x_vector/__init__.py +0 -1
  59. e6data_python_connector-2.3.14/gen-py/e6x_vector/constants.py +0 -15
  60. e6data_python_connector-2.3.14/gen-py/e6x_vector/ttypes.py +0 -1875
  61. e6data_python_connector-2.3.14/pyproject.toml +0 -5
  62. e6data_python_connector-2.3.14/test/test_cluster_manager_efficiency.py +0 -198
  63. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/LICENSE +0 -0
  64. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/MANIFEST.in +0 -0
  65. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/__init__.py +0 -0
  66. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/cluster_server/__init__.py +0 -0
  67. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/cluster_server/cluster_pb2.py +0 -0
  68. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/cluster_server/cluster_pb2_grpc.py +0 -0
  69. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/connection_pool.py +0 -0
  70. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/constants.py +0 -0
  71. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/e6x_vector/__init__.py +0 -0
  72. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/e6x_vector/constants.py +0 -0
  73. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/e6x_vector/ttypes.py +0 -0
  74. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/QueryEngineService.py +0 -0
  75. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/__init__.py +0 -0
  76. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/constants.py +0 -0
  77. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/e6x_engine_pb2.pyi +0 -0
  78. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/e6x_engine_pb2_grpc.py +0 -0
  79. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/server/ttypes.py +0 -0
  80. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/strategy.py +0 -0
  81. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector/typeId.py +0 -0
  82. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/e6data_python_connector.egg-info/dependency_links.txt +0 -0
  83. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/setup.cfg +0 -0
  84. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/__init__.py +0 -0
  85. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/analyze_38_nines.py +0 -0
  86. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/analyze_all_cases.py +0 -0
  87. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/analyze_binary.py +0 -0
  88. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/analyze_correct_value.py +0 -0
  89. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/analyze_fields.py +0 -0
  90. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/check_decimal_errors.py +0 -0
  91. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/cleanup_test_files.py +0 -0
  92. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/debug_38_nines.py +0 -0
  93. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/debug_binary.py +0 -0
  94. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/final_test.py +0 -0
  95. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/move_tests.py +0 -0
  96. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/quick_test.py +0 -0
  97. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_38_nines.py +0 -0
  98. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_all_decimal128_cases.py +0 -0
  99. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_cluster_manager_none_strategy.py +0 -0
  100. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_code_optimizations.py +0 -0
  101. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_comprehensive.py +0 -0
  102. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_connection_pool_e2e.py +0 -0
  103. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_current_implementation.py +0 -0
  104. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_decimal128_binary_parsing.py +0 -0
  105. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_decimal128_parsing.py +0 -0
  106. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_fix.py +0 -0
  107. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_improved_parsing.py +0 -0
  108. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_known_case.py +0 -0
  109. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_manual_analysis.py +0 -0
  110. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_mock_server.py +0 -0
  111. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_multiprocessing_fix.py +0 -0
  112. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_new_implementation.py +0 -0
  113. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_pool_concurrency_simple.py +0 -0
  114. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_pool_threading_multiprocessing.py +0 -0
  115. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_simple_connection_pool.py +0 -0
  116. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_specific_binary.py +0 -0
  117. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_strategy.py +0 -0
  118. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_strategy_logic.py +0 -0
  119. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_strategy_persistence_fix.py +0 -0
  120. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_strategy_sharing_fix.py +0 -0
  121. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/test_user_binary_value.py +0 -0
  122. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/tests.py +0 -0
  123. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/tests_grpc.py +0 -0
  124. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/validate_decimal128.py +0 -0
  125. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/validate_implementation.py +0 -0
  126. {e6data_python_connector-2.3.14 → e6data_python_connector-3.0.0}/test/verify_decimal_fix.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: e6data-python-connector
3
- Version: 2.3.14
3
+ Version: 3.0.0
4
4
  Summary: Client for the e6data distributed SQL Engine.
5
5
  Home-page: https://github.com/e6x-labs/e6data-python-connector
6
6
  Author: e6data, Inc.
@@ -27,6 +27,11 @@ Requires-Dist: pytz>=2024.1
27
27
  Requires-Dist: thrift>=0.20.0
28
28
  Requires-Dist: grpcio>=1.65.1
29
29
  Requires-Dist: grpcio-tools>=1.65.1
30
+ Provides-Extra: async
31
+ Requires-Dist: httpx<1,>=0.28.1; extra == "async"
32
+ Provides-Extra: async-sqlalchemy
33
+ Requires-Dist: httpx<1,>=0.28.1; extra == "async-sqlalchemy"
34
+ Requires-Dist: SQLAlchemy[asyncio]<2.1,>=2.0; extra == "async-sqlalchemy"
30
35
  Dynamic: author
31
36
  Dynamic: author-email
32
37
  Dynamic: classifier
@@ -34,16 +39,38 @@ Dynamic: description
34
39
  Dynamic: description-content-type
35
40
  Dynamic: home-page
36
41
  Dynamic: license
42
+ Dynamic: license-file
43
+ Dynamic: provides-extra
37
44
  Dynamic: requires-dist
38
45
  Dynamic: summary
39
46
 
40
47
  # e6data Python Connector
41
48
 
42
- ![version](https://img.shields.io/badge/version-2.3.14-blue.svg)
49
+ Package version and extras are defined in `setup.py`.
43
50
 
44
51
  ## Introduction
45
52
 
46
- The e6data Connector for Python provides an interface for writing Python applications that can connect to e6data and perform operations. It includes automatic support for blue-green deployments, ensuring seamless failover during server updates without query interruption.
53
+ The e6data Connector for Python provides an interface for writing Python applications that can connect to e6data and perform operations. It includes routing support for blue-green deployments. Active query handles remain tied to the engine that accepted them; failover does not guarantee uninterrupted results.
54
+
55
+ ## Optional native asyncio API
56
+
57
+ This branch adds native async connection, cursor, pooling and SQLAlchemy support for Python 3.11+. Release qualification remains incomplete. Install this branch's built package with `[async]` or `[async-sqlalchemy]`; these instructions do not assert that the feature is published.
58
+
59
+ ```python
60
+ from e6data_python_connector.aio import AsyncConnection
61
+
62
+ async def query(config, sql):
63
+ async with AsyncConnection(**config) as connection:
64
+ async with connection.cursor() as cursor:
65
+ await cursor.execute(sql)
66
+ return await cursor.fetchall()
67
+ ```
68
+
69
+ `config` is application-supplied connection configuration. OAuth requires verified TLS. Native `fetchone()` preserves the one-row outer list, while SQLAlchemy `e6data+asyncio` adapts it to a normal row. Ambiguous submission and incomplete result errors must not be handled by blindly retrying the query. Use context managers for bounded cleanup.
70
+
71
+ See [Async API](docs/ASYNC_API.md) for the complete API map, deadlines, pooling, SQLAlchemy and test configuration, [OAuth lifecycle](docs/OAUTH_LIFECYCLE.md) for renewal and failure behavior, and [the application example](examples/async_query.py).
72
+
73
+ The new CI keeps the full-package coverage denominator and a greater-than-80% gate. The initial baseline was about 19%, so focused async test success is not a release pass. Real-service tests require explicit configuration and are qualified separately.
47
74
 
48
75
  ### Dependencies
49
76
  Make sure to install below dependencies and wheel before install e6data-python-connector.
@@ -120,8 +147,8 @@ The `Connection` class supports the following parameters:
120
147
  |-----------|------|----------|---------|-------------|
121
148
  | `host` | str | Yes | - | IP address or hostname of the e6data cluster |
122
149
  | `port` | int | Yes | - | Port of the e6data engine (typically 80) |
123
- | `username` | str | Yes | - | Your e6data Email ID |
124
- | `password` | str | Yes | - | Access Token generated in the e6data console |
150
+ | `username` | str | Conditional | None | Your e6data Email ID. Required unless authenticating with OAuth |
151
+ | `password` | str | Conditional | None | Access Token generated in the e6data console. Required unless authenticating with OAuth |
125
152
  | `database` | str | No | None | Database to perform queries on |
126
153
  | `catalog` | str | No | None | Catalog name |
127
154
  | `cluster_name` | str | No | None | Name of the cluster for cluster-specific operations |
@@ -131,6 +158,76 @@ The `Connection` class supports the following parameters:
131
158
  | `grpc_options` | dict | No | None | Additional gRPC configuration options |
132
159
  | `debug` | bool | No | False | Enable debug logging for troubleshooting |
133
160
  | `require_fastbinary` | bool | No | True | Require fastbinary module for Thrift deserialization. Set to False to use pure Python implementation if system dependencies cannot be installed |
161
+ | `client_id` | str | No | None | OAuth 2.0 client id. Use with `client_secret` and `token_url` |
162
+ | `client_secret` | str | No | None | OAuth 2.0 client secret |
163
+ | `token_url` | str | No | None | Token endpoint of the authorization server |
164
+ | `oauth_scope` | str | No | None | Space-delimited scopes to request. Omit for the client's full registered set |
165
+ | `access_token` | str | No | None | A previously obtained access token, for callers that mint their own |
166
+ | `client_auth_method` | str | No | `'basic'` | How client credentials reach the token endpoint: `'basic'` or `'post'` |
167
+
168
+ #### Authenticating with OAuth 2.0
169
+
170
+ As an alternative to username and password, a connection can authenticate with an OAuth 2.0 access
171
+ token. The connector obtains a token using the client-credentials grant, caches it, and refreshes it
172
+ shortly before it expires.
173
+
174
+ Matching client-credentials configurations share one token cache and concurrent
175
+ refresh across sync and async connections, separate pools and threads in the same
176
+ process. Connection and transport ownership remain local. See the
177
+ [OAuth lifecycle](docs/OAUTH_LIFECYCLE.md) for matching settings and expiry behavior.
178
+
179
+ ```python
180
+ conn = Connection(
181
+ host=host,
182
+ port=443,
183
+ database=database,
184
+ cluster_name='<cluster-name>',
185
+ secure=True,
186
+ client_id='<client_id>',
187
+ client_secret='<client_secret>',
188
+ token_url='https://<your-workspace>/oauth2/token',
189
+ )
190
+ ```
191
+
192
+ If you mint tokens yourself, pass one directly instead. The connector will not refresh it, so a
193
+ long-lived connection may outlive the token:
194
+
195
+ ```python
196
+ conn = Connection(
197
+ host=host, port=443, database=database, cluster_name='<cluster-name>',
198
+ secure=True, access_token='<token>',
199
+ )
200
+ ```
201
+
202
+ **Supply exactly one authentication method.** Passing both username/password and OAuth settings
203
+ raises a `ValueError` rather than picking one, so a stale value left in a config file cannot quietly
204
+ win.
205
+
206
+ OAuth sends `authorization: Bearer <token>` on every RPC. It does not create an
207
+ authentication session; protobuf username, password and session ID fields remain
208
+ empty. The server stack must support and enable this bearer-only flow.
209
+
210
+ Use `secure=True` with your TLS endpoint and an HTTPS `token_url` to protect tokens
211
+ and client credentials. OAuth does not enable TLS automatically; `secure` defaults
212
+ to `False`. Set `cluster_name` to identify the target cluster explicitly.
213
+
214
+ With `auto_resume=True` (the default), OAuth can recover an initial query prepare
215
+ that returns the exact suspended-cluster response. This requires compatible,
216
+ enabled server components that authorize resume and report readiness. The connector
217
+ performs one recovery sequence and retries prepare after readiness. It never
218
+ replays query execution, metadata retrieval or result fetching, and it does not
219
+ treat generic connection failures as suspension.
220
+
221
+ Recovery has a shared 300-second default deadline covering lock waits, token
222
+ acquisition, status/resume calls, polling and the resumed prepare. Configure it with
223
+ `grpc_options={'grpc_auto_resume_timeout_seconds': 300}`. The deadline starts after
224
+ the suspended response; initial prepare has its own timeout. A dispatched resume
225
+ may still complete after the client times out.
226
+
227
+ Set `auto_resume=False` to disable recovery. Invalid credentials, denied permissions,
228
+ unsupported servers and failed or unknown cluster states stop recovery without
229
+ falling back to username/password authentication. Externally supplied access tokens
230
+ are not refreshed.
134
231
 
135
232
  #### Secure Connection Example
136
233
 
@@ -1,49 +1,30 @@
1
- Metadata-Version: 2.2
2
- Name: e6data-python-connector
3
- Version: 2.3.14
4
- Summary: Client for the e6data distributed SQL Engine.
5
- Home-page: https://github.com/e6x-labs/e6data-python-connector
6
- Author: e6data, Inc.
7
- Author-email: info@e6data.com
8
- License: Apache 2.0
9
- Classifier: Operating System :: POSIX :: Linux
10
- Classifier: License :: OSI Approved :: Apache Software License
11
- Classifier: Programming Language :: Python :: 3.5
12
- Classifier: Programming Language :: Python :: 3.6
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3.12
19
- Classifier: Programming Language :: Python :: 3.13
20
- Description-Content-Type: text/markdown
21
- License-File: LICENSE
22
- Requires-Dist: sqlalchemy>=1.0.0
23
- Requires-Dist: future>=1.0.0
24
- Requires-Dist: python-dateutil>=2.9.0
25
- Requires-Dist: pycryptodome>=3.19.1
26
- Requires-Dist: pytz>=2024.1
27
- Requires-Dist: thrift>=0.20.0
28
- Requires-Dist: grpcio>=1.65.1
29
- Requires-Dist: grpcio-tools>=1.65.1
30
- Dynamic: author
31
- Dynamic: author-email
32
- Dynamic: classifier
33
- Dynamic: description
34
- Dynamic: description-content-type
35
- Dynamic: home-page
36
- Dynamic: license
37
- Dynamic: requires-dist
38
- Dynamic: summary
39
-
40
1
  # e6data Python Connector
41
2
 
42
- ![version](https://img.shields.io/badge/version-2.3.14-blue.svg)
3
+ Package version and extras are defined in `setup.py`.
43
4
 
44
5
  ## Introduction
45
6
 
46
- The e6data Connector for Python provides an interface for writing Python applications that can connect to e6data and perform operations. It includes automatic support for blue-green deployments, ensuring seamless failover during server updates without query interruption.
7
+ The e6data Connector for Python provides an interface for writing Python applications that can connect to e6data and perform operations. It includes routing support for blue-green deployments. Active query handles remain tied to the engine that accepted them; failover does not guarantee uninterrupted results.
8
+
9
+ ## Optional native asyncio API
10
+
11
+ This branch adds native async connection, cursor, pooling and SQLAlchemy support for Python 3.11+. Release qualification remains incomplete. Install this branch's built package with `[async]` or `[async-sqlalchemy]`; these instructions do not assert that the feature is published.
12
+
13
+ ```python
14
+ from e6data_python_connector.aio import AsyncConnection
15
+
16
+ async def query(config, sql):
17
+ async with AsyncConnection(**config) as connection:
18
+ async with connection.cursor() as cursor:
19
+ await cursor.execute(sql)
20
+ return await cursor.fetchall()
21
+ ```
22
+
23
+ `config` is application-supplied connection configuration. OAuth requires verified TLS. Native `fetchone()` preserves the one-row outer list, while SQLAlchemy `e6data+asyncio` adapts it to a normal row. Ambiguous submission and incomplete result errors must not be handled by blindly retrying the query. Use context managers for bounded cleanup.
24
+
25
+ See [Async API](docs/ASYNC_API.md) for the complete API map, deadlines, pooling, SQLAlchemy and test configuration, [OAuth lifecycle](docs/OAUTH_LIFECYCLE.md) for renewal and failure behavior, and [the application example](examples/async_query.py).
26
+
27
+ The new CI keeps the full-package coverage denominator and a greater-than-80% gate. The initial baseline was about 19%, so focused async test success is not a release pass. Real-service tests require explicit configuration and are qualified separately.
47
28
 
48
29
  ### Dependencies
49
30
  Make sure to install below dependencies and wheel before install e6data-python-connector.
@@ -120,8 +101,8 @@ The `Connection` class supports the following parameters:
120
101
  |-----------|------|----------|---------|-------------|
121
102
  | `host` | str | Yes | - | IP address or hostname of the e6data cluster |
122
103
  | `port` | int | Yes | - | Port of the e6data engine (typically 80) |
123
- | `username` | str | Yes | - | Your e6data Email ID |
124
- | `password` | str | Yes | - | Access Token generated in the e6data console |
104
+ | `username` | str | Conditional | None | Your e6data Email ID. Required unless authenticating with OAuth |
105
+ | `password` | str | Conditional | None | Access Token generated in the e6data console. Required unless authenticating with OAuth |
125
106
  | `database` | str | No | None | Database to perform queries on |
126
107
  | `catalog` | str | No | None | Catalog name |
127
108
  | `cluster_name` | str | No | None | Name of the cluster for cluster-specific operations |
@@ -131,6 +112,76 @@ The `Connection` class supports the following parameters:
131
112
  | `grpc_options` | dict | No | None | Additional gRPC configuration options |
132
113
  | `debug` | bool | No | False | Enable debug logging for troubleshooting |
133
114
  | `require_fastbinary` | bool | No | True | Require fastbinary module for Thrift deserialization. Set to False to use pure Python implementation if system dependencies cannot be installed |
115
+ | `client_id` | str | No | None | OAuth 2.0 client id. Use with `client_secret` and `token_url` |
116
+ | `client_secret` | str | No | None | OAuth 2.0 client secret |
117
+ | `token_url` | str | No | None | Token endpoint of the authorization server |
118
+ | `oauth_scope` | str | No | None | Space-delimited scopes to request. Omit for the client's full registered set |
119
+ | `access_token` | str | No | None | A previously obtained access token, for callers that mint their own |
120
+ | `client_auth_method` | str | No | `'basic'` | How client credentials reach the token endpoint: `'basic'` or `'post'` |
121
+
122
+ #### Authenticating with OAuth 2.0
123
+
124
+ As an alternative to username and password, a connection can authenticate with an OAuth 2.0 access
125
+ token. The connector obtains a token using the client-credentials grant, caches it, and refreshes it
126
+ shortly before it expires.
127
+
128
+ Matching client-credentials configurations share one token cache and concurrent
129
+ refresh across sync and async connections, separate pools and threads in the same
130
+ process. Connection and transport ownership remain local. See the
131
+ [OAuth lifecycle](docs/OAUTH_LIFECYCLE.md) for matching settings and expiry behavior.
132
+
133
+ ```python
134
+ conn = Connection(
135
+ host=host,
136
+ port=443,
137
+ database=database,
138
+ cluster_name='<cluster-name>',
139
+ secure=True,
140
+ client_id='<client_id>',
141
+ client_secret='<client_secret>',
142
+ token_url='https://<your-workspace>/oauth2/token',
143
+ )
144
+ ```
145
+
146
+ If you mint tokens yourself, pass one directly instead. The connector will not refresh it, so a
147
+ long-lived connection may outlive the token:
148
+
149
+ ```python
150
+ conn = Connection(
151
+ host=host, port=443, database=database, cluster_name='<cluster-name>',
152
+ secure=True, access_token='<token>',
153
+ )
154
+ ```
155
+
156
+ **Supply exactly one authentication method.** Passing both username/password and OAuth settings
157
+ raises a `ValueError` rather than picking one, so a stale value left in a config file cannot quietly
158
+ win.
159
+
160
+ OAuth sends `authorization: Bearer <token>` on every RPC. It does not create an
161
+ authentication session; protobuf username, password and session ID fields remain
162
+ empty. The server stack must support and enable this bearer-only flow.
163
+
164
+ Use `secure=True` with your TLS endpoint and an HTTPS `token_url` to protect tokens
165
+ and client credentials. OAuth does not enable TLS automatically; `secure` defaults
166
+ to `False`. Set `cluster_name` to identify the target cluster explicitly.
167
+
168
+ With `auto_resume=True` (the default), OAuth can recover an initial query prepare
169
+ that returns the exact suspended-cluster response. This requires compatible,
170
+ enabled server components that authorize resume and report readiness. The connector
171
+ performs one recovery sequence and retries prepare after readiness. It never
172
+ replays query execution, metadata retrieval or result fetching, and it does not
173
+ treat generic connection failures as suspension.
174
+
175
+ Recovery has a shared 300-second default deadline covering lock waits, token
176
+ acquisition, status/resume calls, polling and the resumed prepare. Configure it with
177
+ `grpc_options={'grpc_auto_resume_timeout_seconds': 300}`. The deadline starts after
178
+ the suspended response; initial prepare has its own timeout. A dispatched resume
179
+ may still complete after the client times out.
180
+
181
+ Set `auto_resume=False` to disable recovery. Invalid credentials, denied permissions,
182
+ unsupported servers and failed or unknown cluster states stop recovery without
183
+ falling back to username/password authentication. Externally supplied access tokens
184
+ are not refreshed.
134
185
 
135
186
  #### Secure Connection Example
136
187
 
@@ -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