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
@@ -0,0 +1,464 @@
1
+ """RUST-PORT: partitioned parallel Arrow reads.
2
+
3
+ One TDS session delivers a result at whatever rate SQL Server serializes it
4
+ (about 1.4M rows/s for a 20-column table on a dev box), well below what the
5
+ Rust decoder can take. ``read_arrow`` / ``read_arrow_stream`` split the query
6
+ on a column into ranges and read them over separate connections at once;
7
+ decoding runs without the GIL, so plain threads scale.
8
+
9
+ The range is cut into more slices than workers; each worker holds one
10
+ connection and pulls slices from a queue, so one slow connection does not set
11
+ the wall time. The MIN/MAX probe runs on the first worker's connection.
12
+
13
+ ``read_arrow_stream`` merges the workers' batches (native
14
+ ``ddbc_bindings.ArrowMergeStream``, bounded buffer, unordered) so a consumer
15
+ overlaps with the read; ``read_arrow`` is that stream read into one
16
+ ``ArrowTable``.
17
+ """
18
+
19
+ import datetime
20
+ import math
21
+ from contextlib import closing
22
+ from decimal import Decimal
23
+ import queue
24
+ import re
25
+ import threading
26
+ import weakref
27
+ from typing import Any, List, Optional, Sequence, Tuple
28
+
29
+ from arrowsql import ddbc_bindings
30
+ from arrowsql.db_connection import connect
31
+ from arrowsql._utf8_strings import utf8_query
32
+ from arrowsql.exceptions import raise_exception
33
+
34
+ # Slices per worker, and the fewest rows worth a slice (when the row count is known).
35
+ _SLICES_PER_WORKER = 2
36
+ _MIN_SLICE_ROWS = 8 * 1024
37
+ # A result of at most this many rows (catalog estimate) is read over the probe
38
+ # connection alone (measured: parallel wins from about 20k rows up).
39
+ _MIN_PARALLEL_ROWS = 20_000
40
+ _NAME = r"(?:\[[^\]]+\]|\w+)"
41
+ _PLAIN_TABLE = re.compile(
42
+ rf"^\s*SELECT\s+(?:\*|[\w\s,.\[\]]+?)\s+FROM\s+({_NAME}(?:\.{_NAME}){{0,2}})\s*;?\s*$",
43
+ re.IGNORECASE,
44
+ )
45
+ _STREAM_ERROR = re.compile(r"\[(\w{5})\] (.*)", re.DOTALL)
46
+ _BOUND_TYPES = (int, float, Decimal, str, datetime.date, datetime.datetime, datetime.time, bytes)
47
+
48
+
49
+ def _quote_identifier(name: str) -> str:
50
+ return "[" + name.replace("]", "]]") + "]"
51
+
52
+
53
+ def read_arrow(
54
+ connection_str: str,
55
+ query: str,
56
+ partition_on: Optional[str] = None,
57
+ partitions: int = 1,
58
+ batch_size: int = 65536,
59
+ utf8_strings: bool = True,
60
+ partition_bounds: Optional[Sequence[Any]] = None,
61
+ partition_nullable: Optional[bool] = None,
62
+ **connect_kwargs: Any,
63
+ ) -> "ddbc_bindings.ArrowTable":
64
+ """Run ``query`` and return its whole result as a native ``ArrowTable``.
65
+
66
+ With ``partition_on`` (an integer column of the result) and
67
+ ``partitions`` > 1, the column's [min, max] range is cut into equal-width
68
+ slices (about twice ``partitions``, never more than the number of distinct
69
+ key values in the range) that ``partitions`` connections read in parallel.
70
+ Rows whose partition value is NULL are read as a slice of their own. A
71
+ result known to be small (estimated from the catalog when ``query`` is a
72
+ plain table) is read over a single connection. ``query`` becomes a derived
73
+ table, so it may not end in ORDER BY (without TOP) or contain ``?``
74
+ parameters.
75
+
76
+ ``partition_bounds`` (typed boundaries, any column type) replaces the
77
+ planning; see ``read_arrow_stream``.
78
+
79
+ Slices are equal in key width, not in rows: a skewed key (a few hot values,
80
+ big gaps) is not rebalanced, and a column with fewer distinct values than
81
+ ``partitions`` cannot use all connections. The partitions are separate
82
+ statements on separate connections, so they read at different points in
83
+ time: the result is not one snapshot, and rows changed concurrently may be
84
+ missed or read twice. Row order across partitions is not preserved.
85
+
86
+ With ``utf8_strings`` (the default) nvarchar/nchar/ntext columns are cast
87
+ to UTF-8 varchar on the server, which halves their bytes on the wire for
88
+ mostly-ASCII text; the Arrow result is the same. It needs SQL Server 2019
89
+ or Azure SQL and a ``query`` without ORDER BY; otherwise, or when the
90
+ query cannot be described, the query runs unchanged. Pass False to
91
+ disable. Costs two extra round trips per call.
92
+ """
93
+ if not _partitioned(partition_on, partitions, partition_bounds):
94
+ return _read(connection_str, query, (), batch_size, connect_kwargs, utf8_strings)
95
+ probe_conn, work = _open_plan(
96
+ connection_str, query, partition_on, partitions, partition_bounds,
97
+ partition_nullable, connect_kwargs, utf8_strings,
98
+ )
99
+ if len(work) == 1:
100
+ with closing(probe_conn):
101
+ return _fetch(probe_conn, work[0][0], work[0][1], batch_size)
102
+ stream = _start(probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, None)
103
+ try:
104
+ return stream.read_all()
105
+ finally:
106
+ stream.close()
107
+
108
+
109
+ def read_arrow_stream(
110
+ connection_str: str,
111
+ query: str,
112
+ partition_on: Optional[str] = None,
113
+ partitions: int = 1,
114
+ batch_size: int = 65536,
115
+ partition_bounds: Optional[Sequence[Any]] = None,
116
+ max_buffered_batches: Optional[int] = None,
117
+ partition_nullable: Optional[bool] = None,
118
+ utf8_strings: bool = True,
119
+ **connect_kwargs: Any,
120
+ ) -> "ParallelArrowStream":
121
+ """Like ``read_arrow``, but stream the batches while the slices are read.
122
+
123
+ Returns a ``ParallelArrowStream`` (``__arrow_c_stream__``, so DuckDB,
124
+ polars, pyarrow and deltalake read it directly): up to ``partitions``
125
+ connections decode slices without the GIL and feed one bounded buffer of
126
+ ``max_buffered_batches`` batches (default ``2 * partitions``, at least 4),
127
+ so the consumer works while the server is still sending and memory stays
128
+ bounded. Batches arrive unordered. The stream is one-shot. ``close()``
129
+ (also on leaving a ``with`` block, or when a consumer stops early) cancels
130
+ the running statements, stops every worker and closes their connections.
131
+ The first worker error reaches the consumer and stops the others.
132
+
133
+ ``partition_bounds``: ascending boundaries of the ``partition_on`` column
134
+ (``int``, ``date``/``datetime``, ``Decimal``, ``str``; one type). ``k``
135
+ values give the disjoint half-open slices ``< b1``, ``[b1, b2)``, ...,
136
+ ``>= bk`` (plus an ``IS NULL`` slice when the column is nullable), each
137
+ bound as a typed parameter. No MIN/MAX or size probe is run, so the first
138
+ batch comes sooner, and skew can be handled by passing quantiles. Bounds
139
+ must ascend in the column's own ordering (for strings: its collation); a
140
+ ``str`` bound is sent as nvarchar, so a ``varchar`` column is compared
141
+ after an implicit conversion (no index seek). ``partition_nullable`` says
142
+ whether the column may hold NULL (default: asked from the server, one
143
+ metadata call; pass False to skip it). ``utf8_strings``: as for
144
+ ``read_arrow``.
145
+ """
146
+ if not _partitioned(partition_on, partitions, partition_bounds):
147
+ probe_conn = connect(connection_str, autocommit=True, **connect_kwargs)
148
+ try:
149
+ if utf8_strings:
150
+ query = utf8_query(probe_conn, query)
151
+ except BaseException:
152
+ probe_conn.close()
153
+ raise
154
+ return _start(probe_conn, connection_str, connect_kwargs, [(query, ())], 1, batch_size, max_buffered_batches)
155
+ probe_conn, work = _open_plan(
156
+ connection_str, query, partition_on, partitions, partition_bounds,
157
+ partition_nullable, connect_kwargs, utf8_strings,
158
+ )
159
+ return _start(
160
+ probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, max_buffered_batches
161
+ )
162
+
163
+
164
+ def _partitioned(partition_on, partitions, partition_bounds) -> bool:
165
+ if partition_bounds is not None and not partition_on:
166
+ raise ValueError("partition_bounds needs partition_on")
167
+ return bool(partition_on) and (partitions > 1 or partition_bounds is not None)
168
+
169
+
170
+ def _open_plan(connection_str, query, partition_on, partitions, partition_bounds, nullable, kwargs, utf8_strings):
171
+ """Open the probe connection and plan the slices: (connection, [(sql, params)...])."""
172
+ column = _quote_identifier(partition_on)
173
+ conn = connect(connection_str, autocommit=True, **kwargs)
174
+ try:
175
+ plain = query # the catalog row estimate needs the user's own text
176
+ if utf8_strings:
177
+ query = utf8_query(conn, query)
178
+ if partition_bounds is not None:
179
+ work = _bounds_plan(conn, query, column, partition_bounds, nullable)
180
+ else:
181
+ work = _plan(conn, query, column, partition_on, partitions, plain)
182
+ return conn, work or [(query, ())]
183
+ except BaseException:
184
+ conn.close()
185
+ raise
186
+
187
+
188
+ def _bounds_plan(conn, query, column, bounds, nullable) -> List[Tuple[str, tuple]]:
189
+ """Slices for caller-supplied boundaries (no data probe)."""
190
+ bounds = list(bounds)
191
+ if not bounds:
192
+ return []
193
+ kind = type(bounds[0])
194
+ for b in bounds:
195
+ if isinstance(b, bool) or not isinstance(b, _BOUND_TYPES) or type(b) is not kind:
196
+ raise TypeError(
197
+ "partition_bounds must be non-NULL values of one type "
198
+ f"(int, Decimal, str, date, datetime); got {b!r}"
199
+ )
200
+ for a, b in zip(bounds, bounds[1:]):
201
+ if not a < b:
202
+ raise ValueError(f"partition_bounds must be strictly ascending ({a!r} !< {b!r})")
203
+ if nullable is None:
204
+ with closing(conn.cursor()) as cursor:
205
+ _, nullable = _probe_meta(cursor, query, column, with_rows=False)
206
+ base = f"SELECT * FROM ({query}) AS _q WHERE "
207
+ work: List[Tuple[str, tuple]] = []
208
+ if nullable:
209
+ work.append((base + f"{column} IS NULL", ()))
210
+ work.append((base + f"{column} < ?", (bounds[0],)))
211
+ for a, b in zip(bounds, bounds[1:]):
212
+ work.append((base + f"{column} >= ? AND {column} < ?", (a, b)))
213
+ work.append((base + f"{column} >= ?", (bounds[-1],)))
214
+ return work
215
+
216
+
217
+ class _Workers:
218
+ """Threads and open statements of one parallel stream."""
219
+
220
+ def __init__(self) -> None:
221
+ self.lock = threading.Lock()
222
+ self.cursors: set = set()
223
+ self.threads: List[threading.Thread] = []
224
+ self.error: List[BaseException] = []
225
+
226
+ def cancel_all(self) -> None:
227
+ with self.lock:
228
+ cursors = list(self.cursors)
229
+ for cursor in cursors:
230
+ try:
231
+ cursor.cancel()
232
+ except Exception:
233
+ pass
234
+
235
+ def stop(self, merge, wait: bool) -> None:
236
+ """Close the merge, cancel statements (repeatedly: a cancel can precede its execute)."""
237
+ merge.close()
238
+ budget = 30.0
239
+ while True:
240
+ self.cancel_all()
241
+ alive = [t for t in self.threads if t.is_alive() and t is not threading.current_thread()]
242
+ if not alive or not wait or budget <= 0:
243
+ return
244
+ alive[0].join(0.05)
245
+ budget -= 0.05
246
+
247
+
248
+ def _release(merge, workers) -> None:
249
+ # Finalizer for a stream nobody read. One handed to a consumer stops itself.
250
+ if not merge.in_use:
251
+ workers.stop(merge, wait=False)
252
+
253
+
254
+ class ParallelArrowStream:
255
+ """Batches of a partitioned read, arriving while the slices are read.
256
+
257
+ Created by ``read_arrow_stream``. One-shot, unordered, bounded buffer.
258
+ """
259
+
260
+ def __init__(self, merge, workers: _Workers) -> None:
261
+ self._merge = merge
262
+ self._workers = workers
263
+ self._finalizer = weakref.finalize(self, _release, merge, workers)
264
+
265
+ @property
266
+ def column_names(self) -> List[str]:
267
+ return self._merge.column_names
268
+
269
+ @property
270
+ def num_columns(self) -> int:
271
+ return self._merge.num_columns
272
+
273
+ @property
274
+ def closed(self) -> bool:
275
+ return self._merge.closed
276
+
277
+ def __arrow_c_schema__(self) -> Any:
278
+ return self._merge.__arrow_c_schema__()
279
+
280
+ def __arrow_c_stream__(self, requested_schema: Optional[Any] = None) -> Any:
281
+ return self._merge.__arrow_c_stream__(requested_schema)
282
+
283
+ def read_all(self) -> "ddbc_bindings.ArrowTable":
284
+ """Read every remaining batch into an ``ArrowTable`` (Ctrl-C interrupts)."""
285
+ try:
286
+ return self._merge.read_all()
287
+ except RuntimeError as exc:
288
+ self.close()
289
+ if self._workers.error:
290
+ raise self._workers.error[0] from None
291
+ # A read that failed mid-statement arrives as "[SQLSTATE] message".
292
+ found = _STREAM_ERROR.search(str(exc))
293
+ if found:
294
+ raise_exception(found.group(1), found.group(2))
295
+ raise
296
+ except BaseException:
297
+ self.close()
298
+ raise
299
+
300
+ def close(self) -> None:
301
+ """Cancel running statements, stop the workers, close their connections."""
302
+ self._workers.stop(self._merge, wait=True)
303
+
304
+ def __enter__(self) -> "ParallelArrowStream":
305
+ return self
306
+
307
+ def __exit__(self, *exc: Any) -> None:
308
+ self.close()
309
+
310
+ def __repr__(self) -> str:
311
+ return f"ParallelArrowStream(columns={self.column_names!r}, closed={self.closed})"
312
+
313
+
314
+ def _start(probe_conn, connection_str, connect_kwargs, work, partitions, batch_size, depth):
315
+ """Run work[0] on the probe connection (for the schema), then start the workers."""
316
+ try:
317
+ cursor = probe_conn.cursor()
318
+ sql, params = work[0]
319
+ cursor.execute(sql, params) if params else cursor.execute(sql)
320
+ first = cursor.arrow_stream(batch_size, 1)
321
+ except BaseException:
322
+ probe_conn.close()
323
+ raise
324
+ workers = _Workers()
325
+ count = max(1, min(partitions, len(work)))
326
+ merge = ddbc_bindings.ArrowMergeStream(first, count, depth or max(2 * count, 4))
327
+ pending: "queue.Queue[Tuple[str, tuple]]" = queue.Queue()
328
+ for item in work[1:]:
329
+ pending.put(item)
330
+
331
+ def drain(conn) -> None:
332
+ while not merge.stopped:
333
+ try:
334
+ sql, params = pending.get_nowait()
335
+ except queue.Empty:
336
+ return
337
+ cur = conn.cursor()
338
+ with workers.lock:
339
+ workers.cursors.add(cur)
340
+ try:
341
+ cur.execute(sql, params) if params else cur.execute(sql)
342
+ merge.pump(cur.arrow_stream(batch_size, 1))
343
+ finally:
344
+ with workers.lock:
345
+ workers.cursors.discard(cur)
346
+ cur.close()
347
+
348
+ def run(first_stream, first_cursor, conn) -> None:
349
+ try:
350
+ if first_stream is not None:
351
+ with workers.lock:
352
+ workers.cursors.add(first_cursor)
353
+ try:
354
+ merge.pump(first_stream)
355
+ finally:
356
+ with workers.lock:
357
+ workers.cursors.discard(first_cursor)
358
+ first_cursor.close()
359
+ drain(conn)
360
+ elif not (merge.stopped or pending.empty()):
361
+ with connect(connection_str, autocommit=True, **connect_kwargs) as own:
362
+ drain(own)
363
+ except BaseException as exc:
364
+ if not merge.stopped:
365
+ workers.error.append(exc)
366
+ merge.fail(f"{type(exc).__name__}: {exc}")
367
+ finally:
368
+ try:
369
+ if conn is not None:
370
+ conn.close()
371
+ finally:
372
+ merge.worker_done()
373
+
374
+ jobs = [(first, cursor, probe_conn)] + [(None, None, None)] * (count - 1)
375
+ for first_stream, first_cursor, conn in jobs:
376
+ thread = threading.Thread(
377
+ target=run, args=(first_stream, first_cursor, conn), name="mssql-read-arrow", daemon=True
378
+ )
379
+ workers.threads.append(thread)
380
+ thread.start()
381
+ return ParallelArrowStream(merge, workers)
382
+
383
+
384
+ _NULLABLE_SQL = (
385
+ "(SELECT TOP 1 is_nullable FROM sys.dm_exec_describe_first_result_set(?, NULL, 0))"
386
+ )
387
+ _ROWS_SQL = (
388
+ "(SELECT SUM(row_count) FROM sys.dm_db_partition_stats "
389
+ "WHERE object_id = OBJECT_ID(?) AND index_id IN (0, 1))"
390
+ )
391
+
392
+
393
+ def _probe_meta(cursor, query, column, plain=None, with_rows: bool = True) -> Tuple[Optional[int], bool]:
394
+ """(estimated row count or None, column may hold NULL) in one round trip.
395
+
396
+ The row count comes from the catalog and only when ``query`` is a plain
397
+ ``SELECT cols FROM table`` (a count scan is not worth it just to size the
398
+ plan); filters, joins, views or missing permissions give None. A failed
399
+ nullability check counts as nullable.
400
+ """
401
+ describe = (f"SELECT {column} FROM ({query}) AS _q",)
402
+ match = _PLAIN_TABLE.match(plain if plain is not None else query) if with_rows else None
403
+ if match:
404
+ try:
405
+ cursor.execute(f"SELECT {_ROWS_SQL}, {_NULLABLE_SQL}", (match.group(1),) + describe)
406
+ rows, nullable = cursor.fetchone()
407
+ return (None if rows is None else int(rows)), nullable is None or bool(nullable)
408
+ except Exception:
409
+ pass
410
+ try:
411
+ cursor.execute(f"SELECT {_NULLABLE_SQL}", describe)
412
+ row = cursor.fetchone()
413
+ return None, row is None or row[0] is None or bool(row[0])
414
+ except Exception:
415
+ return None, True
416
+
417
+
418
+ def _plan(conn, query, column, name, partitions, plain=None) -> List[Tuple[str, tuple]]:
419
+ """Probe [min, max]; return the slices, or [] to read in one stream."""
420
+ with closing(conn.cursor()) as cursor:
421
+ cursor.execute(f"SELECT MIN({column}), MAX({column}) FROM ({query}) AS _q")
422
+ low, high = cursor.fetchone()
423
+ if low is None:
424
+ return []
425
+ if not isinstance(low, int) or not isinstance(high, int):
426
+ raise TypeError(f"partition_on column {name!r} must be an integer column")
427
+ span = high - low + 1
428
+ count = partitions * _SLICES_PER_WORKER
429
+ rows, has_null = _probe_meta(cursor, query, column, plain)
430
+ if rows is not None:
431
+ if rows <= _MIN_PARALLEL_ROWS:
432
+ return []
433
+ count = min(count, max(rows // _MIN_SLICE_ROWS, 2))
434
+ # Cannot cut finer than one key value per slice. The size of the key
435
+ # range says nothing about the row count (many rows can share a key),
436
+ # so it never makes the read single-stream except for a single value.
437
+ count = min(count, span)
438
+ if count < 2:
439
+ return []
440
+ step = math.ceil(span / count)
441
+ edges = [low + i * step for i in range(1, count) if low + i * step <= high]
442
+ base = f"SELECT * FROM ({query}) AS _q WHERE "
443
+ work: List[Tuple[str, tuple]] = []
444
+ if has_null:
445
+ work.append((base + f"{column} IS NULL", ()))
446
+ # Open-ended first and last ranges so out-of-range rows cannot be lost.
447
+ work.append((base + f"{column} < ?", (edges[0],)))
448
+ for a, b in zip(edges, edges[1:]):
449
+ work.append((base + f"{column} >= ? AND {column} < ?", (a, b)))
450
+ work.append((base + f"{column} >= ?", (edges[-1],)))
451
+ return work
452
+
453
+
454
+ def _fetch(conn, sql, params, batch_size):
455
+ with closing(conn.cursor()) as cursor:
456
+ cursor.execute(sql, params) if params else cursor.execute(sql)
457
+ return cursor.fetchall_arrow(batch_size)
458
+
459
+
460
+ def _read(connection_str, sql, params, batch_size, connect_kwargs, utf8_strings=False):
461
+ with connect(connection_str, autocommit=True, **connect_kwargs) as conn:
462
+ if utf8_strings and not params:
463
+ sql = utf8_query(conn, sql)
464
+ return _fetch(conn, sql, params, batch_size)
@@ -0,0 +1,40 @@
1
+ """Asynchronous query support backed directly by mssql-py-core.
2
+
3
+ The internal entry points are ``_AsyncConnection`` and ``_AsyncCursor``.
4
+ Their methods retain DB-API names, but the classes are not stable public API.
5
+
6
+ Warning:
7
+ Async query execution APIs are under active development and are not intended
8
+ for production use. Their signatures, behavior, error handling, and compatibility
9
+ may change without notice.
10
+ """
11
+
12
+ from ._native import load_py_core
13
+ from .async_connection import _AsyncConnection # pyright: ignore[reportPrivateUsage]
14
+ from .async_cursor import _AsyncCursor # pyright: ignore[reportPrivateUsage]
15
+ from .exception_translator import (
16
+ DataError,
17
+ DatabaseError,
18
+ Error,
19
+ IntegrityError,
20
+ InterfaceError,
21
+ InternalError,
22
+ NotSupportedError,
23
+ OperationalError,
24
+ ProgrammingError,
25
+ Warning,
26
+ )
27
+
28
+ __all__ = [
29
+ "DataError",
30
+ "DatabaseError",
31
+ "Error",
32
+ "IntegrityError",
33
+ "InterfaceError",
34
+ "InternalError",
35
+ "NotSupportedError",
36
+ "OperationalError",
37
+ "ProgrammingError",
38
+ "Warning",
39
+ "load_py_core",
40
+ ]
@@ -0,0 +1,39 @@
1
+ """Build the internal py-core context for asynchronous connections."""
2
+
3
+ from typing import Any
4
+
5
+ from ..connection_string_parser import _ConnectionStringParser
6
+ from ..exceptions import InterfaceError, NotSupportedError
7
+ from ..helpers import connstr_to_pycore_params
8
+
9
+
10
+ def build_async_connection_context(connection_str: str, timeout: int) -> dict[str, Any]:
11
+ """Parse and validate a public connection string for py-core."""
12
+ if not isinstance(connection_str, str):
13
+ raise TypeError("connection_str must be a string")
14
+ if "\x00" in connection_str:
15
+ raise InterfaceError(
16
+ driver_error="Connection string must not contain a NUL (\\x00) character.",
17
+ ddbc_error="Embedded NUL in connection string.",
18
+ )
19
+ if isinstance(timeout, bool) or not isinstance(timeout, int):
20
+ raise TypeError("Login timeout must be an integer")
21
+ if timeout < 0:
22
+ raise ValueError("Login timeout cannot be negative")
23
+
24
+ parser = _ConnectionStringParser(validate_keywords=True)
25
+ params = parser._parse(connection_str)
26
+ if not any(params.get(key) for key in ("server", "addr", "address")):
27
+ raise ValueError("SERVER parameter is required in connection string")
28
+
29
+ authentication = params.get("authentication", "").strip()
30
+ if authentication and authentication.lower() != "sqlpassword":
31
+ raise NotSupportedError(
32
+ f"Authentication mode '{authentication}' is not supported for async queries",
33
+ "Use Authentication=SqlPassword or Trusted_Connection for async queries",
34
+ )
35
+
36
+ context = connstr_to_pycore_params(params, strict=True)
37
+ if timeout > 0:
38
+ context["connect_timeout"] = timeout
39
+ return context
@@ -0,0 +1,24 @@
1
+ """Native mssql-py-core dependency boundary for asynchronous queries."""
2
+
3
+ from importlib import import_module
4
+ from types import ModuleType
5
+
6
+
7
+ def load_py_core() -> ModuleType:
8
+ """Load the PyO3 extension that owns asynchronous TDS operations."""
9
+ try:
10
+ # Upgrading from a bundled core can remove the package's __init__.py.
11
+ py_core = import_module("mssql_py_core.mssql_py_core")
12
+ except ImportError as exc:
13
+ raise ImportError(
14
+ "Async query support requires the mssql-python-rs dependency "
15
+ "that provides mssql_py_core."
16
+ ) from exc
17
+
18
+ required_types = ("PyAsyncConnection", "PyAsyncCursor")
19
+ missing_types = [name for name in required_types if not hasattr(py_core, name)]
20
+ if missing_types:
21
+ missing = ", ".join(missing_types)
22
+ raise ImportError(f"mssql_py_core does not provide the required async types: {missing}")
23
+
24
+ return py_core