wherobots-python-dbapi 0.28.0__py3-none-any.whl → 0.28.2__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,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()
@@ -1,19 +1,22 @@
1
1
  import json
2
2
  import logging
3
+ import math
3
4
  import textwrap
4
5
  import threading
5
6
  import uuid
6
7
  from dataclasses import dataclass
7
8
  from typing import Any, Callable, Dict
8
9
 
10
+ from wherobots.db.redaction import get_statement_type, redact_sql
11
+
9
12
  import pandas
10
13
  import pyarrow
11
14
  import cbor2
12
15
  import websockets.exceptions
13
- import websockets.protocol
14
16
  import websockets.sync.client
15
17
 
16
18
  from .constants import DEFAULT_READ_TIMEOUT_SECONDS
19
+ from ._transport import abort_connection
17
20
  from .cursor import Cursor
18
21
  from .errors import NotSupportedError, OperationalError
19
22
  from .models import ExecutionResult, ProgressInfo, Store, StoreResult
@@ -31,6 +34,41 @@ ProgressHandler = Callable[[ProgressInfo], None]
31
34
  """A callable invoked with a :class:`ProgressInfo` on every progress event."""
32
35
 
33
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
+
34
72
  @dataclass
35
73
  class Query:
36
74
  sql: str
@@ -62,6 +100,7 @@ class Connection:
62
100
  results_format: ResultsFormat | None = None,
63
101
  data_compression: DataCompression | None = None,
64
102
  geometry_representation: GeometryRepresentation | None = None,
103
+ session_id: str | None = None,
65
104
  ):
66
105
  self.__ws = ws
67
106
  self.__read_timeout = read_timeout
@@ -70,6 +109,15 @@ class Connection:
70
109
  self.__geometry_representation = geometry_representation
71
110
  self.__progress_handler: ProgressHandler | None = None
72
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
73
121
  self.__queries: dict[str, Query] = {}
74
122
  self.__thread = threading.Thread(
75
123
  target=self.__main_loop, daemon=True, name="wherobots-connection"
@@ -83,7 +131,37 @@ class Connection:
83
131
  self.close()
84
132
 
85
133
  def close(self) -> None:
86
- self.__ws.close()
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)
87
165
 
88
166
  def commit(self) -> None:
89
167
  raise NotSupportedError
@@ -112,18 +190,103 @@ class Connection:
112
190
  def __main_loop(self) -> None:
113
191
  """Main background loop listening for messages from the SQL session."""
114
192
  logging.info("Starting background connection handling loop...")
115
- while self.__ws.protocol.state < websockets.protocol.State.CLOSING:
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
116
203
  try:
117
204
  self.__listen()
118
205
  except TimeoutError:
119
206
  # Expected, retry next time
120
207
  continue
121
- except websockets.exceptions.ConnectionClosedOK:
208
+ except websockets.exceptions.ConnectionClosed:
122
209
  logging.info("Connection closed; stopping main loop.")
123
210
  return
211
+ except _TransportError:
212
+ logging.exception("SQL session transport failed; stopping main loop")
213
+ return
124
214
  except Exception as e:
125
215
  logging.exception("Error handling message from SQL session", exc_info=e)
126
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
+
127
290
  def __listen(self) -> None:
128
291
  """Waits for the next message from the SQL session and processes it.
129
292
 
@@ -162,13 +325,36 @@ class Connection:
162
325
  )
163
326
  return
164
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
+
165
351
  # Incoming state transitions are handled here.
166
352
  if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
167
353
  try:
168
354
  query.state = ExecutionState[message["state"].upper()]
169
355
  logging.info("Query %s is now %s.", execution_id, query.state)
170
- except KeyError:
171
- logging.warning("Invalid state update message for %s", execution_id)
356
+ except (KeyError, AttributeError, TypeError) as error:
357
+ fail_query("interpret", error)
172
358
  return
173
359
 
174
360
  if query.state == ExecutionState.SUCCEEDED:
@@ -189,7 +375,7 @@ class Connection:
189
375
  store_result.size,
190
376
  )
191
377
  query.state = ExecutionState.COMPLETED
192
- query.handler(ExecutionResult(store_result=store_result))
378
+ complete_query(ExecutionResult(store_result=store_result))
193
379
  return
194
380
 
195
381
  if query.store is not None:
@@ -199,32 +385,44 @@ class Connection:
199
385
  execution_id,
200
386
  )
201
387
  query.state = ExecutionState.COMPLETED
202
- query.handler(ExecutionResult())
388
+ complete_query(ExecutionResult())
203
389
  return
204
390
 
205
391
  # No store configured, request results normally
206
- self.__request_results(execution_id)
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)
207
398
  return
208
399
 
209
400
  # Otherwise, process the results from the execution_result event.
210
401
  results = message.get("results")
211
- if not results or not isinstance(results, dict):
402
+ if results is None or results == {}:
212
403
  logging.warning("Got no results back from %s.", execution_id)
213
404
  query.state = ExecutionState.COMPLETED
214
- query.handler(ExecutionResult())
405
+ complete_query(ExecutionResult())
215
406
  return
216
407
 
217
- query.state = ExecutionState.COMPLETED
218
- query.handler(
219
- ExecutionResult(results=self._handle_results(execution_id, results))
220
- )
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))
221
420
  elif query.state == ExecutionState.CANCELLED:
222
421
  logging.info(
223
422
  "Query %s has been cancelled; returning empty results.",
224
423
  execution_id,
225
424
  )
226
- query.handler(ExecutionResult(results=pandas.DataFrame()))
227
- self.__queries.pop(execution_id)
425
+ complete_query(ExecutionResult(results=pandas.DataFrame()))
228
426
  elif query.state == ExecutionState.FAILED:
229
427
  # Don't do anything here; the ERROR event is coming with more
230
428
  # details.
@@ -232,7 +430,7 @@ class Connection:
232
430
  elif kind == EventKind.ERROR:
233
431
  query.state = ExecutionState.FAILED
234
432
  error = message.get("message")
235
- query.handler(ExecutionResult(error=OperationalError(error)))
433
+ complete_query(ExecutionResult(error=OperationalError(error)))
236
434
  else:
237
435
  logging.warning("Received unknown %s event!", kind)
238
436
 
@@ -256,15 +454,63 @@ class Connection:
256
454
  with pyarrow.ipc.open_stream(stream) as reader:
257
455
  return reader.read_pandas()
258
456
  else:
259
- return OperationalError(f"Unsupported results format {result_format}")
457
+ raise NotSupportedError("Unsupported results format")
260
458
 
261
- def __send(self, message: Dict[str, Any]) -> None:
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.
262
462
  request = json.dumps(message)
263
- logging.debug("Request: %s", request)
264
- self.__ws.send(request)
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)
265
504
 
266
505
  def __recv(self) -> Dict[str, Any]:
267
- frame = self.__ws.recv(timeout=self.__read_timeout)
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
268
514
  if isinstance(frame, str):
269
515
  message = json.loads(frame)
270
516
  elif isinstance(frame, bytes):
@@ -293,18 +539,24 @@ class Connection:
293
539
  if store:
294
540
  request["store"] = store.to_dict()
295
541
 
296
- self.__queries[execution_id] = Query(
297
- sql=sql,
298
- execution_id=execution_id,
299
- state=ExecutionState.EXECUTION_REQUESTED,
300
- handler=handler,
301
- store=store,
302
- )
303
-
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).
304
544
  logging.info(
305
- "Executing SQL query %s: %s", execution_id, textwrap.shorten(sql, width=60)
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
+ ),
306
559
  )
307
- self.__send(request)
308
560
  return execution_id
309
561
 
310
562
  def __request_results(self, execution_id: str) -> None:
wherobots/db/constants.py CHANGED
@@ -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
wherobots/db/cursor.py CHANGED
@@ -74,6 +74,8 @@ class Cursor:
74
74
  self.__queue: queue.Queue = queue.Queue()
75
75
  self.__results: list[Any] | None = None
76
76
  self.__store_result: StoreResult | None = None
77
+ self.__complete: bool = False
78
+ self.__error: Exception | None = None
77
79
  self.__current_execution_id: str | None = None
78
80
  self.__current_row: int = 0
79
81
 
@@ -93,21 +95,49 @@ class Cursor:
93
95
  def rowcount(self) -> int:
94
96
  return self.__rowcount
95
97
 
96
- def __on_execution_result(self, result) -> None:
97
- self.__queue.put(result)
98
+ def __in_flight_execution_id(self) -> str | None:
99
+ """The current execution's id if its result has not yet arrived.
100
+
101
+ Once a terminal result has been fetched (``__complete``) or is
102
+ waiting in the queue, the execution is finished and must not be
103
+ cancelled.
104
+
105
+ Results are delivered from the connection's reader thread, so one
106
+ may arrive between the ``empty()`` check and a cancellation that
107
+ follows it. That race is benign: the cancel targets an execution
108
+ that already completed — a server-side no-op, same as when a query
109
+ finishes while a legitimate cancel is in flight — and the delivered
110
+ result sits in this execution's own queue, so it can never be
111
+ observed by a later execution's fetches.
112
+ """
113
+ if (
114
+ self.__current_execution_id is not None
115
+ and not self.__complete
116
+ and self.__queue.empty()
117
+ ):
118
+ return self.__current_execution_id
119
+ return None
98
120
 
99
121
  def __get_results(self) -> List[Tuple[Any, ...]] | None:
100
122
  if not self.__current_execution_id:
101
123
  raise ProgrammingError("No query has been executed yet")
102
- if self.__results is not None:
124
+ if self.__complete:
125
+ if self.__error:
126
+ raise self.__error
103
127
  return self.__results
104
128
 
105
129
  execution_result = self.__queue.get()
106
130
  if not isinstance(execution_result, ExecutionResult):
107
131
  raise ProgrammingError("Unexpected result type")
108
132
 
133
+ # Whatever the outcome — rows, store export, empty result, or error —
134
+ # the execution has reached a terminal state. ``__results`` alone
135
+ # cannot signal this: store-backed and empty executions never set it.
136
+ self.__complete = True
137
+
109
138
  if execution_result.error:
110
- raise execution_result.error
139
+ self.__error = execution_result.error
140
+ raise self.__error
111
141
 
112
142
  self.__store_result = execution_result.store_result
113
143
  results = execution_result.results
@@ -140,18 +170,28 @@ class Cursor:
140
170
  parameters: Dict[str, Any] | None = None,
141
171
  store: Store | None = None,
142
172
  ) -> None:
143
- if self.__current_execution_id:
144
- self.__cancel_fn(self.__current_execution_id)
145
-
173
+ in_flight = self.__in_flight_execution_id()
174
+ if in_flight:
175
+ self.__cancel_fn(in_flight)
176
+
177
+ # Each execution gets its own queue, and the handler closes over it:
178
+ # a late result from a superseded execution lands in the orphaned
179
+ # queue and can never be observed by fetches of the current one.
180
+ self.__queue = queue.Queue()
146
181
  self.__results = None
147
182
  self.__store_result = None
183
+ self.__complete = False
184
+ self.__error = None
148
185
  self.__current_row = 0
149
186
  self.__rowcount = -1
150
187
  self.__description = None
151
188
 
189
+ # A rejected submission must not leave the previous execution ID paired
190
+ # with this new empty queue (which would make a later fetch wait forever).
191
+ self.__current_execution_id = None
152
192
  self.__current_execution_id = self.__exec_fn(
153
193
  _substitute_parameters(operation, parameters),
154
- self.__on_execution_result,
194
+ self.__queue.put,
155
195
  store,
156
196
  )
157
197
 
@@ -193,8 +233,9 @@ class Cursor:
193
233
 
194
234
  def close(self) -> None:
195
235
  """Close the cursor."""
196
- if self.__results is None and self.__current_execution_id:
197
- self.__cancel_fn(self.__current_execution_id)
236
+ in_flight = self.__in_flight_execution_id()
237
+ if in_flight:
238
+ self.__cancel_fn(in_flight)
198
239
 
199
240
  def __iter__(self):
200
241
  return self
wherobots/db/driver.py CHANGED
@@ -20,6 +20,7 @@ import certifi
20
20
 
21
21
  from .connection import Connection
22
22
  from .constants import (
23
+ DEFAULT_CLOSE_TIMEOUT_SECONDS,
23
24
  DEFAULT_ENDPOINT,
24
25
  DEFAULT_READ_TIMEOUT_SECONDS,
25
26
  DEFAULT_SESSION_TYPE,
@@ -54,18 +55,59 @@ TRANSIENT_HTTP_STATUS_CODES = {429, 502, 503, 504}
54
55
  DEFAULT_HTTP_TIMEOUT = 30
55
56
 
56
57
 
57
- def gen_user_agent_header():
58
+ def _resolve_dbapi_version() -> str:
59
+ """Resolve this package's version, or "unknown" if it can't be found."""
58
60
  try:
59
- package_version = metadata.version("wherobots-python-dbapi")
61
+ return metadata.version("wherobots-python-dbapi")
60
62
  except PackageNotFoundError:
61
- package_version = "unknown"
63
+ return "unknown"
64
+
65
+
66
+ # Resolved once at import: `importlib.metadata.version` scans the installed
67
+ # package database on each call, and the version can't change within a process.
68
+ _DBAPI_VERSION: Final[str] = _resolve_dbapi_version()
69
+
70
+
71
+ def gen_user_agent_header():
62
72
  python_version = platform.python_version()
63
73
  system = platform.system().lower()
64
74
  return {
65
- "User-Agent": f"wherobots-python-dbapi/{package_version} os/{system} python/{python_version}"
75
+ "User-Agent": f"wherobots-python-dbapi/{_DBAPI_VERSION} os/{system} python/{python_version}"
66
76
  }
67
77
 
68
78
 
79
+ # Canonical name of the shared, cross-service client-chain header.
80
+ WHEROBOTS_CLIENT_HEADER: Final[str] = "X-Wherobots-Client"
81
+
82
+
83
+ def _append_wherobots_client_hop(headers: Dict[str, str]) -> None:
84
+ """Append this driver's hop to the shared ``X-Wherobots-Client`` header.
85
+
86
+ ``X-Wherobots-Client`` is an ordered, append-only, comma-separated list of
87
+ hops; the leftmost is the origin and each component appends its own hop on
88
+ the right. DBAPI's hop is ``client=dbapi;ver=<version>``.
89
+
90
+ The lookup is case-insensitive because HTTP header names are, and any
91
+ differently-cased inbound key is collapsed into the canonical
92
+ ``X-Wherobots-Client`` key so the request carries exactly one such header.
93
+
94
+ This header is advisory: it is informational only and must never affect
95
+ authentication. ``headers`` is mutated in place.
96
+ """
97
+ dbapi_hop = f"client=dbapi;ver={_DBAPI_VERSION}"
98
+
99
+ # Find any existing hop chain case-insensitively and remove differently
100
+ # cased duplicates so we don't emit two headers.
101
+ existing_chain: Union[str, None] = None
102
+ for key in [k for k in headers if k.lower() == WHEROBOTS_CLIENT_HEADER.lower()]:
103
+ existing_chain = headers.pop(key)
104
+
105
+ if existing_chain:
106
+ headers[WHEROBOTS_CLIENT_HEADER] = f"{existing_chain}, {dbapi_hop}"
107
+ else:
108
+ headers[WHEROBOTS_CLIENT_HEADER] = dbapi_hop
109
+
110
+
69
111
  def connect(
70
112
  host: str = DEFAULT_ENDPOINT,
71
113
  token: Union[str, None] = None,
@@ -82,6 +124,7 @@ def connect(
82
124
  data_compression: Union[DataCompression, None] = None,
83
125
  geometry_representation: Union[GeometryRepresentation, None] = None,
84
126
  cancel_event: Union[threading.Event, None] = None,
127
+ extra_headers: Union[Dict[str, str], None] = None,
85
128
  ) -> Connection:
86
129
  """Create a connection to a Wherobots SQL session.
87
130
 
@@ -96,6 +139,13 @@ def connect(
96
139
  your organization — only set this if you intend to use a specific region
97
140
  instead of the one your administrator has configured. When omitted, your
98
141
  organization's default region is used.
142
+ :param extra_headers: Optional extra HTTP headers to send on the session
143
+ requests and the WebSocket upgrade. Merged after the driver's own
144
+ headers, so callers can pass through tracing/correlation headers. If it
145
+ includes an ``X-Wherobots-Client`` hop chain, this driver appends its
146
+ own ``client=dbapi;ver=<version>`` hop to the right of it. This header
147
+ is advisory only and never affects authentication; ``extra_headers``
148
+ cannot be used to override the ``Authorization``/``X-API-Key`` headers.
99
149
  """
100
150
  if not token and not api_key:
101
151
  raise ValueError("At least one of `token` or `api_key` is required")
@@ -103,11 +153,19 @@ def connect(
103
153
  raise ValueError("`token` and `api_key` can't be both provided")
104
154
 
105
155
  headers = gen_user_agent_header()
156
+ # Merge caller-supplied headers first so the driver's own auth headers,
157
+ # applied below, always win and can never be overridden.
158
+ if extra_headers:
159
+ headers.update(extra_headers)
106
160
  if token:
107
161
  headers["Authorization"] = f"Bearer {token}"
108
162
  elif api_key:
109
163
  headers["X-API-Key"] = api_key
110
164
 
165
+ # Append this driver's hop to the shared client-chain header. Done after the
166
+ # auth headers are set so it operates on the final, merged header set.
167
+ _append_wherobots_client_hop(headers)
168
+
111
169
  host = host or DEFAULT_ENDPOINT
112
170
  session_type = session_type or DEFAULT_SESSION_TYPE
113
171
 
@@ -210,6 +268,9 @@ def connect(
210
268
  data_compression=data_compression,
211
269
  geometry_representation=geometry_representation,
212
270
  cancel_event=cancel_event,
271
+ session_id=urllib.parse.urlparse(session_id_url)
272
+ .path.rstrip("/")
273
+ .rsplit("/", 1)[-1],
213
274
  )
214
275
 
215
276
 
@@ -237,6 +298,7 @@ def connect_direct(
237
298
  data_compression: Union[DataCompression, None] = None,
238
299
  geometry_representation: Union[GeometryRepresentation, None] = None,
239
300
  cancel_event: Union[threading.Event, None] = None,
301
+ session_id: str | None = None,
240
302
  ) -> Connection:
241
303
  uri_with_protocol = f"{uri}/{protocol}"
242
304
  ssl_context = ssl.create_default_context()
@@ -264,6 +326,7 @@ def connect_direct(
264
326
  additional_headers=headers,
265
327
  max_size=MAX_MESSAGE_SIZE,
266
328
  open_timeout=DEFAULT_HTTP_TIMEOUT,
329
+ close_timeout=DEFAULT_CLOSE_TIMEOUT_SECONDS,
267
330
  ssl=ssl_context,
268
331
  )
269
332
 
@@ -280,4 +343,5 @@ def connect_direct(
280
343
  results_format=results_format,
281
344
  data_compression=data_compression,
282
345
  geometry_representation=geometry_representation,
346
+ session_id=session_id,
283
347
  )
@@ -0,0 +1,78 @@
1
+ """Redaction helpers for SQL statements logged by this driver.
2
+
3
+ The driver logs SQL statements for observability (e.g. ``Executing SQL query
4
+ <id>: ...``). Raw statements can embed literal PII (for example ``WHERE ssn =
5
+ '123-45-6789'``), and because this library is embedded by other services its log
6
+ output ends up in their log streams. This module replaces literal *values*
7
+ (single-quoted string literals and numeric literals) with a ``?`` placeholder
8
+ while preserving the statement structure: keywords, function names, and
9
+ identifiers (including double-quoted/back-quoted identifiers) are kept intact so
10
+ the redacted form is still useful for debugging and aggregation.
11
+
12
+ The implementation tokenizes with ``sqlparse``, a lenient, pure-Python tokenizer
13
+ with zero transitive dependencies. It is dialect-agnostic, never raises on
14
+ malformed input, and classifies literals by token type, which lets us redact
15
+ precisely the value-bearing tokens while copying everything else (keywords,
16
+ identifiers, operators, comments, whitespace) through verbatim.
17
+
18
+ This logic is duplicated from the ``sql-session`` service (PR #197); the two
19
+ repositories do not share a package, so the implementation is intentionally
20
+ replicated here rather than imported.
21
+
22
+ This is a best-effort redaction for *logging*. It is not a security boundary and
23
+ must not be relied on to sanitize untrusted input for any other purpose.
24
+ """
25
+
26
+ import re
27
+
28
+ import sqlparse
29
+ from sqlparse import tokens as T
30
+
31
+ REDACTED_PLACEHOLDER = "?"
32
+
33
+ # Leading SQL keyword -> statement type. Used purely for observability tagging.
34
+ # Intentionally a regex (not sqlparse's ``Statement.get_type()``) because
35
+ # ``get_type()`` returns "UNKNOWN" for SHOW/DESCRIBE/SET, which would regress the
36
+ # observability tagging this module exists to support.
37
+ _STATEMENT_TYPE_RE = re.compile(r"\s*([A-Za-z]+)")
38
+
39
+
40
+ def get_statement_type(statement: str) -> str:
41
+ """Return the upper-cased leading keyword of a statement (e.g. ``SELECT``).
42
+
43
+ Returns ``"UNKNOWN"`` when the statement does not begin with a word.
44
+ """
45
+ match = _STATEMENT_TYPE_RE.match(statement)
46
+ if match is None:
47
+ return "UNKNOWN"
48
+ return match.group(1).upper()
49
+
50
+
51
+ def redact_sql(statement: str) -> str:
52
+ """Return ``statement`` with string and numeric literals replaced by ``?``.
53
+
54
+ Single-quoted string literals (``String.Single``) and numeric literals
55
+ (``Number.*``) are each collapsed to a single ``?`` placeholder. Everything
56
+ else -- keywords, function names, plain and quoted identifiers (``"col"`` /
57
+ `` `col` ``), comments, whitespace, and operators -- is preserved verbatim.
58
+ """
59
+ out: list[str] = []
60
+ for stmt in sqlparse.parse(statement):
61
+ # sqlparse ships no type stubs, so ``flatten`` is untyped under strict.
62
+ for token in stmt.flatten(): # type: ignore[no-untyped-call]
63
+ ttype = token.ttype
64
+ if ttype in T.String.Single or ttype in T.Number:
65
+ out.append(REDACTED_PLACEHOLDER)
66
+ elif ttype in T.Error and token.value in ("'", '"', "`"):
67
+ # An unterminated quote: sqlparse emits the lone opener as an
68
+ # Error token and tokenizes the trailing characters as ordinary
69
+ # text. Redact the opener and fail closed by bailing on the
70
+ # entire input -- returning here (rather than ``break``, which
71
+ # only exits the inner loop) ensures that for multi-statement
72
+ # input no later statement is appended verbatim, which would
73
+ # leak the literals this function exists to hide.
74
+ out.append(REDACTED_PLACEHOLDER)
75
+ return "".join(out)
76
+ else:
77
+ out.append(token.value)
78
+ return "".join(out)
@@ -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
@@ -0,0 +1,17 @@
1
+ wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
2
+ wherobots/db/_transport.py,sha256=b26gp20zreMAjvXxfBlP_Nl6xrxjmG3k1POiDhfVphw,887
3
+ wherobots/db/connection.py,sha256=o3UsjmFcntMavPUg6aQQTDMV1uPBMGmTrKXvSnm7Xt4,24955
4
+ wherobots/db/constants.py,sha256=SbJVxGSuN-2kBGu4WxNVxUlPi-8hhoG6eeuOewaaq_I,861
5
+ wherobots/db/cursor.py,sha256=jF9v4UhfBqZ0iZEAmIYRxWYU4E8UbMFIrwfY_PlNI60,8701
6
+ wherobots/db/driver.py,sha256=oxztjXrdsjqglOYEHn5JDMgmKh8_VCWj-YDGEb4OZsg,13245
7
+ wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
8
+ wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
9
+ wherobots/db/redaction.py,sha256=issl5e5QvZQbs4d5UGUJIW7SIZIYfQ04MIyuT0Eo5_g,3744
10
+ wherobots/db/region.py,sha256=GoFzazvgOjE1kVVT1QKAsRK88c_1XSAebR3ME4foSlQ,400
11
+ wherobots/db/runtime.py,sha256=eAR1oJEtssgkp-IRPTLOrlPI-_5SM_ASw6T6hQ_qF4I,985
12
+ wherobots/db/session_type.py,sha256=jZNwCl0nxvY-0reIn8RR_-fIYSSvIcaGx_4pRc71Lww,137
13
+ wherobots/db/types.py,sha256=WXKT_gbdQCH2_yXD_FECk0BpB7yfNWOLewCfax0U_Lk,2516
14
+ wherobots_python_dbapi-0.28.2.dist-info/METADATA,sha256=NrBYfI2FBGEDhuejl5zhV0f-HMUjpFEU2yBomo8u4Ho,11173
15
+ wherobots_python_dbapi-0.28.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
16
+ wherobots_python_dbapi-0.28.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
17
+ wherobots_python_dbapi-0.28.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
wherobots/__init__.py DELETED
File without changes
@@ -1,16 +0,0 @@
1
- wherobots/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
3
- wherobots/db/connection.py,sha256=BWQwVzFgujEwPKvhDePM9q45M2opCk6Jq998L0ekMyY,12795
4
- wherobots/db/constants.py,sha256=qV-6CmOya7-f-DoRMzcDzlhcmV5PkLCcqG5TFIsxQ7o,744
5
- wherobots/db/cursor.py,sha256=Z0rNdhJ985IP9XnZpjrCzvM0OqDFVgCNfJn8lrbijAo,6809
6
- wherobots/db/driver.py,sha256=fHfYtujvafmAnJh9uf_0oZUNVtyf8XLsgs9sqfiDMk4,10304
7
- wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
8
- wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
9
- wherobots/db/region.py,sha256=GoFzazvgOjE1kVVT1QKAsRK88c_1XSAebR3ME4foSlQ,400
10
- wherobots/db/runtime.py,sha256=eAR1oJEtssgkp-IRPTLOrlPI-_5SM_ASw6T6hQ_qF4I,985
11
- wherobots/db/session_type.py,sha256=jZNwCl0nxvY-0reIn8RR_-fIYSSvIcaGx_4pRc71Lww,137
12
- wherobots/db/types.py,sha256=WXKT_gbdQCH2_yXD_FECk0BpB7yfNWOLewCfax0U_Lk,2516
13
- wherobots_python_dbapi-0.28.0.dist-info/METADATA,sha256=qUqbExsrE8Q6GwBE8f_mWL8-yNnGAp4vY1qgc_2OU0c,11142
14
- wherobots_python_dbapi-0.28.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
- wherobots_python_dbapi-0.28.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- wherobots_python_dbapi-0.28.0.dist-info/RECORD,,