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.
Files changed (45) hide show
  1. arrowsql/__init__.py +653 -0
  2. arrowsql/_rust_auth.py +170 -0
  3. arrowsql/_server_sqlstate.py +11 -0
  4. arrowsql/_utf8_strings.py +78 -0
  5. arrowsql/agent.py +620 -0
  6. arrowsql/aio.py +533 -0
  7. arrowsql/arrow_parallel.py +464 -0
  8. arrowsql/async_query/__init__.py +40 -0
  9. arrowsql/async_query/_connection_context.py +39 -0
  10. arrowsql/async_query/_native.py +24 -0
  11. arrowsql/async_query/async_connection.py +160 -0
  12. arrowsql/async_query/async_cursor.py +225 -0
  13. arrowsql/async_query/async_execute.py +113 -0
  14. arrowsql/async_query/async_fetch.py +153 -0
  15. arrowsql/async_query/exception_translator.py +132 -0
  16. arrowsql/auth.py +913 -0
  17. arrowsql/bulk_parallel.py +385 -0
  18. arrowsql/connection.py +2377 -0
  19. arrowsql/connection_string_builder.py +114 -0
  20. arrowsql/connection_string_parser.py +417 -0
  21. arrowsql/constants.py +701 -0
  22. arrowsql/cursor.py +4516 -0
  23. arrowsql/db_connection.py +98 -0
  24. arrowsql/ddbc_bindings.cp313-win_amd64.pyd +0 -0
  25. arrowsql/ddbc_bindings.pyi +283 -0
  26. arrowsql/decimal_config.py +98 -0
  27. arrowsql/exceptions.py +378 -0
  28. arrowsql/helpers.py +436 -0
  29. arrowsql/logging.py +700 -0
  30. arrowsql/mssql_python.pyi +579 -0
  31. arrowsql/odbc_provider.py +269 -0
  32. arrowsql/parameter_helper.py +442 -0
  33. arrowsql/perf_timer.py +286 -0
  34. arrowsql/pooling.py +147 -0
  35. arrowsql/py.typed +1 -0
  36. arrowsql/row.py +462 -0
  37. arrowsql/type.py +160 -0
  38. arrowsql-0.1.0.dist-info/METADATA +146 -0
  39. arrowsql-0.1.0.dist-info/RECORD +45 -0
  40. arrowsql-0.1.0.dist-info/WHEEL +4 -0
  41. arrowsql-0.1.0.dist-info/entry_points.txt +2 -0
  42. arrowsql-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. arrowsql-0.1.0.dist-info/licenses/LICENSE.upstream-mssql-python +47 -0
  44. arrowsql-0.1.0.dist-info/sboms/ddbc_bindings.cyclonedx.json +5195 -0
  45. 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())