wherobots-python-dbapi 0.28.1__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,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import math
3
4
  import textwrap
4
5
  import threading
5
6
  import uuid
@@ -12,10 +13,10 @@ import pandas
12
13
  import pyarrow
13
14
  import cbor2
14
15
  import websockets.exceptions
15
- import websockets.protocol
16
16
  import websockets.sync.client
17
17
 
18
18
  from .constants import DEFAULT_READ_TIMEOUT_SECONDS
19
+ from ._transport import abort_connection
19
20
  from .cursor import Cursor
20
21
  from .errors import NotSupportedError, OperationalError
21
22
  from .models import ExecutionResult, ProgressInfo, Store, StoreResult
@@ -33,6 +34,41 @@ ProgressHandler = Callable[[ProgressInfo], None]
33
34
  """A callable invoked with a :class:`ProgressInfo` on every progress event."""
34
35
 
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
+
36
72
  @dataclass
37
73
  class Query:
38
74
  sql: str
@@ -64,6 +100,7 @@ class Connection:
64
100
  results_format: ResultsFormat | None = None,
65
101
  data_compression: DataCompression | None = None,
66
102
  geometry_representation: GeometryRepresentation | None = None,
103
+ session_id: str | None = None,
67
104
  ):
68
105
  self.__ws = ws
69
106
  self.__read_timeout = read_timeout
@@ -72,6 +109,15 @@ class Connection:
72
109
  self.__geometry_representation = geometry_representation
73
110
  self.__progress_handler: ProgressHandler | None = None
74
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
75
121
  self.__queries: dict[str, Query] = {}
76
122
  self.__thread = threading.Thread(
77
123
  target=self.__main_loop, daemon=True, name="wherobots-connection"
@@ -85,7 +131,37 @@ class Connection:
85
131
  self.close()
86
132
 
87
133
  def close(self) -> None:
88
- 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)
89
165
 
90
166
  def commit(self) -> None:
91
167
  raise NotSupportedError
@@ -114,18 +190,103 @@ class Connection:
114
190
  def __main_loop(self) -> None:
115
191
  """Main background loop listening for messages from the SQL session."""
116
192
  logging.info("Starting background connection handling loop...")
117
- 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
118
203
  try:
119
204
  self.__listen()
120
205
  except TimeoutError:
121
206
  # Expected, retry next time
122
207
  continue
123
- except websockets.exceptions.ConnectionClosedOK:
208
+ except websockets.exceptions.ConnectionClosed:
124
209
  logging.info("Connection closed; stopping main loop.")
125
210
  return
211
+ except _TransportError:
212
+ logging.exception("SQL session transport failed; stopping main loop")
213
+ return
126
214
  except Exception as e:
127
215
  logging.exception("Error handling message from SQL session", exc_info=e)
128
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
+
129
290
  def __listen(self) -> None:
130
291
  """Waits for the next message from the SQL session and processes it.
131
292
 
@@ -168,16 +329,32 @@ class Connection:
168
329
  # Terminal delivery: stop tracking the query first. Keeping it in
169
330
  # __queries would retain its handler — and the results the handler
170
331
  # references — for the connection's lifetime (WBC-922).
171
- self.__queries.pop(execution_id, None)
172
- query.handler(result)
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)))
173
350
 
174
351
  # Incoming state transitions are handled here.
175
352
  if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
176
353
  try:
177
354
  query.state = ExecutionState[message["state"].upper()]
178
355
  logging.info("Query %s is now %s.", execution_id, query.state)
179
- except KeyError:
180
- logging.warning("Invalid state update message for %s", execution_id)
356
+ except (KeyError, AttributeError, TypeError) as error:
357
+ fail_query("interpret", error)
181
358
  return
182
359
 
183
360
  if query.state == ExecutionState.SUCCEEDED:
@@ -212,21 +389,34 @@ class Connection:
212
389
  return
213
390
 
214
391
  # No store configured, request results normally
215
- 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)
216
398
  return
217
399
 
218
400
  # Otherwise, process the results from the execution_result event.
219
401
  results = message.get("results")
220
- if not results or not isinstance(results, dict):
402
+ if results is None or results == {}:
221
403
  logging.warning("Got no results back from %s.", execution_id)
222
404
  query.state = ExecutionState.COMPLETED
223
405
  complete_query(ExecutionResult())
224
406
  return
225
407
 
226
- query.state = ExecutionState.COMPLETED
227
- complete_query(
228
- ExecutionResult(results=self._handle_results(execution_id, results))
229
- )
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))
230
420
  elif query.state == ExecutionState.CANCELLED:
231
421
  logging.info(
232
422
  "Query %s has been cancelled; returning empty results.",
@@ -264,16 +454,40 @@ class Connection:
264
454
  with pyarrow.ipc.open_stream(stream) as reader:
265
455
  return reader.read_pandas()
266
456
  else:
267
- return OperationalError(f"Unsupported results format {result_format}")
457
+ raise NotSupportedError("Unsupported results format")
268
458
 
269
- 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.
270
462
  request = json.dumps(message)
271
463
  # Only compute the redacted request (json.dumps + sqlparse parse) when
272
464
  # DEBUG is actually enabled; the log argument is evaluated eagerly, so an
273
465
  # unguarded call would redact on every request even with DEBUG off.
274
466
  if logging.getLogger().isEnabledFor(logging.DEBUG):
275
467
  logging.debug("Request: %s", self.__redacted_request(message))
276
- self.__ws.send(request)
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()
277
491
 
278
492
  @staticmethod
279
493
  def __redacted_request(message: Dict[str, Any]) -> str:
@@ -289,7 +503,14 @@ class Connection:
289
503
  return json.dumps(message)
290
504
 
291
505
  def __recv(self) -> Dict[str, Any]:
292
- 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
293
514
  if isinstance(frame, str):
294
515
  message = json.loads(frame)
295
516
  elif isinstance(frame, bytes):
@@ -318,14 +539,6 @@ class Connection:
318
539
  if store:
319
540
  request["store"] = store.to_dict()
320
541
 
321
- self.__queries[execution_id] = Query(
322
- sql=sql,
323
- execution_id=execution_id,
324
- state=ExecutionState.EXECUTION_REQUESTED,
325
- handler=handler,
326
- store=store,
327
- )
328
-
329
542
  # Redact literal values before logging: this driver is embedded by other
330
543
  # services, so raw SQL here would leak into their log streams (WBC-139).
331
544
  logging.info(
@@ -334,7 +547,16 @@ class Connection:
334
547
  get_statement_type(sql),
335
548
  textwrap.shorten(redact_sql(sql), width=200),
336
549
  )
337
- self.__send(request)
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
+ )
338
560
  return execution_id
339
561
 
340
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
@@ -186,6 +186,9 @@ class Cursor:
186
186
  self.__rowcount = -1
187
187
  self.__description = None
188
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
189
192
  self.__current_execution_id = self.__exec_fn(
190
193
  _substitute_parameters(operation, parameters),
191
194
  self.__queue.put,
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,
@@ -267,6 +268,9 @@ def connect(
267
268
  data_compression=data_compression,
268
269
  geometry_representation=geometry_representation,
269
270
  cancel_event=cancel_event,
271
+ session_id=urllib.parse.urlparse(session_id_url)
272
+ .path.rstrip("/")
273
+ .rsplit("/", 1)[-1],
270
274
  )
271
275
 
272
276
 
@@ -294,6 +298,7 @@ def connect_direct(
294
298
  data_compression: Union[DataCompression, None] = None,
295
299
  geometry_representation: Union[GeometryRepresentation, None] = None,
296
300
  cancel_event: Union[threading.Event, None] = None,
301
+ session_id: str | None = None,
297
302
  ) -> Connection:
298
303
  uri_with_protocol = f"{uri}/{protocol}"
299
304
  ssl_context = ssl.create_default_context()
@@ -321,6 +326,7 @@ def connect_direct(
321
326
  additional_headers=headers,
322
327
  max_size=MAX_MESSAGE_SIZE,
323
328
  open_timeout=DEFAULT_HTTP_TIMEOUT,
329
+ close_timeout=DEFAULT_CLOSE_TIMEOUT_SECONDS,
324
330
  ssl=ssl_context,
325
331
  )
326
332
 
@@ -337,4 +343,5 @@ def connect_direct(
337
343
  results_format=results_format,
338
344
  data_compression=data_compression,
339
345
  geometry_representation=geometry_representation,
346
+ session_id=session_id,
340
347
  )
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: wherobots-python-dbapi
3
- Version: 0.28.1
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
@@ -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.31.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,16 +0,0 @@
1
- wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
2
- wherobots/db/connection.py,sha256=VqXqFoKeyy9DMXP8-t0GhijWBMYB154HevbDRvyMzF4,14352
3
- wherobots/db/constants.py,sha256=qV-6CmOya7-f-DoRMzcDzlhcmV5PkLCcqG5TFIsxQ7o,744
4
- wherobots/db/cursor.py,sha256=vaQY1WcTSehx-XmCkn-vRsv5YcTddxj0HR1ZaGAtEis,8495
5
- wherobots/db/driver.py,sha256=5iZXEsR2TM0xVPdcPLwlOifIkJHsYHLM0zKkwMk4UTo,12975
6
- wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
7
- wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
8
- wherobots/db/redaction.py,sha256=issl5e5QvZQbs4d5UGUJIW7SIZIYfQ04MIyuT0Eo5_g,3744
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.1.dist-info/METADATA,sha256=EuIBcH4k9NCG9eIQOkyJvowab-iD0LVDXbWFQ1jg28c,11173
14
- wherobots_python_dbapi-0.28.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
- wherobots_python_dbapi-0.28.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- wherobots_python_dbapi-0.28.1.dist-info/RECORD,,