wherobots-python-dbapi 0.27.0__tar.gz → 0.28.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/PKG-INFO +12 -1
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/README.md +10 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/pyproject.toml +2 -1
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/connection.py +39 -9
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/cursor.py +48 -10
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/driver.py +89 -14
- wherobots_python_dbapi-0.28.1/wherobots/db/redaction.py +78 -0
- wherobots_python_dbapi-0.27.0/wherobots/__init__.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/.gitignore +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/LICENSE +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/__init__.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/constants.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/errors.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/models.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/region.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/runtime.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/session_type.py +0 -0
- {wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wherobots-python-dbapi
|
|
3
|
-
Version: 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
|
|
@@ -107,6 +108,16 @@ with connect(
|
|
|
107
108
|
print(results)
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
`runtime` and `region` are optional and accept either the provided enums (handy
|
|
112
|
+
for autocomplete) or a plain string — strings are passed to the API as-is, so
|
|
113
|
+
new or BYOC regions (e.g. `region="byoc-acme-us-east-1"`) work without an SDK
|
|
114
|
+
upgrade. When omitted, your organization's configured defaults are used:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
with connect(api_key='...') as conn: # uses your org's default runtime + region
|
|
118
|
+
...
|
|
119
|
+
```
|
|
120
|
+
|
|
110
121
|
The `Cursor` supports the context manager protocol, so you can use it
|
|
111
122
|
within a `with` statement when needed:
|
|
112
123
|
|
|
@@ -83,6 +83,16 @@ with connect(
|
|
|
83
83
|
print(results)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
`runtime` and `region` are optional and accept either the provided enums (handy
|
|
87
|
+
for autocomplete) or a plain string — strings are passed to the API as-is, so
|
|
88
|
+
new or BYOC regions (e.g. `region="byoc-acme-us-east-1"`) work without an SDK
|
|
89
|
+
upgrade. When omitted, your organization's configured defaults are used:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
with connect(api_key='...') as conn: # uses your org's default runtime + region
|
|
93
|
+
...
|
|
94
|
+
```
|
|
95
|
+
|
|
86
96
|
The `Cursor` supports the context manager protocol, so you can use it
|
|
87
97
|
within a `with` statement when needed:
|
|
88
98
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wherobots-python-dbapi"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.28.1"
|
|
4
4
|
description = "Python DB-API driver for Wherobots DB"
|
|
5
5
|
authors = [{ name = "Maxime Petazzoni", email = "max@wherobots.com" }]
|
|
6
6
|
requires-python = ">=3.10, <4"
|
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
|
17
17
|
"pandas",
|
|
18
18
|
"types-requests>=2.31.0",
|
|
19
19
|
"pandas-stubs>=2.0.3.230814",
|
|
20
|
+
"sqlparse>=0.5.0",
|
|
20
21
|
]
|
|
21
22
|
|
|
22
23
|
[project.optional-dependencies]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
complete_query(ExecutionResult())
|
|
215
224
|
return
|
|
216
225
|
|
|
217
226
|
query.state = ExecutionState.COMPLETED
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
|
@@ -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,25 @@ 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
|
|
|
152
189
|
self.__current_execution_id = self.__exec_fn(
|
|
153
190
|
_substitute_parameters(operation, parameters),
|
|
154
|
-
self.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
@@ -21,8 +21,6 @@ import certifi
|
|
|
21
21
|
from .connection import Connection
|
|
22
22
|
from .constants import (
|
|
23
23
|
DEFAULT_ENDPOINT,
|
|
24
|
-
DEFAULT_REGION,
|
|
25
|
-
DEFAULT_RUNTIME,
|
|
26
24
|
DEFAULT_READ_TIMEOUT_SECONDS,
|
|
27
25
|
DEFAULT_SESSION_TYPE,
|
|
28
26
|
DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS,
|
|
@@ -56,24 +54,65 @@ TRANSIENT_HTTP_STATUS_CODES = {429, 502, 503, 504}
|
|
|
56
54
|
DEFAULT_HTTP_TIMEOUT = 30
|
|
57
55
|
|
|
58
56
|
|
|
59
|
-
def
|
|
57
|
+
def _resolve_dbapi_version() -> str:
|
|
58
|
+
"""Resolve this package's version, or "unknown" if it can't be found."""
|
|
60
59
|
try:
|
|
61
|
-
|
|
60
|
+
return metadata.version("wherobots-python-dbapi")
|
|
62
61
|
except PackageNotFoundError:
|
|
63
|
-
|
|
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():
|
|
64
71
|
python_version = platform.python_version()
|
|
65
72
|
system = platform.system().lower()
|
|
66
73
|
return {
|
|
67
|
-
"User-Agent": f"wherobots-python-dbapi/{
|
|
74
|
+
"User-Agent": f"wherobots-python-dbapi/{_DBAPI_VERSION} os/{system} python/{python_version}"
|
|
68
75
|
}
|
|
69
76
|
|
|
70
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
|
+
|
|
71
110
|
def connect(
|
|
72
111
|
host: str = DEFAULT_ENDPOINT,
|
|
73
112
|
token: Union[str, None] = None,
|
|
74
113
|
api_key: Union[str, None] = None,
|
|
75
|
-
runtime: Union[Runtime, None] = None,
|
|
76
|
-
region: Union[Region, None] = None,
|
|
114
|
+
runtime: Union[str, Runtime, None] = None,
|
|
115
|
+
region: Union[str, Region, None] = None,
|
|
77
116
|
version: Union[str, None] = None,
|
|
78
117
|
wait_timeout: float = DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS,
|
|
79
118
|
read_timeout: float = DEFAULT_READ_TIMEOUT_SECONDS,
|
|
@@ -84,29 +123,63 @@ def connect(
|
|
|
84
123
|
data_compression: Union[DataCompression, None] = None,
|
|
85
124
|
geometry_representation: Union[GeometryRepresentation, None] = None,
|
|
86
125
|
cancel_event: Union[threading.Event, None] = None,
|
|
126
|
+
extra_headers: Union[Dict[str, str], None] = None,
|
|
87
127
|
) -> Connection:
|
|
128
|
+
"""Create a connection to a Wherobots SQL session.
|
|
129
|
+
|
|
130
|
+
:param runtime: The compute runtime to use. Accepts a ``Runtime`` enum value
|
|
131
|
+
or a raw string; strings are passed to the API as-is. Override the
|
|
132
|
+
default runtime set for your organization — only set this if you need a
|
|
133
|
+
specific runtime instead of the one your administrator has configured.
|
|
134
|
+
When omitted, your organization's default runtime is used.
|
|
135
|
+
:param region: The compute region to run in. Accepts a ``Region`` enum value
|
|
136
|
+
or a raw string (e.g. a BYOC region such as ``byoc-acme-us-east-1``);
|
|
137
|
+
strings are passed to the API as-is. Override the default region set for
|
|
138
|
+
your organization — only set this if you intend to use a specific region
|
|
139
|
+
instead of the one your administrator has configured. When omitted, your
|
|
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.
|
|
148
|
+
"""
|
|
88
149
|
if not token and not api_key:
|
|
89
150
|
raise ValueError("At least one of `token` or `api_key` is required")
|
|
90
151
|
if token and api_key:
|
|
91
152
|
raise ValueError("`token` and `api_key` can't be both provided")
|
|
92
153
|
|
|
93
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)
|
|
94
159
|
if token:
|
|
95
160
|
headers["Authorization"] = f"Bearer {token}"
|
|
96
161
|
elif api_key:
|
|
97
162
|
headers["X-API-Key"] = api_key
|
|
98
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
|
+
|
|
99
168
|
host = host or DEFAULT_ENDPOINT
|
|
100
|
-
runtime = runtime or DEFAULT_RUNTIME
|
|
101
|
-
region = region or DEFAULT_REGION
|
|
102
169
|
session_type = session_type or DEFAULT_SESSION_TYPE
|
|
103
170
|
|
|
171
|
+
# Normalize enum values to their string form and pass raw strings through
|
|
172
|
+
# untouched. When omitted (None) the field is dropped from the request so
|
|
173
|
+
# the API applies the organization's configured default.
|
|
174
|
+
runtime_id = runtime.value if isinstance(runtime, Runtime) else runtime
|
|
175
|
+
region_name = region.value if isinstance(region, Region) else region
|
|
176
|
+
|
|
104
177
|
logging.info(
|
|
105
178
|
"Requesting %s%s runtime %sin %s from %s ...",
|
|
106
179
|
"new " if force_new else "",
|
|
107
|
-
|
|
180
|
+
runtime_id or "org-default",
|
|
108
181
|
f"running {version} " if version else "",
|
|
109
|
-
|
|
182
|
+
region_name or "org-default",
|
|
110
183
|
host,
|
|
111
184
|
)
|
|
112
185
|
|
|
@@ -119,9 +192,11 @@ def connect(
|
|
|
119
192
|
try:
|
|
120
193
|
resp = requests.post(
|
|
121
194
|
url=f"{host}/sql/session",
|
|
122
|
-
params
|
|
195
|
+
# `requests` omits query params whose value is None, so an omitted
|
|
196
|
+
# region is simply not sent and the API applies the org default.
|
|
197
|
+
params={"region": region_name, "force_new": force_new},
|
|
123
198
|
json={
|
|
124
|
-
"runtimeId":
|
|
199
|
+
"runtimeId": runtime_id,
|
|
125
200
|
"shutdownAfterInactiveSeconds": shutdown_after_inactive_seconds,
|
|
126
201
|
"version": version,
|
|
127
202
|
"sessionType": session_type.value,
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wherobots_python_dbapi-0.27.0 → wherobots_python_dbapi-0.28.1}/wherobots/db/session_type.py
RENAMED
|
File without changes
|
|
File without changes
|