atlan-application-sdk-api 3.51.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. application_sdk/__init__.py +7 -0
  2. application_sdk/_build_info.py +62 -0
  3. application_sdk/_context_errors.py +65 -0
  4. application_sdk/_install.py +130 -0
  5. application_sdk/_logging.py +171 -0
  6. application_sdk/_runtime/__init__.py +29 -0
  7. application_sdk/_runtime/offload.py +1020 -0
  8. application_sdk/_runtime/progress.py +757 -0
  9. application_sdk/clients/__init__.py +63 -0
  10. application_sdk/clients/_interface.py +58 -0
  11. application_sdk/clients/_sql_failure_classifier.py +112 -0
  12. application_sdk/clients/base.py +565 -0
  13. application_sdk/clients/http_errors.py +28 -0
  14. application_sdk/clients/models.py +49 -0
  15. application_sdk/clients/sql.py +1218 -0
  16. application_sdk/clients/sql_errors.py +192 -0
  17. application_sdk/clients/sql_typecasters.py +431 -0
  18. application_sdk/clients/ssl_utils.py +247 -0
  19. application_sdk/common/__init__.py +53 -0
  20. application_sdk/common/_generated_tree.py +321 -0
  21. application_sdk/common/_listing.py +192 -0
  22. application_sdk/common/aws_utils.py +522 -0
  23. application_sdk/common/aws_utils_errors.py +88 -0
  24. application_sdk/common/build_identity.py +120 -0
  25. application_sdk/common/concurrency.py +45 -0
  26. application_sdk/common/errors.py +99 -0
  27. application_sdk/common/filter_matching.py +261 -0
  28. application_sdk/common/logger.py +11 -0
  29. application_sdk/common/models.py +31 -0
  30. application_sdk/common/sql_filters.py +719 -0
  31. application_sdk/common/sql_filters_errors.py +17 -0
  32. application_sdk/common/task_queue.py +431 -0
  33. application_sdk/common/types.py +51 -0
  34. application_sdk/common/utils.py +87 -0
  35. application_sdk/constants.py +1120 -0
  36. application_sdk/contracts/__init__.py +158 -0
  37. application_sdk/contracts/base.py +1178 -0
  38. application_sdk/contracts/events.py +251 -0
  39. application_sdk/contracts/types.py +801 -0
  40. application_sdk/contracts/types_errors.py +17 -0
  41. application_sdk/credentials/__init__.py +326 -0
  42. application_sdk/credentials/errors.py +226 -0
  43. application_sdk/credentials/extra.py +114 -0
  44. application_sdk/credentials/ingress.py +283 -0
  45. application_sdk/credentials/ref.py +376 -0
  46. application_sdk/credentials/spec.py +182 -0
  47. application_sdk/credentials/types.py +339 -0
  48. application_sdk/credentials/utils.py +257 -0
  49. application_sdk/errors/__init__.py +224 -0
  50. application_sdk/errors/base.py +504 -0
  51. application_sdk/errors/categories.py +118 -0
  52. application_sdk/errors/http.py +81 -0
  53. application_sdk/errors/leaves.py +526 -0
  54. application_sdk/errors/wire.py +124 -0
  55. application_sdk/handler/__init__.py +97 -0
  56. application_sdk/handler/_warmup.py +198 -0
  57. application_sdk/handler/asgi.py +252 -0
  58. application_sdk/handler/base.py +281 -0
  59. application_sdk/handler/context.py +212 -0
  60. application_sdk/handler/contracts.py +1166 -0
  61. application_sdk/handler/manifest.py +81 -0
  62. application_sdk/handler/metadata_routes.py +699 -0
  63. application_sdk/handler/object_routes.py +418 -0
  64. application_sdk/handler/request_contract.py +91 -0
  65. application_sdk/handler/routes.py +1118 -0
  66. application_sdk/handler/service_errors.py +37 -0
  67. application_sdk/server/__init__.py +15 -0
  68. application_sdk/server/fastapi/models.py +338 -0
  69. application_sdk/templates/__init__.py +52 -0
  70. application_sdk/templates/contracts/__init__.py +111 -0
  71. application_sdk/templates/contracts/base_metadata_extraction.py +32 -0
  72. application_sdk/templates/contracts/incremental_sql.py +563 -0
  73. application_sdk/templates/contracts/sql_metadata.py +824 -0
  74. application_sdk/templates/contracts/sql_query.py +105 -0
  75. application_sdk/version.py +40 -0
  76. atlan_application_sdk_api-3.51.0.dist-info/METADATA +230 -0
  77. atlan_application_sdk_api-3.51.0.dist-info/RECORD +78 -0
  78. atlan_application_sdk_api-3.51.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,1218 @@
1
+ """
2
+ SQL client implementation for database connections.
3
+
4
+ This module provides SQL client classes for both synchronous and asynchronous
5
+ database operations, supporting batch processing and server-side cursors.
6
+ """
7
+
8
+ import asyncio
9
+ import concurrent.futures
10
+ import contextlib
11
+ import contextvars
12
+ import datetime
13
+ import functools
14
+ import hashlib
15
+ import math
16
+ import re
17
+ import threading
18
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping
19
+ from dataclasses import dataclass
20
+ from decimal import Decimal
21
+ from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
22
+ from urllib.parse import quote, quote_plus
23
+
24
+ from application_sdk._logging import get_logger
25
+ from application_sdk._runtime.offload import (
26
+ CancelHandle,
27
+ run_in_thread,
28
+ submit_in_thread,
29
+ )
30
+ from application_sdk.clients._interface import ClientInterface
31
+ from application_sdk.clients._sql_failure_classifier import (
32
+ SqlFailureKind,
33
+ classify_sql_exception,
34
+ )
35
+ from application_sdk.clients.models import DatabaseConfig
36
+ from application_sdk.clients.sql_errors import (
37
+ EngineNotInitializedError,
38
+ InvalidSqlEngineTypeError,
39
+ MissingSqlParamError,
40
+ SqlAwsCredentialsError,
41
+ SqlBindParameterError,
42
+ SqlClientAuthFailedError,
43
+ SqlClientConfigError,
44
+ SqlClientConnectTimeoutError,
45
+ SqlClientConnectUnclassifiedError,
46
+ SqlClientSourceUnavailableError,
47
+ SqlCredentialsParseError,
48
+ SqlPandasResultError,
49
+ UnsupportedSqlCursorError,
50
+ )
51
+ from application_sdk.clients.sql_typecasters import install_tolerant_text_decoder_hook
52
+ from application_sdk.common.aws_utils import (
53
+ _sts_deadline,
54
+ generate_aws_rds_token_with_iam_role,
55
+ generate_aws_rds_token_with_iam_user,
56
+ )
57
+ from application_sdk.constants import (
58
+ AWS_SESSION_NAME,
59
+ SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
60
+ USE_SERVER_SIDE_CURSOR,
61
+ )
62
+ from application_sdk.credentials.utils import parse_credentials_extra
63
+ from application_sdk.errors import AppError, sanitize_cause_repr
64
+
65
+ logger = get_logger(__name__)
66
+
67
+ T = TypeVar("T")
68
+
69
+
70
+ SqlBindValue = (
71
+ str
72
+ | int
73
+ | float
74
+ | bool
75
+ | Decimal
76
+ | datetime.datetime
77
+ | datetime.date
78
+ | datetime.time
79
+ | bytes
80
+ | None
81
+ )
82
+ """A value ``run_query`` can bind to a ``:name`` placeholder: a scalar every DB-API driver accepts."""
83
+
84
+ # SQL read just far enough to find placeholders: quoted text and comments are skipped whole, so a
85
+ # ``:name`` inside them stays literal; outside them, a ``:name`` placeholder by SQLAlchemy
86
+ # ``text()``'s own rule (not preceded by a colon, word character or backslash), or any other colon.
87
+ # Escaping and validating ``params`` both read the query through this pattern, so they cannot
88
+ # disagree on what is a bind. Quoting is standard SQL ('' doubles a quote; a backslash is literal)
89
+ # plus MySQL backtick identifiers and Postgres dollar quotes.
90
+ _SQL_COLON_SCAN = re.compile(
91
+ r"""
92
+ (?P<quoted>
93
+ '[^']*(?:''[^']*)*'
94
+ | "[^"]*(?:""[^"]*)*"
95
+ | `[^`]*`
96
+ | (?<!\w)\$(?P<tag>(?:[A-Za-z_]\w*)?)\$.*?\$(?P=tag)\$
97
+ | --[^\n]*
98
+ | /\*.*?\*/
99
+ )
100
+ | (?<![:\w\\]):(?P<name>\w+)
101
+ | :
102
+ """,
103
+ re.DOTALL | re.VERBOSE,
104
+ )
105
+
106
+
107
+ def _escape_colons_for_text(
108
+ query: str, bind_names: frozenset[str] = frozenset()
109
+ ) -> str:
110
+ """Backslash-escape literal colons so SQLAlchemy ``text()`` does not treat them as bind params.
111
+
112
+ ``text()`` reads any ``:name`` as a required bind parameter and raises at compile time on
113
+ colon-bearing SQL -- a regex ``(?:...)``, a ``::text`` cast, a ``'12:30:00'`` literal.
114
+ SQLAlchemy strips the backslash before the driver, so the DB receives the literal colon.
115
+
116
+ Every colon is escaped except a ``:name`` placeholder whose name is in ``bind_names``,
117
+ the parameters the caller is binding. A colon inside quoted text or a comment is always
118
+ escaped, so ``':name'`` stays a literal. Both colons of ``::`` are escaped, so ``x::name``
119
+ is never taken for the bind ``:name`` while ``:name::type`` binds ``name`` and keeps the cast.
120
+ """
121
+
122
+ def escape(match: re.Match[str]) -> str:
123
+ token = match.group(0)
124
+ if match.group("name") in bind_names:
125
+ return token
126
+ return token.replace(":", "\\:")
127
+
128
+ return _SQL_COLON_SCAN.sub(escape, query)
129
+
130
+
131
+ def _bound_params(
132
+ query: str, params: Mapping[str, SqlBindValue] | None
133
+ ) -> dict[str, SqlBindValue] | None:
134
+ """``params`` checked against ``query``, or None when nothing is bound.
135
+
136
+ Raises :class:`SqlBindParameterError` for a name that ``query`` never references as a
137
+ ``:name`` placeholder outside quoted text and comments: executing anyway would run the query without that value, so a
138
+ filter would silently not apply.
139
+ """
140
+ if not params:
141
+ return None
142
+ referenced = {
143
+ m.group("name") for m in _SQL_COLON_SCAN.finditer(query) if m.group("name")
144
+ }
145
+ for name in params:
146
+ if name not in referenced:
147
+ raise SqlBindParameterError(
148
+ message=f"Bind parameter {name!r} is not referenced as a :name placeholder in the query"
149
+ )
150
+ return dict(params)
151
+
152
+
153
+ if TYPE_CHECKING:
154
+ import pandas as pd
155
+ from sqlalchemy.engine import URL, Connection
156
+ from sqlalchemy.engine.interfaces import DBAPICursor, ExecutionContext
157
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
158
+ from sqlalchemy.orm import Session
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class _CancellableRead:
163
+ """One cancellable read, as seen from the worker thread running it."""
164
+
165
+ handle: CancelHandle
166
+ cancel_cursor: "Callable[[DBAPICursor], None]"
167
+
168
+
169
+ #: The read the current worker thread is running, if it is cancellable. Set
170
+ #: only inside the thread (run_in_thread runs each call in its own copy of the
171
+ #: context), so it never leaks into the task or across calls.
172
+ _ACTIVE_READ: contextvars.ContextVar[_CancellableRead | None] = contextvars.ContextVar(
173
+ "_ACTIVE_READ", default=None
174
+ )
175
+
176
+
177
+ @contextlib.contextmanager
178
+ def _reading(read: _CancellableRead) -> Iterator[None]:
179
+ token = _ACTIVE_READ.set(read)
180
+ try:
181
+ yield
182
+ finally:
183
+ _ACTIVE_READ.reset(token)
184
+
185
+
186
+ def _register_cursor_for_cancel(
187
+ conn: "Connection",
188
+ cursor: "DBAPICursor",
189
+ statement: str,
190
+ parameters: object,
191
+ context: "ExecutionContext | None",
192
+ executemany: bool,
193
+ ) -> None:
194
+ """``before_cursor_execute`` listener: hand the cursor to the read's handle.
195
+
196
+ Runs on the worker thread, at the first moment the DBAPI cursor exists.
197
+ A statement on any other thread — or outside a cancellable read — is left
198
+ alone.
199
+ """
200
+ read = _ACTIVE_READ.get()
201
+ if read is None:
202
+ return
203
+ read.handle.set(functools.partial(read.cancel_cursor, cursor))
204
+ if read.handle.requested:
205
+ # The caller is already gone; the action fired on set. Don't start a
206
+ # statement nobody is waiting for.
207
+ raise concurrent.futures.CancelledError
208
+
209
+
210
+ def _install_cursor_cancel_listener(engine: object) -> None:
211
+ """Attach the cursor listener to ``engine`` once; skip anything not an Engine.
212
+
213
+ Done lazily on each read rather than in ``load()``, so a subclass that
214
+ builds its own engine is covered too. An async engine's reads don't run
215
+ on a worker thread, and a test double has no event surface.
216
+ """
217
+ from sqlalchemy import event # noqa: PLC0415 — optional dep: sqlalchemy
218
+ from sqlalchemy.engine import Engine # noqa: PLC0415 — optional dep: sqlalchemy
219
+
220
+ if not isinstance(engine, Engine):
221
+ return
222
+ if not event.contains(engine, "before_cursor_execute", _register_cursor_for_cancel):
223
+ event.listen(engine, "before_cursor_execute", _register_cursor_for_cancel)
224
+
225
+
226
+ def _invalidate_if_cancelled(connection: "Connection", handle: CancelHandle) -> None:
227
+ """Drop a connection whose statement was cancelled instead of pooling it.
228
+
229
+ A cancelled connection may be mid-protocol, or carry server state the next
230
+ borrower does not expect; and a pool that pings or re-runs session setup on
231
+ checkout would pay a round trip to find out.
232
+ """
233
+ if handle.requested:
234
+ connection.invalidate()
235
+
236
+
237
+ #: The connect-timeout keyword of each driver that has no connect timeout by
238
+ #: default, so a black-holed host holds the connect (and its worker thread)
239
+ #: until the OS gives up. Drivers that already bound their connect (pymysql,
240
+ #: asyncpg, snowflake, pymssql, oracledb) are left alone so their bound is never
241
+ #: loosened, and so are drivers whose ``timeout`` bounds every socket read
242
+ #: (pg8000, redshift_connector): that would cut long queries short too.
243
+ _DRIVER_CONNECT_TIMEOUT_ARG: dict[str, str] = {
244
+ "psycopg2": "connect_timeout",
245
+ "psycopg": "connect_timeout",
246
+ "mysqldb": "connect_timeout",
247
+ }
248
+
249
+ #: SQLAlchemy's default driver for a URL that names only the dialect.
250
+ _DIALECT_DEFAULT_DRIVER: dict[str, str] = {
251
+ "postgresql": "psycopg2",
252
+ "mysql": "mysqldb",
253
+ "mariadb": "mysqldb",
254
+ }
255
+
256
+
257
+ def _connect_timeout_arg(connection_string: object) -> tuple[str, "URL"] | None:
258
+ """The URL's driver connect-timeout keyword and the parsed URL, if it has one.
259
+
260
+ None for a URL that does not parse or names a driver not in
261
+ ``_DRIVER_CONNECT_TIMEOUT_ARG``.
262
+ """
263
+ from sqlalchemy.engine import make_url # noqa: PLC0415 — optional dep: sqlalchemy
264
+ from sqlalchemy.exc import ArgumentError # noqa: PLC0415 — optional dep: sqlalchemy
265
+
266
+ try:
267
+ url = make_url(cast(Any, connection_string))
268
+ except (ArgumentError, ValueError):
269
+ return None
270
+ # Read from the URL rather than ``get_driver_name()``, which imports the
271
+ # dialect (and fails for one that is not installed).
272
+ dialect, _, driver = url.drivername.lower().partition("+")
273
+ driver = driver or _DIALECT_DEFAULT_DRIVER.get(dialect, "")
274
+ arg = _DRIVER_CONNECT_TIMEOUT_ARG.get(driver)
275
+ return None if arg is None else (arg, url)
276
+
277
+
278
+ def _with_connect_timeout(
279
+ connection_string: object, connect_args: dict[str, Any], seconds: float
280
+ ) -> dict[str, Any]:
281
+ """``connect_args`` plus the driver's connect timeout, when it has none.
282
+
283
+ Never overrides a value already set in ``connect_args`` or in the URL's
284
+ query string. A URL that does not parse, or names a driver not in
285
+ ``_DRIVER_CONNECT_TIMEOUT_ARG``, gets ``connect_args`` unchanged.
286
+ """
287
+ found = _connect_timeout_arg(connection_string)
288
+ if found is None:
289
+ return connect_args
290
+ arg, url = found
291
+ if arg in connect_args or arg in url.query:
292
+ return connect_args
293
+ return {**connect_args, arg: max(1, math.ceil(seconds))}
294
+
295
+
296
+ def _cap_next_connect_timeout(
297
+ engine: object, connection_string: object, seconds: float
298
+ ) -> None:
299
+ """Cap the driver connect timeout of ``engine``'s next connect at ``seconds``.
300
+
301
+ ``load()``'s ping starts after the connection string is built, which may
302
+ have spent much of the budget on STS. With the full timeout from
303
+ ``connect_args`` a black-holed connect would outlive ``load()`` and hold
304
+ its worker thread past the deadline; capped at what is left, the thread is
305
+ freed when ``load()`` gives up. Only the next connect (the ping) is capped:
306
+ later connects keep the full value. A value of any size — ours, from
307
+ ``connect_args`` or from the URL — is capped. Anything not an Engine (a
308
+ test double), or a driver not in ``_DRIVER_CONNECT_TIMEOUT_ARG``, is left
309
+ alone.
310
+ """
311
+ from sqlalchemy import event # noqa: PLC0415 — optional dep: sqlalchemy
312
+ from sqlalchemy.engine import Engine # noqa: PLC0415 — optional dep: sqlalchemy
313
+
314
+ found = _connect_timeout_arg(connection_string)
315
+ if not isinstance(engine, Engine) or found is None:
316
+ return
317
+ arg, _ = found
318
+ cap = max(1, math.ceil(seconds))
319
+
320
+ def _cap(
321
+ dialect: object, conn_rec: object, cargs: object, cparams: dict[str, Any]
322
+ ) -> None:
323
+ try:
324
+ current = int(float(cparams.get(arg, cap)))
325
+ except (TypeError, ValueError):
326
+ return # not a number: the driver will reject it on its own
327
+ cparams[arg] = min(current, cap)
328
+
329
+ event.listen(engine, "do_connect", _cap, once=True)
330
+
331
+
332
+ def _budget_left(budget: asyncio.Timeout) -> float:
333
+ """Seconds until ``budget`` expires, measured on the running loop's clock."""
334
+ return cast(float, budget.when()) - asyncio.get_running_loop().time()
335
+
336
+
337
+ def _load_budget_expired() -> SqlClientConnectTimeoutError:
338
+ """The error for a ``load()`` that outran ``SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
339
+
340
+ Raised in place of the bare ``TimeoutError`` the expired budget leaves
341
+ behind, which says nothing about what ran out of time.
342
+ """
343
+ return SqlClientConnectTimeoutError(
344
+ message=(
345
+ "SQL client did not connect to the source within "
346
+ f"{SQL_CLIENT_CONNECT_TIMEOUT_SECONDS:g}s"
347
+ )
348
+ )
349
+
350
+
351
+ def _load_failure(exc: Exception) -> AppError:
352
+ """Type a failed ``load()`` by its root cause instead of calling it auth.
353
+
354
+ Classified by the same reader ``SqlApp.prime_sql_auth`` uses. An
355
+ unrecognised failure is reported as pending classification, not as a
356
+ rejected password.
357
+
358
+ No message carries driver text: SQLAlchemy embeds the connection string,
359
+ password included. The driver error rides ``cause``, which serialises
360
+ sanitised.
361
+ """
362
+ kind = classify_sql_exception(exc)
363
+ if kind is SqlFailureKind.AUTH:
364
+ return SqlClientAuthFailedError(cause=exc)
365
+ if kind is SqlFailureKind.TIMEOUT:
366
+ return SqlClientConnectTimeoutError(cause=exc)
367
+ if kind is SqlFailureKind.SOURCE_UNAVAILABLE:
368
+ return SqlClientSourceUnavailableError(cause=exc)
369
+ return SqlClientConnectUnclassifiedError(cause=exc)
370
+
371
+
372
+ class BaseSQLClient(ClientInterface):
373
+ """SQL client for database operations.
374
+
375
+ This class provides functionality for connecting to and querying SQL databases,
376
+ with support for batch processing and server-side cursors.
377
+
378
+ Attributes:
379
+ connection: Database connection instance.
380
+ engine: SQLAlchemy engine instance.
381
+ credentials (Dict[str, Any]): Database credentials.
382
+ resolved_credentials (Dict[str, Any]): Resolved credentials after reading from secret manager.
383
+ use_server_side_cursor (bool): Whether to use server-side cursors.
384
+ """
385
+
386
+ connection = None
387
+ engine = None
388
+ credentials: dict[str, Any]
389
+ resolved_credentials: dict[str, Any]
390
+ use_server_side_cursor: bool = USE_SERVER_SIDE_CURSOR
391
+ DB_CONFIG: DatabaseConfig | None = None
392
+
393
+ def __init__(
394
+ self,
395
+ use_server_side_cursor: bool = USE_SERVER_SIDE_CURSOR,
396
+ credentials: dict[str, Any] | None = None,
397
+ chunk_size: int = 5000,
398
+ ):
399
+ """
400
+ Initialize the SQL client.
401
+
402
+ Args:
403
+ use_server_side_cursor (bool, optional): Whether to use server-side cursors.
404
+ Defaults to USE_SERVER_SIDE_CURSOR.
405
+ credentials (Optional[Dict[str, Any]], optional): Database credentials.
406
+ Defaults to None, which is treated as an empty dict.
407
+ """
408
+ self.use_server_side_cursor = use_server_side_cursor
409
+ self.credentials = credentials if credentials is not None else {}
410
+ self.resolved_credentials = {}
411
+ self.chunk_size = chunk_size
412
+
413
+ async def load(self, credentials: dict[str, Any]) -> None:
414
+ """Load credentials and prepare engine for lazy connections.
415
+
416
+ This method now only stores credentials and creates the engine without
417
+ establishing a persistent connection. Connections are created on-demand.
418
+
419
+ Args:
420
+ credentials (Dict[str, Any]): Database connection credentials.
421
+
422
+ Raises:
423
+ SqlClientAuthFailedError: If the source rejected the credentials.
424
+ SqlClientSourceUnavailableError: If the source could not be reached
425
+ (DNS, refused or filtered connection, TLS).
426
+ SqlClientConnectTimeoutError: If the connect or login timed out, or
427
+ ``load()`` as a whole outran ``ATLAN_SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
428
+ SqlClientConnectUnclassifiedError: If the failure matched none of
429
+ the above.
430
+ AppError: Any typed error raised while connecting, unchanged — e.g.
431
+ ``MissingSqlParamError`` or ``SqlCredentialsParseError``.
432
+ """
433
+ if not self.DB_CONFIG:
434
+ raise SqlClientConfigError()
435
+
436
+ self.credentials = credentials # Update the instance credentials
437
+ budget = asyncio.timeout(SQL_CLIENT_CONNECT_TIMEOUT_SECONDS)
438
+ try:
439
+ from sqlalchemy import ( # noqa: PLC0415 — optional dep: sqlalchemy
440
+ create_engine,
441
+ )
442
+
443
+ async with budget:
444
+ # Offloaded: for IAM-role auth this runs an STS AssumeRole,
445
+ # a network round trip that would otherwise block the loop.
446
+ # Cancelling the await does not stop the thread, so the STS
447
+ # call is fitted into the budget too.
448
+ with _sts_deadline(_budget_left(budget)):
449
+ connection_string = await run_in_thread(
450
+ self.get_sqlalchemy_connection_string
451
+ )
452
+
453
+ # Create engine but no persistent connection
454
+ self.engine = create_engine(
455
+ connection_string,
456
+ connect_args=_with_connect_timeout(
457
+ connection_string,
458
+ self.DB_CONFIG.connect_args,
459
+ SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
460
+ ),
461
+ pool_pre_ping=self.DB_CONFIG.pool_pre_ping,
462
+ )
463
+
464
+ # Install a tolerant UTF-8 text decoder for psycopg2/psycopg3 so
465
+ # query-history rows containing non-UTF-8 bytes (e.g. 0x96 from
466
+ # Windows-1252 paste) yield a logged U+FFFD instead of raising
467
+ # UnicodeDecodeError. psycopg3 is wired per connection; psycopg2 has
468
+ # to be remapped once per process, before any connection is opened —
469
+ # see sql_typecasters for why. Tracking: WARE-970, CONAT-767.
470
+ install_tolerant_text_decoder_hook(self.engine)
471
+
472
+ # Test connection briefly to validate credentials.
473
+ # Wrapped in run_in_thread because SQLAlchemy's synchronous
474
+ # engine.connect() blocks the event loop — critical for Temporal
475
+ # activities where blocking starves the auto-heartbeat. run_in_thread
476
+ # dispatches onto the SDK's dedicated pool rather than asyncio's
477
+ # shared default executor, which Temporal's own scheduling also uses.
478
+ # Capture engine in a local variable so the closure doesn't need to
479
+ # re-read self.engine (which is typed Optional) and pyright can narrow it.
480
+ _engine = self.engine
481
+
482
+ def _ping() -> None:
483
+ with _engine.connect() as _:
484
+ pass # Connection test successful
485
+
486
+ # Cancelling the await does not stop the thread, so the driver
487
+ # must give up by the deadline on its own.
488
+ _cap_next_connect_timeout(
489
+ _engine, connection_string, _budget_left(budget)
490
+ )
491
+ await run_in_thread(_ping)
492
+
493
+ # Don't store persistent connection
494
+ self.connection = None
495
+
496
+ # conformance: ignore[E004] exc_info omitted intentionally; SQLAlchemy embeds password in traceback; cause is sanitized and re-raised as typed error
497
+ except Exception as e:
498
+ # No exc_info here: SQLAlchemy errors embed the full connection
499
+ # string (including the password) in their message, and the
500
+ # traceback would print it verbatim into logs.
501
+ logger.error( # conformance: ignore[E005,L004,L009] exc_info would expose SQLAlchemy password; logs sanitized cause at failure site — raised typed error wraps only the raw unsanitized cause
502
+ "Error loading SQL client: %s", sanitize_cause_repr(e)
503
+ )
504
+ if self.engine:
505
+ self.engine.dispose()
506
+ self.engine = None
507
+ if budget.expired():
508
+ raise _load_budget_expired() from e
509
+ # Already typed (a MissingSqlParamError from building the
510
+ # connection string, a connector's own leaf): keep its verdict.
511
+ if isinstance(e, AppError):
512
+ raise
513
+ raise _load_failure(e) from e
514
+
515
+ async def close(self) -> None:
516
+ """Close the database connection."""
517
+ if self.engine:
518
+ self.engine.dispose()
519
+ self.engine = None
520
+ self.connection = None # Should already be None, but ensure cleanup
521
+
522
+ def cancel_cursor(self, dbapi_cursor: "DBAPICursor") -> None:
523
+ """Stop the statement running on ``dbapi_cursor``. Default: do nothing.
524
+
525
+ Called when the task awaiting a read (``get_results``,
526
+ ``get_batched_results``, ``run_query``) is cancelled while the
527
+ statement is still running on its worker thread. It runs on the SDK's
528
+ ``sdk-cancel-`` pool — a third thread, concurrently with the blocked
529
+ driver call — so override it only with a driver call documented as
530
+ safe from another thread, e.g. ``dbapi_cursor.cancel()`` or
531
+ ``dbapi_cursor.connection.cancel()``.
532
+
533
+ DBAPI has no standard cancel, so the default is a no-op: the read
534
+ still hands control back at once, and the statement runs to
535
+ completion on its thread. Either way the connection is invalidated
536
+ rather than returned to the pool.
537
+
538
+ A failure is logged at WARNING and otherwise ignored. Never stop or
539
+ suspend a warehouse here: that is not this statement's to stop.
540
+
541
+ Args:
542
+ dbapi_cursor: The driver's own cursor, as passed to SQLAlchemy's
543
+ ``before_cursor_execute`` event.
544
+ """
545
+
546
+ def get_iam_user_token(self):
547
+ """Get an IAM user token for AWS RDS database authentication.
548
+
549
+ This method generates a temporary authentication token for IAM user-based
550
+ authentication with AWS RDS databases. It requires AWS access credentials
551
+ and database connection details.
552
+
553
+ Returns:
554
+ str: A temporary authentication token for database access.
555
+
556
+ Raises:
557
+ SqlCredentialsParseError: If required credentials (username or database) are missing.
558
+ """
559
+ extra = parse_credentials_extra(self.credentials)
560
+ aws_access_key_id = self.credentials.get("username")
561
+ aws_secret_access_key = self.credentials.get("password")
562
+ host = self.credentials.get("host")
563
+ user = extra.get("username")
564
+ database = extra.get("database")
565
+ if not user:
566
+ raise SqlCredentialsParseError(
567
+ field="username",
568
+ message="username is required for IAM user authentication",
569
+ )
570
+ if not database:
571
+ raise SqlCredentialsParseError(
572
+ field="database",
573
+ message="database is required for IAM user authentication",
574
+ )
575
+
576
+ port = self.credentials.get("port")
577
+ region = self.credentials.get("region")
578
+ token = generate_aws_rds_token_with_iam_user(
579
+ aws_access_key_id=aws_access_key_id,
580
+ aws_secret_access_key=aws_secret_access_key,
581
+ host=host,
582
+ user=user,
583
+ port=port,
584
+ region=region,
585
+ )
586
+
587
+ return token
588
+
589
+ def get_iam_role_token(self):
590
+ """Get an IAM role token for AWS RDS database authentication.
591
+
592
+ This method generates a temporary authentication token for IAM role-based
593
+ authentication with AWS RDS databases. It requires an AWS role ARN and
594
+ database connection details.
595
+
596
+ Returns:
597
+ str: A temporary authentication token for database access.
598
+
599
+ Raises:
600
+ SqlAwsCredentialsError: If required credentials (aws_role_arn or database) are missing.
601
+ """
602
+ extra = parse_credentials_extra(self.credentials)
603
+ aws_role_arn = extra.get("aws_role_arn")
604
+ database = extra.get("database")
605
+ external_id = extra.get("aws_external_id")
606
+
607
+ if not aws_role_arn:
608
+ raise SqlAwsCredentialsError(
609
+ message="aws_role_arn is required for IAM role authentication",
610
+ )
611
+ if not database:
612
+ raise SqlAwsCredentialsError(
613
+ message="database is required for IAM role authentication",
614
+ )
615
+
616
+ session_name = AWS_SESSION_NAME
617
+ username = self.credentials.get("username")
618
+ host = self.credentials.get("host")
619
+ port = self.credentials.get("port")
620
+ region = self.credentials.get("region")
621
+
622
+ token = generate_aws_rds_token_with_iam_role(
623
+ role_arn=aws_role_arn,
624
+ host=host,
625
+ user=username,
626
+ external_id=external_id,
627
+ session_name=session_name,
628
+ port=port,
629
+ region=region,
630
+ )
631
+ return token
632
+
633
+ def get_auth_token(self) -> str:
634
+ """Get the appropriate authentication token based on auth type.
635
+
636
+ This method determines the authentication type from credentials and returns
637
+ the corresponding token. Supports basic auth, IAM user, and IAM role
638
+ authentication methods.
639
+
640
+ Returns:
641
+ str: URL-encoded authentication token.
642
+
643
+ Raises:
644
+ SqlCredentialsParseError: If an invalid authentication type is specified.
645
+ """
646
+ authType = self.credentials.get("authType", "basic") # Default to basic auth
647
+ token = None
648
+
649
+ match authType:
650
+ case "iam_user":
651
+ token = self.get_iam_user_token()
652
+ case "iam_role":
653
+ token = self.get_iam_role_token()
654
+ case "basic":
655
+ token = self.credentials.get("password")
656
+ case _:
657
+ raise SqlCredentialsParseError(field="authType", value_summary=authType)
658
+
659
+ # Handle None values and ensure token is a string before encoding.
660
+ # This token is interpolated into the userinfo of a SQLAlchemy URL,
661
+ # which SQLAlchemy decodes with ``urllib.parse.unquote`` (percent-only).
662
+ # ``quote_plus`` encodes space as ``+``, and ``unquote`` never turns
663
+ # ``+`` back into a space, so a password containing a space would reach
664
+ # the driver corrupted (CONNECT-361). ``quote`` encodes space as ``%20``,
665
+ # which round-trips correctly. ``safe=""`` is required so that ``/`` in
666
+ # the credential is also encoded (an unencoded ``/`` in userinfo would
667
+ # otherwise break URL parsing). Query-string params below still use
668
+ # ``quote_plus`` — SQLAlchemy decodes query values with ``+`` -> space.
669
+ encoded_token = quote(str(token or ""), safe="")
670
+ return encoded_token
671
+
672
+ def add_connection_params(
673
+ self, connection_string: str, source_connection_params: dict[str, Any]
674
+ ) -> str:
675
+ """Add additional connection parameters to a SQLAlchemy connection string.
676
+
677
+ Args:
678
+ connection_string (str): Base SQLAlchemy connection string.
679
+ source_connection_params (Dict[str, Any]): Additional connection parameters
680
+ to append to the connection string. Keys and values must be
681
+ **raw, not pre-encoded** — both are URL-encoded here (via
682
+ ``quote_plus``) to prevent ``&``/``=`` injection, so a
683
+ pre-encoded value would be double-encoded.
684
+
685
+ Returns:
686
+ str: Connection string with additional parameters appended.
687
+ """
688
+ for key, value in source_connection_params.items():
689
+ if "?" not in connection_string:
690
+ connection_string += "?"
691
+ else:
692
+ connection_string += "&"
693
+ # URL-encode so values containing &, =, or spaces can't inject
694
+ # extra connection parameters (e.g. sslmode overrides).
695
+ connection_string += f"{quote_plus(str(key))}={quote_plus(str(value))}"
696
+
697
+ return connection_string
698
+
699
+ def get_sqlalchemy_connection_string(self) -> str:
700
+ """Generate a SQLAlchemy connection string for database connection.
701
+
702
+ This method constructs a connection string using the configured database
703
+ parameters and credentials. It handles different authentication methods
704
+ and includes necessary connection parameters.
705
+
706
+ Returns:
707
+ str: Complete SQLAlchemy connection string.
708
+
709
+ Raises:
710
+ SqlClientConfigError: If DB_CONFIG is not set.
711
+ MissingSqlParamError: If required connection parameters are missing.
712
+ """
713
+ if not self.DB_CONFIG:
714
+ raise SqlClientConfigError()
715
+
716
+ extra = parse_credentials_extra(self.credentials)
717
+
718
+ auth_token = self.get_auth_token()
719
+
720
+ # Prepare parameters
721
+ param_values = {}
722
+ for param in self.DB_CONFIG.required:
723
+ if param == "password":
724
+ param_values[param] = auth_token
725
+ else:
726
+ value = self.credentials.get(param) or extra.get(param)
727
+ if value is None:
728
+ raise MissingSqlParamError(field=param)
729
+ param_values[param] = value
730
+
731
+ # Fill in base template
732
+ conn_str = self.DB_CONFIG.template.format(**param_values)
733
+
734
+ # Append defaults if not already in the template
735
+ if self.DB_CONFIG.defaults:
736
+ conn_str = self.add_connection_params(conn_str, self.DB_CONFIG.defaults)
737
+
738
+ if self.DB_CONFIG.parameters:
739
+ parameter_keys = self.DB_CONFIG.parameters
740
+ parameter_values = {
741
+ key: value
742
+ for key in parameter_keys
743
+ if (value := self.credentials.get(key) or extra.get(key)) is not None
744
+ }
745
+ if parameter_values:
746
+ conn_str = self.add_connection_params(conn_str, parameter_values)
747
+
748
+ return conn_str
749
+
750
+ async def run_query(
751
+ self,
752
+ query: str,
753
+ batch_size: int = 100000,
754
+ params: Mapping[str, SqlBindValue] | None = None,
755
+ ):
756
+ """Execute a SQL query and return results in batches using lazy connections.
757
+
758
+ This method creates a connection on-demand, executes the query in batches,
759
+ and automatically closes the connection when done. This prevents memory
760
+ leaks from persistent connections.
761
+
762
+ Args:
763
+ query (str): SQL query to execute.
764
+ batch_size (int, optional): Number of records to fetch in each batch.
765
+ Defaults to 100000.
766
+ params (Mapping[str, SqlBindValue], optional): Values to bind to ``:name``
767
+ placeholders in ``query``. The driver receives them as bound
768
+ values, never as SQL text, so a user-supplied filter value cannot
769
+ change the statement. Every name must appear in ``query``.
770
+
771
+ Yields:
772
+ List[Dict[str, Any]]: Batches of query results, where each result is
773
+ a dictionary mapping column names to values.
774
+
775
+ Raises:
776
+ EngineNotInitializedError: If engine is not initialized.
777
+ SqlBindParameterError: If ``params`` does not match the query.
778
+ """
779
+ if not self.engine:
780
+ raise EngineNotInitializedError()
781
+ bound = _bound_params(query, params)
782
+
783
+ logger.debug(
784
+ "Running query (sha=%s, len=%d, bound=%d)",
785
+ hashlib.sha256(query.encode("utf-8", errors="replace")).hexdigest()[:16],
786
+ len(query),
787
+ len(bound or {}),
788
+ )
789
+
790
+ _install_cursor_cancel_listener(self.engine)
791
+ engine = self.engine
792
+ # Driver calls, connect included, go through run_in_thread, so a cancelled caller stops
793
+ # waiting at once — but the thread keeps using the connection until the
794
+ # call returns. The lock keeps the close (below) from running on the
795
+ # connection while an abandoned call still holds it. The cancel handle
796
+ # is what makes that call return early: the listener registers the
797
+ # cursor on it, and cancel_cursor() fires off both of those threads.
798
+ in_use = threading.Lock()
799
+ handle = CancelHandle()
800
+ read = _CancellableRead(handle, self.cancel_cursor)
801
+ opened: list["Connection"] = []
802
+
803
+ def _holding_connection(func: Callable[..., T]) -> Callable[..., T]:
804
+ @functools.wraps(func) # keeps the callee's name for run_in_thread's label
805
+ def call(*args: Any) -> T:
806
+ with in_use, _reading(read):
807
+ return func(*args)
808
+
809
+ return call
810
+
811
+ def _connect() -> "Connection":
812
+ connection = engine.connect()
813
+ if handle.requested:
814
+ connection.close()
815
+ raise concurrent.futures.CancelledError
816
+ opened.append(connection)
817
+ return connection
818
+
819
+ def _close() -> None:
820
+ for connection in opened:
821
+ _invalidate_if_cancelled(connection, handle)
822
+ connection.close()
823
+
824
+ try:
825
+ connection = await run_in_thread(handle.bind(_holding_connection(_connect)))
826
+ if self.use_server_side_cursor:
827
+ connection = connection.execution_options(yield_per=batch_size)
828
+
829
+ from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
830
+
831
+ statement = text(_escape_colons_for_text(query, frozenset(bound or ())))
832
+ execute = handle.bind(_holding_connection(connection.execute))
833
+ cursor = await run_in_thread(execute, statement, bound)
834
+ if not cursor or not cursor.cursor:
835
+ raise UnsupportedSqlCursorError()
836
+ column_names: list[str] = [
837
+ description.name.lower() for description in cursor.cursor.description
838
+ ]
839
+
840
+ fetchmany = handle.bind(_holding_connection(cursor.fetchmany))
841
+ while True:
842
+ rows = await run_in_thread(fetchmany, batch_size)
843
+ if not rows:
844
+ break
845
+
846
+ results = [dict(zip(column_names, row)) for row in rows]
847
+ yield results
848
+ except BaseException:
849
+ # Cancellation, an early aclose(), or a failure. A driver call may
850
+ # still be running, so closing here on the loop would block it on
851
+ # that call; hand the close to the pool to run once the call ends.
852
+ submit_in_thread(_holding_connection(_close))
853
+ raise
854
+ await run_in_thread(_holding_connection(_close))
855
+
856
+ logger.info("Query execution completed")
857
+
858
+ def _execute_pandas_query(
859
+ self, conn, query, chunksize: int | None
860
+ ) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
861
+ """Helper function to execute SQL query using pandas.
862
+ The function is responsible for using import_optional_dependency method of the pandas library to import sqlalchemy
863
+ This function helps pandas in determining weather to use the sqlalchemy connection object and constructs like text()
864
+ or use the underlying database connection object. This has been done to make sure connectors like the Redshift connector,
865
+ which do not support the sqlalchemy connection object, can be made compatible with the application-sdk.
866
+
867
+ Args:
868
+ conn: Database connection object.
869
+
870
+ Returns:
871
+ Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
872
+ or iterator of DataFrames if chunked.
873
+ """
874
+ import pandas as pd # noqa: PLC0415 — optional dep: pandas
875
+ from pandas.compat._optional import ( # noqa: PLC0415 — optional dep: pandas
876
+ import_optional_dependency,
877
+ )
878
+ from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
879
+
880
+ if import_optional_dependency("sqlalchemy", errors="ignore"):
881
+ return pd.read_sql_query(
882
+ text(_escape_colons_for_text(query)), conn, chunksize=chunksize
883
+ )
884
+ else:
885
+ dbapi_conn = getattr(conn, "connection", None)
886
+ return pd.read_sql_query(query, dbapi_conn, chunksize=chunksize)
887
+
888
+ def _read_sql_query(
889
+ self, session: "Session", query: str, chunksize: int | None
890
+ ) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
891
+ """Execute SQL query using the provided session.
892
+
893
+ Args:
894
+ session: SQLAlchemy session for database operations.
895
+
896
+ Returns:
897
+ Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
898
+ or iterator of DataFrames if chunked.
899
+ """
900
+ conn = session.connection()
901
+ return self._execute_pandas_query(conn, query, chunksize=chunksize)
902
+
903
+ def _execute_query(
904
+ self, query: str, chunksize: int | None
905
+ ) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
906
+ """Execute SQL query using the provided engine and pandas.
907
+
908
+ Returns:
909
+ Union["pd.DataFrame", Iterator["pd.DataFrame"]]: Query results as DataFrame
910
+ or iterator of DataFrames if chunked.
911
+ """
912
+ if not self.engine:
913
+ raise EngineNotInitializedError()
914
+
915
+ with self.engine.connect() as conn:
916
+ try:
917
+ return self._execute_pandas_query(conn, query, chunksize)
918
+ finally:
919
+ read = _ACTIVE_READ.get()
920
+ if read is not None:
921
+ _invalidate_if_cancelled(conn, read.handle)
922
+
923
+ async def _execute_async_read_operation(
924
+ self, query: str, chunksize: int | None
925
+ ) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
926
+ """Helper to execute async read operation with either async session or thread executor."""
927
+ if isinstance(self.engine, str):
928
+ raise InvalidSqlEngineTypeError()
929
+
930
+ from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 — optional dep: sqlalchemy
931
+ AsyncEngine,
932
+ AsyncSession,
933
+ )
934
+
935
+ async_session = None
936
+ if self.engine and isinstance(self.engine, AsyncEngine):
937
+ from sqlalchemy.orm import ( # noqa: PLC0415 — optional dep: sqlalchemy
938
+ sessionmaker,
939
+ )
940
+
941
+ async_session = sessionmaker(
942
+ self.engine, expire_on_commit=False, class_=AsyncSession
943
+ )
944
+
945
+ if async_session:
946
+ async with async_session() as session:
947
+ return await session.run_sync(
948
+ self._read_sql_query, query, chunksize=chunksize
949
+ )
950
+ else:
951
+ # run_in_thread, not a per-call ThreadPoolExecutor: leaving that
952
+ # executor's `with` block on cancellation calls shutdown(wait=True)
953
+ # on the loop thread, which freezes the loop until the query ends.
954
+ # The shared pool lets a cancelled read return at once while its
955
+ # thread drains on its own — and the handle lets cancel_cursor()
956
+ # cut that drain short at the driver.
957
+ _install_cursor_cancel_listener(self.engine)
958
+ handle = CancelHandle()
959
+ read = _CancellableRead(handle, self.cancel_cursor)
960
+ execute_query = self._execute_query
961
+
962
+ @functools.wraps(execute_query) # keeps the name for the hold label
963
+ def _cancellable_query(
964
+ query: str, chunksize: int | None
965
+ ) -> Union["pd.DataFrame", Iterator["pd.DataFrame"]]:
966
+ with _reading(read):
967
+ return execute_query(query, chunksize)
968
+
969
+ return await run_in_thread(
970
+ handle.bind(_cancellable_query), query, chunksize
971
+ )
972
+
973
+ async def get_batched_results(
974
+ self,
975
+ query: str,
976
+ ) -> AsyncIterator["pd.DataFrame"] | Iterator["pd.DataFrame"]: # type: ignore
977
+ """Get query results as batched pandas DataFrames asynchronously.
978
+
979
+ Returns:
980
+ AsyncIterator["pd.DataFrame"]: Async iterator yielding batches of query results.
981
+
982
+ Raises:
983
+ InvalidSqlEngineTypeError: If engine is a string instead of a SQLAlchemy engine.
984
+ SqlPandasResultError: If there's an error executing the query.
985
+ """
986
+ try:
987
+ # We cast to Iterator because passing chunk_size guarantees an Iterator return
988
+ result = await self._execute_async_read_operation(query, self.chunk_size)
989
+ return cast(Iterator["pd.DataFrame"], result)
990
+ except AppError:
991
+ raise
992
+ # conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
993
+ except Exception as e:
994
+ # retryable=True: unclassified driver exception — do not override the
995
+ # activity's declared retry policy. See SqlPandasResultError docstring.
996
+ raise SqlPandasResultError(
997
+ message="Error reading batched data from SQL", cause=e, retryable=True
998
+ ) from e
999
+
1000
+ async def get_results(self, query: str) -> "pd.DataFrame":
1001
+ """Get all query results as a single pandas DataFrame asynchronously.
1002
+
1003
+ Returns:
1004
+ pd.DataFrame: Query results as a DataFrame.
1005
+
1006
+ Raises:
1007
+ InvalidSqlEngineTypeError: If engine is a string instead of a SQLAlchemy engine.
1008
+ SqlPandasResultError: If there's an error executing the query.
1009
+ """
1010
+ try:
1011
+ result = await self._execute_async_read_operation(query, None)
1012
+ import pandas as pd # noqa: PLC0415 — optional dep: pandas
1013
+
1014
+ if isinstance(result, pd.DataFrame):
1015
+ return result
1016
+ raise SqlPandasResultError(
1017
+ invariant="_execute_async_read_operation must return a pd.DataFrame"
1018
+ )
1019
+ except AppError:
1020
+ raise
1021
+ # conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
1022
+ except Exception as e:
1023
+ # retryable=True: unclassified driver exception — do not override the
1024
+ # activity's declared retry policy. See SqlPandasResultError docstring.
1025
+ raise SqlPandasResultError(cause=e, retryable=True) from e
1026
+
1027
+
1028
+ class AsyncBaseSQLClient(BaseSQLClient):
1029
+ """Asynchronous SQL client for database operations.
1030
+
1031
+ This class extends BaseSQLClient to provide asynchronous database operations,
1032
+ with support for batch processing and server-side cursors. It uses SQLAlchemy's
1033
+ async engine and connection interfaces for non-blocking database operations.
1034
+
1035
+ Attributes:
1036
+ connection (AsyncConnection): Async database connection instance.
1037
+ engine (AsyncEngine): Async SQLAlchemy engine instance.
1038
+ credentials (Dict[str, Any]): Database credentials.
1039
+ use_server_side_cursor (bool): Whether to use server-side cursors.
1040
+ """
1041
+
1042
+ connection: "AsyncConnection"
1043
+ engine: "AsyncEngine"
1044
+
1045
+ async def load(self, credentials: dict[str, Any]) -> None:
1046
+ """Load credentials and prepare async engine for lazy connections.
1047
+
1048
+ This method stores credentials and creates an async engine without establishing
1049
+ a persistent connection. Connections are created on-demand for better memory efficiency.
1050
+
1051
+ Args:
1052
+ credentials (Dict[str, Any]): Database connection credentials including
1053
+ host, port, username, password, and other connection parameters.
1054
+
1055
+ Raises:
1056
+ SqlClientConfigError: If DB_CONFIG is not set.
1057
+ SqlClientAuthFailedError: If the source rejected the credentials.
1058
+ SqlClientSourceUnavailableError: If the source could not be reached
1059
+ (DNS, refused or filtered connection, TLS).
1060
+ SqlClientConnectTimeoutError: If the connect or login timed out, or
1061
+ ``load()`` as a whole outran ``ATLAN_SQL_CLIENT_CONNECT_TIMEOUT_SECONDS``.
1062
+ SqlClientConnectUnclassifiedError: If the failure matched none of
1063
+ the above.
1064
+ AppError: Any typed error raised while connecting, unchanged — e.g.
1065
+ ``MissingSqlParamError`` or ``SqlCredentialsParseError``.
1066
+ """
1067
+ self.credentials = credentials
1068
+ if not self.DB_CONFIG:
1069
+ raise SqlClientConfigError()
1070
+
1071
+ budget = asyncio.timeout(SQL_CLIENT_CONNECT_TIMEOUT_SECONDS)
1072
+ try:
1073
+ from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 — optional dep: sqlalchemy
1074
+ create_async_engine,
1075
+ )
1076
+
1077
+ async with budget:
1078
+ # Offloaded: for IAM-role auth this runs an STS AssumeRole,
1079
+ # a network round trip that would otherwise block the loop.
1080
+ # Cancelling the await does not stop the thread, so the STS
1081
+ # call is fitted into the budget too.
1082
+ with _sts_deadline(_budget_left(budget)):
1083
+ connection_string = await run_in_thread(
1084
+ self.get_sqlalchemy_connection_string
1085
+ )
1086
+
1087
+ # Create async engine but no persistent connection
1088
+ self.engine = create_async_engine(
1089
+ connection_string,
1090
+ connect_args=_with_connect_timeout(
1091
+ connection_string,
1092
+ self.DB_CONFIG.connect_args,
1093
+ SQL_CLIENT_CONNECT_TIMEOUT_SECONDS,
1094
+ ),
1095
+ pool_pre_ping=self.DB_CONFIG.pool_pre_ping,
1096
+ )
1097
+
1098
+ # Same WARE-970 tolerant-decoder hook as the sync client. Async
1099
+ # engines surface DBAPI events on the underlying sync_engine.
1100
+ install_tolerant_text_decoder_hook(self.engine.sync_engine)
1101
+
1102
+ # Test connection briefly to validate credentials
1103
+ async with self.engine.connect() as _:
1104
+ pass # Connection test successful
1105
+
1106
+ # Don't store persistent connection
1107
+ self.connection = None
1108
+
1109
+ # conformance: ignore[E004] exc_info omitted intentionally; SQLAlchemy embeds password in traceback; cause is sanitized and re-raised as typed error
1110
+ except Exception as e:
1111
+ # No exc_info here: SQLAlchemy errors embed the full connection
1112
+ # string (including the password) in their message, and the
1113
+ # traceback would print it verbatim into logs.
1114
+ logger.error( # conformance: ignore[E005,L004,L009] exc_info would expose SQLAlchemy password; logs sanitized cause at failure site — raised typed error wraps only the raw unsanitized cause
1115
+ "Error establishing database connection: %s", sanitize_cause_repr(e)
1116
+ )
1117
+ if self.engine:
1118
+ await self.engine.dispose()
1119
+ self.engine = None
1120
+ if budget.expired():
1121
+ raise _load_budget_expired() from e
1122
+ # Already typed (a MissingSqlParamError from building the
1123
+ # connection string, a connector's own leaf): keep its verdict.
1124
+ if isinstance(e, AppError):
1125
+ raise
1126
+ raise _load_failure(e) from e
1127
+
1128
+ async def close(self) -> None:
1129
+ """Close the async database connection and dispose of the engine."""
1130
+ if self.engine:
1131
+ await self.engine.dispose()
1132
+ self.engine = None
1133
+ self.connection = None
1134
+
1135
+ async def run_query(
1136
+ self,
1137
+ query: str,
1138
+ batch_size: int = 100000,
1139
+ params: Mapping[str, SqlBindValue] | None = None,
1140
+ ):
1141
+ """Execute a SQL query asynchronously and return results in batches using lazy connections.
1142
+
1143
+ This method creates an async connection on-demand, executes the query in batches,
1144
+ and automatically closes the connection when done. This prevents memory leaks
1145
+ from persistent connections.
1146
+
1147
+ Args:
1148
+ query (str): SQL query to execute.
1149
+ batch_size (int, optional): Number of records to fetch in each batch.
1150
+ Defaults to 100000.
1151
+ params (Mapping[str, SqlBindValue], optional): Values to bind to ``:name``
1152
+ placeholders in ``query``, as for :meth:`BaseSQLClient.run_query`.
1153
+
1154
+ Yields:
1155
+ List[Dict[str, Any]]: Batches of query results, where each result is
1156
+ a dictionary mapping column names to values.
1157
+
1158
+ Raises:
1159
+ EngineNotInitializedError: If engine is not initialized.
1160
+ """
1161
+ if not self.engine:
1162
+ raise EngineNotInitializedError()
1163
+ bound = _bound_params(query, params)
1164
+
1165
+ logger.debug(
1166
+ "Running query (sha=%s, len=%d, bound=%d)",
1167
+ hashlib.sha256(query.encode("utf-8", errors="replace")).hexdigest()[:16],
1168
+ len(query),
1169
+ len(bound or {}),
1170
+ )
1171
+ use_server_side_cursor = self.use_server_side_cursor
1172
+
1173
+ # Use async context manager for automatic connection cleanup
1174
+ async with self.engine.connect() as connection:
1175
+ try:
1176
+ from sqlalchemy import text # noqa: PLC0415 — optional dep: sqlalchemy
1177
+
1178
+ if use_server_side_cursor:
1179
+ connection = await connection.execution_options(
1180
+ yield_per=batch_size
1181
+ )
1182
+
1183
+ statement = text(_escape_colons_for_text(query, frozenset(bound or ())))
1184
+ result = (
1185
+ await connection.stream(statement, bound)
1186
+ if use_server_side_cursor
1187
+ else await connection.execute(statement, bound)
1188
+ )
1189
+
1190
+ column_names = list(result.keys())
1191
+
1192
+ while True:
1193
+ rows = (
1194
+ await result.fetchmany(batch_size)
1195
+ if use_server_side_cursor
1196
+ else result.cursor.fetchmany(batch_size)
1197
+ if result.cursor
1198
+ else None
1199
+ )
1200
+ if not rows:
1201
+ break
1202
+ yield [dict(zip(column_names, row)) for row in rows]
1203
+
1204
+ except AppError:
1205
+ raise
1206
+ # conformance: ignore[E004] exception re-raised immediately as typed SqlPandasResultError; cause chain preserved via `from e`
1207
+ except Exception as e:
1208
+ # retryable=True: unclassified driver exception — do not override
1209
+ # the activity's declared retry policy. A transient source-side
1210
+ # condition (concurrent DDL, lock contention, dropped connection)
1211
+ # is indistinguishable from a permanent one here.
1212
+ # See SqlPandasResultError docstring.
1213
+ raise SqlPandasResultError(
1214
+ message="Error executing SQL query", cause=e, retryable=True
1215
+ ) from e
1216
+ # Async connection automatically closed by context manager
1217
+
1218
+ logger.info("Query execution completed")