Skip to content

Commit b11ebce

Browse files
authored
Merge branch 'main' into saumya/update-test-matrix
2 parents aa9ae9a + 3b7a613 commit b11ebce

6 files changed

Lines changed: 222 additions & 77 deletions

File tree

PyPI_Description.md

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,19 @@ PyBind11 provides:
3535
- Memory-safe bindings
3636
- Clean and Pythonic API, while performance-critical logic remains in robust, maintainable C++.
3737

38-
## What's new in v1.7.1
38+
## What's new in v1.8.0
3939

4040
### Enhancements
4141

42-
- **Platform Support: manylinux_2_28 Build Targets** - Added build targets for RHEL 8 and glibc 2.28 compatible distributions (#548).
43-
- **Platform Support: macOS universal2 Wheel for Python 3.10** - Now producing a universal2 wheel for Python 3.10 on macOS, enabling native performance on Apple Silicon (#542).
44-
- **Performance: UTF-16 String Handling via simdutf** - UTF-16 string processing now uses `simdutf` and `std::u16string` for significantly faster encoding/decoding (#526).
45-
- **Performance: Optimized execute() Hot Path** - `execute()` gains soft reset, prepare caching, and guarded diagnostics for reduced overhead on repeated statement execution (#528).
46-
- **Documentation: Azure Linux Installation Guide** - Added installation instructions for Azure Linux (#567).
42+
- **ActiveDirectoryMSI Support for Bulk Copy** - Adds `Authentication=ActiveDirectoryMSI` support to bulk copy, enabling both system-assigned and user-assigned managed identity authentication for Azure-hosted services (#573).
43+
- **Row String-Key Indexing** - Row objects now support accessing values by column name as a string key (e.g., `row["col"]`), in addition to integer index and attribute access. Case-insensitive lookup is supported when the cursor's `lowercase` attribute is enabled (#589).
44+
- **Bundled ODBC Driver Upgrade** - Updated the bundled Microsoft ODBC Driver for SQL Server from 18.5.1.1 to 18.6.2.1 (#569).
4745

4846
### Bug Fixes
4947

50-
- **Login Failures Now Raise Correct Exception Type** - Authentication failures previously surfaced as `RuntimeError`; they now raise the appropriate `mssql_python` exception type (#562).
51-
- **GIL Release Around Blocking ODBC Calls** - The GIL is now released around blocking `SQLSetConnectAttr` calls (#568), ODBC statement/fetch/transaction calls (#541), preventing thread stalls in multi-threaded workloads.
52-
- **executemany Decimal Sign Change Fix** - Fixed a `RuntimeError` in `executemany` when decimal parameter values change sign between rows (#560).
53-
- **CP1252 VARCHAR Encoding Consistency** - Fixed inconsistent retrieval of CP1252 encoded data in `VARCHAR` columns between Windows and Linux (#495).
54-
- **BulkCopy Empty String in NVARCHAR(MAX)/VARCHAR(MAX)** - Fixed `cursor.bulkcopy()` failing with SQL error 40197/4804 when any row contained an empty string `""` in an `NVARCHAR(MAX)` or `VARCHAR(MAX)` column. Fix ships via `mssql_py_core` 0.1.4 (#559).
48+
- **Deferred Connect-Attribute Use-After-Free** - Fixed a use-after-free in `Connection.setAttribute` for deferred ODBC attributes (e.g., `SQL_COPT_SS_ACCESS_TOKEN`) that caused SIGBUS on macOS arm64 and authentication failures on Windows and Azure SQL (#596).
49+
- **Connection String Parsed Multiple Times in Auth Path** - Refactored authentication handling to use dictionary-based parameter processing instead of repeated string parsing, improving reliability and performance (#590).
50+
- **executemany Type Annotation Regression** - Fixed a typing regression where `Cursor.executemany` rejected valid `list[tuple[...]]` arguments under mypy due to invariant `List` type. The parameter type now uses covariant `Sequence` matching PEP 249 (#586).
5551

5652
For more information, please visit the project link on Github: https://github.com/microsoft/mssql-python
5753

mssql_python/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from .helpers import Settings, get_settings, _settings, _settings_lock
1515

1616
# Driver version
17-
__version__ = "1.7.1"
17+
__version__ = "1.8.0"
1818

1919
# Exceptions
2020
# https://www.python.org/dev/peps/pep-0249/#exceptions

mssql_python/cursor.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,7 +2022,10 @@ def columns(self, table=None, catalog=None, schema=None, column=None):
20222022
# Use the helper method to prepare the result set
20232023
return self._prepare_metadata_result_set(fallback_description=fallback_description)
20242024

2025-
def _transpose_rowwise_to_columnwise(self, seq_of_parameters: list) -> tuple[list, int]:
2025+
def _transpose_rowwise_to_columnwise(
2026+
self,
2027+
seq_of_parameters: Sequence[Sequence[Any]],
2028+
) -> tuple[list, int]:
20262029
"""
20272030
Convert sequence of rows (row-wise) into list of columns (column-wise),
20282031
for array binding via ODBC. Works with both iterables and generators.
@@ -2140,7 +2143,9 @@ def _compute_column_type(self, column):
21402143
return sample_value, None, None, max_decimal_formatted_len
21412144

21422145
def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
2143-
self, operation: str, seq_of_parameters: Union[List[Sequence[Any]], List[Mapping[str, Any]]]
2146+
self,
2147+
operation: str,
2148+
seq_of_parameters: Union[Sequence[Sequence[Any]], Sequence[Mapping[str, Any]]],
21442149
) -> None:
21452150
"""
21462151
Prepare a database operation and execute it against all parameter sequences.

mssql_python/mssql_python.pyi

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ class Cursor:
193193
reset_cursor: bool = True,
194194
) -> "Cursor": ...
195195
def executemany(
196-
self, operation: str, seq_of_parameters: Union[List[Sequence[Any]], List[Mapping[str, Any]]]
196+
self,
197+
operation: str,
198+
seq_of_parameters: Union[Sequence[Sequence[Any]], Sequence[Mapping[str, Any]]],
197199
) -> None: ...
198200
def fetchone(self) -> Optional[Row]: ...
199201
def fetchmany(self, size: Optional[int] = None) -> List[Row]: ...

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def run(self):
176176

177177
setup(
178178
name="mssql-python",
179-
version="1.7.1",
179+
version="1.8.0",
180180
description="A Python library for interacting with Microsoft SQL Server",
181181
long_description=open("PyPI_Description.md", encoding="utf-8").read(),
182182
long_description_content_type="text/markdown",

tests/test_009_pooling.py

Lines changed: 203 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,50 @@
1616
"""
1717

1818
import pytest
19+
import os
20+
import re
21+
import subprocess
22+
import sys
23+
import textwrap
1924
import time
2025
import threading
26+
27+
28+
def _run_in_subprocess(body: str, conn_str: str) -> None:
29+
"""Run a test body in a fresh Python process.
30+
31+
Some tests need to be the *first* to call ``pooling(...)`` in the
32+
process (the C++ ``enable_pooling`` is wrapped in ``std::call_once``
33+
so only the first call's max_size/idle_timeout take effect). Running
34+
them in a subprocess gives each a clean process state.
35+
36+
The subprocess inherits the current ``DB_CONNECTION_STRING`` env var
37+
so the worker uses the same database. ``body`` must be a self-contained
38+
Python snippet that exits non-zero on failure (any uncaught assertion
39+
is fine).
40+
"""
41+
env = os.environ.copy()
42+
env["DB_CONNECTION_STRING"] = conn_str
43+
proc = subprocess.run(
44+
[sys.executable, "-c", textwrap.dedent(body)],
45+
env=env,
46+
capture_output=True,
47+
text=True,
48+
timeout=120,
49+
)
50+
# Sentinel exit code 77 means the subprocess decided to skip
51+
# (e.g. the test prerequisite is unmet on this server, like missing
52+
# KILL permission). The reason is printed to stderr.
53+
if proc.returncode == 77:
54+
pytest.skip(proc.stderr.strip() or "Subprocess requested skip")
55+
if proc.returncode != 0:
56+
pytest.fail(
57+
"Subprocess test body failed\n"
58+
f"--- stdout ---\n{proc.stdout}\n"
59+
f"--- stderr ---\n{proc.stderr}"
60+
)
61+
62+
2163
import statistics
2264
from mssql_python import connect, pooling
2365
from mssql_python.pooling import PoolingManager
@@ -314,82 +356,182 @@ def test_pool_release_overflow_disconnects_outside_mutex(conn_str):
314356
conn3.close()
315357

316358

317-
@pytest.mark.skip("Flaky test - idle timeout behavior needs investigation")
318359
def test_pool_idle_timeout_removes_connections(conn_str):
319-
"""Test that idle_timeout removes connections from the pool after the timeout."""
320-
pooling(max_size=2, idle_timeout=1)
321-
conn1 = connect(conn_str)
322-
spid_list = []
323-
cursor1 = conn1.cursor()
324-
cursor1.execute("SELECT @@SPID")
325-
spid1 = cursor1.fetchone()[0]
326-
spid_list.append(spid1)
327-
conn1.close()
328-
329-
# Wait for longer than idle_timeout
330-
time.sleep(3)
331-
332-
# Get a new connection, which should not reuse the previous SPID
333-
conn2 = connect(conn_str)
334-
cursor2 = conn2.cursor()
335-
cursor2.execute("SELECT @@SPID")
336-
spid2 = cursor2.fetchone()[0]
337-
spid_list.append(spid2)
338-
conn2.close()
339-
340-
assert spid1 != spid2, "Idle timeout did not remove connection from pool"
360+
"""Test that idle_timeout removes connections from the pool after the timeout.
361+
362+
Run in a subprocess so this test's pooling(idle_timeout=1) is the
363+
first call in the process — the C++ ``enable_pooling`` is wrapped in
364+
``std::call_once``, so only the first call's settings take effect for
365+
the lifetime of the process.
366+
367+
A bare SPID-inequality assertion is unreliable: SQL Server is free to
368+
reassign a recently-freed SPID to the next session. So we identify a
369+
session by the (SPID, login_time) tuple from sys.dm_exec_sessions —
370+
login_time has millisecond resolution and is unique per physical
371+
connection.
372+
"""
373+
_run_in_subprocess(
374+
"""
375+
import os, time
376+
from mssql_python import connect, pooling
377+
378+
conn_str = os.environ["DB_CONNECTION_STRING"]
379+
pooling(max_size=2, idle_timeout=1)
380+
381+
def session_identity(conn):
382+
cur = conn.cursor()
383+
cur.execute(
384+
"SELECT @@SPID, "
385+
" (SELECT login_time FROM sys.dm_exec_sessions "
386+
" WHERE session_id = @@SPID)"
387+
)
388+
spid, login_time = cur.fetchone()
389+
return (spid, login_time)
390+
391+
c1 = connect(conn_str)
392+
id1 = session_identity(c1)
393+
c1.close()
394+
395+
time.sleep(3)
396+
397+
c2 = connect(conn_str)
398+
id2 = session_identity(c2)
399+
c2.close()
400+
401+
assert id1 != id2, (
402+
f"Idle timeout did not remove connection from pool: "
403+
f"got the same session both times {id1}"
404+
)
405+
""",
406+
conn_str,
407+
)
341408

342409

343410
# =============================================================================
344411
# Error Handling and Recovery Tests
345412
# =============================================================================
346413

347414

348-
@pytest.mark.skip(
349-
"Test causes fatal crash - forcibly closing underlying connection leads to undefined behavior"
350-
)
351415
def test_pool_removes_invalid_connections(conn_str):
352-
"""Test that the pool removes connections that become invalid (simulate by closing underlying connection)."""
353-
pooling(max_size=1, idle_timeout=30)
354-
conn = connect(conn_str)
355-
cursor = conn.cursor()
356-
cursor.execute("SELECT 1")
357-
# Simulate invalidation by forcibly closing the connection at the driver level
358-
try:
359-
# Try to access a private attribute or method to forcibly close the underlying connection
360-
# This is implementation-specific; if not possible, skip
361-
if hasattr(conn, "_conn") and hasattr(conn._conn, "close"):
362-
conn._conn.close()
363-
else:
364-
pytest.skip("Cannot forcibly close underlying connection for this driver")
365-
except Exception:
366-
pass
367-
# Safely close the connection, ignoring errors due to forced invalidation
368-
try:
369-
conn.close()
370-
except RuntimeError as e:
371-
if "not initialized" not in str(e):
416+
"""Pool must replace a pooled connection whose server-side session has died.
417+
418+
Run in a subprocess so this test does not pollute the in-process pool
419+
state for sibling tests (KILL leaves dead pool entries that survive
420+
Python-side teardown because the C++ pool config is locked in for the
421+
lifetime of the process via ``std::call_once``).
422+
423+
Simulates the realistic failure mode (DBA KILL, failover, server-side
424+
idle timeout) by:
425+
1. Opening two connections concurrently (distinct physical sessions)
426+
in autocommit mode.
427+
2. Using one to KILL the other's server-side session out-of-band.
428+
3. Returning both to the pool.
429+
4. Re-acquiring repeatedly: every connection must work and the
430+
killed SPID must never reappear.
431+
432+
Only public APIs are used.
433+
"""
434+
_run_in_subprocess(
435+
"""
436+
import os
437+
import time
438+
from mssql_python import connect, pooling
439+
440+
conn_str = os.environ["DB_CONNECTION_STRING"]
441+
pooling(max_size=2, idle_timeout=30)
442+
443+
def session_identity(conn):
444+
cur = conn.cursor()
445+
cur.execute(
446+
"SELECT @@SPID, "
447+
" (SELECT login_time FROM sys.dm_exec_sessions "
448+
" WHERE session_id = @@SPID)"
449+
)
450+
spid, login_time = cur.fetchone()
451+
return (spid, login_time)
452+
453+
# Step 1: two distinct, autocommit connections. Autocommit avoids
454+
# the implicit rollback in Connection.close(), which would
455+
# otherwise fail on the killed session and leak its pool slot.
456+
victim = connect(conn_str)
457+
admin = connect(conn_str)
458+
victim.autocommit = True
459+
admin.autocommit = True
460+
461+
victim_id = session_identity(victim)
462+
admin_id = session_identity(admin)
463+
assert victim_id != admin_id, (
464+
"Pool handed out the same physical session to two concurrent "
465+
"acquires"
466+
)
467+
victim_spid = victim_id[0]
468+
469+
# Step 2: admin KILLs the victim's session. Requires server
470+
# permission (ALTER ANY CONNECTION or sysadmin); on hosted/CI
471+
# databases the test login often lacks it, so skip gracefully.
472+
try:
473+
admin.cursor().execute(f"KILL {victim_spid}")
474+
except Exception as e:
475+
msg = str(e)
476+
if "permission" in msg.lower() or "KILL" in msg:
477+
import sys as _sys
478+
print(
479+
f"Skipping: KILL not permitted for this login: {msg}",
480+
file=_sys.stderr,
481+
)
482+
victim.close()
483+
admin.close()
484+
_sys.exit(77)
372485
raise
373-
# Now, get a new connection from the pool and ensure it works
374-
new_conn = connect(conn_str)
375-
new_cursor = new_conn.cursor()
376-
try:
377-
new_cursor.execute("SELECT 1")
378-
result = new_cursor.fetchone()
379-
assert result is not None and result[0] == 1, "Pool did not remove invalid connection"
380-
finally:
381-
new_conn.close()
486+
487+
# KILL is processed asynchronously on the server, but we don't
488+
# need to wait for it here. The test's correctness contract is
489+
# "the killed (SPID, login_time) must never reappear in
490+
# subsequent acquires." Any session that gets handed back
491+
# later — whether the same SPID reused by the server or a
492+
# transparently-reconnected one — necessarily has a different
493+
# login_time, so the identity check below catches the only
494+
# failure mode that matters.
495+
496+
# Step 3: return both to the pool.
497+
victim.close()
498+
admin.close()
499+
500+
# Step 4: re-acquire from the pool. Each must be working; the
501+
# killed *physical session* (SPID, login_time) must never come
502+
# back. SQL Server is free to reassign the SPID number to a new
503+
# session, so SPID alone is not a reliable identity.
504+
seen_ids = set()
505+
for _ in range(4):
506+
c = connect(conn_str)
507+
try:
508+
seen_ids.add(session_identity(c))
509+
assert c.cursor().execute("SELECT 1").fetchone()[0] == 1, (
510+
"Pool handed out an unusable connection"
511+
)
512+
finally:
513+
c.close()
514+
assert victim_id not in seen_ids, (
515+
f"Pool returned the killed session {victim_id}; "
516+
f"saw sessions {seen_ids}"
517+
)
518+
""",
519+
conn_str,
520+
)
382521

383522

384523
def test_pool_recovery_after_failed_connection(conn_str):
385524
"""Test that the pool recovers after a failed connection attempt."""
386525
pooling(max_size=1, idle_timeout=30)
387-
# First, try to connect with a bad password (should fail)
388-
if "Pwd=" in conn_str:
389-
bad_conn_str = conn_str.replace("Pwd=", "Pwd=wrongpassword")
390-
elif "Password=" in conn_str:
391-
bad_conn_str = conn_str.replace("Password=", "Password=wrongpassword")
392-
else:
526+
# First, try to connect with a bad password (should fail).
527+
# Match the password keyword case-insensitively since ODBC accepts any case.
528+
bad_conn_str = re.sub(
529+
r"(?i)(\b(?:pwd|password)\s*=)([^;]*)",
530+
r"\1wrongpassword",
531+
conn_str,
532+
count=1,
533+
)
534+
if bad_conn_str == conn_str:
393535
pytest.skip("No password found in connection string to modify")
394536
with pytest.raises(Exception):
395537
connect(bad_conn_str)

0 commit comments

Comments
 (0)