arrowsql 0.1.0__cp313-cp313-win_amd64.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.
- arrowsql/__init__.py +653 -0
- arrowsql/_rust_auth.py +170 -0
- arrowsql/_server_sqlstate.py +11 -0
- arrowsql/_utf8_strings.py +78 -0
- arrowsql/agent.py +620 -0
- arrowsql/aio.py +533 -0
- arrowsql/arrow_parallel.py +464 -0
- arrowsql/async_query/__init__.py +40 -0
- arrowsql/async_query/_connection_context.py +39 -0
- arrowsql/async_query/_native.py +24 -0
- arrowsql/async_query/async_connection.py +160 -0
- arrowsql/async_query/async_cursor.py +225 -0
- arrowsql/async_query/async_execute.py +113 -0
- arrowsql/async_query/async_fetch.py +153 -0
- arrowsql/async_query/exception_translator.py +132 -0
- arrowsql/auth.py +913 -0
- arrowsql/bulk_parallel.py +385 -0
- arrowsql/connection.py +2377 -0
- arrowsql/connection_string_builder.py +114 -0
- arrowsql/connection_string_parser.py +417 -0
- arrowsql/constants.py +701 -0
- arrowsql/cursor.py +4516 -0
- arrowsql/db_connection.py +98 -0
- arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
- arrowsql/ddbc_bindings.pyi +283 -0
- arrowsql/decimal_config.py +98 -0
- arrowsql/exceptions.py +378 -0
- arrowsql/helpers.py +436 -0
- arrowsql/logging.py +700 -0
- arrowsql/mssql_python.pyi +579 -0
- arrowsql/odbc_provider.py +269 -0
- arrowsql/parameter_helper.py +442 -0
- arrowsql/perf_timer.py +286 -0
- arrowsql/pooling.py +147 -0
- arrowsql/py.typed +1 -0
- arrowsql/row.py +462 -0
- arrowsql/type.py +160 -0
- arrowsql-0.1.0.dist-info/METADATA +146 -0
- arrowsql-0.1.0.dist-info/RECORD +45 -0
- arrowsql-0.1.0.dist-info/WHEEL +4 -0
- arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
- arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
- arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
- mssql_python/__init__.py +39 -0
arrowsql/agent.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""Agent mode: compact, bounded, typed query output for LLM agents and terminals.
|
|
2
|
+
|
|
3
|
+
``query()`` runs one SQL batch and returns one string: typed TSV (or a Markdown
|
|
4
|
+
table) with explicit truncation on success, or a JSON object with a category,
|
|
5
|
+
the server's own message and a recovery hint on failure. Rows are fetched
|
|
6
|
+
lazily and rendered to text in Rust (``ddbc_bindings.AgentRender``) without
|
|
7
|
+
building Python row objects; fetching stops once the row or byte limit is
|
|
8
|
+
reached, so a capped preview of a huge table stays cheap.
|
|
9
|
+
|
|
10
|
+
from arrowsql.agent import query
|
|
11
|
+
print(query(conn_str, "SELECT TOP 5 name, create_date FROM sys.objects"))
|
|
12
|
+
|
|
13
|
+
CLI: ``python -m arrowsql.agent -c "SELECT 1 AS answer"`` or ``arrowsql-agent``.
|
|
14
|
+
Success goes to stdout (exit 0), error JSON to stderr (exit 1), bad
|
|
15
|
+
arguments to stderr as JSON (exit 2). SQL is sent unchanged and a failed
|
|
16
|
+
statement is never rewritten or retried.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Literal, NoReturn, Optional, Sequence, Union
|
|
30
|
+
|
|
31
|
+
from . import ddbc_bindings
|
|
32
|
+
from .constants import ConstantsDDBC
|
|
33
|
+
from .helpers import check_error
|
|
34
|
+
|
|
35
|
+
AgentFormat = Literal["tsv", "markdown"]
|
|
36
|
+
AGENT_FORMATS = ("tsv", "markdown")
|
|
37
|
+
DEFAULT_FORMAT: AgentFormat = "tsv"
|
|
38
|
+
DEFAULT_MAX_ROWS = 1000
|
|
39
|
+
DEFAULT_MAX_BYTES = 10_000
|
|
40
|
+
DEFAULT_MAX_CELL_CHARS = 500
|
|
41
|
+
DEFAULT_TIMEOUT_S = 60.0
|
|
42
|
+
ENV_CONNECTION_STRINGS = ("MSSQL_CONNECTION_STRING", "DB_CONNECTION_STRING")
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AGENT_FORMATS",
|
|
46
|
+
"error_json",
|
|
47
|
+
"main",
|
|
48
|
+
"query",
|
|
49
|
+
"query_cursor",
|
|
50
|
+
"render_cursor",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_SQL_HANDLE_STMT = ConstantsDDBC.SQL_HANDLE_STMT.value
|
|
54
|
+
|
|
55
|
+
# (category, recovery hint, retryable) by SQL Server error number.
|
|
56
|
+
_Category = tuple[str, str, bool]
|
|
57
|
+
_MISSING_TABLE: _Category = (
|
|
58
|
+
"missing_table",
|
|
59
|
+
"Verify the table or view name, schema and database, e.g. "
|
|
60
|
+
"SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES, and your access to it.",
|
|
61
|
+
False,
|
|
62
|
+
)
|
|
63
|
+
_MISSING_COLUMN: _Category = (
|
|
64
|
+
"missing_column",
|
|
65
|
+
"Check column names against the table schema, e.g. "
|
|
66
|
+
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '<table>'.",
|
|
67
|
+
False,
|
|
68
|
+
)
|
|
69
|
+
_SYNTAX: _Category = (
|
|
70
|
+
"syntax_error",
|
|
71
|
+
"Correct the SQL at the position the server names before submitting again.",
|
|
72
|
+
False,
|
|
73
|
+
)
|
|
74
|
+
_PERMISSION: _Category = (
|
|
75
|
+
"permission_denied",
|
|
76
|
+
"Request access to the referenced objects or use an authorized object.",
|
|
77
|
+
False,
|
|
78
|
+
)
|
|
79
|
+
_AUTH: _Category = (
|
|
80
|
+
"authentication",
|
|
81
|
+
"Check the login, password or token and that the login can reach the database.",
|
|
82
|
+
False,
|
|
83
|
+
)
|
|
84
|
+
_DEADLOCK: _Category = (
|
|
85
|
+
"deadlock",
|
|
86
|
+
"The server chose this statement as a deadlock victim and rolled it back; retry it.",
|
|
87
|
+
True,
|
|
88
|
+
)
|
|
89
|
+
_TIMEOUT: _Category = (
|
|
90
|
+
"timeout",
|
|
91
|
+
"Increase the timeout or narrow the query (filter, TOP, aggregate) before retrying.",
|
|
92
|
+
True,
|
|
93
|
+
)
|
|
94
|
+
_LOCK_TIMEOUT: _Category = (
|
|
95
|
+
"lock_timeout",
|
|
96
|
+
"Another session holds a conflicting lock; retry later or find the blocker in sys.dm_exec_requests.",
|
|
97
|
+
True,
|
|
98
|
+
)
|
|
99
|
+
_CONSTRAINT: _Category = (
|
|
100
|
+
"constraint_violation",
|
|
101
|
+
"The data violates a constraint named in the message; change the values, not the constraint.",
|
|
102
|
+
False,
|
|
103
|
+
)
|
|
104
|
+
_CONVERSION: _Category = (
|
|
105
|
+
"conversion_error",
|
|
106
|
+
"Check the value and target type; use TRY_CAST/TRY_CONVERT to find the offending rows.",
|
|
107
|
+
False,
|
|
108
|
+
)
|
|
109
|
+
_TRUNCATION: _Category = (
|
|
110
|
+
"data_truncation",
|
|
111
|
+
"A value is longer than its column; shorten it or widen the column.",
|
|
112
|
+
False,
|
|
113
|
+
)
|
|
114
|
+
_EXISTS: _Category = (
|
|
115
|
+
"already_exists",
|
|
116
|
+
"An object with that name exists; pick another name or check it first.",
|
|
117
|
+
False,
|
|
118
|
+
)
|
|
119
|
+
_MISSING_PROC: _Category = (
|
|
120
|
+
"missing_function",
|
|
121
|
+
"Check the procedure or function name and schema, e.g. "
|
|
122
|
+
"SELECT ROUTINE_SCHEMA, ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES.",
|
|
123
|
+
False,
|
|
124
|
+
)
|
|
125
|
+
_MISSING_DB: _Category = (
|
|
126
|
+
"missing_database",
|
|
127
|
+
"Verify the database name and that the login has access: SELECT name FROM sys.databases.",
|
|
128
|
+
False,
|
|
129
|
+
)
|
|
130
|
+
_TRANSIENT: _Category = (
|
|
131
|
+
"transient",
|
|
132
|
+
"The server or network was briefly unavailable; retry after a short wait.",
|
|
133
|
+
True,
|
|
134
|
+
)
|
|
135
|
+
_CONNECTION: _Category = (
|
|
136
|
+
"connection",
|
|
137
|
+
"Check the server name, port, network access and TLS settings in the connection string.",
|
|
138
|
+
True,
|
|
139
|
+
)
|
|
140
|
+
_DIVIDE: _Category = (
|
|
141
|
+
"arithmetic_error",
|
|
142
|
+
"Guard the expression (NULLIF for divisions) or filter out the offending rows.",
|
|
143
|
+
False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
_BY_NUMBER: dict[int, _Category] = {
|
|
147
|
+
208: _MISSING_TABLE,
|
|
148
|
+
3701: _MISSING_TABLE,
|
|
149
|
+
207: _MISSING_COLUMN,
|
|
150
|
+
4104: _MISSING_COLUMN,
|
|
151
|
+
102: _SYNTAX,
|
|
152
|
+
105: _SYNTAX,
|
|
153
|
+
156: _SYNTAX,
|
|
154
|
+
170: _SYNTAX,
|
|
155
|
+
174: _SYNTAX,
|
|
156
|
+
128: _SYNTAX,
|
|
157
|
+
4145: _SYNTAX,
|
|
158
|
+
8120: _SYNTAX,
|
|
159
|
+
8127: _SYNTAX,
|
|
160
|
+
195: _MISSING_PROC,
|
|
161
|
+
2812: _MISSING_PROC,
|
|
162
|
+
4060: _MISSING_DB,
|
|
163
|
+
911: _MISSING_DB,
|
|
164
|
+
229: _PERMISSION,
|
|
165
|
+
230: _PERMISSION,
|
|
166
|
+
262: _PERMISSION,
|
|
167
|
+
297: _PERMISSION,
|
|
168
|
+
300: _PERMISSION,
|
|
169
|
+
916: _PERMISSION,
|
|
170
|
+
18456: _AUTH,
|
|
171
|
+
18452: _AUTH,
|
|
172
|
+
18487: _AUTH,
|
|
173
|
+
18488: _AUTH,
|
|
174
|
+
18470: _AUTH,
|
|
175
|
+
1205: _DEADLOCK,
|
|
176
|
+
1222: _LOCK_TIMEOUT,
|
|
177
|
+
515: _CONSTRAINT,
|
|
178
|
+
547: _CONSTRAINT,
|
|
179
|
+
2601: _CONSTRAINT,
|
|
180
|
+
2627: _CONSTRAINT,
|
|
181
|
+
241: _CONVERSION,
|
|
182
|
+
242: _CONVERSION,
|
|
183
|
+
245: _CONVERSION,
|
|
184
|
+
8114: _CONVERSION,
|
|
185
|
+
8115: _CONVERSION,
|
|
186
|
+
232: _CONVERSION,
|
|
187
|
+
248: _CONVERSION,
|
|
188
|
+
220: _CONVERSION,
|
|
189
|
+
8134: _DIVIDE,
|
|
190
|
+
8152: _TRUNCATION,
|
|
191
|
+
2628: _TRUNCATION,
|
|
192
|
+
2714: _EXISTS,
|
|
193
|
+
1801: _EXISTS,
|
|
194
|
+
40613: _TRANSIENT,
|
|
195
|
+
40501: _TRANSIENT,
|
|
196
|
+
40197: _TRANSIENT,
|
|
197
|
+
49918: _TRANSIENT,
|
|
198
|
+
10928: _TRANSIENT,
|
|
199
|
+
10929: _TRANSIENT,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Used when the server error number is missing (driver-side errors).
|
|
203
|
+
_BY_SQLSTATE: dict[str, _Category] = {
|
|
204
|
+
"42S02": _MISSING_TABLE,
|
|
205
|
+
"42S22": _MISSING_COLUMN,
|
|
206
|
+
"28000": _AUTH,
|
|
207
|
+
"28P01": _AUTH,
|
|
208
|
+
"40001": _DEADLOCK,
|
|
209
|
+
"HYT00": _TIMEOUT,
|
|
210
|
+
"HYT01": _TIMEOUT,
|
|
211
|
+
"08001": _CONNECTION,
|
|
212
|
+
"08004": _CONNECTION,
|
|
213
|
+
"08S01": _CONNECTION,
|
|
214
|
+
"08003": _CONNECTION,
|
|
215
|
+
"08007": _CONNECTION,
|
|
216
|
+
"23000": _CONSTRAINT,
|
|
217
|
+
"22001": _TRUNCATION,
|
|
218
|
+
"22003": _CONVERSION,
|
|
219
|
+
"22007": _CONVERSION,
|
|
220
|
+
"22018": _CONVERSION,
|
|
221
|
+
"22012": _DIVIDE,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_GENERIC: _Category = (
|
|
225
|
+
"execution_error",
|
|
226
|
+
"Inspect the server's message and verify the statement; do not retry unchanged.",
|
|
227
|
+
False,
|
|
228
|
+
)
|
|
229
|
+
_DRIVER_PREFIX = re.compile(r"^(?:\[[^\]]*\])+")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _server_message(exc: BaseException) -> str:
|
|
233
|
+
"""The server's own text, without the ODBC driver prefix."""
|
|
234
|
+
text = getattr(exc, "ddbc_error", None) or getattr(exc, "driver_error", None) or str(exc)
|
|
235
|
+
text = str(text)
|
|
236
|
+
_, sep, server_text = text.partition("[SQL Server]")
|
|
237
|
+
if sep:
|
|
238
|
+
return server_text.strip()
|
|
239
|
+
return _DRIVER_PREFIX.sub("", text, count=1).strip() or text
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _classify(exc: BaseException, timeout: bool) -> _Category:
|
|
243
|
+
if timeout or isinstance(exc, TimeoutError):
|
|
244
|
+
return _TIMEOUT
|
|
245
|
+
number = getattr(exc, "native_error", None)
|
|
246
|
+
state = getattr(exc, "sqlstate", None)
|
|
247
|
+
if number in _BY_NUMBER:
|
|
248
|
+
return _BY_NUMBER[number]
|
|
249
|
+
if state in _BY_SQLSTATE:
|
|
250
|
+
return _BY_SQLSTATE[state]
|
|
251
|
+
if isinstance(exc, ValueError):
|
|
252
|
+
return (
|
|
253
|
+
"configuration",
|
|
254
|
+
"Check the budgets (max_bytes >= 256, max_rows >= 1, max_cell_chars >= 1) and arguments.",
|
|
255
|
+
False,
|
|
256
|
+
)
|
|
257
|
+
if state == "42000":
|
|
258
|
+
# Generic "syntax error or access violation" with an unmapped number.
|
|
259
|
+
return _SYNTAX if "syntax" in _server_message(exc).lower() else _GENERIC
|
|
260
|
+
return _GENERIC
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def error_json(exc: BaseException, max_bytes: int = DEFAULT_MAX_BYTES, timeout: bool = False) -> str:
|
|
264
|
+
"""One JSON object describing ``exc``, at most ``max_bytes`` UTF-8 bytes.
|
|
265
|
+
|
|
266
|
+
Keys: ``category``, ``message`` (the server's text, clipped from the end if
|
|
267
|
+
needed, with ``truncated: true``), ``next_step`` (advice only; nothing is
|
|
268
|
+
executed), ``retryable``, and ``sqlstate`` / ``native_error`` (the SQL
|
|
269
|
+
Server error number) when known.
|
|
270
|
+
"""
|
|
271
|
+
category, next_step, retryable = _classify(exc, timeout)
|
|
272
|
+
message = _server_message(exc) if not isinstance(exc, TimeoutError) else str(exc)
|
|
273
|
+
value: dict[str, Any] = {
|
|
274
|
+
"category": category,
|
|
275
|
+
"message": message,
|
|
276
|
+
"next_step": next_step,
|
|
277
|
+
"retryable": retryable,
|
|
278
|
+
}
|
|
279
|
+
state = getattr(exc, "sqlstate", None)
|
|
280
|
+
number = getattr(exc, "native_error", None)
|
|
281
|
+
if state:
|
|
282
|
+
value["sqlstate"] = state
|
|
283
|
+
if number:
|
|
284
|
+
value["native_error"] = number
|
|
285
|
+
max_bytes = max(int(max_bytes), 256)
|
|
286
|
+
|
|
287
|
+
def size() -> int:
|
|
288
|
+
return len(json.dumps(value, ensure_ascii=False).encode("utf-8"))
|
|
289
|
+
|
|
290
|
+
if size() <= max_bytes:
|
|
291
|
+
return json.dumps(value, ensure_ascii=False)
|
|
292
|
+
value["truncated"] = True
|
|
293
|
+
low, high = 0, len(message)
|
|
294
|
+
while low < high:
|
|
295
|
+
middle = (low + high + 1) // 2
|
|
296
|
+
value["message"] = message[:middle]
|
|
297
|
+
if size() <= max_bytes:
|
|
298
|
+
low = middle
|
|
299
|
+
else:
|
|
300
|
+
high = middle - 1
|
|
301
|
+
value["message"] = message[:low]
|
|
302
|
+
if size() > max_bytes: # the fixed fields alone are too big
|
|
303
|
+
value["next_step"] = "Inspect the server's message before retrying."
|
|
304
|
+
value.pop("sqlstate", None)
|
|
305
|
+
return json.dumps(value, ensure_ascii=False)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _split_message(item: Any) -> str:
|
|
309
|
+
"""Text of a ``cursor.messages`` entry, with the SQL Server number when present."""
|
|
310
|
+
label, text = item if isinstance(item, (tuple, list)) and len(item) == 2 else ("", str(item))
|
|
311
|
+
text = str(text)
|
|
312
|
+
_, sep, server_text = text.partition("[SQL Server]")
|
|
313
|
+
if sep:
|
|
314
|
+
text = server_text
|
|
315
|
+
match = re.search(r"\((\d+)\)", str(label))
|
|
316
|
+
prefix = f"({match.group(1)}) " if match and match.group(1) != "0" else ""
|
|
317
|
+
return prefix + text.strip()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _flush_messages(render: Any, cursor: Any, seen: int) -> int:
|
|
321
|
+
messages = cursor.messages
|
|
322
|
+
for item in messages[seen:]:
|
|
323
|
+
render.message(_split_message(item))
|
|
324
|
+
return len(messages)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _render_all(cursor: Any, render: Any) -> None:
|
|
328
|
+
"""Walk the cursor's result sets and parts into ``render``."""
|
|
329
|
+
seen = 0
|
|
330
|
+
while True:
|
|
331
|
+
seen = _flush_messages(render, cursor, seen)
|
|
332
|
+
exhausted = True
|
|
333
|
+
if cursor.description:
|
|
334
|
+
ret, exhausted = render.result_set(cursor.hstmt, cursor.messages)
|
|
335
|
+
check_error(_SQL_HANDLE_STMT, cursor.hstmt, ret)
|
|
336
|
+
seen = _flush_messages(render, cursor, seen)
|
|
337
|
+
elif cursor.rowcount is not None and cursor.rowcount >= 0:
|
|
338
|
+
render.rows_affected(cursor.rowcount)
|
|
339
|
+
# Past a truncated result set (or the budget) later ones stay unread:
|
|
340
|
+
# skipping to them would read the rest of this one.
|
|
341
|
+
if not exhausted or not cursor.nextset():
|
|
342
|
+
break
|
|
343
|
+
if not render.has_room():
|
|
344
|
+
render.omit(1)
|
|
345
|
+
break
|
|
346
|
+
seen = 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _make_render(
|
|
350
|
+
format: str,
|
|
351
|
+
max_bytes: int,
|
|
352
|
+
max_rows: int,
|
|
353
|
+
max_cell_chars: int,
|
|
354
|
+
tail_rows: int,
|
|
355
|
+
timeout_s: Optional[float],
|
|
356
|
+
) -> Any:
|
|
357
|
+
if format not in AGENT_FORMATS:
|
|
358
|
+
raise ValueError("format must be 'tsv' or 'markdown'")
|
|
359
|
+
if timeout_s is not None and (not math.isfinite(timeout_s) or timeout_s < 0):
|
|
360
|
+
raise ValueError("timeout_s must be a finite, non-negative number of seconds")
|
|
361
|
+
return ddbc_bindings.AgentRender(format, max_bytes, max_rows, max_cell_chars, tail_rows, timeout_s)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_cursor(
|
|
365
|
+
cursor: Any,
|
|
366
|
+
*,
|
|
367
|
+
format: AgentFormat = DEFAULT_FORMAT,
|
|
368
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
369
|
+
max_rows: int = DEFAULT_MAX_ROWS,
|
|
370
|
+
max_cell_chars: int = DEFAULT_MAX_CELL_CHARS,
|
|
371
|
+
tail_rows: int = 0,
|
|
372
|
+
timeout_s: Optional[float] = DEFAULT_TIMEOUT_S,
|
|
373
|
+
) -> str:
|
|
374
|
+
"""Render the results of a cursor that has just executed, raising on errors.
|
|
375
|
+
|
|
376
|
+
Reads every result set of the batch (stopping after one that was cut short)
|
|
377
|
+
and returns the text. Raises the driver's exception when the statement
|
|
378
|
+
fails, so no success footer is ever produced for a failed statement.
|
|
379
|
+
"""
|
|
380
|
+
render = _make_render(format, max_bytes, max_rows, max_cell_chars, tail_rows, timeout_s)
|
|
381
|
+
_render_all(cursor, render)
|
|
382
|
+
return render.finish()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _execute(cursor: Any, sql: str, params: Any) -> None:
|
|
386
|
+
if params is None:
|
|
387
|
+
cursor.execute(sql)
|
|
388
|
+
elif isinstance(params, (list, tuple)):
|
|
389
|
+
cursor.execute(sql, *params)
|
|
390
|
+
else:
|
|
391
|
+
cursor.execute(sql, params)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _run(
|
|
395
|
+
conn_or_str: Any,
|
|
396
|
+
sql: str,
|
|
397
|
+
params: Any,
|
|
398
|
+
*,
|
|
399
|
+
format: str,
|
|
400
|
+
max_bytes: int,
|
|
401
|
+
max_rows: int,
|
|
402
|
+
max_cell_chars: int,
|
|
403
|
+
tail_rows: int,
|
|
404
|
+
timeout_s: Optional[float],
|
|
405
|
+
cursor: Any = None,
|
|
406
|
+
) -> tuple[str, bool]:
|
|
407
|
+
"""``(text, ok)``: the preview, or the error JSON with ``ok`` False."""
|
|
408
|
+
try:
|
|
409
|
+
render = _make_render(format, max_bytes, max_rows, max_cell_chars, tail_rows, timeout_s)
|
|
410
|
+
except (ValueError, OverflowError, TypeError) as exc:
|
|
411
|
+
return error_json(exc, max(max_bytes, 256) if isinstance(max_bytes, int) else 256), False
|
|
412
|
+
owned = None
|
|
413
|
+
own_cursor = None
|
|
414
|
+
previous_timeout = None
|
|
415
|
+
try:
|
|
416
|
+
if cursor is None:
|
|
417
|
+
if isinstance(conn_or_str, str):
|
|
418
|
+
from .db_connection import connect
|
|
419
|
+
|
|
420
|
+
owned = conn = connect(conn_or_str, autocommit=True)
|
|
421
|
+
else:
|
|
422
|
+
conn = conn_or_str
|
|
423
|
+
if timeout_s:
|
|
424
|
+
previous_timeout = conn.timeout
|
|
425
|
+
conn.timeout = max(1, math.ceil(timeout_s))
|
|
426
|
+
cursor = own_cursor = conn.cursor()
|
|
427
|
+
_execute(cursor, sql, params)
|
|
428
|
+
_render_all(cursor, render)
|
|
429
|
+
return render.finish(), True
|
|
430
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
431
|
+
return error_json(exc, max_bytes), False
|
|
432
|
+
finally:
|
|
433
|
+
if own_cursor is not None:
|
|
434
|
+
with contextlib.suppress(Exception):
|
|
435
|
+
own_cursor.close()
|
|
436
|
+
if previous_timeout is not None and owned is None:
|
|
437
|
+
with contextlib.suppress(Exception):
|
|
438
|
+
conn_or_str.timeout = previous_timeout
|
|
439
|
+
if owned is not None:
|
|
440
|
+
with contextlib.suppress(Exception):
|
|
441
|
+
owned.close()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def query(
|
|
445
|
+
conn_or_str: Any,
|
|
446
|
+
sql: str,
|
|
447
|
+
params: Union[Sequence[Any], dict, None] = None,
|
|
448
|
+
*,
|
|
449
|
+
format: AgentFormat = DEFAULT_FORMAT,
|
|
450
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
451
|
+
max_rows: int = DEFAULT_MAX_ROWS,
|
|
452
|
+
max_cell_chars: int = DEFAULT_MAX_CELL_CHARS,
|
|
453
|
+
tail_rows: int = 0,
|
|
454
|
+
timeout_s: Optional[float] = DEFAULT_TIMEOUT_S,
|
|
455
|
+
) -> str:
|
|
456
|
+
"""Run ``sql`` and return its bounded, typed preview as one string.
|
|
457
|
+
|
|
458
|
+
``conn_or_str`` is a connection or a connection string (opened with
|
|
459
|
+
autocommit and closed afterwards). On success the text is typed TSV (or a
|
|
460
|
+
Markdown table) ending with a ``rows_shown=...; complete=...`` footer per
|
|
461
|
+
result set; ``-- rows_affected=N`` and ``-- message: ...`` lines report DML
|
|
462
|
+
and PRINT/RAISERROR output. On any failure the result is a JSON object with
|
|
463
|
+
``category``, ``message`` (the server's own text), ``next_step`` and
|
|
464
|
+
``retryable``; tell the two apart by whether the text parses as a JSON
|
|
465
|
+
object. The whole string, footer or JSON, is at most ``max_bytes`` bytes.
|
|
466
|
+
|
|
467
|
+
``tail_rows`` > 0 keeps the last rows of a longer result next to the first
|
|
468
|
+
ones (head and tail sample) at the cost of reading the whole result.
|
|
469
|
+
``timeout_s`` bounds the statement and the fetching.
|
|
470
|
+
"""
|
|
471
|
+
return _run(
|
|
472
|
+
conn_or_str,
|
|
473
|
+
sql,
|
|
474
|
+
params,
|
|
475
|
+
format=format,
|
|
476
|
+
max_bytes=max_bytes,
|
|
477
|
+
max_rows=max_rows,
|
|
478
|
+
max_cell_chars=max_cell_chars,
|
|
479
|
+
tail_rows=tail_rows,
|
|
480
|
+
timeout_s=timeout_s,
|
|
481
|
+
)[0]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def query_cursor(
|
|
485
|
+
cursor: Any,
|
|
486
|
+
sql: str,
|
|
487
|
+
params: Union[Sequence[Any], dict, None] = None,
|
|
488
|
+
*,
|
|
489
|
+
format: AgentFormat = DEFAULT_FORMAT,
|
|
490
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
491
|
+
max_rows: int = DEFAULT_MAX_ROWS,
|
|
492
|
+
max_cell_chars: int = DEFAULT_MAX_CELL_CHARS,
|
|
493
|
+
tail_rows: int = 0,
|
|
494
|
+
timeout_s: Optional[float] = DEFAULT_TIMEOUT_S,
|
|
495
|
+
) -> str:
|
|
496
|
+
"""Like :func:`query` on an existing cursor (which is left open and usable)."""
|
|
497
|
+
return _run(
|
|
498
|
+
None,
|
|
499
|
+
sql,
|
|
500
|
+
params,
|
|
501
|
+
format=format,
|
|
502
|
+
max_bytes=max_bytes,
|
|
503
|
+
max_rows=max_rows,
|
|
504
|
+
max_cell_chars=max_cell_chars,
|
|
505
|
+
tail_rows=tail_rows,
|
|
506
|
+
timeout_s=timeout_s,
|
|
507
|
+
cursor=cursor,
|
|
508
|
+
)[0]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# --- command line -----------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _emit_error(category: str, message: str, next_step: str) -> None:
|
|
515
|
+
payload = {"category": category, "message": message, "next_step": next_step, "retryable": False}
|
|
516
|
+
print(json.dumps(payload), file=sys.stderr)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _configuration_error(message: str) -> None:
|
|
520
|
+
_emit_error(
|
|
521
|
+
"configuration",
|
|
522
|
+
message,
|
|
523
|
+
"Check --help, the connection string, SQL input and positive budgets (max_bytes >= 256).",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class _Parser(argparse.ArgumentParser):
|
|
528
|
+
def error(self, message: str) -> NoReturn:
|
|
529
|
+
_configuration_error(message)
|
|
530
|
+
raise SystemExit(2)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _parser() -> argparse.ArgumentParser:
|
|
534
|
+
parser = _Parser(
|
|
535
|
+
prog="arrowsql-agent",
|
|
536
|
+
description="SQL Server query to a bounded typed preview (TSV by default); errors are JSON on stderr.",
|
|
537
|
+
)
|
|
538
|
+
source = parser.add_mutually_exclusive_group()
|
|
539
|
+
source.add_argument("sql_text", nargs="?", help="SQL statement, sent unchanged")
|
|
540
|
+
source.add_argument("-c", "--sql", "--command", dest="sql", help="SQL statement, sent unchanged")
|
|
541
|
+
source.add_argument("-f", "--file", help="UTF-8 SQL file, or - for stdin; defaults to piped stdin")
|
|
542
|
+
parser.add_argument("--agent", "-agent", action="store_true", help="agent output (the default)")
|
|
543
|
+
parser.add_argument(
|
|
544
|
+
"--connection-string",
|
|
545
|
+
"--conn",
|
|
546
|
+
dest="connection_string",
|
|
547
|
+
default=None,
|
|
548
|
+
help="ODBC-style connection string (or set MSSQL_CONNECTION_STRING / DB_CONNECTION_STRING)",
|
|
549
|
+
)
|
|
550
|
+
parser.add_argument("--max-rows", type=int, default=DEFAULT_MAX_ROWS)
|
|
551
|
+
parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
|
|
552
|
+
parser.add_argument("--max-cell-chars", type=int, default=DEFAULT_MAX_CELL_CHARS)
|
|
553
|
+
parser.add_argument("--tail-rows", type=int, default=0, help="also show the last N rows (reads the whole result)")
|
|
554
|
+
parser.add_argument(
|
|
555
|
+
"--format",
|
|
556
|
+
dest="output_format",
|
|
557
|
+
choices=AGENT_FORMATS,
|
|
558
|
+
default=DEFAULT_FORMAT,
|
|
559
|
+
help="tab-separated values (default) or a Markdown pipe table",
|
|
560
|
+
)
|
|
561
|
+
parser.add_argument("--total-timeout-s", "--timeout", dest="timeout_s", type=float, default=DEFAULT_TIMEOUT_S)
|
|
562
|
+
return parser
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
566
|
+
"""Run one SQL batch; return 0 for a preview, 1 for query errors, 2 for bad arguments."""
|
|
567
|
+
parser = _parser()
|
|
568
|
+
args = parser.parse_args(argv)
|
|
569
|
+
connection_string = args.connection_string
|
|
570
|
+
for name in ENV_CONNECTION_STRINGS:
|
|
571
|
+
connection_string = connection_string or os.environ.get(name)
|
|
572
|
+
if not connection_string:
|
|
573
|
+
parser.error("set MSSQL_CONNECTION_STRING or DB_CONNECTION_STRING, or pass --connection-string")
|
|
574
|
+
try:
|
|
575
|
+
if args.sql is not None:
|
|
576
|
+
sql = args.sql
|
|
577
|
+
elif args.sql_text is not None:
|
|
578
|
+
sql = args.sql_text
|
|
579
|
+
elif args.file and args.file != "-":
|
|
580
|
+
sql = Path(args.file).read_bytes().decode("utf-8")
|
|
581
|
+
else:
|
|
582
|
+
if sys.stdin.isatty():
|
|
583
|
+
parser.error("supply SQL as an argument, --sql, --file, or pipe it on stdin")
|
|
584
|
+
sql = sys.stdin.buffer.read().decode("utf-8")
|
|
585
|
+
except (OSError, UnicodeError) as exc:
|
|
586
|
+
parser.error(f"cannot read SQL input: {exc}")
|
|
587
|
+
if not sql.strip():
|
|
588
|
+
parser.error("SQL input is empty; supply an argument, --sql, --file or pipe SQL on stdin")
|
|
589
|
+
try:
|
|
590
|
+
text, ok = _run(
|
|
591
|
+
connection_string,
|
|
592
|
+
sql,
|
|
593
|
+
None,
|
|
594
|
+
format=args.output_format,
|
|
595
|
+
max_bytes=args.max_bytes,
|
|
596
|
+
max_rows=args.max_rows,
|
|
597
|
+
max_cell_chars=args.max_cell_chars,
|
|
598
|
+
tail_rows=args.tail_rows,
|
|
599
|
+
timeout_s=args.timeout_s,
|
|
600
|
+
)
|
|
601
|
+
except KeyboardInterrupt:
|
|
602
|
+
_emit_error("interrupted", "Query interrupted.", "Retry only if you still need the result.")
|
|
603
|
+
return 130
|
|
604
|
+
parsed = _is_configuration(text) if not ok else False
|
|
605
|
+
# Locale encoding and newline translation must not change the byte budget.
|
|
606
|
+
stream = sys.stdout if ok else sys.stderr
|
|
607
|
+
stream.buffer.write(text.encode("utf-8"))
|
|
608
|
+
stream.buffer.flush()
|
|
609
|
+
return 2 if parsed else (0 if ok else 1)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _is_configuration(text: str) -> bool:
|
|
613
|
+
try:
|
|
614
|
+
return json.loads(text).get("category") == "configuration"
|
|
615
|
+
except ValueError:
|
|
616
|
+
return False
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
if __name__ == "__main__":
|
|
620
|
+
sys.exit(main())
|