wherobots-python-dbapi 0.28.0__tar.gz → 0.28.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/PKG-INFO +3 -2
  2. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/pyproject.toml +2 -1
  3. wherobots_python_dbapi-0.28.2/wherobots/db/_transport.py +24 -0
  4. wherobots_python_dbapi-0.28.2/wherobots/db/connection.py +593 -0
  5. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/constants.py +2 -0
  6. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/cursor.py +51 -10
  7. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/driver.py +68 -4
  8. wherobots_python_dbapi-0.28.2/wherobots/db/redaction.py +78 -0
  9. wherobots_python_dbapi-0.28.0/wherobots/__init__.py +0 -0
  10. wherobots_python_dbapi-0.28.0/wherobots/db/connection.py +0 -341
  11. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/.gitignore +0 -0
  12. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/LICENSE +0 -0
  13. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/README.md +0 -0
  14. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/__init__.py +0 -0
  15. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/errors.py +0 -0
  16. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/models.py +0 -0
  17. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/region.py +0 -0
  18. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/runtime.py +0 -0
  19. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/session_type.py +0 -0
  20. {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/types.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: wherobots-python-dbapi
3
- Version: 0.28.0
3
+ Version: 0.28.2
4
4
  Summary: Python DB-API driver for Wherobots DB
5
5
  Project-URL: Homepage, https://github.com/wherobots/wherobots-python-dbapi-driver
6
6
  Project-URL: Tracker, https://github.com/wherobots/wherobots-python-dbapi-driver/issues
@@ -14,6 +14,7 @@ Requires-Dist: pandas
14
14
  Requires-Dist: pandas-stubs>=2.0.3.230814
15
15
  Requires-Dist: pyarrow>=14.0.2
16
16
  Requires-Dist: requests>=2.31.0
17
+ Requires-Dist: sqlparse>=0.5.0
17
18
  Requires-Dist: strenum<0.5,>=0.4.15
18
19
  Requires-Dist: tenacity>=8.2.3
19
20
  Requires-Dist: types-requests>=2.31.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wherobots-python-dbapi"
3
- version = "0.28.0"
3
+ version = "0.28.2"
4
4
  description = "Python DB-API driver for Wherobots DB"
5
5
  authors = [{ name = "Maxime Petazzoni", email = "max@wherobots.com" }]
6
6
  requires-python = ">=3.10, <4"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "pandas",
18
18
  "types-requests>=2.31.0",
19
19
  "pandas-stubs>=2.0.3.230814",
20
+ "sqlparse>=0.5.0",
20
21
  ]
21
22
 
22
23
  [project.optional-dependencies]
@@ -0,0 +1,24 @@
1
+ """Transport termination independent of the WebSocket protocol's send lock."""
2
+
3
+ import errno
4
+ import socket
5
+
6
+ from websockets.sync.client import ClientConnection
7
+
8
+
9
+ def abort_connection(ws: ClientConnection) -> None:
10
+ """Disable further writes and wake blocked I/O before publishing failures.
11
+
12
+ ClientConnection.close() takes the library's protocol mutex, which a
13
+ stalled sendall() may hold. Shutdown the owned socket instead; the library's
14
+ receive thread will observe EOF/error and finish protocol cleanup itself.
15
+ Already transmitted bytes may still execute remotely.
16
+ """
17
+ try:
18
+ ws.socket.shutdown(socket.SHUT_RDWR)
19
+ except OSError as exc:
20
+ if exc.errno not in (errno.ENOTCONN, errno.EBADF, errno.ECONNRESET):
21
+ raise
22
+ finally:
23
+ # Even if shutdown fails, prevent any later send on this socket object.
24
+ ws.socket.close()
@@ -0,0 +1,593 @@
1
+ import json
2
+ import logging
3
+ import math
4
+ import textwrap
5
+ import threading
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable, Dict
9
+
10
+ from wherobots.db.redaction import get_statement_type, redact_sql
11
+
12
+ import pandas
13
+ import pyarrow
14
+ import cbor2
15
+ import websockets.exceptions
16
+ import websockets.sync.client
17
+
18
+ from .constants import DEFAULT_READ_TIMEOUT_SECONDS
19
+ from ._transport import abort_connection
20
+ from .cursor import Cursor
21
+ from .errors import NotSupportedError, OperationalError
22
+ from .models import ExecutionResult, ProgressInfo, Store, StoreResult
23
+ from .types import (
24
+ RequestKind,
25
+ EventKind,
26
+ ExecutionState,
27
+ ResultsFormat,
28
+ DataCompression,
29
+ GeometryRepresentation,
30
+ )
31
+
32
+
33
+ ProgressHandler = Callable[[ProgressInfo], None]
34
+ """A callable invoked with a :class:`ProgressInfo` on every progress event."""
35
+
36
+
37
+ class _TransportError(Exception):
38
+ """An I/O failure raised while receiving from the WebSocket."""
39
+
40
+
41
+ _READER_JOIN_SECONDS = 1.0
42
+ """How long close() waits for the reader thread once teardown has finished."""
43
+
44
+
45
+ def _handshake_bound(ws: Any) -> float | None:
46
+ """The transport's ``close_timeout`` if it bounds the close handshake.
47
+
48
+ websockets documents ``close_timeout=None`` as disabling the timeout, which
49
+ would make a graceful close unbounded against an unresponsive peer. Return
50
+ ``None`` for that, and for a missing, non-numeric (including ``bool`` and
51
+ mock), non-finite, non-positive, or too-large value; callers then abort
52
+ instead. Too large means the ``bound + 1s`` wait in ``close()`` would
53
+ exceed ``threading.TIMEOUT_MAX``.
54
+ Never raises ``Exception``: a failure here would strand pending queries.
55
+ """
56
+ try:
57
+ value = getattr(ws, "close_timeout", None)
58
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
59
+ return None
60
+ # Range-check a plain float, so a subclass can't override the
61
+ # comparisons. float() raises OverflowError for a huge int.
62
+ value = float(value)
63
+ if not math.isfinite(value) or value <= 0:
64
+ return None
65
+ if value > threading.TIMEOUT_MAX - 1.0:
66
+ return None
67
+ return value
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ @dataclass
73
+ class Query:
74
+ sql: str
75
+ execution_id: str
76
+ state: ExecutionState
77
+ handler: Callable[[Any], None]
78
+ store: Store | None = None
79
+
80
+
81
+ class Connection:
82
+ """
83
+ A PEP-0249 compatible Connection object for Wherobots DB.
84
+
85
+ The connection is backed by the WebSocket connected to the Wherobots SQL session instance.
86
+ Transactions are not supported, so commit() and rollback() raise NotSupportedError.
87
+
88
+ This class handles all the interactions with the remote SQL session, and the details of the
89
+ Wherobots Spatial SQL API protocol. It supports multiple concurrent cursors, each one executing
90
+ a single query at a time.
91
+
92
+ A background thread listens for events from the SQL session, and handles update to the
93
+ corresponding query state. Queries are tracked by their unique execution ID.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ ws: websockets.sync.client.ClientConnection,
99
+ read_timeout: float = DEFAULT_READ_TIMEOUT_SECONDS,
100
+ results_format: ResultsFormat | None = None,
101
+ data_compression: DataCompression | None = None,
102
+ geometry_representation: GeometryRepresentation | None = None,
103
+ session_id: str | None = None,
104
+ ):
105
+ self.__ws = ws
106
+ self.__read_timeout = read_timeout
107
+ self.__results_format = results_format
108
+ self.__data_compression = data_compression
109
+ self.__geometry_representation = geometry_representation
110
+ self.__progress_handler: ProgressHandler | None = None
111
+
112
+ self.__session_id = session_id
113
+ self.__lock = threading.Lock()
114
+ self.__send_lock = threading.Lock()
115
+ self.__shutdown_done = threading.Event()
116
+ self.__shutdown_owner: int | None = None
117
+ # Resolved when shutdown starts (websockets reads close_timeout live),
118
+ # so the graceful/abort decision and a second closer's wait agree.
119
+ self.__close_bound: float | None = None
120
+ self.__closed = False
121
+ self.__queries: dict[str, Query] = {}
122
+ self.__thread = threading.Thread(
123
+ target=self.__main_loop, daemon=True, name="wherobots-connection"
124
+ )
125
+ self.__thread.start()
126
+
127
+ def __enter__(self):
128
+ return self
129
+
130
+ def __exit__(self, exc_type, exc_val, exc_tb):
131
+ self.close()
132
+
133
+ def close(self) -> None:
134
+ """Close the transport, fail pending work, then wait for the reader.
135
+
136
+ The WebSocket close handshake is attempted when no send is in flight,
137
+ so the server can distinguish a client exit from a crash; otherwise the
138
+ socket is aborted. The handshake is bounded by the ``close_timeout`` of
139
+ the underlying ``ClientConnection`` (1s via ``connect``/
140
+ ``connect_direct``; the library default of 10s for a caller-built
141
+ socket). If ``close_timeout`` doesn't bound it (``None``, missing, or
142
+ not a finite positive number), the socket is aborted instead.
143
+
144
+ Pending work has been failed by the time ``close()`` returns, also when
145
+ another thread is closing concurrently. After teardown, ``close()``
146
+ waits up to 1s for the reader thread, so a single call takes at most
147
+ about ``close_timeout`` + 1s. A second, concurrent ``close()`` can take
148
+ up to ``close_timeout`` + 2s (about 12s on a caller-built socket using
149
+ the library's 10s default).
150
+
151
+ Closing doesn't imply that server-side writes were rolled back. A
152
+ decoder or callback can outlive the bounded reader join.
153
+ """
154
+ self.__fail_pending(graceful=True)
155
+ # A handler may close its own connection during terminal delivery.
156
+ if self.__shutdown_owner == threading.get_ident():
157
+ return
158
+ # Another thread may own shutdown and be mid-handshake, which is bounded
159
+ # by close_timeout; outlast it so close() never returns before pending
160
+ # work has been failed.
161
+ self.__shutdown_done.wait((self.__close_bound or 0.0) + 1.0)
162
+ # Timed from here, not from entry, so a slow handshake can't consume it.
163
+ if self.__thread is not threading.current_thread():
164
+ self.__thread.join(timeout=_READER_JOIN_SECONDS)
165
+
166
+ def commit(self) -> None:
167
+ raise NotSupportedError
168
+
169
+ def rollback(self) -> None:
170
+ raise NotSupportedError
171
+
172
+ def cursor(self) -> Cursor:
173
+ return Cursor(self.__execute_sql, self.__cancel_query)
174
+
175
+ def set_progress_handler(self, handler: ProgressHandler | None) -> None:
176
+ """Register a callback invoked for execution progress events.
177
+
178
+ When a handler is set, every ``execute_sql`` request automatically
179
+ includes ``enable_progress_events: true`` so the SQL session streams
180
+ progress updates for running queries.
181
+
182
+ Pass ``None`` to disable progress reporting.
183
+
184
+ This follows the `sqlite3 Connection.set_progress_handler()
185
+ <https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.set_progress_handler>`_
186
+ pattern (PEP 249 vendor extension).
187
+ """
188
+ self.__progress_handler = handler
189
+
190
+ def __main_loop(self) -> None:
191
+ """Main background loop listening for messages from the SQL session."""
192
+ logging.info("Starting background connection handling loop...")
193
+ try:
194
+ self.__receive_loop()
195
+ finally:
196
+ self.__fail_pending()
197
+
198
+ def __receive_loop(self) -> None:
199
+ # recv drains buffered results before raising ConnectionClosed.
200
+ while True:
201
+ if self.__shutdown_done.is_set():
202
+ return
203
+ try:
204
+ self.__listen()
205
+ except TimeoutError:
206
+ # Expected, retry next time
207
+ continue
208
+ except websockets.exceptions.ConnectionClosed:
209
+ logging.info("Connection closed; stopping main loop.")
210
+ return
211
+ except _TransportError:
212
+ logging.exception("SQL session transport failed; stopping main loop")
213
+ return
214
+ except Exception as e:
215
+ logging.exception("Error handling message from SQL session", exc_info=e)
216
+
217
+ def __connection_error(self, execution_id: str) -> OperationalError:
218
+ message = (
219
+ f"SQL connection lost (session={self.__session_id or 'unknown'}, "
220
+ f"execution={execution_id}). Commit outcome is unknown; "
221
+ "verify the operation before retrying writes."
222
+ )
223
+ return OperationalError(message)
224
+
225
+ def __fail_pending(self, graceful: bool = False) -> None:
226
+ # Stop admission first. Do not wait for __send_lock: its owner may be
227
+ # blocked in network I/O. __closed means closing until shutdown_done.
228
+ # Resolve the bound before taking __lock: it reads a transport
229
+ # attribute, which may be a property, and nothing foreign may run under
230
+ # __lock or between the latch and the delivery try/finally.
231
+ bound = _handshake_bound(self.__ws)
232
+ with self.__lock:
233
+ if self.__closed:
234
+ return
235
+ self.__closed = True
236
+ self.__shutdown_owner = threading.get_ident()
237
+ self.__close_bound = bound
238
+ # __closed is a one-way latch: nothing below may be skipped, or pending
239
+ # queries are stranded with no path to recovery.
240
+ try:
241
+ self.__terminate_transport(graceful)
242
+ with self.__lock:
243
+ pending = list(self.__queries.values())
244
+ self.__queries.clear()
245
+ for query in pending:
246
+ try:
247
+ query.handler(
248
+ ExecutionResult(
249
+ error=self.__connection_error(query.execution_id)
250
+ )
251
+ )
252
+ except Exception:
253
+ logging.exception(
254
+ "Could not deliver connection failure to query handler"
255
+ )
256
+ finally:
257
+ self.__shutdown_owner = None
258
+ self.__shutdown_done.set()
259
+
260
+ def __terminate_transport(self, graceful: bool) -> None:
261
+ """Disable the transport. Never raises: delivery must not be skipped."""
262
+ # A non-blocking acquire tells us whether a sender is inside ws.send()
263
+ # right now, possibly stalled holding the library's protocol mutex,
264
+ # which close() would deadlock on. Releasing immediately is safe: the
265
+ # __closed latch is already set, and __send re-checks it under __lock
266
+ # after taking __send_lock, so no sender can reach ws.send() from here
267
+ # on. Holding the lock across the handshake would only park unrelated
268
+ # senders (including the reader's own __request_results) for up to
269
+ # close_timeout instead of letting them fail fast. On a reader-side
270
+ # failure the transport is already broken and a close frame is
271
+ # pointless, so callers abort directly. Without a handshake bound the
272
+ # close could block forever, so abort then too.
273
+ if (
274
+ graceful
275
+ and self.__close_bound is not None
276
+ and self.__send_lock.acquire(blocking=False)
277
+ ):
278
+ self.__send_lock.release()
279
+ try:
280
+ self.__ws.close()
281
+ return
282
+ except Exception:
283
+ logging.debug("Graceful close failed; aborting", exc_info=True)
284
+ try:
285
+ abort_connection(self.__ws)
286
+ except Exception:
287
+ # The adapter still closes the socket object in its finally block.
288
+ logging.exception("Could not abort SQL session transport")
289
+
290
+ def __listen(self) -> None:
291
+ """Waits for the next message from the SQL session and processes it.
292
+
293
+ The code in this method is purposefully defensive to avoid unexpected situations killing the thread.
294
+ """
295
+ message = self.__recv()
296
+ kind = message.get("kind")
297
+ execution_id = message.get("execution_id")
298
+ if not kind or not execution_id:
299
+ # Invalid event.
300
+ return
301
+
302
+ # Progress events are independent of the query state machine and don't
303
+ # require a tracked query — the handler is connection-level.
304
+ if kind == EventKind.EXECUTION_PROGRESS:
305
+ handler = self.__progress_handler
306
+ if handler is None:
307
+ return
308
+ try:
309
+ handler(
310
+ ProgressInfo(
311
+ execution_id=execution_id,
312
+ tasks_total=message.get("tasks_total", 0),
313
+ tasks_completed=message.get("tasks_completed", 0),
314
+ tasks_active=message.get("tasks_active", 0),
315
+ )
316
+ )
317
+ except Exception:
318
+ logging.exception("Progress handler raised an exception")
319
+ return
320
+
321
+ query = self.__queries.get(execution_id)
322
+ if not query:
323
+ logging.warning(
324
+ "Received %s event for unknown execution ID %s", kind, execution_id
325
+ )
326
+ return
327
+
328
+ def complete_query(result: ExecutionResult) -> None:
329
+ # Terminal delivery: stop tracking the query first. Keeping it in
330
+ # __queries would retain its handler — and the results the handler
331
+ # references — for the connection's lifetime (WBC-922).
332
+ with self.__lock:
333
+ claimed = self.__queries.pop(execution_id, None)
334
+ if claimed is not None:
335
+ claimed.handler(result)
336
+
337
+ def fail_query(action: str, error: Exception) -> None:
338
+ # This is a query-local failure, not evidence of transport loss.
339
+ # Exception text may contain result data; report only its type.
340
+ query.state = ExecutionState.FAILED
341
+ message = (
342
+ f"Could not {action} SQL results "
343
+ f"(session={self.__session_id or 'unknown'}, "
344
+ f"execution={execution_id}; {type(error).__name__}). "
345
+ "The statement may have completed; verify the operation before "
346
+ "retrying writes."
347
+ )
348
+ logging.error("%s", message)
349
+ complete_query(ExecutionResult(error=OperationalError(message)))
350
+
351
+ # Incoming state transitions are handled here.
352
+ if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
353
+ try:
354
+ query.state = ExecutionState[message["state"].upper()]
355
+ logging.info("Query %s is now %s.", execution_id, query.state)
356
+ except (KeyError, AttributeError, TypeError) as error:
357
+ fail_query("interpret", error)
358
+ return
359
+
360
+ if query.state == ExecutionState.SUCCEEDED:
361
+ # On a state_updated event telling us the query succeeded,
362
+ # check if results are stored in cloud storage or need to be fetched.
363
+ if kind == EventKind.STATE_UPDATED:
364
+ result_uri = message.get("result_uri")
365
+ if result_uri:
366
+ # Results are stored in cloud storage
367
+ store_result = StoreResult(
368
+ result_uri=result_uri,
369
+ size=message.get("size"),
370
+ )
371
+ logging.info(
372
+ "Query %s results stored at: %s (size: %s)",
373
+ execution_id,
374
+ result_uri,
375
+ store_result.size,
376
+ )
377
+ query.state = ExecutionState.COMPLETED
378
+ complete_query(ExecutionResult(store_result=store_result))
379
+ return
380
+
381
+ if query.store is not None:
382
+ # Store was configured but produced no results (empty result set)
383
+ logging.info(
384
+ "Query %s completed with store configured but no results to store.",
385
+ execution_id,
386
+ )
387
+ query.state = ExecutionState.COMPLETED
388
+ complete_query(ExecutionResult())
389
+ return
390
+
391
+ # No store configured, request results normally
392
+ try:
393
+ self.__request_results(execution_id)
394
+ except Exception as error:
395
+ # Transport failures are handled by __send; a local
396
+ # retrieval failure must not orphan this execution.
397
+ fail_query("request", error)
398
+ return
399
+
400
+ # Otherwise, process the results from the execution_result event.
401
+ results = message.get("results")
402
+ if results is None or results == {}:
403
+ logging.warning("Got no results back from %s.", execution_id)
404
+ query.state = ExecutionState.COMPLETED
405
+ complete_query(ExecutionResult())
406
+ return
407
+
408
+ try:
409
+ if not isinstance(results, dict):
410
+ raise TypeError("Expected a result object")
411
+ decoded = self._handle_results(execution_id, results)
412
+ except Exception as error:
413
+ # Even OSError/TimeoutError here belong to decoding, not
414
+ # recv. Claim and deliver exactly once, including if close
415
+ # concurrently claims this execution.
416
+ fail_query("decode", error)
417
+ else:
418
+ query.state = ExecutionState.COMPLETED
419
+ complete_query(ExecutionResult(results=decoded))
420
+ elif query.state == ExecutionState.CANCELLED:
421
+ logging.info(
422
+ "Query %s has been cancelled; returning empty results.",
423
+ execution_id,
424
+ )
425
+ complete_query(ExecutionResult(results=pandas.DataFrame()))
426
+ elif query.state == ExecutionState.FAILED:
427
+ # Don't do anything here; the ERROR event is coming with more
428
+ # details.
429
+ pass
430
+ elif kind == EventKind.ERROR:
431
+ query.state = ExecutionState.FAILED
432
+ error = message.get("message")
433
+ complete_query(ExecutionResult(error=OperationalError(error)))
434
+ else:
435
+ logging.warning("Received unknown %s event!", kind)
436
+
437
+ def _handle_results(self, execution_id: str, results: Dict[str, Any]) -> Any:
438
+ result_bytes = results.get("result_bytes")
439
+ result_format = results.get("format")
440
+ result_compression = results.get("compression")
441
+ logging.info(
442
+ "Received %d bytes of %s-compressed %s results from %s.",
443
+ len(result_bytes),
444
+ result_compression,
445
+ result_format,
446
+ execution_id,
447
+ )
448
+
449
+ if result_format == ResultsFormat.JSON:
450
+ return json.loads(result_bytes.decode("utf-8"))
451
+ elif result_format == ResultsFormat.ARROW:
452
+ buffer = pyarrow.py_buffer(result_bytes)
453
+ stream = pyarrow.input_stream(buffer, result_compression)
454
+ with pyarrow.ipc.open_stream(stream) as reader:
455
+ return reader.read_pandas()
456
+ else:
457
+ raise NotSupportedError("Unsupported results format")
458
+
459
+ def __send(self, message: Dict[str, Any], query: Query | None = None) -> None:
460
+ # Serialization and redaction are local work. Fail before registration,
461
+ # without poisoning unrelated cursors or misreporting a transport loss.
462
+ request = json.dumps(message)
463
+ # Only compute the redacted request (json.dumps + sqlparse parse) when
464
+ # DEBUG is actually enabled; the log argument is evaluated eagerly, so an
465
+ # unguarded call would redact on every request even with DEBUG off.
466
+ if logging.getLogger().isEnabledFor(logging.DEBUG):
467
+ logging.debug("Request: %s", self.__redacted_request(message))
468
+ with self.__send_lock:
469
+ with self.__lock:
470
+ if self.__closed:
471
+ if query is not None:
472
+ raise self.__connection_error(query.execution_id)
473
+ return
474
+ if query is not None:
475
+ self.__queries[query.execution_id] = query
476
+ elif message.get("execution_id") not in self.__queries:
477
+ return
478
+ try:
479
+ self.__ws.send(request)
480
+ except (websockets.exceptions.ConnectionClosed, OSError):
481
+ pass # Terminate outside the send gate, before delivering errors.
482
+ except Exception:
483
+ # API/programming errors aren't evidence of connection loss.
484
+ if query is not None:
485
+ with self.__lock:
486
+ self.__queries.pop(query.execution_id, None)
487
+ raise
488
+ else:
489
+ return
490
+ self.__fail_pending()
491
+
492
+ @staticmethod
493
+ def __redacted_request(message: Dict[str, Any]) -> str:
494
+ """Serialize a request for logging with any SQL statement redacted.
495
+
496
+ The wire payload (sent verbatim by ``__send``) carries the raw
497
+ ``statement``; this driver is embedded by other services, so logging it
498
+ -- even at DEBUG -- would leak raw SQL into their log streams (WBC-139).
499
+ """
500
+ statement = message.get("statement")
501
+ if isinstance(statement, str):
502
+ message = {**message, "statement": redact_sql(statement)}
503
+ return json.dumps(message)
504
+
505
+ def __recv(self) -> Dict[str, Any]:
506
+ try:
507
+ frame = self.__ws.recv(timeout=self.__read_timeout)
508
+ except TimeoutError:
509
+ raise # Idle polls are expected, not terminal I/O failures.
510
+ except OSError as e:
511
+ # Distinguish transport I/O failures from OSErrors raised later by
512
+ # protocol parsing or result decoding; only the former are terminal.
513
+ raise _TransportError from e
514
+ if isinstance(frame, str):
515
+ message = json.loads(frame)
516
+ elif isinstance(frame, bytes):
517
+ message = cbor2.loads(frame)
518
+ else:
519
+ raise ValueError("Unexpected frame type received")
520
+ return message
521
+
522
+ def __execute_sql(
523
+ self,
524
+ sql: str,
525
+ handler: Callable[[Any], None],
526
+ store: Store | None = None,
527
+ ) -> str:
528
+ """Triggers the execution of the given SQL query."""
529
+ execution_id = str(uuid.uuid4())
530
+ request = {
531
+ "kind": RequestKind.EXECUTE_SQL.value,
532
+ "execution_id": execution_id,
533
+ "statement": sql,
534
+ }
535
+
536
+ if self.__progress_handler is not None:
537
+ request["enable_progress_events"] = True
538
+
539
+ if store:
540
+ request["store"] = store.to_dict()
541
+
542
+ # Redact literal values before logging: this driver is embedded by other
543
+ # services, so raw SQL here would leak into their log streams (WBC-139).
544
+ logging.info(
545
+ "Executing SQL query %s (%s): %s",
546
+ execution_id,
547
+ get_statement_type(sql),
548
+ textwrap.shorten(redact_sql(sql), width=200),
549
+ )
550
+ self.__send(
551
+ request,
552
+ Query(
553
+ sql=sql,
554
+ execution_id=execution_id,
555
+ state=ExecutionState.EXECUTION_REQUESTED,
556
+ handler=handler,
557
+ store=store,
558
+ ),
559
+ )
560
+ return execution_id
561
+
562
+ def __request_results(self, execution_id: str) -> None:
563
+ query = self.__queries.get(execution_id)
564
+ if not query:
565
+ return
566
+
567
+ request = {
568
+ "kind": RequestKind.RETRIEVE_RESULTS.value,
569
+ "execution_id": execution_id,
570
+ }
571
+ if self.__results_format:
572
+ request["format"] = self.__results_format.value
573
+ if self.__data_compression:
574
+ request["compression"] = self.__data_compression.value
575
+ if self.__geometry_representation:
576
+ request["geometry"] = self.__geometry_representation.value
577
+
578
+ query.state = ExecutionState.RESULTS_REQUESTED
579
+ logging.info("Requesting results from %s ...", execution_id)
580
+ self.__send(request)
581
+
582
+ def __cancel_query(self, execution_id: str) -> None:
583
+ """Cancels the query with the given execution ID."""
584
+ query = self.__queries.get(execution_id)
585
+ if not query:
586
+ return
587
+
588
+ request = {
589
+ "kind": RequestKind.CANCEL.value,
590
+ "execution_id": execution_id,
591
+ }
592
+ logging.info("Cancelling query %s...", execution_id)
593
+ self.__send(request)
@@ -14,6 +14,8 @@ DEFAULT_REGION: Region = Region.AWS_US_WEST_2
14
14
  DEFAULT_SESSION_TYPE: SessionType = SessionType.MULTI
15
15
  DEFAULT_STORAGE_FORMAT: StorageFormat = StorageFormat.PARQUET
16
16
  DEFAULT_READ_TIMEOUT_SECONDS: float = 0.25
17
+ # Bound on the WebSocket close handshake performed by a graceful close().
18
+ DEFAULT_CLOSE_TIMEOUT_SECONDS: float = 1.0
17
19
  DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS: float = 900
18
20
 
19
21
  MAX_MESSAGE_SIZE: int = 100 * 2**20 # 100MiB