wherobots-python-dbapi 0.28.0__py3-none-any.whl → 0.28.1__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.
@@ -6,6 +6,8 @@ import uuid
6
6
  from dataclasses import dataclass
7
7
  from typing import Any, Callable, Dict
8
8
 
9
+ from wherobots.db.redaction import get_statement_type, redact_sql
10
+
9
11
  import pandas
10
12
  import pyarrow
11
13
  import cbor2
@@ -162,6 +164,13 @@ class Connection:
162
164
  )
163
165
  return
164
166
 
167
+ def complete_query(result: ExecutionResult) -> None:
168
+ # Terminal delivery: stop tracking the query first. Keeping it in
169
+ # __queries would retain its handler — and the results the handler
170
+ # references — for the connection's lifetime (WBC-922).
171
+ self.__queries.pop(execution_id, None)
172
+ query.handler(result)
173
+
165
174
  # Incoming state transitions are handled here.
166
175
  if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
167
176
  try:
@@ -189,7 +198,7 @@ class Connection:
189
198
  store_result.size,
190
199
  )
191
200
  query.state = ExecutionState.COMPLETED
192
- query.handler(ExecutionResult(store_result=store_result))
201
+ complete_query(ExecutionResult(store_result=store_result))
193
202
  return
194
203
 
195
204
  if query.store is not None:
@@ -199,7 +208,7 @@ class Connection:
199
208
  execution_id,
200
209
  )
201
210
  query.state = ExecutionState.COMPLETED
202
- query.handler(ExecutionResult())
211
+ complete_query(ExecutionResult())
203
212
  return
204
213
 
205
214
  # No store configured, request results normally
@@ -211,11 +220,11 @@ class Connection:
211
220
  if not results or not isinstance(results, dict):
212
221
  logging.warning("Got no results back from %s.", execution_id)
213
222
  query.state = ExecutionState.COMPLETED
214
- query.handler(ExecutionResult())
223
+ complete_query(ExecutionResult())
215
224
  return
216
225
 
217
226
  query.state = ExecutionState.COMPLETED
218
- query.handler(
227
+ complete_query(
219
228
  ExecutionResult(results=self._handle_results(execution_id, results))
220
229
  )
221
230
  elif query.state == ExecutionState.CANCELLED:
@@ -223,8 +232,7 @@ class Connection:
223
232
  "Query %s has been cancelled; returning empty results.",
224
233
  execution_id,
225
234
  )
226
- query.handler(ExecutionResult(results=pandas.DataFrame()))
227
- self.__queries.pop(execution_id)
235
+ complete_query(ExecutionResult(results=pandas.DataFrame()))
228
236
  elif query.state == ExecutionState.FAILED:
229
237
  # Don't do anything here; the ERROR event is coming with more
230
238
  # details.
@@ -232,7 +240,7 @@ class Connection:
232
240
  elif kind == EventKind.ERROR:
233
241
  query.state = ExecutionState.FAILED
234
242
  error = message.get("message")
235
- query.handler(ExecutionResult(error=OperationalError(error)))
243
+ complete_query(ExecutionResult(error=OperationalError(error)))
236
244
  else:
237
245
  logging.warning("Received unknown %s event!", kind)
238
246
 
@@ -260,9 +268,26 @@ class Connection:
260
268
 
261
269
  def __send(self, message: Dict[str, Any]) -> None:
262
270
  request = json.dumps(message)
263
- logging.debug("Request: %s", request)
271
+ # Only compute the redacted request (json.dumps + sqlparse parse) when
272
+ # DEBUG is actually enabled; the log argument is evaluated eagerly, so an
273
+ # unguarded call would redact on every request even with DEBUG off.
274
+ if logging.getLogger().isEnabledFor(logging.DEBUG):
275
+ logging.debug("Request: %s", self.__redacted_request(message))
264
276
  self.__ws.send(request)
265
277
 
278
+ @staticmethod
279
+ def __redacted_request(message: Dict[str, Any]) -> str:
280
+ """Serialize a request for logging with any SQL statement redacted.
281
+
282
+ The wire payload (sent verbatim by ``__send``) carries the raw
283
+ ``statement``; this driver is embedded by other services, so logging it
284
+ -- even at DEBUG -- would leak raw SQL into their log streams (WBC-139).
285
+ """
286
+ statement = message.get("statement")
287
+ if isinstance(statement, str):
288
+ message = {**message, "statement": redact_sql(statement)}
289
+ return json.dumps(message)
290
+
266
291
  def __recv(self) -> Dict[str, Any]:
267
292
  frame = self.__ws.recv(timeout=self.__read_timeout)
268
293
  if isinstance(frame, str):
@@ -301,8 +326,13 @@ class Connection:
301
326
  store=store,
302
327
  )
303
328
 
329
+ # Redact literal values before logging: this driver is embedded by other
330
+ # services, so raw SQL here would leak into their log streams (WBC-139).
304
331
  logging.info(
305
- "Executing SQL query %s: %s", execution_id, textwrap.shorten(sql, width=60)
332
+ "Executing SQL query %s (%s): %s",
333
+ execution_id,
334
+ get_statement_type(sql),
335
+ textwrap.shorten(redact_sql(sql), width=200),
306
336
  )
307
337
  self.__send(request)
308
338
  return execution_id
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,25 @@ 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
 
152
189
  self.__current_execution_id = self.__exec_fn(
153
190
  _substitute_parameters(operation, parameters),
154
- self.__on_execution_result,
191
+ self.__queue.put,
155
192
  store,
156
193
  )
157
194
 
@@ -193,8 +230,9 @@ class Cursor:
193
230
 
194
231
  def close(self) -> None:
195
232
  """Close the cursor."""
196
- if self.__results is None and self.__current_execution_id:
197
- self.__cancel_fn(self.__current_execution_id)
233
+ in_flight = self.__in_flight_execution_id()
234
+ if in_flight:
235
+ self.__cancel_fn(in_flight)
198
236
 
199
237
  def __iter__(self):
200
238
  return self
wherobots/db/driver.py CHANGED
@@ -54,18 +54,59 @@ TRANSIENT_HTTP_STATUS_CODES = {429, 502, 503, 504}
54
54
  DEFAULT_HTTP_TIMEOUT = 30
55
55
 
56
56
 
57
- def gen_user_agent_header():
57
+ def _resolve_dbapi_version() -> str:
58
+ """Resolve this package's version, or "unknown" if it can't be found."""
58
59
  try:
59
- package_version = metadata.version("wherobots-python-dbapi")
60
+ return metadata.version("wherobots-python-dbapi")
60
61
  except PackageNotFoundError:
61
- package_version = "unknown"
62
+ return "unknown"
63
+
64
+
65
+ # Resolved once at import: `importlib.metadata.version` scans the installed
66
+ # package database on each call, and the version can't change within a process.
67
+ _DBAPI_VERSION: Final[str] = _resolve_dbapi_version()
68
+
69
+
70
+ def gen_user_agent_header():
62
71
  python_version = platform.python_version()
63
72
  system = platform.system().lower()
64
73
  return {
65
- "User-Agent": f"wherobots-python-dbapi/{package_version} os/{system} python/{python_version}"
74
+ "User-Agent": f"wherobots-python-dbapi/{_DBAPI_VERSION} os/{system} python/{python_version}"
66
75
  }
67
76
 
68
77
 
78
+ # Canonical name of the shared, cross-service client-chain header.
79
+ WHEROBOTS_CLIENT_HEADER: Final[str] = "X-Wherobots-Client"
80
+
81
+
82
+ def _append_wherobots_client_hop(headers: Dict[str, str]) -> None:
83
+ """Append this driver's hop to the shared ``X-Wherobots-Client`` header.
84
+
85
+ ``X-Wherobots-Client`` is an ordered, append-only, comma-separated list of
86
+ hops; the leftmost is the origin and each component appends its own hop on
87
+ the right. DBAPI's hop is ``client=dbapi;ver=<version>``.
88
+
89
+ The lookup is case-insensitive because HTTP header names are, and any
90
+ differently-cased inbound key is collapsed into the canonical
91
+ ``X-Wherobots-Client`` key so the request carries exactly one such header.
92
+
93
+ This header is advisory: it is informational only and must never affect
94
+ authentication. ``headers`` is mutated in place.
95
+ """
96
+ dbapi_hop = f"client=dbapi;ver={_DBAPI_VERSION}"
97
+
98
+ # Find any existing hop chain case-insensitively and remove differently
99
+ # cased duplicates so we don't emit two headers.
100
+ existing_chain: Union[str, None] = None
101
+ for key in [k for k in headers if k.lower() == WHEROBOTS_CLIENT_HEADER.lower()]:
102
+ existing_chain = headers.pop(key)
103
+
104
+ if existing_chain:
105
+ headers[WHEROBOTS_CLIENT_HEADER] = f"{existing_chain}, {dbapi_hop}"
106
+ else:
107
+ headers[WHEROBOTS_CLIENT_HEADER] = dbapi_hop
108
+
109
+
69
110
  def connect(
70
111
  host: str = DEFAULT_ENDPOINT,
71
112
  token: Union[str, None] = None,
@@ -82,6 +123,7 @@ def connect(
82
123
  data_compression: Union[DataCompression, None] = None,
83
124
  geometry_representation: Union[GeometryRepresentation, None] = None,
84
125
  cancel_event: Union[threading.Event, None] = None,
126
+ extra_headers: Union[Dict[str, str], None] = None,
85
127
  ) -> Connection:
86
128
  """Create a connection to a Wherobots SQL session.
87
129
 
@@ -96,6 +138,13 @@ def connect(
96
138
  your organization — only set this if you intend to use a specific region
97
139
  instead of the one your administrator has configured. When omitted, your
98
140
  organization's default region is used.
141
+ :param extra_headers: Optional extra HTTP headers to send on the session
142
+ requests and the WebSocket upgrade. Merged after the driver's own
143
+ headers, so callers can pass through tracing/correlation headers. If it
144
+ includes an ``X-Wherobots-Client`` hop chain, this driver appends its
145
+ own ``client=dbapi;ver=<version>`` hop to the right of it. This header
146
+ is advisory only and never affects authentication; ``extra_headers``
147
+ cannot be used to override the ``Authorization``/``X-API-Key`` headers.
99
148
  """
100
149
  if not token and not api_key:
101
150
  raise ValueError("At least one of `token` or `api_key` is required")
@@ -103,11 +152,19 @@ def connect(
103
152
  raise ValueError("`token` and `api_key` can't be both provided")
104
153
 
105
154
  headers = gen_user_agent_header()
155
+ # Merge caller-supplied headers first so the driver's own auth headers,
156
+ # applied below, always win and can never be overridden.
157
+ if extra_headers:
158
+ headers.update(extra_headers)
106
159
  if token:
107
160
  headers["Authorization"] = f"Bearer {token}"
108
161
  elif api_key:
109
162
  headers["X-API-Key"] = api_key
110
163
 
164
+ # Append this driver's hop to the shared client-chain header. Done after the
165
+ # auth headers are set so it operates on the final, merged header set.
166
+ _append_wherobots_client_hop(headers)
167
+
111
168
  host = host or DEFAULT_ENDPOINT
112
169
  session_type = session_type or DEFAULT_SESSION_TYPE
113
170
 
@@ -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
1
  Metadata-Version: 2.4
2
2
  Name: wherobots-python-dbapi
3
- Version: 0.28.0
3
+ Version: 0.28.1
4
4
  Summary: Python DB-API driver for Wherobots DB
5
5
  Project-URL: Homepage, https://github.com/wherobots/wherobots-python-dbapi-driver
6
6
  Project-URL: Tracker, https://github.com/wherobots/wherobots-python-dbapi-driver/issues
@@ -14,6 +14,7 @@ Requires-Dist: pandas
14
14
  Requires-Dist: pandas-stubs>=2.0.3.230814
15
15
  Requires-Dist: pyarrow>=14.0.2
16
16
  Requires-Dist: requests>=2.31.0
17
+ Requires-Dist: sqlparse>=0.5.0
17
18
  Requires-Dist: strenum<0.5,>=0.4.15
18
19
  Requires-Dist: tenacity>=8.2.3
19
20
  Requires-Dist: types-requests>=2.31.0
@@ -1,16 +1,16 @@
1
- wherobots/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
1
  wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
3
- wherobots/db/connection.py,sha256=BWQwVzFgujEwPKvhDePM9q45M2opCk6Jq998L0ekMyY,12795
2
+ wherobots/db/connection.py,sha256=VqXqFoKeyy9DMXP8-t0GhijWBMYB154HevbDRvyMzF4,14352
4
3
  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
4
+ wherobots/db/cursor.py,sha256=vaQY1WcTSehx-XmCkn-vRsv5YcTddxj0HR1ZaGAtEis,8495
5
+ wherobots/db/driver.py,sha256=5iZXEsR2TM0xVPdcPLwlOifIkJHsYHLM0zKkwMk4UTo,12975
7
6
  wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
8
7
  wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
8
+ wherobots/db/redaction.py,sha256=issl5e5QvZQbs4d5UGUJIW7SIZIYfQ04MIyuT0Eo5_g,3744
9
9
  wherobots/db/region.py,sha256=GoFzazvgOjE1kVVT1QKAsRK88c_1XSAebR3ME4foSlQ,400
10
10
  wherobots/db/runtime.py,sha256=eAR1oJEtssgkp-IRPTLOrlPI-_5SM_ASw6T6hQ_qF4I,985
11
11
  wherobots/db/session_type.py,sha256=jZNwCl0nxvY-0reIn8RR_-fIYSSvIcaGx_4pRc71Lww,137
12
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,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.31.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
wherobots/__init__.py DELETED
File without changes