e6data-python-connector 3.0.0__tar.gz → 3.0.1rc1__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.
- {e6data_python_connector-3.0.0/e6data_python_connector.egg-info → e6data_python_connector-3.0.1rc1}/PKG-INFO +83 -1
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/README.md +82 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_connection.py +6 -2
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_cursor.py +169 -13
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_dialect.py +2 -1
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/dialect.py +7 -2
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6data_grpc.py +276 -88
- e6data_python_connector-3.0.1rc1/e6data_python_connector/result_batch.py +49 -0
- e6data_python_connector-3.0.1rc1/e6data_python_connector/server/e6x_engine_pb2.py +209 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1/e6data_python_connector.egg-info}/PKG-INFO +83 -1
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/SOURCES.txt +4 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/setup.py +1 -1
- e6data_python_connector-3.0.1rc1/test/integration/benchmark_result_batch_v2.py +149 -0
- e6data_python_connector-3.0.1rc1/test/integration/result_batch_benchmark_support.py +196 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/support.py +3 -0
- e6data_python_connector-3.0.1rc1/test/integration/test_result_batch_v2.py +120 -0
- e6data_python_connector-3.0.0/e6data_python_connector/server/e6x_engine_pb2.py +0 -207
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/LICENSE +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/MANIFEST.in +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/aio.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_cluster_manager.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_connection_pool.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_oauth.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_work.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_manager.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/cluster_pb2.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/cluster_pb2_grpc.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/common.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/connection_pool.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/constants.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/datainputstream.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/date_time_utils.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/constants.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/ttypes.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/exceptions.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth_cache.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth_common.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/QueryEngineService.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/constants.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/e6x_engine_pb2.pyi +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/ttypes.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/strategy.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/typeId.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/dependency_links.txt +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/entry_points.txt +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/requires.txt +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/top_level.txt +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/pyproject.toml +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/setup.cfg +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_38_nines.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_all_cases.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_binary.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_correct_value.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_fields.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/check_decimal_errors.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/cleanup_test_files.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/conftest.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/debug_38_nines.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/debug_binary.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/final_test.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/__init__.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/benchmark_preflight.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/benchmark_sync_async.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/expiry_preflight.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_connection.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_pool.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_queries.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_resume.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_sqlalchemy.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_token_lifecycle.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_auth_boundary.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_oauth_continuation.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_readonly_matrix.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_shared_token_issuance.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_sync_oauth_lifecycle.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/token_observation.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/move_tests.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/quick_test.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_38_nines.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_all_decimal128_cases.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_efficiency.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_none_strategy.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_oauth.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_strategy.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_code_optimizations.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_comprehensive.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_connection_pool.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_connection_pool_e2e.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_current_implementation.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_decimal128_binary_parsing.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_decimal128_parsing.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_dialect_columns.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_fix.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_improved_parsing.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_known_case.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_manual_analysis.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_mock_server.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_multiprocessing_fix.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_new_implementation.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_authentication.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_auto_resume.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_resume_integration.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_pool_concurrency_simple.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_pool_threading_multiprocessing.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_simple_connection_pool.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_specific_binary.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_logic.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_persistence_fix.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_sharing_fix.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_user_binary_value.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/tests.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/tests_grpc.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/validate_decimal128.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/validate_implementation.py +0 -0
- {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/verify_decimal_fix.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e6data-python-connector
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.1rc1
|
|
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.
|
|
@@ -469,6 +469,88 @@ database = '<new_database_name>' # Replace with the new database.
|
|
|
469
469
|
cursor = conn.cursor(database, catalog_name)
|
|
470
470
|
```
|
|
471
471
|
|
|
472
|
+
### Opt in to multi-chunk result batches
|
|
473
|
+
|
|
474
|
+
`enable_result_batch_v2=True` lets a compatible planner return several result
|
|
475
|
+
chunks in one `getNextResultBatchV2` response. The default is `False`, which keeps
|
|
476
|
+
the existing V1 protocol. Increasing `arraysize` or `fetchmany(size)` changes how
|
|
477
|
+
many rows your application receives per call; it does not change the planner's
|
|
478
|
+
response size.
|
|
479
|
+
|
|
480
|
+
The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
|
|
481
|
+
`ENABLE_GET_NEXT_CHUNK_V2` controls the separate executor-to-planner boundary.
|
|
482
|
+
Record both flags when qualifying a deployment. V2 can reduce network round
|
|
483
|
+
trips, but it does not reduce the number of result bytes or extend the planner's
|
|
484
|
+
query lifetime. Completion of a 20-million-row result within 900 seconds must be
|
|
485
|
+
measured against the actual workload and deployment.
|
|
486
|
+
|
|
487
|
+
The following examples use your existing `connection_options`, `sql`, and
|
|
488
|
+
`consume` function. Choose a positive `result_receive_limit_bytes` from measured
|
|
489
|
+
V2 response sizes and the client's memory budget. The sync API preserves its
|
|
490
|
+
existing gRPC options, so supply a finite receive limit for a V2 rollout:
|
|
491
|
+
|
|
492
|
+
```python
|
|
493
|
+
from e6data_python_connector import Connection
|
|
494
|
+
|
|
495
|
+
sync_options = {
|
|
496
|
+
**connection_options,
|
|
497
|
+
"enable_result_batch_v2": True,
|
|
498
|
+
"grpc_options": {
|
|
499
|
+
**connection_options.get("grpc_options", {}),
|
|
500
|
+
"max_receive_message_length": result_receive_limit_bytes,
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
with Connection(**sync_options) as connection:
|
|
504
|
+
with connection.cursor() as cursor:
|
|
505
|
+
cursor.execute(sql)
|
|
506
|
+
for rows in cursor.fetchall_buffer():
|
|
507
|
+
consume(rows)
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The async API keeps its finite 64 MiB default. Set
|
|
511
|
+
`max_receive_message_bytes` to change that limit. If `grpc_options` also contains
|
|
512
|
+
a receive limit, it must agree with this value.
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
from e6data_python_connector.aio import AsyncConnection
|
|
516
|
+
|
|
517
|
+
async_options = {
|
|
518
|
+
**connection_options,
|
|
519
|
+
"enable_result_batch_v2": True,
|
|
520
|
+
"max_receive_message_bytes": result_receive_limit_bytes,
|
|
521
|
+
}
|
|
522
|
+
async with AsyncConnection(**async_options) as connection:
|
|
523
|
+
async with connection.cursor() as cursor:
|
|
524
|
+
await cursor.execute(sql)
|
|
525
|
+
async for rows in cursor.fetchall_buffer():
|
|
526
|
+
consume(rows)
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
SQLAlchemy accepts the same opt-in through `connect_args`:
|
|
530
|
+
|
|
531
|
+
```python
|
|
532
|
+
from sqlalchemy import create_engine
|
|
533
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
534
|
+
|
|
535
|
+
engine = create_engine("e6data://", connect_args=sync_options)
|
|
536
|
+
async_engine = create_async_engine("e6data+asyncio://", connect_args=async_options)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Fetch return shapes and row order stay the same. `fetchall_buffer()` yields
|
|
540
|
+
decoded chunks; a V2 response can contain several chunks. All chunks in an
|
|
541
|
+
envelope are decoded before any are exposed, so V2 may use more memory and delay
|
|
542
|
+
the first chunk compared with V1. `fetchall()` still retains the full result.
|
|
543
|
+
There is no prefetch or parallel fetching for a query.
|
|
544
|
+
|
|
545
|
+
An `UNIMPLEMENTED` response switches that query to V1. A later query may try V2
|
|
546
|
+
again. Other fetch failures do not trigger a protocol fallback or query replay.
|
|
547
|
+
To disable V2 for new connections, omit the option or set it to `False`.
|
|
548
|
+
|
|
549
|
+
The [result batch qualification instructions](test/README.md#result-batch-v2-qualification)
|
|
550
|
+
cover protocol parity and the explicit large-result benchmark. Real-engine
|
|
551
|
+
qualification and the 900-second acceptance measurement have not been run for
|
|
552
|
+
this change.
|
|
553
|
+
|
|
472
554
|
### Get Query Time Metrics
|
|
473
555
|
```python
|
|
474
556
|
import json
|
|
@@ -423,6 +423,88 @@ database = '<new_database_name>' # Replace with the new database.
|
|
|
423
423
|
cursor = conn.cursor(database, catalog_name)
|
|
424
424
|
```
|
|
425
425
|
|
|
426
|
+
### Opt in to multi-chunk result batches
|
|
427
|
+
|
|
428
|
+
`enable_result_batch_v2=True` lets a compatible planner return several result
|
|
429
|
+
chunks in one `getNextResultBatchV2` response. The default is `False`, which keeps
|
|
430
|
+
the existing V1 protocol. Increasing `arraysize` or `fetchmany(size)` changes how
|
|
431
|
+
many rows your application receives per call; it does not change the planner's
|
|
432
|
+
response size.
|
|
433
|
+
|
|
434
|
+
The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
|
|
435
|
+
`ENABLE_GET_NEXT_CHUNK_V2` controls the separate executor-to-planner boundary.
|
|
436
|
+
Record both flags when qualifying a deployment. V2 can reduce network round
|
|
437
|
+
trips, but it does not reduce the number of result bytes or extend the planner's
|
|
438
|
+
query lifetime. Completion of a 20-million-row result within 900 seconds must be
|
|
439
|
+
measured against the actual workload and deployment.
|
|
440
|
+
|
|
441
|
+
The following examples use your existing `connection_options`, `sql`, and
|
|
442
|
+
`consume` function. Choose a positive `result_receive_limit_bytes` from measured
|
|
443
|
+
V2 response sizes and the client's memory budget. The sync API preserves its
|
|
444
|
+
existing gRPC options, so supply a finite receive limit for a V2 rollout:
|
|
445
|
+
|
|
446
|
+
```python
|
|
447
|
+
from e6data_python_connector import Connection
|
|
448
|
+
|
|
449
|
+
sync_options = {
|
|
450
|
+
**connection_options,
|
|
451
|
+
"enable_result_batch_v2": True,
|
|
452
|
+
"grpc_options": {
|
|
453
|
+
**connection_options.get("grpc_options", {}),
|
|
454
|
+
"max_receive_message_length": result_receive_limit_bytes,
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
with Connection(**sync_options) as connection:
|
|
458
|
+
with connection.cursor() as cursor:
|
|
459
|
+
cursor.execute(sql)
|
|
460
|
+
for rows in cursor.fetchall_buffer():
|
|
461
|
+
consume(rows)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The async API keeps its finite 64 MiB default. Set
|
|
465
|
+
`max_receive_message_bytes` to change that limit. If `grpc_options` also contains
|
|
466
|
+
a receive limit, it must agree with this value.
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from e6data_python_connector.aio import AsyncConnection
|
|
470
|
+
|
|
471
|
+
async_options = {
|
|
472
|
+
**connection_options,
|
|
473
|
+
"enable_result_batch_v2": True,
|
|
474
|
+
"max_receive_message_bytes": result_receive_limit_bytes,
|
|
475
|
+
}
|
|
476
|
+
async with AsyncConnection(**async_options) as connection:
|
|
477
|
+
async with connection.cursor() as cursor:
|
|
478
|
+
await cursor.execute(sql)
|
|
479
|
+
async for rows in cursor.fetchall_buffer():
|
|
480
|
+
consume(rows)
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
SQLAlchemy accepts the same opt-in through `connect_args`:
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
from sqlalchemy import create_engine
|
|
487
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
488
|
+
|
|
489
|
+
engine = create_engine("e6data://", connect_args=sync_options)
|
|
490
|
+
async_engine = create_async_engine("e6data+asyncio://", connect_args=async_options)
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Fetch return shapes and row order stay the same. `fetchall_buffer()` yields
|
|
494
|
+
decoded chunks; a V2 response can contain several chunks. All chunks in an
|
|
495
|
+
envelope are decoded before any are exposed, so V2 may use more memory and delay
|
|
496
|
+
the first chunk compared with V1. `fetchall()` still retains the full result.
|
|
497
|
+
There is no prefetch or parallel fetching for a query.
|
|
498
|
+
|
|
499
|
+
An `UNIMPLEMENTED` response switches that query to V1. A later query may try V2
|
|
500
|
+
again. Other fetch failures do not trigger a protocol fallback or query replay.
|
|
501
|
+
To disable V2 for new connections, omit the option or set it to `False`.
|
|
502
|
+
|
|
503
|
+
The [result batch qualification instructions](test/README.md#result-batch-v2-qualification)
|
|
504
|
+
cover protocol parity and the explicit large-result benchmark. Real-engine
|
|
505
|
+
qualification and the 900-second acceptance measurement have not been run for
|
|
506
|
+
this change.
|
|
507
|
+
|
|
426
508
|
### Get Query Time Metrics
|
|
427
509
|
```python
|
|
428
510
|
import json
|
|
@@ -50,7 +50,8 @@ class AsyncConnection:
|
|
|
50
50
|
token_url=None, oauth_scope=None, access_token=None,
|
|
51
51
|
client_auth_method='basic', *, operation_timeout=600.0,
|
|
52
52
|
oauth_timeout=10.0, cleanup_timeout=10.0, auto_resume_timeout=300.0,
|
|
53
|
-
max_receive_message_bytes=64 * 1024 * 1024
|
|
53
|
+
max_receive_message_bytes=64 * 1024 * 1024,
|
|
54
|
+
enable_result_batch_v2=False):
|
|
54
55
|
if sys.version_info < (3, 11):
|
|
55
56
|
raise RuntimeError('The async API requires Python 3.11 or newer.')
|
|
56
57
|
try:
|
|
@@ -78,13 +79,16 @@ class AsyncConnection:
|
|
|
78
79
|
raise ValueError('Async OAuth requires verified TLS (secure=True).')
|
|
79
80
|
if ssl_cert is not None and not isinstance(ssl_cert, (str, bytes)):
|
|
80
81
|
raise ValueError('ssl_cert must be a certificate path or PEM bytes.')
|
|
82
|
+
if not isinstance(enable_result_batch_v2, bool):
|
|
83
|
+
raise ValueError('enable_result_batch_v2 must be a boolean.')
|
|
81
84
|
settings = dict(host=host, port=port, username=username, password=password,
|
|
82
85
|
catalog=catalog, database=database, cluster_name=cluster_name,
|
|
83
86
|
secure=bool(secure), ssl_cert=ssl_cert, auto_resume=auto_resume,
|
|
84
87
|
scheme=scheme, debug=debug, require_fastbinary=require_fastbinary,
|
|
85
88
|
client_id=client_id, client_secret=client_secret, token_url=token_url,
|
|
86
89
|
oauth_scope=oauth_scope, access_token=access_token,
|
|
87
|
-
client_auth_method=client_auth_method
|
|
90
|
+
client_auth_method=client_auth_method,
|
|
91
|
+
enable_result_batch_v2=enable_result_batch_v2)
|
|
88
92
|
for name, value in [('operation_timeout', operation_timeout), ('oauth_timeout', oauth_timeout),
|
|
89
93
|
('cleanup_timeout', cleanup_timeout), ('auto_resume_timeout', auto_resume_timeout)]:
|
|
90
94
|
settings[name] = validate_positive_timeout(value, name)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Native asynchronous query lifecycle, with no replay of consumed results."""
|
|
2
2
|
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
3
5
|
from collections import deque
|
|
4
6
|
from contextlib import contextmanager
|
|
5
7
|
from io import BytesIO
|
|
@@ -11,6 +13,22 @@ from .e6data_grpc import _escaper
|
|
|
11
13
|
from .exceptions import (AmbiguousSubmissionError, IncompleteResultError,
|
|
12
14
|
NotSupportedError, OperationalError, ProgrammingError, OAuthError)
|
|
13
15
|
from .server import e6x_engine_pb2 as pb
|
|
16
|
+
from .result_batch import ResultBatchBuffer, decode_result_batches
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _log_result_batch(protocol, rpc_seconds, status, response=None, decode_seconds=0.0):
|
|
23
|
+
logger.debug('Result batch fetch completed.', extra={
|
|
24
|
+
'result_batch_protocol': protocol,
|
|
25
|
+
'result_batch_rpc_seconds': rpc_seconds,
|
|
26
|
+
'result_batch_chunk_count': (len(response.resultBatches) if protocol == 'v2'
|
|
27
|
+
else int(bool(response.resultBatch))) if response else 0,
|
|
28
|
+
'result_batch_serialized_bytes': response.ByteSize() if response else 0,
|
|
29
|
+
'result_batch_decode_seconds': decode_seconds,
|
|
30
|
+
'result_batch_status': status,
|
|
31
|
+
})
|
|
14
32
|
|
|
15
33
|
|
|
16
34
|
def _metadata_columns(payload):
|
|
@@ -46,8 +64,16 @@ class AsyncCursor:
|
|
|
46
64
|
self._active_call = None
|
|
47
65
|
self._close_task = None
|
|
48
66
|
self._cleanup_error = None
|
|
67
|
+
self._result_batches = ResultBatchBuffer()
|
|
68
|
+
self._reset_result_protocol()
|
|
49
69
|
connection._cursors.add(self)
|
|
50
70
|
|
|
71
|
+
def _reset_result_protocol(self):
|
|
72
|
+
self._result_batch_v2 = self._connection.enable_result_batch_v2
|
|
73
|
+
self._result_protocol = 'v2' if self._result_batch_v2 else 'v1'
|
|
74
|
+
self._result_session_id = None
|
|
75
|
+
self._result_batches.clear()
|
|
76
|
+
|
|
51
77
|
@property
|
|
52
78
|
def connection(self):
|
|
53
79
|
return self._public_connection
|
|
@@ -137,6 +163,9 @@ class AsyncCursor:
|
|
|
137
163
|
self._failure = IncompleteResultError(reason, query_id=self.query_id)
|
|
138
164
|
if self._state != 'CLOSED':
|
|
139
165
|
self._state = 'RESULT_FAILED'
|
|
166
|
+
if self._result_batch_v2:
|
|
167
|
+
self._rows.clear()
|
|
168
|
+
self._result_batches.clear()
|
|
140
169
|
return self._failure
|
|
141
170
|
|
|
142
171
|
def _accept_rows(self, rows):
|
|
@@ -147,10 +176,18 @@ class AsyncCursor:
|
|
|
147
176
|
self._rownumber += len(rows)
|
|
148
177
|
return rows
|
|
149
178
|
|
|
150
|
-
async def _query_request(self, cls, deadline):
|
|
179
|
+
async def _query_request(self, cls, deadline, *, owned=False):
|
|
151
180
|
if self._route is None:
|
|
152
181
|
raise ProgrammingError('No query handle is known.')
|
|
153
|
-
|
|
182
|
+
if self._result_batch_v2 and not self._connection._oauth_enabled and self._result_session_id:
|
|
183
|
+
session = self._result_session_id
|
|
184
|
+
elif owned:
|
|
185
|
+
# Owned cleanup cannot mint a replacement authentication session.
|
|
186
|
+
session = '' if self._connection._oauth_enabled else self._connection._session_id
|
|
187
|
+
if session is None:
|
|
188
|
+
raise OperationalError('Cleanup has no authenticated legacy session.')
|
|
189
|
+
else:
|
|
190
|
+
session = await self._connection.get_session_id(deadline=deadline)
|
|
154
191
|
return cls(sessionId=session, queryId=self.query_id, engineIP=self._route.engine_ip)
|
|
155
192
|
|
|
156
193
|
async def _refresh_metadata(self, deadline):
|
|
@@ -168,6 +205,7 @@ class AsyncCursor:
|
|
|
168
205
|
from .async_connection import QueryRoute
|
|
169
206
|
if self._state != 'EMPTY':
|
|
170
207
|
raise ProgrammingError('Clear the previous query before executing another.')
|
|
208
|
+
self._reset_result_protocol()
|
|
171
209
|
if not isinstance(operation, str) or not operation.strip():
|
|
172
210
|
raise ProgrammingError('operation must be a nonempty SQL string.')
|
|
173
211
|
sql = operation.strip().removesuffix(';')
|
|
@@ -268,6 +306,8 @@ class AsyncCursor:
|
|
|
268
306
|
|
|
269
307
|
async def _next_batch(self, deadline):
|
|
270
308
|
from .async_work import reserve_work
|
|
309
|
+
if self._result_batch_v2:
|
|
310
|
+
return await self._next_v2_batch(deadline)
|
|
271
311
|
if self._state == 'EXHAUSTED':
|
|
272
312
|
return None
|
|
273
313
|
if self._columns is None:
|
|
@@ -279,13 +319,23 @@ class AsyncCursor:
|
|
|
279
319
|
def mark_dispatch():
|
|
280
320
|
nonlocal consumed
|
|
281
321
|
consumed = True
|
|
322
|
+
started = time.monotonic()
|
|
323
|
+
response = None
|
|
324
|
+
decode_seconds = 0.0
|
|
282
325
|
try:
|
|
283
|
-
|
|
326
|
+
try:
|
|
327
|
+
response = await self._call('getNextResultBatch', request, deadline, on_dispatch=mark_dispatch)
|
|
328
|
+
finally:
|
|
329
|
+
rpc_seconds = time.monotonic() - started
|
|
284
330
|
payload = response.resultBatch
|
|
285
331
|
if not payload:
|
|
286
332
|
self._state = 'EXHAUSTED'
|
|
287
333
|
return None
|
|
288
|
-
|
|
334
|
+
decode_started = time.monotonic()
|
|
335
|
+
try:
|
|
336
|
+
return await self._decode_batch(payload, reservation, revision)
|
|
337
|
+
finally:
|
|
338
|
+
decode_seconds = time.monotonic() - decode_started
|
|
289
339
|
except asyncio.CancelledError:
|
|
290
340
|
if consumed:
|
|
291
341
|
self._fail_result('ambiguous_result')
|
|
@@ -298,6 +348,110 @@ class AsyncCursor:
|
|
|
298
348
|
raise
|
|
299
349
|
finally:
|
|
300
350
|
reservation.release()
|
|
351
|
+
if consumed:
|
|
352
|
+
status = 'ok' if response is not None and self._failure is None else 'error'
|
|
353
|
+
_log_result_batch('v1', rpc_seconds,
|
|
354
|
+
status, response if status == 'ok' else None, decode_seconds if status == 'ok' else 0.0)
|
|
355
|
+
|
|
356
|
+
def _retain_result_session(self, response, revision):
|
|
357
|
+
self._publish(revision)
|
|
358
|
+
if self._result_batch_v2 and not self._connection._oauth_enabled and response.sessionId:
|
|
359
|
+
self._result_session_id = response.sessionId
|
|
360
|
+
|
|
361
|
+
async def _decode_v2_response(self, response, reservation, revision):
|
|
362
|
+
self._retain_result_session(response, revision)
|
|
363
|
+
try:
|
|
364
|
+
chunks = await reservation.run(decode_result_batches, self._columns, response.resultBatches)
|
|
365
|
+
except Exception as error:
|
|
366
|
+
raise self._fail_result('decode_failed') from error
|
|
367
|
+
self._publish(revision)
|
|
368
|
+
self._result_batches.accept(chunks, response.endOfStream)
|
|
369
|
+
|
|
370
|
+
async def _next_v2_batch(self, deadline):
|
|
371
|
+
from .async_work import reserve_work
|
|
372
|
+
batch = self._result_batches.pop()
|
|
373
|
+
if batch is not None:
|
|
374
|
+
return batch
|
|
375
|
+
if self._result_batches.finished or self._state == 'EXHAUSTED':
|
|
376
|
+
self._state = 'EXHAUSTED'
|
|
377
|
+
return None
|
|
378
|
+
if self._columns is None:
|
|
379
|
+
await self._refresh_metadata(deadline)
|
|
380
|
+
consumed = False
|
|
381
|
+
backoff = .01
|
|
382
|
+
try:
|
|
383
|
+
while True:
|
|
384
|
+
request = await self._query_request(pb.GetNextResultBatchRequest, deadline)
|
|
385
|
+
reservation = await reserve_work(deadline=deadline)
|
|
386
|
+
revision = self._revision
|
|
387
|
+
protocol = self._result_protocol
|
|
388
|
+
previously_consumed = consumed
|
|
389
|
+
dispatched = False
|
|
390
|
+
response = None
|
|
391
|
+
status = 'error'
|
|
392
|
+
rpc_seconds = decode_seconds = 0.0
|
|
393
|
+
started = time.monotonic()
|
|
394
|
+
|
|
395
|
+
def mark_dispatch():
|
|
396
|
+
nonlocal consumed, dispatched
|
|
397
|
+
consumed = dispatched = True
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
try:
|
|
401
|
+
response = await self._call(
|
|
402
|
+
'getNextResultBatchV2' if protocol == 'v2' else 'getNextResultBatch',
|
|
403
|
+
request, deadline, on_dispatch=mark_dispatch)
|
|
404
|
+
except grpc.RpcError as error:
|
|
405
|
+
if protocol == 'v2' and error.code() == grpc.StatusCode.UNIMPLEMENTED:
|
|
406
|
+
consumed = previously_consumed
|
|
407
|
+
self._result_protocol = 'v1'
|
|
408
|
+
status = 'unimplemented'
|
|
409
|
+
logger.debug('Result batch compatibility fallback.',
|
|
410
|
+
extra={'result_batch_fallback': True})
|
|
411
|
+
continue
|
|
412
|
+
raise
|
|
413
|
+
finally:
|
|
414
|
+
rpc_seconds = time.monotonic() - started
|
|
415
|
+
decode_started = time.monotonic()
|
|
416
|
+
try:
|
|
417
|
+
if protocol == 'v2':
|
|
418
|
+
await self._decode_v2_response(response, reservation, revision)
|
|
419
|
+
else:
|
|
420
|
+
self._retain_result_session(response, revision)
|
|
421
|
+
if response.resultBatch:
|
|
422
|
+
batch = await self._decode_batch(response.resultBatch, reservation, revision)
|
|
423
|
+
else:
|
|
424
|
+
self._state = 'EXHAUSTED'
|
|
425
|
+
batch = None
|
|
426
|
+
finally:
|
|
427
|
+
decode_seconds = time.monotonic() - decode_started
|
|
428
|
+
status = 'ok'
|
|
429
|
+
if protocol == 'v1':
|
|
430
|
+
return batch
|
|
431
|
+
batch = self._result_batches.pop()
|
|
432
|
+
if batch is not None:
|
|
433
|
+
return batch
|
|
434
|
+
if self._result_batches.finished:
|
|
435
|
+
self._state = 'EXHAUSTED'
|
|
436
|
+
return None
|
|
437
|
+
finally:
|
|
438
|
+
reservation.release()
|
|
439
|
+
if dispatched:
|
|
440
|
+
_log_result_batch(protocol, rpc_seconds, status,
|
|
441
|
+
response if status == 'ok' else None,
|
|
442
|
+
decode_seconds if status == 'ok' else 0.0)
|
|
443
|
+
await asyncio.sleep(min(backoff, self._connection._remaining(deadline)))
|
|
444
|
+
backoff = min(backoff * 2, .1)
|
|
445
|
+
except asyncio.CancelledError:
|
|
446
|
+
if consumed:
|
|
447
|
+
self._fail_result('ambiguous_result')
|
|
448
|
+
raise
|
|
449
|
+
except IncompleteResultError:
|
|
450
|
+
raise
|
|
451
|
+
except Exception as error:
|
|
452
|
+
if consumed:
|
|
453
|
+
raise self._fail_result('ambiguous_result') from error
|
|
454
|
+
raise
|
|
301
455
|
|
|
302
456
|
async def _fetchmany(self, size, deadline):
|
|
303
457
|
while len(self._rows) < size and self._state != 'EXHAUSTED':
|
|
@@ -451,27 +605,25 @@ class AsyncCursor:
|
|
|
451
605
|
if self._route is None:
|
|
452
606
|
if self._state == 'SUBMISSION_UNKNOWN' or self in self._connection._ambiguous_submissions:
|
|
453
607
|
raise AmbiguousSubmissionError()
|
|
608
|
+
self._reset_query_state()
|
|
454
609
|
return None
|
|
455
|
-
|
|
456
|
-
# Owned cleanup has no authority to mint a new legacy session silently.
|
|
457
|
-
session = '' if self._connection._oauth_enabled else self._connection._session_id
|
|
458
|
-
if session is None:
|
|
459
|
-
raise OperationalError('Cleanup has no authenticated legacy session.')
|
|
460
|
-
else:
|
|
461
|
-
session = await self._connection.get_session_id(deadline=deadline)
|
|
462
|
-
request = pb.ClearOrCancelQueryRequest(sessionId=session, queryId=self.query_id, engineIP=self._route.engine_ip)
|
|
610
|
+
request = await self._query_request(pb.ClearOrCancelQueryRequest, deadline, owned=owned)
|
|
463
611
|
response = await self._connection._rpc('clearOrCancelQuery', request, deadline=deadline,
|
|
464
612
|
route=self._route, _cleanup=owned)
|
|
465
613
|
self._connection._release_route(self.query_id)
|
|
466
614
|
self._connection._ambiguous_submissions.discard(self)
|
|
615
|
+
self._reset_query_state()
|
|
616
|
+
return response
|
|
617
|
+
|
|
618
|
+
def _reset_query_state(self):
|
|
467
619
|
self._route = None
|
|
468
620
|
self._rows.clear()
|
|
621
|
+
self._reset_result_protocol()
|
|
469
622
|
self._columns = self._description = None
|
|
470
623
|
self._rowcount, self._rownumber = -1, 0
|
|
471
624
|
self._failure = self._cleanup_error = None
|
|
472
625
|
if self._state != 'CLOSED':
|
|
473
626
|
self._state = 'EMPTY'
|
|
474
|
-
return response
|
|
475
627
|
|
|
476
628
|
async def clear(self, query_id=None, *, timeout=None):
|
|
477
629
|
self._check()
|
|
@@ -496,6 +648,8 @@ class AsyncCursor:
|
|
|
496
648
|
try:
|
|
497
649
|
async with asyncio.timeout_at(deadline):
|
|
498
650
|
await self._stop_active()
|
|
651
|
+
if self._result_batch_v2:
|
|
652
|
+
self._fail_result('cancelled_result')
|
|
499
653
|
request = await self._query_request(pb.CancelQueryRequest, deadline)
|
|
500
654
|
await self._connection._rpc('cancelQuery', request, deadline=deadline, route=self._route)
|
|
501
655
|
self._fail_result('cancelled_result')
|
|
@@ -524,6 +678,8 @@ class AsyncCursor:
|
|
|
524
678
|
self._cleanup_error = OperationalError('Query cleanup is unconfirmed.')
|
|
525
679
|
finally:
|
|
526
680
|
self._rows.clear()
|
|
681
|
+
self._result_batches.clear()
|
|
682
|
+
self._result_session_id = None
|
|
527
683
|
self._connection._cursors.discard(self)
|
|
528
684
|
|
|
529
685
|
async def close(self):
|
|
@@ -216,7 +216,8 @@ class E6dataAsyncDialect(E6dataDialect):
|
|
|
216
216
|
aliases = {'schema': 'database', 'cluster-name': 'cluster_name',
|
|
217
217
|
'cluster-uuid': 'cluster_name', 'auto-resume': 'auto_resume'}
|
|
218
218
|
strings = {'catalog', 'database', 'cluster_name', 'ssl_cert'}
|
|
219
|
-
booleans = {'secure', 'auto_resume', 'debug', 'require_fastbinary'
|
|
219
|
+
booleans = {'secure', 'auto_resume', 'debug', 'require_fastbinary',
|
|
220
|
+
'enable_result_batch_v2'}
|
|
220
221
|
floats = {'operation_timeout', 'cleanup_timeout', 'oauth_timeout', 'auto_resume_timeout'}
|
|
221
222
|
options = {}
|
|
222
223
|
if url.host is not None:
|
|
@@ -248,6 +248,9 @@ class E6dataDialect(default.DefaultDialect):
|
|
|
248
248
|
self.secure = url.query.get("secure") == "true"
|
|
249
249
|
self.auto_resume = url.query.get("auto-resume", "true") == "true" # default to True
|
|
250
250
|
self.debug = url.query.get("debug", "false") == "true" # default to True
|
|
251
|
+
result_batch_v2 = url.query.get('enable_result_batch_v2', 'false')
|
|
252
|
+
if result_batch_v2 not in ('true', 'false'):
|
|
253
|
+
raise ValueError('enable_result_batch_v2 must be true or false.')
|
|
251
254
|
if not self.catalog_name:
|
|
252
255
|
raise Exception('Please specify catalog in query parameter.')
|
|
253
256
|
|
|
@@ -255,7 +258,7 @@ class E6dataDialect(default.DefaultDialect):
|
|
|
255
258
|
grpc_options = {}
|
|
256
259
|
for key, value in url.query.items():
|
|
257
260
|
# Skip known parameters that are not grpc_options
|
|
258
|
-
if key not in {"schema", "catalog", "cluster-uuid", "secure", "auto-resume"}:
|
|
261
|
+
if key not in {"schema", "catalog", "cluster-uuid", "secure", "auto-resume", "enable_result_batch_v2"}:
|
|
259
262
|
grpc_options[key] = value
|
|
260
263
|
|
|
261
264
|
kwargs = {
|
|
@@ -270,8 +273,10 @@ class E6dataDialect(default.DefaultDialect):
|
|
|
270
273
|
'secure': self.secure,
|
|
271
274
|
'auto_resume': self.auto_resume,
|
|
272
275
|
'grpc_options': grpc_options,
|
|
273
|
-
'debug': self.debug
|
|
276
|
+
'debug': self.debug,
|
|
274
277
|
}
|
|
278
|
+
if 'enable_result_batch_v2' in url.query:
|
|
279
|
+
kwargs['enable_result_batch_v2'] = result_batch_v2 == 'true'
|
|
275
280
|
return [], kwargs
|
|
276
281
|
|
|
277
282
|
def get_schema_names(self, connection, **kw):
|