data-connect-hub 0.1.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.
@@ -0,0 +1,75 @@
1
+ """Data Connect Hub Python SDK."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .client import DataConnectClient
6
+ from .exceptions import (
7
+ DCHAuthenticationError,
8
+ DCHConfigError,
9
+ DCHConnectionError,
10
+ DCHError,
11
+ DCHForbiddenError,
12
+ DCHHTTPError,
13
+ DCHNoDataError,
14
+ DCHNotFoundError,
15
+ DCHQueryError,
16
+ DCHResponseError,
17
+ DCHServerError,
18
+ DCHTimeoutError,
19
+ DCHValidationError,
20
+ )
21
+ from .models import (
22
+ Capabilities,
23
+ ConnectionType,
24
+ ConnectionTypeStatus,
25
+ CreateConnectionRequest,
26
+ CreateConnectionTypeRequest,
27
+ CredentialField,
28
+ CredentialsRef,
29
+ DataConnection,
30
+ DataConnectionState,
31
+ DataConnectionStatus,
32
+ DataFormat,
33
+ EnumValue,
34
+ UpdateConnectionRequest,
35
+ UpdateConnectionTypeRequest,
36
+ )
37
+
38
+ try:
39
+ __version__ = version("data-connect-hub")
40
+ except PackageNotFoundError:
41
+ # Imported from a source tree without an installed distribution. Keep this
42
+ # PEP 440 parseable so callers can compare it without special-casing.
43
+ __version__ = "0.0.0+unknown"
44
+
45
+ __all__ = [
46
+ "Capabilities",
47
+ "ConnectionType",
48
+ "ConnectionTypeStatus",
49
+ "CreateConnectionRequest",
50
+ "CreateConnectionTypeRequest",
51
+ "CredentialField",
52
+ "CredentialsRef",
53
+ "DCHAuthenticationError",
54
+ "DCHConfigError",
55
+ "DCHConnectionError",
56
+ "DCHError",
57
+ "DCHForbiddenError",
58
+ "DCHHTTPError",
59
+ "DCHNoDataError",
60
+ "DCHNotFoundError",
61
+ "DCHQueryError",
62
+ "DCHResponseError",
63
+ "DCHServerError",
64
+ "DCHTimeoutError",
65
+ "DCHValidationError",
66
+ "DataConnectClient",
67
+ "DataConnection",
68
+ "DataConnectionState",
69
+ "DataConnectionStatus",
70
+ "DataFormat",
71
+ "EnumValue",
72
+ "UpdateConnectionRequest",
73
+ "UpdateConnectionTypeRequest",
74
+ "__version__",
75
+ ]
@@ -0,0 +1,72 @@
1
+ """Header construction and token caching for REST and Flight SQL authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+
8
+ from .exceptions import DCHConfigError
9
+
10
+ _BEARER_PREFIX = "Bearer "
11
+
12
+
13
+ def _normalize_token(token: str) -> str:
14
+ """Ensure *token* carries the ``Bearer`` prefix exactly once.
15
+
16
+ Raises ``DCHConfigError`` if the token appears to use a non-Bearer
17
+ auth scheme (e.g. ``Basic``).
18
+ """
19
+ if not token:
20
+ return token
21
+
22
+ while token.upper().startswith(_BEARER_PREFIX.upper()):
23
+ token = token[len(_BEARER_PREFIX) :]
24
+
25
+ detected_scheme = token.split(None, 1)[0] if token else ""
26
+ if detected_scheme.lower() in ("basic", "digest"):
27
+ raise DCHConfigError(f"Unsupported auth scheme {detected_scheme!r} — pass only the raw Bearer token value")
28
+
29
+ return f"{_BEARER_PREFIX}{token}"
30
+
31
+
32
+ def build_headers(
33
+ *,
34
+ token: str,
35
+ tenant_id: str,
36
+ ) -> dict[str, str]:
37
+ """Build standard HTTP headers for authentication."""
38
+ headers: dict[str, str] = {}
39
+ if token:
40
+ headers["Authorization"] = _normalize_token(token)
41
+ if tenant_id:
42
+ headers["x-tenant-id"] = tenant_id
43
+ return headers
44
+
45
+
46
+ class TokenCache:
47
+ """Thread-safe cache that calls a provider once and reuses the token until refreshed."""
48
+
49
+ def __init__(self, provider: Callable[[], str]) -> None:
50
+ self._provider = provider
51
+ self._token: str | None = None
52
+ self._lock = threading.Lock()
53
+
54
+ def _call_provider(self) -> str:
55
+ try:
56
+ return self._provider()
57
+ except Exception as exc:
58
+ raise DCHConfigError(f"Token provider failed: {exc}") from exc
59
+
60
+ def get(self) -> str:
61
+ with self._lock:
62
+ if self._token is None:
63
+ self._token = self._call_provider()
64
+ return self._token
65
+
66
+ def refresh(self) -> str:
67
+ with self._lock:
68
+ self._token = self._call_provider()
69
+ return self._token
70
+
71
+
72
+ ADBC_HEADER_PREFIX = "adbc.flight.sql.rpc.call_header."
@@ -0,0 +1,342 @@
1
+ """Arrow Flight SQL client for tabular data queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ from collections.abc import Callable, Generator, Sequence
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ import pandas as pd
13
+
14
+ import adbc_driver_flightsql.dbapi as flight_dbapi
15
+ import pyarrow as pa
16
+ import pyarrow.flight as flight
17
+ from adbc_driver_flightsql import DatabaseOptions
18
+
19
+ from ._auth import ADBC_HEADER_PREFIX, TokenCache, build_headers
20
+ from .exceptions import DCHConfigError, DCHConnectionError, DCHQueryError
21
+
22
+ _GRPC_UNAUTHENTICATED = "unauthenticated"
23
+ _CMD_GET_TABLES_TYPE_URL = "type.googleapis.com/arrow.flight.protocol.sql.CommandGetTables"
24
+
25
+ _QUERY_TIMEOUT = DatabaseOptions.TIMEOUT_QUERY.value
26
+ _FETCH_TIMEOUT = DatabaseOptions.TIMEOUT_FETCH.value
27
+ _TLS_ROOT_CERTS = DatabaseOptions.TLS_ROOT_CERTS.value
28
+ _TLS_SKIP_VERIFY = DatabaseOptions.TLS_SKIP_VERIFY.value
29
+
30
+ _FLIGHT_DISABLE_SERVER_VERIFICATION = "disable_server_verification"
31
+ _FLIGHT_TLS_ROOT_CERTS = "tls_root_certs"
32
+
33
+
34
+ def _is_auth_error(exc: Exception) -> bool:
35
+ return _GRPC_UNAUTHENTICATED in str(exc).lower()
36
+
37
+
38
+ class FlightClient:
39
+ """Thin wrapper around ADBC Flight SQL for Data Connect Hub queries.
40
+
41
+ Parameters
42
+ ----------
43
+ url : str
44
+ gRPC endpoint, e.g. ``grpc://host:50051`` or ``grpc+tls://host:50051``.
45
+ token : str
46
+ Static Bearer token value.
47
+ tenant_id : str
48
+ Tenant identifier.
49
+ token_provider : Callable[[], str], optional
50
+ A callable that returns a valid Bearer token string. The SDK calls
51
+ this once and caches the result. On authentication failure, the
52
+ token is refreshed automatically. Mutually exclusive with *token*.
53
+ timeout : float, optional
54
+ Timeout in seconds for Flight SQL RPC calls (applies to both query and fetch).
55
+ ca_cert : str, optional
56
+ Path to a PEM-encoded CA certificate file for TLS verification.
57
+ insecure : bool
58
+ Skip TLS certificate verification (default False).
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ url: str,
64
+ token: str = "",
65
+ tenant_id: str = "",
66
+ *,
67
+ token_provider: Callable[[], str] | None = None,
68
+ timeout: float | None = None,
69
+ ca_cert: str | None = None,
70
+ insecure: bool = False,
71
+ ) -> None:
72
+ if token and token_provider:
73
+ raise DCHConfigError(
74
+ "Cannot specify both 'token' and 'token_provider'."
75
+ " Please provide either a static token or a token_provider callable, not both."
76
+ )
77
+ self._url = url
78
+ self._tenant_id = tenant_id
79
+ self._token_cache: TokenCache | None = TokenCache(token_provider) if token_provider else None
80
+ self._insecure = insecure
81
+ self._tls_root_certs: str | None = None
82
+ self._adbc_opts: dict[str, str] = {}
83
+ if timeout is not None:
84
+ self._adbc_opts[_QUERY_TIMEOUT] = str(timeout)
85
+ self._adbc_opts[_FETCH_TIMEOUT] = str(timeout)
86
+ if insecure:
87
+ self._adbc_opts[_TLS_SKIP_VERIFY] = "true"
88
+ elif ca_cert:
89
+ cert_path = Path(ca_cert)
90
+ if not cert_path.is_file():
91
+ raise DCHConfigError(f"CA certificate file not found: {ca_cert}")
92
+ self._tls_root_certs = cert_path.read_text()
93
+ self._adbc_opts[_TLS_ROOT_CERTS] = self._tls_root_certs
94
+ self._static_headers: dict[str, str] = {}
95
+ if not token_provider:
96
+ self._static_headers = build_headers(token=token, tenant_id=tenant_id)
97
+
98
+ def _headers(self) -> dict[str, str]:
99
+ if self._token_cache:
100
+ return build_headers(token=self._token_cache.get(), tenant_id=self._tenant_id)
101
+ return dict(self._static_headers)
102
+
103
+ def _base_kwargs(self) -> dict[str, str]:
104
+ prefixed = {f"{ADBC_HEADER_PREFIX}{k.lower()}": v for k, v in self._headers().items()}
105
+ return {**self._adbc_opts, **prefixed}
106
+
107
+ def _call_options(self) -> flight.FlightCallOptions:
108
+ return flight.FlightCallOptions(headers=[(k.lower().encode(), v.encode()) for k, v in self._headers().items()])
109
+
110
+ def _flight_connect(self) -> flight.FlightClient:
111
+ kwargs: dict[str, Any] = {}
112
+ if self._insecure:
113
+ kwargs[_FLIGHT_DISABLE_SERVER_VERIFICATION] = True
114
+ if self._tls_root_certs:
115
+ kwargs[_FLIGHT_TLS_ROOT_CERTS] = self._tls_root_certs.encode()
116
+ try:
117
+ return flight.connect(self._url, **kwargs)
118
+ except Exception as exc:
119
+ raise DCHConnectionError(str(exc)) from exc
120
+
121
+ def _connect(self, connection_id: str) -> flight_dbapi.Connection:
122
+ db_kwargs = {
123
+ **self._base_kwargs(),
124
+ f"{ADBC_HEADER_PREFIX}x-data-connection-id": connection_id,
125
+ }
126
+ try:
127
+ return flight_dbapi.connect(self._url, db_kwargs=db_kwargs)
128
+ except flight_dbapi.Error as exc:
129
+ raise DCHConnectionError(str(exc)) from exc
130
+
131
+ def read(self, sql: str, connection_id: str, *, parameters: Sequence[Any] | None = None) -> pa.Table:
132
+ """Execute *sql* and return the full result as a PyArrow Table."""
133
+ try:
134
+ return self._do_read(sql, connection_id, parameters=parameters)
135
+ except DCHConnectionError as exc:
136
+ if self._token_cache is not None and _is_auth_error(exc):
137
+ self._token_cache.refresh()
138
+ return self._do_read(sql, connection_id, parameters=parameters)
139
+ raise
140
+
141
+ def _do_read(self, sql: str, connection_id: str, *, parameters: Sequence[Any] | None = None) -> pa.Table:
142
+ conn = self._connect(connection_id)
143
+ try:
144
+ cursor = conn.cursor()
145
+ try:
146
+ cursor.execute(sql, parameters)
147
+ return cursor.fetch_arrow_table()
148
+ except flight_dbapi.Error as exc:
149
+ raise DCHQueryError(str(exc)) from exc
150
+ finally:
151
+ with contextlib.suppress(Exception):
152
+ cursor.close()
153
+ finally:
154
+ conn.close()
155
+
156
+ def read_batches(
157
+ self, sql: str, connection_id: str, *, parameters: Sequence[Any] | None = None
158
+ ) -> Generator[pa.RecordBatch, None, None]:
159
+ """Execute *sql* and return a streaming iterator of RecordBatches.
160
+
161
+ Yields one :class:`pyarrow.RecordBatch` per iteration. The
162
+ connection is opened and the cursor closed automatically when the
163
+ generator is first iterated and when it is exhausted or closed::
164
+
165
+ for batch in client.read_batches("SELECT ...", "conn-1"):
166
+ process(batch)
167
+ """
168
+ return self._iter_batches(sql, connection_id, parameters)
169
+
170
+ def _iter_batches(
171
+ self, sql: str, connection_id: str, parameters: Sequence[Any] | None
172
+ ) -> Generator[pa.RecordBatch, None, None]:
173
+ try:
174
+ conn = self._connect(connection_id)
175
+ except DCHConnectionError as exc:
176
+ if self._token_cache is not None and _is_auth_error(exc):
177
+ self._token_cache.refresh()
178
+ conn = self._connect(connection_id)
179
+ else:
180
+ raise
181
+ try:
182
+ cursor = conn.cursor()
183
+ try:
184
+ cursor.execute(sql, parameters)
185
+ reader = cursor.fetch_record_batch()
186
+ yield from reader
187
+ except flight_dbapi.Error as exc:
188
+ raise DCHQueryError(str(exc)) from exc
189
+ finally:
190
+ with contextlib.suppress(Exception):
191
+ cursor.close()
192
+ finally:
193
+ with contextlib.suppress(Exception):
194
+ conn.close()
195
+
196
+ def read_pandas(self, sql: str, connection_id: str, *, parameters: Sequence[Any] | None = None) -> pd.DataFrame:
197
+ """Execute *sql* and return the result as a pandas DataFrame."""
198
+ return self.read(sql, connection_id, parameters=parameters).to_pandas()
199
+
200
+ def server_info(self) -> dict[str, Any]:
201
+ """Return Flight SQL server metadata."""
202
+ try:
203
+ return self._do_server_info()
204
+ except DCHConnectionError as exc:
205
+ if self._token_cache is not None and _is_auth_error(exc):
206
+ self._token_cache.refresh()
207
+ return self._do_server_info()
208
+ raise
209
+
210
+ def _do_server_info(self) -> dict[str, Any]:
211
+ try:
212
+ conn = flight_dbapi.connect(self._url, db_kwargs=self._base_kwargs())
213
+ except flight_dbapi.Error as exc:
214
+ raise DCHConnectionError(str(exc)) from exc
215
+ try:
216
+ info: dict[str, Any] = {str(k): v for k, v in conn.adbc_get_info().items()}
217
+ info["supported_connectors"] = self._do_supported_connectors()
218
+ return info
219
+ except flight_dbapi.Error as exc:
220
+ raise DCHConnectionError(str(exc)) from exc
221
+ finally:
222
+ conn.close()
223
+
224
+ def _do_supported_connectors(self) -> list[str]:
225
+ client = self._flight_connect()
226
+ try:
227
+ action = flight.Action("GetSupportedConnectors", b"")
228
+ results = list(client.do_action(action, self._call_options()))
229
+ if not results:
230
+ return []
231
+ body = results[0].body.to_pybytes()
232
+ try:
233
+ reader = pa.ipc.open_stream(body)
234
+ table = reader.read_all()
235
+ connectors: list[str] = table.column("name").to_pylist()
236
+ except pa.ArrowInvalid:
237
+ connectors = [str(entry["name"]) for entry in json.loads(body)]
238
+ return connectors
239
+ except Exception as exc:
240
+ raise DCHConnectionError(str(exc)) from exc
241
+ finally:
242
+ client.close()
243
+
244
+ def get_tables(
245
+ self,
246
+ connection_id: str,
247
+ *,
248
+ table_name_filter: str | None = None,
249
+ include_schema: bool = False,
250
+ ) -> pa.Table:
251
+ """Retrieve table metadata via Flight SQL ``CommandGetTables``.
252
+
253
+ Parameters
254
+ ----------
255
+ connection_id : str
256
+ Data-connection ID to query.
257
+ table_name_filter : str, optional
258
+ SQL ``LIKE`` pattern to filter table names (e.g. ``"cities"``).
259
+ include_schema : bool
260
+ If True, the response includes each table's Arrow schema
261
+ serialized as IPC bytes in a ``table_schema`` column.
262
+ """
263
+ try:
264
+ return self._do_get_tables(
265
+ connection_id, table_name_filter=table_name_filter, include_schema=include_schema
266
+ )
267
+ except DCHConnectionError as exc:
268
+ if self._token_cache is not None and _is_auth_error(exc):
269
+ self._token_cache.refresh()
270
+ return self._do_get_tables(
271
+ connection_id, table_name_filter=table_name_filter, include_schema=include_schema
272
+ )
273
+ raise
274
+
275
+ def _do_get_tables(
276
+ self,
277
+ connection_id: str,
278
+ *,
279
+ table_name_filter: str | None = None,
280
+ include_schema: bool = False,
281
+ ) -> pa.Table:
282
+ cmd = _build_command_get_tables(
283
+ table_name_filter_pattern=table_name_filter,
284
+ include_schema=include_schema,
285
+ )
286
+
287
+ headers = [
288
+ *self._call_options().headers,
289
+ (b"x-data-connection-id", connection_id.encode()),
290
+ ]
291
+ opts = flight.FlightCallOptions(headers=headers)
292
+
293
+ client = self._flight_connect()
294
+ try:
295
+ descriptor = flight.FlightDescriptor.for_command(cmd)
296
+ info = client.get_flight_info(descriptor, opts)
297
+ if not info.endpoints:
298
+ raise DCHConnectionError("server returned no Flight endpoints for CommandGetTables")
299
+ reader = client.do_get(info.endpoints[0].ticket, opts)
300
+ return reader.read_all()
301
+ except Exception as exc:
302
+ raise DCHConnectionError(str(exc)) from exc
303
+ finally:
304
+ client.close()
305
+
306
+ def close(self) -> None:
307
+ """No-op — connections are opened and closed per call."""
308
+
309
+
310
+ def _encode_varint(value: int) -> bytes:
311
+ result = bytearray()
312
+ while value > 0x7F:
313
+ result.append((value & 0x7F) | 0x80)
314
+ value >>= 7
315
+ result.append(value & 0x7F)
316
+ return bytes(result)
317
+
318
+
319
+ # CommandGetTables protobuf field numbers (from FlightSql.proto)
320
+ _CGT_FIELD_TABLE_NAME_FILTER = 3
321
+ _CGT_FIELD_INCLUDE_SCHEMA = 5
322
+
323
+
324
+ def _build_command_get_tables(
325
+ *,
326
+ table_name_filter_pattern: str | None = None,
327
+ include_schema: bool = False,
328
+ ) -> bytes:
329
+ """Serialize a Flight SQL ``CommandGetTables`` wrapped in ``google.protobuf.Any``."""
330
+ from google.protobuf import any_pb2 # type: ignore[import-untyped]
331
+
332
+ msg = b""
333
+ if table_name_filter_pattern is not None:
334
+ encoded = table_name_filter_pattern.encode("utf-8")
335
+ msg += _encode_varint((_CGT_FIELD_TABLE_NAME_FILTER << 3) | 2) + _encode_varint(len(encoded)) + encoded
336
+ if include_schema:
337
+ msg += _encode_varint((_CGT_FIELD_INCLUDE_SCHEMA << 3) | 0) + _encode_varint(1)
338
+
339
+ any_msg = any_pb2.Any()
340
+ any_msg.type_url = _CMD_GET_TABLES_TYPE_URL
341
+ any_msg.value = msg
342
+ return bytes(any_msg.SerializeToString())