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.
- wherobots/db/_transport.py +24 -0
- wherobots/db/connection.py +285 -33
- wherobots/db/constants.py +2 -0
- wherobots/db/cursor.py +51 -10
- wherobots/db/driver.py +68 -4
- wherobots/db/redaction.py +78 -0
- {wherobots_python_dbapi-0.28.0.dist-info → wherobots_python_dbapi-0.28.2.dist-info}/METADATA +3 -2
- wherobots_python_dbapi-0.28.2.dist-info/RECORD +17 -0
- {wherobots_python_dbapi-0.28.0.dist-info → wherobots_python_dbapi-0.28.2.dist-info}/WHEEL +1 -1
- wherobots/__init__.py +0 -0
- wherobots_python_dbapi-0.28.0.dist-info/RECORD +0 -16
- {wherobots_python_dbapi-0.28.0.dist-info → wherobots_python_dbapi-0.28.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|
wherobots/db/connection.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
complete_query(ExecutionResult())
|
|
203
389
|
return
|
|
204
390
|
|
|
205
391
|
# No store configured, request results normally
|
|
206
|
-
|
|
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
|
|
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
|
-
|
|
405
|
+
complete_query(ExecutionResult())
|
|
215
406
|
return
|
|
216
407
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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",
|
|
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
|
|
97
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
58
|
+
def _resolve_dbapi_version() -> str:
|
|
59
|
+
"""Resolve this package's version, or "unknown" if it can't be found."""
|
|
58
60
|
try:
|
|
59
|
-
|
|
61
|
+
return metadata.version("wherobots-python-dbapi")
|
|
60
62
|
except PackageNotFoundError:
|
|
61
|
-
|
|
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/{
|
|
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)
|
{wherobots_python_dbapi-0.28.0.dist-info → wherobots_python_dbapi-0.28.2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: wherobots-python-dbapi
|
|
3
|
-
Version: 0.28.
|
|
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,,
|
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,,
|
{wherobots_python_dbapi-0.28.0.dist-info → wherobots_python_dbapi-0.28.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|