From 80f77d0c9de86e6e7b09dc19f977f9775e7be1a1 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Tue, 2 Jun 2026 10:02:12 +0530 Subject: [PATCH 1/6] Accept Row objects in bulkcopy without manual tuple conversion (GH-482) mssql_py_core expects native tuples but Row objects from fetchmany() fail the strict type check in Rust with: ValueError: Expected tuple, got: 'Row' object cannot be cast as 'tuple' Added _ensure_tuples() wrapper that auto-converts Row/list objects to tuples. Tuples pass through with zero overhead. Unexpected types raise TypeError immediately instead of producing confusing Rust-level errors. Fixes #482 --- mssql_python/cursor.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 01b3f157..269b0cc8 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2997,11 +2997,26 @@ def bulkcopy( ) pycore_cursor = pycore_connection.cursor() + # Auto-convert Row/list objects to tuples for the Rust layer. + # mssql_py_core expects native tuples; Row objects (from fetchmany) + # are iterable but fail the strict type check in Rust. + def _ensure_tuples(iterable): + for item in iterable: + if isinstance(item, tuple): + yield item + elif isinstance(item, (list, Row)): + yield tuple(item) + else: + raise TypeError( + f"bulkcopy data rows must be tuples, lists, or Row objects, " + f"got {type(item).__name__}" + ) + # Call bulkcopy with explicit keyword arguments # The API signature: bulkcopy(table_name, data_source, batch_size=0, timeout=30, ...) result = pycore_cursor.bulkcopy( table_name, - iter(data), + _ensure_tuples(data), batch_size=batch_size, timeout=timeout, column_mappings=column_mappings, From cc05a41e3ad26a75c6c862d39f3ea045f07b9b2f Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar <61936179+jahnvi480@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:47:50 +0530 Subject: [PATCH 2/6] FIX: executemany SQL_C_NUMERIC mismatch (#611) ### Work Item / Issue Reference > [AB#45380](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/45380) > GitHub Issue: #609 ------------------------------------------------------------------- ### Summary This pull request addresses a critical bug in the handling of `executemany` with large `Decimal` values in the `mssql_python` driver, specifically when values exceed the SQL Server `MONEY` range. The main fix ensures that parameter type detection and conversion are consistent, preventing runtime errors when binding large decimal values. Extensive unit and integration tests are added to verify the fix and cover edge cases involving `Decimal` values, including scenarios with `NULL`s and multi-column inserts. **Bug Fix: Executemany Decimal Handling** * In `cursor.py`, the `executemany` method now explicitly overrides the C type for parameters with SQL type `DECIMAL` or `NUMERIC` to use `SQL_C_CHAR` (string binding) when the data is converted to strings. This prevents mismatches that previously caused runtime errors when inserting large decimal values. The column size is also adjusted to fit the longest string representation. **Testing: Unit and Integration Tests for Decimal Handling** * Added comprehensive unit tests in `test_001_globals.py` to verify type detection, mapping, and the override logic for `executemany` with large `Decimal` values, both within and outside the `MONEY` range. These tests confirm that the C type override is necessary and correctly applied. * Added integration tests in `test_004_cursor.py` to exercise the fixed behavior in real database scenarios, including: - Inserting batches with decimals inside and outside the `MONEY` range. - Handling `NULL` values alongside large decimals. - Multi-column inserts where one column contains large decimals. --- mssql_python/cursor.py | 14 +++ tests/test_004_cursor.py | 223 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 269b0cc8..97a3887d 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2344,6 +2344,20 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s paraminfo.paramSQLType = ddbc_sql_const.SQL_VARCHAR.value paraminfo.columnSize = 1 + # Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding. + # _map_sql_type may return SQL_C_NUMERIC (expecting NumericData structs) + # but the conversion loop below converts all Decimal values to strings. + # The C type must match the actual data to avoid: + # RuntimeError: Parameter's object type does not match parameter's C type + if paraminfo.paramSQLType in ( + ddbc_sql_const.SQL_DECIMAL.value, + ddbc_sql_const.SQL_NUMERIC.value, + ): + paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value + # Ensure columnSize accommodates the longest string representation + if max_decimal_len > paraminfo.columnSize: + paraminfo.columnSize = max_decimal_len + # Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557). # The sample value's formatted string may be shorter than another # row's (e.g. positive sample "1.0" = 3 chars vs negative "-0.1" = 4). diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index a63c4d8f..349bbc55 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -16239,3 +16239,226 @@ def test_long_print_message(cursor, message_len): msg = cursor.messages[0][1] # SQL Server truncates at 8000 characters assert msg.endswith("a" * min(8000, message_len)), msg + + +# --------------------------------------------------------- +# GH-609: Unit tests (no DB connection needed) +# --------------------------------------------------------- +from mssql_python.cursor import Cursor as _Cursor, MONEY_MAX as _MONEY_MAX +from mssql_python.constants import ConstantsDDBC as _C + + +def _make_bare_cursor(): + """Create a Cursor instance without a connection for unit testing.""" + cur = _Cursor.__new__(_Cursor) + cur._inputsizes = None + return cur + + +def test_compute_column_type_large_decimal(): + """_compute_column_type picks the highest-precision Decimal as sample.""" + cur = _make_bare_cursor() + column = [ + decimal.Decimal("100.50"), + decimal.Decimal("999999999999999999.123456"), + decimal.Decimal("200.75"), + ] + sample, _, _, max_dec_len = cur._compute_column_type(column) + assert isinstance(sample, decimal.Decimal) + assert sample > _MONEY_MAX + assert max_dec_len > 0 + + +def test_map_sql_type_decimal_outside_money_returns_numeric(): + """_map_sql_type returns SQL_C_NUMERIC for Decimal outside MONEY range.""" + cur = _make_bare_cursor() + val = decimal.Decimal("999999999999999999.123456") + dummy_row = [val] + sql_type, c_type, _, _, _ = cur._map_sql_type(val, dummy_row, 0) + assert sql_type == _C.SQL_NUMERIC.value + assert c_type == _C.SQL_C_NUMERIC.value + assert isinstance(format(val, "f"), str) + + +def test_map_sql_type_decimal_in_money_returns_varchar(): + """_map_sql_type returns SQL_VARCHAR for Decimal within MONEY range.""" + cur = _make_bare_cursor() + val = decimal.Decimal("100.50") + dummy_row = [val] + sql_type, c_type, _, _, _ = cur._map_sql_type(val, dummy_row, 0) + assert sql_type == _C.SQL_VARCHAR.value + assert c_type == _C.SQL_C_CHAR.value + + +def test_executemany_numeric_override_needed(): + """The executemany auto-detection path must override SQL_C_NUMERIC to SQL_C_CHAR (GH-609).""" + from mssql_python import ddbc_bindings + + cur = _make_bare_cursor() + data = [ + (decimal.Decimal("100.50"),), + (decimal.Decimal("999999999999999999.123456"),), + ] + column = [row[0] for row in data] + sample_value, min_val, max_val, max_decimal_len = cur._compute_column_type(column) + dummy_row = list(data[0]) + paraminfo = cur._create_parameter_types_list( + sample_value, + ddbc_bindings.ParamInfo, + dummy_row, + 0, + min_val=min_val, + max_val=max_val, + ) + assert paraminfo.paramSQLType == _C.SQL_NUMERIC.value + original_c_type = paraminfo.paramCType + if paraminfo.paramSQLType in (_C.SQL_DECIMAL.value, _C.SQL_NUMERIC.value): + paraminfo.paramCType = _C.SQL_C_CHAR.value + if max_decimal_len > paraminfo.columnSize: + paraminfo.columnSize = max_decimal_len + assert paraminfo.paramCType == _C.SQL_C_CHAR.value + assert original_c_type == _C.SQL_C_NUMERIC.value + assert paraminfo.columnSize >= max_decimal_len + + +def test_executemany_decimal_numeric_override_coverage(monkeypatch): + """Call the real executemany method to cover the GH-609 override lines.""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + captured["columnwise_params"] = col_params + captured["row_count"] = row_count + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 2) + data = [ + (decimal.Decimal("100.50"),), + (decimal.Decimal("999999999999999999.123456"),), + ] + cur.executemany("INSERT INTO t VALUES (?)", data) + pt = captured["parameters_type"] + assert len(pt) == 1 + assert pt[0].paramSQLType == _C.SQL_NUMERIC.value + assert pt[0].paramCType == _C.SQL_C_CHAR.value + col_values = captured["columnwise_params"][0] + for val in col_values: + assert val is None or isinstance(val, str) + + +# --------------------------------------------------------- +# GH-609: Integration tests (need DB connection) +# --------------------------------------------------------- +def test_executemany_decimal_outside_money_range(cursor, db_connection): + """Test executemany with Decimal values exceeding the MONEY range (GH-609). + + When a batch contains Decimal values outside ±922,337,203,685,477.5807, + _map_sql_type returns SQL_C_NUMERIC (expecting NumericData structs), but + the conversion loop converts all Decimals to strings. Without the GH-609 + fix, this mismatch causes: + RuntimeError: Parameter's object type does not match parameter's C type + + Also exercises separate batches (customer scenario: most batches have + in-MONEY-range values, one batch exceeds the range). + """ + try: + cursor.execute("CREATE TABLE #pytest_gh609 (val DECIMAL(38, 6))") + + # Batch 1: all values inside MONEY range + batch1 = [(decimal.Decimal("100.50"),), (decimal.Decimal("200.75"),)] + cursor.executemany("INSERT INTO #pytest_gh609 VALUES (?)", batch1) + + # Batch 2: mix of inside and outside MONEY range + batch2 = [ + (decimal.Decimal("0.000001"),), + (decimal.Decimal("999999999999999999.123456"),), # exceeds MONEY_MAX + (decimal.Decimal("-999999999999999999.654321"),), # exceeds MONEY_MIN + ] + cursor.executemany("INSERT INTO #pytest_gh609 VALUES (?)", batch2) + db_connection.commit() + + cursor.execute("SELECT val FROM #pytest_gh609 ORDER BY val") + rows = [row[0] for row in cursor.fetchall()] + assert len(rows) == 5 + assert rows[0] == decimal.Decimal("-999999999999999999.654321") + assert rows[-1] == decimal.Decimal("999999999999999999.123456") + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_gh609") + db_connection.commit() + + +def test_executemany_decimal_with_nulls_outside_money(cursor, db_connection): + """Test executemany with NULL + large Decimal values outside MONEY range (GH-609).""" + try: + cursor.execute("CREATE TABLE #pytest_gh609_nulls (val DECIMAL(38, 10))") + data = [ + (None,), + (decimal.Decimal("12345678901234567890.1234567890"),), + (None,), + (decimal.Decimal("-12345678901234567890.1234567890"),), + ] + cursor.executemany("INSERT INTO #pytest_gh609_nulls VALUES (?)", data) + db_connection.commit() + + cursor.execute("SELECT val FROM #pytest_gh609_nulls WHERE val IS NOT NULL ORDER BY val") + rows = [row[0] for row in cursor.fetchall()] + assert len(rows) == 2 + assert rows[0] == decimal.Decimal("-12345678901234567890.1234567890") + assert rows[1] == decimal.Decimal("12345678901234567890.1234567890") + + cursor.execute("SELECT COUNT(*) FROM #pytest_gh609_nulls WHERE val IS NULL") + assert cursor.fetchone()[0] == 2 + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_gh609_nulls") + db_connection.commit() + + +def test_executemany_multi_column_with_large_decimal(cursor, db_connection): + """Test executemany with multiple columns including large Decimal (GH-609). + + Mirrors the customer's scenario: a table with many columns where one + NUMERIC column has values outside the MONEY range. + """ + try: + cursor.execute(""" + CREATE TABLE #pytest_gh609_multi ( + id INT, + name NVARCHAR(100), + amount DECIMAL(38, 6), + description VARCHAR(200) + ) + """) + data = [ + (1, "row1", decimal.Decimal("999999999999999999.123456"), "test"), + (2, "row2", decimal.Decimal("50.0"), "small"), + (3, "row3", decimal.Decimal("-999999999999999999.654321"), "negative large"), + ] + cursor.executemany("INSERT INTO #pytest_gh609_multi VALUES (?, ?, ?, ?)", data) + db_connection.commit() + + cursor.execute("SELECT id, amount FROM #pytest_gh609_multi ORDER BY id") + rows = cursor.fetchall() + assert len(rows) == 3 + assert rows[0][1] == decimal.Decimal("999999999999999999.123456") + assert rows[2][1] == decimal.Decimal("-999999999999999999.654321") + finally: + cursor.execute("DROP TABLE IF EXISTS #pytest_gh609_multi") + db_connection.commit() From 6af198e28106148712b4b891bc4d4a00b00edf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= <16805946+edgarrmondragon@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:31:21 -0600 Subject: [PATCH 3/6] FIX: always statically link simdutf via FetchContent (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Work Item / Issue Reference > [AB#45378](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/45378) > GitHub Issue: #607 ------------------------------------------------------------------- ### Summary The published macOS universal2 wheel dynamically links simdutf against a Homebrew path baked in at CI build time, causing an import failure on any machine that doesn't have simdutf installed at that exact path. Fix: remove the find_package(simdutf) call in CMakeLists.txt so FetchContent is always used, which builds simdutf as a static library and embeds its symbols directly into the extension. Signed-off-by: Edgar Ramírez Mondragón Co-authored-by: Jahnvi Thakkar <61936179+jahnvi480@users.noreply.github.com> Co-authored-by: Gaurav Sharma --- mssql_python/pybind/CMakeLists.txt | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/mssql_python/pybind/CMakeLists.txt b/mssql_python/pybind/CMakeLists.txt index 214e351c..d1835b1a 100644 --- a/mssql_python/pybind/CMakeLists.txt +++ b/mssql_python/pybind/CMakeLists.txt @@ -215,24 +215,20 @@ endif() message(STATUS "Final Python library directory: ${PYTHON_LIB_DIR}") -find_package(simdutf CONFIG QUIET) - -if(NOT simdutf_FOUND) - include(FetchContent) - message(STATUS "simdutf not found via find_package; downloading v8.2.0 source archive with FetchContent") - set(simdutf_fetchcontent_args - URL https://github.com/simdutf/simdutf/archive/refs/tags/v8.2.0.tar.gz - URL_HASH SHA256=033a91b1d7d1cb818c1eff49e61faaa1b64a3a530d59ef9efef0195e56bda8b1 - ) - if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24") - list(APPEND simdutf_fetchcontent_args DOWNLOAD_EXTRACT_TIMESTAMP FALSE) - endif() - FetchContent_Declare(simdutf ${simdutf_fetchcontent_args}) - set(SIMDUTF_TESTS OFF CACHE BOOL "Disable simdutf tests" FORCE) - set(SIMDUTF_TOOLS OFF CACHE BOOL "Disable simdutf tools" FORCE) - set(SIMDUTF_BENCHMARKS OFF CACHE BOOL "Disable simdutf benchmarks" FORCE) - FetchContent_MakeAvailable(simdutf) +include(FetchContent) +message(STATUS "Downloading simdutf v8.2.0 source archive with FetchContent") +set(simdutf_fetchcontent_args + URL https://github.com/simdutf/simdutf/archive/refs/tags/v8.2.0.tar.gz + URL_HASH SHA256=033a91b1d7d1cb818c1eff49e61faaa1b64a3a530d59ef9efef0195e56bda8b1 +) +if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24") + list(APPEND simdutf_fetchcontent_args DOWNLOAD_EXTRACT_TIMESTAMP FALSE) endif() +FetchContent_Declare(simdutf ${simdutf_fetchcontent_args}) +set(SIMDUTF_TESTS OFF CACHE BOOL "Disable simdutf tests" FORCE) +set(SIMDUTF_TOOLS OFF CACHE BOOL "Disable simdutf tools" FORCE) +set(SIMDUTF_BENCHMARKS OFF CACHE BOOL "Disable simdutf benchmarks" FORCE) +FetchContent_MakeAvailable(simdutf) set(DDBC_SOURCE "ddbc_bindings.cpp") message(STATUS "Using standard source file: ${DDBC_SOURCE}") From f897890aaae151008cb9e0828340a4b4fdeb387c Mon Sep 17 00:00:00 2001 From: Gaurav Sharma Date: Wed, 3 Jun 2026 18:48:44 +0530 Subject: [PATCH 4/6] FIX: Flaky tests in CI and mark stress tests (#617) --- eng/pipelines/pr-validation-pipeline.yml | 1 + tests/conftest.py | 19 ++++++++++ tests/test_013_SqlHandle_free_shutdown.py | 36 +++++++++++-------- .../test_022_concurrent_query_gil_release.py | 2 ++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/eng/pipelines/pr-validation-pipeline.yml b/eng/pipelines/pr-validation-pipeline.yml index 5f6efbc1..8cc7ea8e 100644 --- a/eng/pipelines/pr-validation-pipeline.yml +++ b/eng/pipelines/pr-validation-pipeline.yml @@ -621,6 +621,7 @@ jobs: python benchmarks/perf-benchmarking.py --baseline benchmark_baseline.json --json benchmark_results.json displayName: 'Run performance benchmarks on macOS $(sqlVersion)' condition: or(eq(variables['sqlVersion'], 'SQL2022'), eq(variables['sqlVersion'], 'SQL2025')) + timeoutInMinutes: 20 continueOnError: true env: DB_CONNECTION_STRING: 'Server=tcp:127.0.0.1,1433;Database=AdventureWorks2022;Uid=SA;Pwd=$(DB_PASSWORD);TrustServerCertificate=yes' diff --git a/tests/conftest.py b/tests/conftest.py index 90fd5de7..3440e598 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,25 @@ import time +def is_qemu_emulated(): + """Detect if running under QEMU user-mode emulation (e.g. ARM64 on x86_64 host). + + QEMU reports CPU implementer 0x51 in /proc/cpuinfo. Native ARM64 hardware + uses vendor-specific IDs (0x41 ARM, 0x61 Apple, etc.). + """ + try: + with open("/proc/cpuinfo") as f: + for line in f: + if line.startswith("CPU implementer") and "0x51" in line: + return True + except (FileNotFoundError, PermissionError): + pass + return False + + +QEMU = is_qemu_emulated() + + def is_azure_sql_connection(conn_str): """Helper function to detect if connection string is for Azure SQL Database""" if not conn_str: diff --git a/tests/test_013_SqlHandle_free_shutdown.py b/tests/test_013_SqlHandle_free_shutdown.py index 9944d898..daf4fafb 100644 --- a/tests/test_013_SqlHandle_free_shutdown.py +++ b/tests/test_013_SqlHandle_free_shutdown.py @@ -34,7 +34,13 @@ import pytest +from conftest import QEMU + +@pytest.mark.skipif( + QEMU, + reason="Subprocess shutdown tests SIGSEGV under QEMU user-mode emulation — not reproducible on native ARM64", +) class TestHandleFreeShutdown: """Test SqlHandle::free() behavior for all handle types during Python shutdown.""" @@ -85,7 +91,7 @@ def test_aggressive_dbc_segfault_reproduction(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) # Check for segfault @@ -141,7 +147,7 @@ def on_exit(): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) if result.returncode < 0: @@ -205,7 +211,7 @@ def test_force_gc_finalization_order_issue(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) if result.returncode < 0: @@ -247,7 +253,7 @@ def test_stmt_handle_cleanup_at_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -290,7 +296,7 @@ def test_dbc_handle_cleanup_at_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -338,7 +344,7 @@ def test_env_handle_cleanup_at_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -410,7 +416,7 @@ def test_mixed_handle_cleanup_at_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -463,7 +469,7 @@ def test_rapid_connection_churn_with_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -502,7 +508,7 @@ def test_exception_during_query_with_shutdown(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -555,7 +561,7 @@ def callback(ref): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -613,7 +619,7 @@ def execute_query(self): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -685,7 +691,7 @@ def test_all_handle_types_comprehensive(self, conn_str): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=5 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Process crashed. stderr: {result.stderr}" @@ -940,7 +946,7 @@ def test_cleanup_connections_scenarios(self, conn_str, scenario, test_code, expe """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=3 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Test failed. stderr: {result.stderr}" @@ -1126,7 +1132,7 @@ def close(self): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=3 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Test failed. stderr: {result.stderr}" @@ -1216,7 +1222,7 @@ def close(self): """) result = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True, timeout=3 + [sys.executable, "-c", script], capture_output=True, text=True, timeout=15 ) assert result.returncode == 0, f"Test failed. stderr: {result.stderr}" diff --git a/tests/test_022_concurrent_query_gil_release.py b/tests/test_022_concurrent_query_gil_release.py index 4bc09dc2..85b16c53 100644 --- a/tests/test_022_concurrent_query_gil_release.py +++ b/tests/test_022_concurrent_query_gil_release.py @@ -70,6 +70,7 @@ def _run_waitfor(conn_str: str) -> float: # ============================================================================ +@pytest.mark.stress # Heartbeat tick counts flake under CI CPU contention (macOS Py3.14) def test_query_does_not_block_other_python_threads(conn_str): """ While one thread executes a 2-second ``WAITFOR DELAY``, a second pure-Python @@ -134,6 +135,7 @@ def run_query(): # ============================================================================ +@pytest.mark.stress # Heartbeat tick counts flake under CI CPU contention (macOS Py3.14) def test_commit_does_not_block_other_python_threads(conn_str): """ Smoke test for the SQLEndTran GIL-release added to ``Connection::commit`` From db59fa97265382634a1658ccf83361b5602b88e3 Mon Sep 17 00:00:00 2001 From: Sumit Sarabhai Date: Thu, 4 Jun 2026 14:14:47 +0530 Subject: [PATCH 5/6] DOC: Revise target timelines for roadmap features (#510) Updated target timelines for several features in the roadmap. ### Work Item / Issue Reference > [AB#43952](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/43952) > GitHub Issue: # This pull request updates the feature roadmap in `ROADMAP.md` to adjust the planned timelines for several upcoming features. The main changes are revised target dates for features such as returning rows as dictionaries, asynchronous query execution, vector datatype support, table-valued parameters, and JSON datatype support. Roadmap timeline updates: * Changed the target timeline for "Return Rows as Dictionaries" to Q3 2026. * Changed the target timeline for "Asynchronous Query Execution" to Q4 2026. * Changed the target timeline for "Vector Datatype Support" to Q3 2026. * Changed the target timeline for "Table-Valued Parameters (TVPs)" to Q3 2026. * Changed the target timeline for "JSON Datatype Support" to Q4 2026. ------------------------------------------------------------------- ### Summary This pull request updates the feature roadmap in `ROADMAP.md` to revise the target timelines for several planned features. The most important changes are: Roadmap timeline updates: * Changed the target timeline for "Return Rows as Dictionaries" from Q4 2025 to Q3 2026. * Changed the target timeline for "Asynchronous Query Execution" from Q1 2026 to Q4 2026. * Changed the target timeline for "Vector Datatype Support" from Q1 2026 to Q3 2026. * Changed the target timeline for "Table-Valued Parameters (TVPs)" from Q1 2026 to Q3 2026. * Changed the target timeline for "JSON Datatype Support" from "ETA will be updated soon" to Q4 2026. --- ROADMAP.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 22f5e6e1..fa1350da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,15 +4,8 @@ The following roadmap summarizes the features planned for the Python Driver for | Feature | Description | Status | Target Timeline | | ------------------------------ | ----------------------------------------------------------------- | ------------ | ------------------------ | -| Parameter Dictionaries | Allow parameters to be supplied as Python dicts | Planned | Q4 2025 | -| Return Rows as Dictionaries | Fetch rows as dictionaries for more Pythonic access | Planned | Q4 2025 | -| Bulk Copy (BCP) | High-throughput ingestion API for ETL workloads | Under Design | Q1 2026 | -| Asynchronous Query Execution | Non-blocking queries with asyncio support | Planned | Q1 2026 | -| Vector Datatype Support | Native support for SQL Server vector datatype | Planned | Q1 2026 | -| Table-Valued Parameters (TVPs) | Pass tabular data structures into stored procedures | Planned | Q1 2026 | -| C++ Abstraction | Modular separation via pybind11 for performance & maintainability | In Progress | ETA will be updated soon | -| JSON Datatype Support | Automatic mapping of JSON datatype to Python dicts/lists | Planned | ETA will be updated soon | -| callproc() | Full DBAPI compliance & stored procedure enhancements | Planned | ETA will be updated soon | -| setinputsize() | Full DBAPI compliance & stored procedure enhancements | Planned | ETA will be updated soon | -| setoutputsize() | Full DBAPI compliance & stored procedure enhancements | Planned | ETA will be updated soon | -| Output/InputOutput Params | Full DBAPI compliance & stored procedure enhancements | Planned | ETA will be updated soon | +| Return Rows as Dictionaries | Fetch rows as dictionaries for more Pythonic access | Planned | Q3 2026 | +| Asynchronous Query Execution | Non-blocking queries with asyncio support | Planned | Q4 2026 | +| Vector Datatype Support | Native support for SQL Server vector datatype | Planned | Q3 2026 | +| Table-Valued Parameters (TVPs) | Pass tabular data structures into stored procedures | Planned | Q3 2026 | +| JSON Datatype Support | Automatic mapping of JSON datatype to Python dicts/lists | Planned | Q4 2026 | From cfad9f7e41a23880d6884344b294b8b90f0a0437 Mon Sep 17 00:00:00 2001 From: Jahnvi Thakkar Date: Fri, 5 Jun 2026 20:28:25 +0530 Subject: [PATCH 6/6] Address review: remove list branch, fix type hint and docstring (GH-482) Per @bewithgaurav review: - Removed list branch from _ensure_tuples: Rust rejects lists at cast::(), so silently converting them was scope creep - Rewritten with check-first pattern using tuple(item._values) for Row objects (4x faster than __iter__, zero per-item isinstance) - Fixed type hint: Iterable[Union[Tuple, List]] -> Iterable[Union[Tuple, Row]] - Fixed docstring: removed 'or lists', documented Row acceptance - Added docstring on _ensure_tuples as type contract enforcement point --- mssql_python/cursor.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 97a3887d..a8180952 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2839,7 +2839,7 @@ def nextset(self) -> Union[bool, None]: def bulkcopy( self, table_name: str, - data: Iterable[Union[Tuple, List]], + data: Iterable[Union[Tuple, "Row"]], batch_size: int = 0, timeout: int = 30, column_mappings: Optional[Union[List[str], List[Tuple[int, str]]]] = None, @@ -2857,11 +2857,13 @@ def bulkcopy( table_name: Target table name (can include schema, e.g., 'dbo.MyTable'). The table must exist and the user must have INSERT permissions. - data: Iterable of tuples or lists containing row data to be inserted. + data: Iterable of tuples or Row objects containing row data to be inserted. + Row objects from fetchone/fetchmany/fetchall are automatically + converted to tuples. Lists and other types are not accepted. Data Format Requirements: - Each element in the iterable represents one row - - Each row should be a tuple or list of column values + - Each row should be a tuple or Row object - Column order must match the target table's column order (by ordinal position), unless column_mappings is specified - The number of values in each row must match the number of columns @@ -3011,20 +3013,27 @@ def bulkcopy( ) pycore_cursor = pycore_connection.cursor() - # Auto-convert Row/list objects to tuples for the Rust layer. - # mssql_py_core expects native tuples; Row objects (from fetchmany) - # are iterable but fail the strict type check in Rust. + # Enforce the bulkcopy type contract: only tuple and Row accepted. + # Rust (mssql_py_core) requires native PyTuple via cast::(). + # Row objects from fetch methods are converted using direct _values + # access (4x faster than __iter__). All other types raise TypeError. def _ensure_tuples(iterable): - for item in iterable: - if isinstance(item, tuple): - yield item - elif isinstance(item, (list, Row)): - yield tuple(item) - else: - raise TypeError( - f"bulkcopy data rows must be tuples, lists, or Row objects, " - f"got {type(item).__name__}" - ) + it = iter(iterable) + first = next(it, None) + if first is None: + return + if isinstance(first, tuple): + yield first + yield from it + elif isinstance(first, Row): + yield tuple(first._values) + for item in it: + yield tuple(item._values) + else: + raise TypeError( + f"bulkcopy data rows must be tuples or Row objects, " + f"got {type(first).__name__}" + ) # Call bulkcopy with explicit keyword arguments # The API signature: bulkcopy(table_name, data_source, batch_size=0, timeout=30, ...)